472 lines
12 KiB
Vue
472 lines
12 KiB
Vue
<template>
|
||
<el-aside :width="width" class="common-aside">
|
||
<div v-if="loading" class="loading-spinner"> <!-- Show spinner if loading -->
|
||
<i class="el-icon-loading" style="font-size: 24px; color: #fff;"></i>
|
||
</div>
|
||
<el-menu v-else
|
||
:collapse="isCollapse"
|
||
:collapse-transition="false"
|
||
:background-color="asideBgColor"
|
||
:text-color="asideTextColor"
|
||
:active-text-color="activeColor"
|
||
:active-background-color="activeBgColor"
|
||
class="el-menu-vertical-demo"
|
||
@select="handleMenuSelect"
|
||
:default-active="route.path"
|
||
>
|
||
<h3 v-if="!isCollapse">管理后台</h3>
|
||
<h3 v-else>管理</h3>
|
||
<!-- 固定的仪表盘菜单项 -->
|
||
<el-menu-item index="/dashboard">
|
||
<i :class="['icons', 'fa', 'fa-solid', 'fa-gauge']"></i>
|
||
<template #title>
|
||
<span>仪表盘</span>
|
||
</template>
|
||
</el-menu-item>
|
||
<template v-for="item in sortedMenuList" :key="item.path">
|
||
<el-menu-item
|
||
v-if="!item.children || item.children.length === 0"
|
||
:index="item.path"
|
||
>
|
||
<i :class="['icons', 'fa', item.icon]"></i>
|
||
<template #title>
|
||
<span>{{ item.label }}</span>
|
||
</template>
|
||
</el-menu-item>
|
||
<el-sub-menu
|
||
v-else
|
||
:index="item.path"
|
||
>
|
||
<template #title>
|
||
<i :class="['icons', 'fa', item.icon]"></i>
|
||
<span>{{ item.label }}</span>
|
||
</template>
|
||
<el-menu-item
|
||
v-for="subItem in item.children"
|
||
:key="subItem.path"
|
||
:index="subItem.path"
|
||
>
|
||
<i :class="['icons', 'fa', subItem.icon && typeof subItem.icon === 'string' ? subItem.icon.trim() : '']"></i>
|
||
<template #title>
|
||
<span>{{ subItem.label }}</span>
|
||
</template>
|
||
</el-menu-item>
|
||
</el-sub-menu>
|
||
</template>
|
||
</el-menu>
|
||
</el-aside>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { useAllDataStore } from '@/stores';
|
||
import { getAllMenus } from '@/api/menu';
|
||
|
||
const emit = defineEmits(['menu-click']);
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const list = ref([]);
|
||
const loading = ref(true);
|
||
|
||
const store = useAllDataStore();
|
||
const isCollapse = computed(() => store.state.isCollapse);
|
||
const width = computed(() => store.state.isCollapse ? '64px' : '180px');
|
||
// 使用 Element Plus 的颜色变量,主题切换时会自动适配
|
||
// 这些值会被 el-menu 组件使用,el-menu 会自动适配主题
|
||
// 计算是否为暗色主题(响应式)
|
||
const isDark = ref(document.documentElement.classList.contains('dark'));
|
||
|
||
// 监听主题变化
|
||
const updateTheme = () => {
|
||
isDark.value = document.documentElement.classList.contains('dark');
|
||
};
|
||
|
||
// 使用 MutationObserver 监听 html 元素的 class 变化(主题切换时更新)
|
||
let themeObserver = null;
|
||
|
||
// 自定义颜色配置
|
||
const BG_COLOR = '#062da3'; // 背景和 active 颜色
|
||
const HOVER_COLOR = '#4f84ff'; // hover 颜色
|
||
|
||
// 将 hex 颜色转换为 rgba
|
||
function hexToRgba(hex, alpha = 1) {
|
||
const r = parseInt(hex.slice(1, 3), 16);
|
||
const g = parseInt(hex.slice(3, 5), 16);
|
||
const b = parseInt(hex.slice(5, 7), 16);
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
|
||
// 亮色主题使用自定义背景色,暗色主题使用默认背景
|
||
const asideBgColor = computed(() => {
|
||
if (isDark.value) {
|
||
return 'var(--el-bg-color)';
|
||
}
|
||
// 亮色主题使用 #062da3 背景
|
||
return BG_COLOR;
|
||
});
|
||
|
||
const asideTextColor = computed(() => {
|
||
if (isDark.value) {
|
||
return 'var(--el-text-color-primary)';
|
||
}
|
||
// 亮色主题使用白色文字
|
||
return '#ffffff';
|
||
});
|
||
|
||
const activeColor = ref('#ffffff'); // active 文字颜色为白色
|
||
|
||
// hover 和 active 背景色
|
||
const activeBgColor = computed(() => {
|
||
if (isDark.value) {
|
||
return 'rgba(255, 255, 255, 0.1)';
|
||
}
|
||
// 亮色主题 active 使用 #4f84ff
|
||
return HOVER_COLOR;
|
||
});
|
||
|
||
// 不再需要手动更新主题颜色,因为使用了 Element Plus 的 CSS 变量,会自动适配
|
||
|
||
// 将后端数据转换为前端需要的格式
|
||
const transformMenuData = (menus) => {
|
||
// 首先映射字段格式
|
||
const mappedMenus = menus.map(menu => ({
|
||
id: menu.id,
|
||
path: menu.path,
|
||
icon: menu.icon || 'fa-circle',
|
||
label: menu.name,
|
||
route: menu.path,
|
||
parentId: menu.parentId || 0,
|
||
order: menu.order || 0,
|
||
children: []
|
||
}));
|
||
|
||
// 构建树形结构
|
||
const menuMap = new Map();
|
||
const rootMenus = [];
|
||
|
||
// 先创建所有菜单的映射
|
||
mappedMenus.forEach(menu => {
|
||
menuMap.set(menu.id, menu);
|
||
});
|
||
|
||
// 构建树形结构
|
||
mappedMenus.forEach(menu => {
|
||
if (menu.parentId === 0) {
|
||
rootMenus.push(menu);
|
||
} else {
|
||
const parent = menuMap.get(menu.parentId);
|
||
if (parent) {
|
||
if (!parent.children) {
|
||
parent.children = [];
|
||
}
|
||
parent.children.push(menu);
|
||
} else {
|
||
// 如果找不到父节点,作为根节点处理
|
||
rootMenus.push(menu);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 按 order 排序(确保排序正确)
|
||
const sortMenus = (menus) => {
|
||
if (!menus || menus.length === 0) return;
|
||
|
||
// 对当前层级的菜单进行排序
|
||
menus.sort((a, b) => {
|
||
const orderA = Number(a.order) ?? 999999; // 没有order的排在最后
|
||
const orderB = Number(b.order) ?? 999999;
|
||
const diff = orderA - orderB;
|
||
|
||
// 如果 order 相同,按 id 排序(保证稳定性)
|
||
if (diff === 0) {
|
||
return (a.id || 0) - (b.id || 0);
|
||
}
|
||
|
||
return diff;
|
||
});
|
||
|
||
// 递归排序子菜单
|
||
menus.forEach(menu => {
|
||
if (menu.children && menu.children.length > 0) {
|
||
sortMenus(menu.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 先排序根菜单
|
||
sortMenus(rootMenus);
|
||
|
||
return rootMenus;
|
||
};
|
||
|
||
// 获取菜单数据
|
||
const fetchMenus = async () => {
|
||
loading.value = true;
|
||
try {
|
||
// 直接从接口获取菜单数据,不使用缓存
|
||
const res = await getAllMenus();
|
||
if (res && res.success && res.data) {
|
||
const menuData = res.data;
|
||
// 转换并排序菜单数据
|
||
const transformedMenus = transformMenuData(menuData);
|
||
list.value = transformedMenus;
|
||
} else {
|
||
console.error('获取菜单失败:', res?.message || '未知错误');
|
||
list.value = [];
|
||
loading.value = false;
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取菜单异常:', error);
|
||
list.value = [];
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// 组件挂载时初始化主题监听和获取菜单
|
||
onMounted(() => {
|
||
// 初始化主题监听
|
||
themeObserver = new MutationObserver(() => {
|
||
updateTheme();
|
||
});
|
||
|
||
themeObserver.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['class']
|
||
});
|
||
|
||
// 初始化时检查一次主题
|
||
updateTheme();
|
||
|
||
// 延迟一点获取菜单,确保主题已初始化
|
||
setTimeout(() => {
|
||
fetchMenus();
|
||
}, 100);
|
||
});
|
||
|
||
// 计算属性:统一排序所有菜单项(不再区分有无子菜单)
|
||
const sortedMenuList = computed(() => {
|
||
// 创建副本并排序,确保按 order 排序
|
||
const sorted = [...list.value].sort((a, b) => {
|
||
const orderA = Number(a.order) ?? 999999;
|
||
const orderB = Number(b.order) ?? 999999;
|
||
if (orderA === orderB) {
|
||
return (a.id || 0) - (b.id || 0);
|
||
}
|
||
return orderA - orderB;
|
||
});
|
||
|
||
// 确保子菜单也排序
|
||
sorted.forEach(menu => {
|
||
if (menu.children && menu.children.length > 0) {
|
||
menu.children.sort((a, b) => {
|
||
const orderA = Number(a.order) ?? 999999;
|
||
const orderB = Number(b.order) ?? 999999;
|
||
if (orderA === orderB) {
|
||
return (a.id || 0) - (b.id || 0);
|
||
}
|
||
return orderA - orderB;
|
||
});
|
||
}
|
||
});
|
||
|
||
return sorted;
|
||
});
|
||
|
||
// 固定的仪表盘菜单项
|
||
const dashboardMenuItem = {
|
||
path: '/dashboard',
|
||
label: '仪表盘',
|
||
icon: 'fa-solid fa-gauge',
|
||
route: '/dashboard',
|
||
order: 0
|
||
};
|
||
|
||
// 菜单点击事件:emit 通知父组件
|
||
const handleMenuSelect = (index) => {
|
||
// 如果是仪表盘路径,直接使用固定的仪表盘菜单项
|
||
if (index === '/dashboard') {
|
||
emit('menu-click', dashboardMenuItem);
|
||
return;
|
||
}
|
||
|
||
const menuItem = findMenuItemByPath(list.value, index);
|
||
if (menuItem && menuItem.route) {
|
||
emit('menu-click', menuItem);
|
||
}
|
||
};
|
||
|
||
// 递归查找菜单项
|
||
const findMenuItemByPath = (menus, path) => {
|
||
for (const menu of menus) {
|
||
if (menu.path === path) {
|
||
return menu;
|
||
}
|
||
if (menu.children && menu.children.length > 0) {
|
||
const found = findMenuItemByPath(menu.children, path);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.common-aside {
|
||
height: 100%;
|
||
// 亮色主题使用 primary 浅色,暗色主题使用默认背景
|
||
background-color: var(--el-bg-color);
|
||
transition: width 0.3s, background-color 0.3s ease;
|
||
overflow: hidden;
|
||
position: relative;
|
||
display: block !important;
|
||
visibility: visible !important;
|
||
|
||
// 亮色主题下的特殊处理
|
||
html:not(.dark) & {
|
||
background-color: #062da3;
|
||
}
|
||
}
|
||
|
||
.icons {
|
||
width: 16px;
|
||
height: 16px;
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
transition: var(--transition-fast);
|
||
|
||
// 亮色主题下默认使用白色
|
||
html:not(.dark) & {
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
}
|
||
|
||
h3{
|
||
line-height: 80px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
// 亮色主题使用白色,暗色主题使用默认文字颜色
|
||
color: var(--el-text-color-primary);
|
||
|
||
html:not(.dark) & {
|
||
color: #ffffff;
|
||
}
|
||
}
|
||
|
||
// 菜单项样式:优化 hover 和 active 状态的视觉效果
|
||
:deep(.el-menu) {
|
||
.el-menu-item {
|
||
transition: all 0.3s ease;
|
||
position: relative;
|
||
|
||
// hover 状态:亮色主题使用 #4f84ff
|
||
&:hover:not(.is-active) {
|
||
background-color: #4f84ff !important;
|
||
color: #ffffff !important;
|
||
|
||
// 确保图标在 hover 状态下也有正确的颜色
|
||
.icons {
|
||
color: #ffffff !important;
|
||
}
|
||
}
|
||
|
||
// active 状态:使用 #4f84ff 背景色,白色文字
|
||
&.is-active {
|
||
background-color: #4f84ff !important;
|
||
color: #ffffff !important;
|
||
font-weight: 600;
|
||
|
||
// 添加左侧边框作为指示器(使用更亮的颜色)
|
||
border-left: 3px solid #ffffff;
|
||
margin-left: -3px; // 使用负边距补偿,保持文字位置不变
|
||
|
||
// 确保图标在 active 状态下也有正确的颜色
|
||
.icons {
|
||
color: #ffffff !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 子菜单项也需要相同的效果
|
||
.el-sub-menu {
|
||
.el-menu-item {
|
||
&:hover:not(.is-active) {
|
||
background-color: #4f84ff !important;
|
||
color: #ffffff !important;
|
||
|
||
.icons {
|
||
color: #ffffff !important;
|
||
}
|
||
}
|
||
|
||
&.is-active {
|
||
background-color: #4f84ff !important;
|
||
color: #ffffff !important;
|
||
font-weight: 600;
|
||
|
||
// 添加左侧边框作为指示器
|
||
border-left: 3px solid #ffffff;
|
||
margin-left: -3px; // 使用负边距补偿,保持文字位置不变
|
||
|
||
.icons {
|
||
color: #ffffff !important;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 暗色主题下的特殊处理
|
||
html.dark & {
|
||
.el-menu-item {
|
||
&:hover:not(.is-active) {
|
||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||
color: var(--el-color-primary-light-3) !important;
|
||
|
||
.icons {
|
||
color: var(--el-color-primary-light-3) !important;
|
||
}
|
||
}
|
||
|
||
&.is-active {
|
||
background-color: rgba(255, 255, 255, 0.12) !important;
|
||
color: var(--el-color-primary-light-3) !important;
|
||
border-left-color: var(--el-color-primary-light-3);
|
||
|
||
.icons {
|
||
color: var(--el-color-primary-light-3) !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
.el-sub-menu {
|
||
.el-menu-item {
|
||
&:hover:not(.is-active) {
|
||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||
color: var(--el-color-primary-light-3) !important;
|
||
|
||
.icons {
|
||
color: var(--el-color-primary-light-3) !important;
|
||
}
|
||
}
|
||
|
||
&.is-active {
|
||
background-color: rgba(255, 255, 255, 0.12) !important;
|
||
color: var(--el-color-primary-light-3) !important;
|
||
border-left-color: var(--el-color-primary-light-3);
|
||
|
||
.icons {
|
||
color: var(--el-color-primary-light-3) !important;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|