backend/src/components/CommonAside.vue
2026-06-15 23:32:34 +08:00

537 lines
14 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-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 ? "管理" : asideTitle }}</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 [];
}
const currentPath = route.path;
// 访问“父级 index 页面”时不展开子菜单;只有进入“子页面”后才展示 children。
// 例如:/apps/cms/products父级时不展示其 children如 /apps/cms/products/types
if (currentPath === currentModule.value.path) {
return [];
}
return currentModule.value.children || [];
});
const asideTitle = computed(() => {
if (isCollapse.value) return "管理";
if (!currentModule.value) return "子菜单";
return displayMenus.value.length > 0 ? (currentModule.value.title || "子菜单") : "子菜单";
});
const processMenus = (menus) => {
return menus
.filter((menu) => {
// is_visible 控制“侧边栏是否展示”,不参与动态路由与缓存层面的过滤
if (menu.is_visible !== undefined && Number(menu.is_visible) === 0) {
return false;
}
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>