440 lines
13 KiB
Vue
440 lines
13 KiB
Vue
<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>
|