LAOP-web/src/layouts/MainLayout.vue
2026-09-18 16:50:27 +08:00

440 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-container class="main-layout">
<!-- 侧边栏 -->
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
<div class="logo-area">
<img src="@/assets/logo.png" alt="logo" class="logo-img" v-if="!collapsed" />
<span class="logo-text" v-if="!collapsed">翼云Hub</span>
<span class="logo-text-mini" v-else>YH</span>
</div>
<el-menu
:default-active="activeMenu"
:default-openeds="openedSubMenus"
:collapse="collapsed"
:collapse-transition="false"
background-color="#1f2937"
text-color="#d1d5db"
active-text-color="#ffffff"
router
unique-opened
>
<template v-for="item in menuList" :key="item.path || item.groupKey">
<!-- 二级菜单(有 children) -->
<el-sub-menu v-if="item.meta?.isSubMenu && item.children?.length" :index="item.groupKey">
<template #title>
<el-icon>
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<span>{{ item.meta.title }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="child.path"
>
<template #title>
<span>{{ child.meta.title }}</span>
<span v-if="child.meta.subTitle" class="sub-title-tag">{{ child.meta.subTitle }}</span>
</template>
</el-menu-item>
</el-sub-menu>
<!-- 平级菜单项 -->
<el-menu-item v-else :index="item.path">
<el-icon>
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶栏 -->
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="collapsed = !collapsed">
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<template v-for="(crumb, idx) in breadcrumbItems" :key="idx">
<el-breadcrumb-item :to="crumb.path" v-if="crumb.path">
{{ crumb.title }}
</el-breadcrumb-item>
<el-breadcrumb-item v-else>
{{ crumb.title }}
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" class="avatar">
{{ userStore.username?.charAt(0).toUpperCase() || 'A' }}
</el-avatar>
<span class="username">{{ userStore.username }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { routes as rawRoutes, menuGroups } from '@/router'
import { ElMessageBox, ElMessage } from 'element-plus'
import {
Fold,
Expand,
ArrowDown,
SwitchButton,
Odometer,
Reading,
Bell,
List,
User,
Monitor
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 侧边栏折叠状态
const collapsed = ref(false)
// 当前激活的菜单
const activeMenu = computed(() => route.path)
const currentRoute = computed(() => route)
// ==================== 图标解析 ====================
/** 图标名称 → Element Plus 组件实例映射 */
const iconMap = {
Odometer,
Reading,
Bell,
List,
User,
Monitor
}
/**
* 根据 meta.icon 字符串解析对应的 Element Plus 图标组件
* @param {string} iconName 图标名称,如 'Odometer'
*/
function resolveIcon(iconName) {
return iconMap[iconName] || Odometer
}
// ==================== 菜单数据 ====================
/**
* 侧边栏菜单列表:
* - 顶层路由(无 meta.parentKey)→ 直接渲染为 el-menu-item
* - 有 meta.parentKey 的路由 → 按 parentKey 分组,渲染为 el-sub-menu
*
* 排序规则:menuGroups 的 sort 优先,未分组路由按原始顺序追加
*/
const menuList = computed(() => {
const root = rawRoutes.find(r => r.path === '/')
if (!root?.children) return []
const allChildren = root.children.filter(item => !item.meta?.hidden)
// 1. 分离顶层路由和有父级的路由
const topLevel = []
const groupedMap = new Map() // parentKey -> routes[]
for (const route of allChildren) {
const parentKey = route.meta?.parentKey
if (parentKey && menuGroups[parentKey]) {
if (!groupedMap.has(parentKey)) groupedMap.set(parentKey, [])
groupedMap.get(parentKey).push(route)
} else {
topLevel.push(route)
}
}
// 2. 构造 sub-menu 项(来自 menuGroups 的 sort 排序)
const groupItems = Object.keys(menuGroups)
.filter(key => groupedMap.has(key))
.sort((a, b) => (menuGroups[a].sort || 99) - (menuGroups[b].sort || 99))
.map(key => ({
groupKey: key,
meta: {
title: menuGroups[key].title,
icon: menuGroups[key].icon,
isSubMenu: true
},
children: groupedMap.get(key).map(r => ({
path: r.path.startsWith('/') ? r.path : '/' + r.path,
meta: r.meta
}))
}))
// 3. 合并输出:先 topLevel,再 groupItems
// 这里保持原有的 el-menu-item 字段结构(path + meta),
// sub-menu 项额外带 children 字段
const result = []
// 给顶层路由 path 补全前导斜杠
for (const r of topLevel) {
result.push({
path: r.path.startsWith('/') ? r.path : '/' + r.path,
meta: r.meta
})
}
for (const g of groupItems) {
result.push(g)
}
return result
})
/**
* 当前路由所属的 sub-menu groupKey 集合,用于默认展开
*/
const openedSubMenus = computed(() => {
const parentKey = route.meta?.parentKey
return parentKey && menuGroups[parentKey] ? [parentKey] : []
})
// ==================== 面包屑 ====================
/**
* 根据当前路由 meta.parentKey 动态组装面包屑:
* - 若路由有 parentKey → "首页 / 分组标题 / 当前标题"
* - 否则 → "首页 / 当前标题"
*/
const breadcrumbItems = computed(() => {
const items = []
const parentKey = route.meta?.parentKey
if (parentKey && menuGroups[parentKey]) {
items.push({ title: menuGroups[parentKey].title, path: null })
}
if (route.meta?.title && route.meta.title !== '登录') {
items.push({ title: route.meta.title, path: null })
}
return items
})
// ==================== 下拉菜单 ====================
/**
* 下拉菜单命令处理
*/
function handleCommand(command) {
if (command === 'logout') {
ElMessageBox.confirm('确认退出登录吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
userStore.logout()
ElMessage.success('已退出登录')
router.replace('/login')
})
.catch(() => {})
}
}
</script>
<style lang="scss" scoped>
.main-layout {
height: 100vh;
}
// ==================== 侧边栏 ====================
.sidebar {
background-color: #1f2937;
transition: width 0.28s ease;
overflow: hidden;
.logo-area {
height: 72px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
overflow: hidden;
}
.logo-img {
height: 52px;
width: auto;
object-fit: contain;
}
.logo-text {
font-size: 24px;
font-weight: 700;
line-height: 1;
letter-spacing: 2px;
white-space: nowrap;
}
.logo-text-mini {
font-size: 16px;
font-weight: 700;
}
:deep(.el-menu) {
border-right: none;
}
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
margin: 4px 8px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
&.is-active {
background-color: #5e46f6 !important;
color: #fff !important;
}
}
:deep(.el-sub-menu) {
margin: 4px 8px;
border-radius: 6px;
.el-sub-menu__title {
height: 50px;
line-height: 50px;
border-radius: 6px;
margin: 0;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
}
.el-menu-item {
margin: 2px 8px 2px 24px;
padding-left: 20px;
height: 44px;
line-height: 44px;
}
}
.sub-title-tag {
margin-left: 8px;
padding: 1px 8px;
font-size: 11px;
color: #a5a9b3;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
}
// ==================== 顶栏 ====================
.header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0 20px;
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #606266;
padding: 6px;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
.username {
font-size: 14px;
color: #303133;
}
}
}
// ==================== 内容区 ====================
.main-content {
padding: 20px;
background-color: #f0f2f5;
overflow-y: auto;
}
// ==================== 页面切换动画 ====================
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>