backend/src/components/CommonAside.vue
2026-01-27 18:01:54 +08:00

519 lines
13 KiB
Vue

<template>
<el-aside :width="width" class="common-aside">
<!-- 加载状态 -->
<div v-if="loading" class="loading-spinner">
<i class="el-icon-loading" style="font-size: 24px; color: #fff"></i>
</div>
<!-- 错误提示 -->
<div v-else-if="hasError" class="error-container">
<el-icon class="error-icon"><Warning /></el-icon>
<div class="error-text">{{ errorMsg }}</div>
<el-button size="small" @click="fetchMenus">重新加载</el-button>
</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"
:unique-opened="true"
@select="handleMenuSelect"
:default-active="route.path"
>
<!-- 菜单标题 -->
<h3>{{ isCollapse ? "管理" : currentModule?.title || "子菜单" }}</h3>
<!-- 无模块时显示提示 -->
<el-menu-item v-if="!currentModule" index="/home">
<i class="fa-solid fa-house"></i>
<template #title>返回首页</template>
</el-menu-item>
<!-- 动态菜单项 -->
<template v-for="item in displayMenus" :key="item.id">
<!-- 如果没有子菜单,渲染为菜单项 -->
<el-menu-item
v-if="!item.children || item.children.length === 0"
:index="item.path || item.id.toString()"
>
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
<template #title>
<span>{{ item.title }}</span>
</template>
</el-menu-item>
<!-- 如果有子菜单,渲染为子菜单 -->
<el-sub-menu
v-else
:index="item.path || item.id.toString()"
:unique-opened="true"
>
<template #title>
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
<span>{{ item.title }}</span>
</template>
<!-- 递归渲染子菜单 -->
<template v-for="child in item.children" :key="child.id">
<el-menu-item
v-if="!child.children || child.children.length === 0"
:index="child.path || child.id.toString()"
>
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
<template #title>
<span>{{ child.title }}</span>
</template>
</el-menu-item>
<el-sub-menu
v-else
:index="child.path || child.id.toString()"
:unique-opened="true"
>
<template #title>
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
<span>{{ child.title }}</span>
</template>
<!-- 继续递归渲染子菜单 -->
<template
v-for="grandchild in child.children"
:key="grandchild.id"
>
<el-menu-item
v-if="
!grandchild.children || grandchild.children.length === 0
"
:index="grandchild.path || grandchild.id.toString()"
>
<i
v-if="grandchild.icon"
:class="grandchild.icon"
class="menu-icon"
></i>
<template #title>
<span>{{ grandchild.title }}</span>
</template>
</el-menu-item>
<el-sub-menu
v-else
:index="grandchild.path || grandchild.id.toString()"
:unique-opened="true"
>
<template #title>
<i
v-if="grandchild.icon"
:class="grandchild.icon"
class="menu-icon"
></i>
<span>{{ grandchild.title }}</span>
</template>
<!-- 继续递归渲染... -->
<template
v-for="greatGrandchild in grandchild.children"
:key="greatGrandchild.id"
>
<el-menu-item
:index="
greatGrandchild.path || greatGrandchild.id.toString()
"
>
<i
v-if="greatGrandchild.icon"
:class="greatGrandchild.icon"
class="menu-icon"
></i>
<template #title>
<span>{{ greatGrandchild.title }}</span>
</template>
</el-menu-item>
</template>
</el-sub-menu>
</template>
</el-sub-menu>
</template>
</el-sub-menu>
</template>
</el-menu>
</el-aside>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { Document, Warning } from "@element-plus/icons-vue";
import { useAllDataStore, useMenuStore } from "@/stores";
const emit = defineEmits(["menu-click"]);
const router = useRouter();
const route = useRoute();
const menuStore = useMenuStore();
const loading = computed(() => menuStore.loading);
const hasError = computed(() => menuStore.error);
const errorMsg = computed(() => menuStore.error || "加载菜单失败");
const store = useAllDataStore();
const isCollapse = computed(() => store.state.isCollapse);
const width = computed(() => (store.state.isCollapse ? "64px" : "200px"));
const asideBgColor = ref("#304156");
const asideTextColor = ref("#bfcbd9");
const activeColor = ref("#3973FF");
const activeBgColor = ref("#3973FF");
const currentModuleId = ref(null);
const findMenuItem = (menus, targetIndex) => {
for (const menu of menus) {
if (menu.path === targetIndex) {
return menu;
}
if (menu.children && menu.children.length > 0) {
const found = findMenuItem(menu.children, targetIndex);
if (found) return found;
}
}
return null;
};
const findParentModule = (menus, currentPath) => {
for (const menu of menus) {
if (!menu.path || menu.path === "/home") {
if (menu.children && menu.children.length > 0) {
const found = findParentModule(menu.children, currentPath);
if (found) return found;
}
continue;
}
if (currentPath === menu.path || currentPath.startsWith(menu.path + "/")) {
return menu;
}
if (menu.children && menu.children.length > 0) {
for (const child of menu.children) {
if (!child.path) continue;
if (
currentPath === child.path ||
currentPath.startsWith(child.path + "/")
) {
return menu;
}
if (child.children && child.children.length > 0) {
for (const grandchild of child.children) {
if (!grandchild.path) continue;
if (
currentPath === grandchild.path ||
currentPath.startsWith(grandchild.path + "/")
) {
return menu;
}
}
}
}
}
}
return null;
};
const findCurrentMenu = findParentModule;
const currentModule = computed(() => {
const path = route.path;
if (path === "/home") {
currentModuleId.value = null;
return null;
}
const menu = findCurrentMenu(list.value, path);
if (menu) {
currentModuleId.value = menu.id;
}
return menu;
});
const displayMenus = computed(() => {
if (!currentModule.value) {
return [];
}
return currentModule.value.children || [];
});
const processMenus = (menus) => {
return menus
.filter((menu) => {
if (menu.path && menu.path.trim() !== "") return true;
if (menu.children && menu.children.length > 0) return true;
return false;
})
.map((menu) => ({
id: menu.id,
path: menu.path,
icon: menu.icon || "Document",
title: menu.title,
route: menu.path,
component_path: menu.component_path,
parentId: menu.pid || 0,
order: menu.sort || 0,
children: menu.children ? processMenus(menu.children) : [],
}));
};
const list = computed(() => {
const menuData = menuStore.menus;
if (!menuData || menuData.length === 0) {
return [];
}
const allMenus = processMenus(menuData);
const sortMenusRecursively = (menus) => {
menus.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;
});
sortMenusRecursively(menu.children);
}
});
};
sortMenusRecursively(allMenus);
return allMenus;
});
const handleMenuSelect = (index) => {
if (index === "/home") {
emit("menu-click", {
path: "/home",
title: "首页",
icon: "fa-solid fa-house",
component_path: "/home",
});
return;
}
const menuItem = findMenuItem(list.value, index);
if (menuItem) {
emit("menu-click", menuItem);
}
};
const fetchMenus = async () => {
try {
await menuStore.fetchMenus();
} catch (error) {}
};
const handleMenuRefresh = () => {
fetchMenus();
};
watch(
() => route.path,
() => {
findCurrentMenu(list.value, route.path);
},
{ immediate: true },
);
onMounted(() => {
if (!menuStore.menus || menuStore.menus.length === 0) {
setTimeout(() => {
fetchMenus();
}, 100);
}
window.addEventListener("menu-cache-refreshed", handleMenuRefresh);
});
onUnmounted(() => {
window.removeEventListener("menu-cache-refreshed", handleMenuRefresh);
});
</script>
<style scoped lang="less">
.common-aside {
height: 100%;
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s ease;
overflow: hidden;
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
html:not(.dark) & {
background: #3973ff;
box-shadow: 2px 0 12px rgba(6, 45, 163, 0.3);
}
html.dark & {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
}
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
i {
font-size: 28px;
color: rgba(255, 255, 255, 0.8);
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
h3 {
line-height: 60px;
text-align: center;
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
margin: 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
position: relative;
}
// 菜单样式
:deep(.el-menu) {
border-right: none;
height: calc(100% - 80px);
padding: 16px 8px;
background: transparent;
.el-menu-item,
.el-sub-menu__title {
color: rgba(255, 255, 255, 0.85);
transition: all 0.3s ease;
border-radius: 8px;
margin: 2px 0;
font-size: 14px;
font-weight: 500;
position: relative;
html:not(.dark) & {
color: rgba(255, 255, 255, 0.85);
}
html.dark & {
color: var(--el-text-color-primary);
}
.menu-icon {
margin-right: 8px;
font-size: 16px;
}
}
// 高亮样式
.el-menu-item.is-active {
html:not(.dark) & {
background-color: rgba(57, 115, 255, 0.3) !important;
}
html.dark & {
background-color: rgba(60, 60, 60, 0.8) !important;
}
color: #ffffff !important;
border-left: 3px solid #4f84ff;
margin-left: -3px;
.menu-icon {
color: #fff;
}
}
// 悬浮样式
.el-menu-item:hover:not(.is-active),
.el-sub-menu__title:hover {
html:not(.dark) & {
background-color: rgba(255, 255, 255, 0.1) !important;
}
html.dark & {
background-color: rgba(60, 60, 60, 0.8) !important;
}
color: #ffffff !important;
}
// 子菜单样式
.el-sub-menu {
.el-sub-menu__title {
position: relative;
}
&.is-opened .el-sub-menu__title {
background: rgba(255, 255, 255, 0.08) !important;
}
.el-menu-item {
padding-left: 48px !important;
font-size: 13px;
}
}
// 暗色主题适配
html.dark & {
.el-menu-item.is-active {
background: rgba(219, 148, 148, 0.8) !important;
color: var(--el-color-primary-light-3) !important;
border-left-color: var(--el-color-primary);
.menu-icon {
color: var(--el-color-primary);
}
}
.el-menu-item:hover:not(.is-active),
.el-sub-menu__title:hover {
background: rgba(255, 255, 255, 0.08) !important;
color: var(--el-color-primary-light-3) !important;
}
.el-sub-menu.is-opened .el-sub-menu__title {
background: rgba(64, 158, 255, 0.08) !important;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.common-aside {
width: 100% !important;
}
:deep(.el-menu) {
padding: 12px 4px;
}
}
</style>