680 lines
18 KiB
Vue
680 lines
18 KiB
Vue
<template>
|
||
<el-aside :width="width" :class="['common-aside', { 'mobile-open': isMobile && !isCollapse }]">
|
||
<!-- 加载状态 -->
|
||
<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 }}
|
||
<span class="mobile-close-btn" @click="closeMobile">✕</span>
|
||
</h3>
|
||
|
||
<!-- 无模块时显示提示(在首页 /home 时不显示,避免重复) -->
|
||
<el-menu-item v-if="!currentModule && route.path !== '/home'" 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>
|
||
|
||
<div v-if="!loading && !hasError && !isCollapse" class="aside-toggle-bottom">
|
||
<el-button class="aside-toggle-btn" size="small" @click="handleCollapse">
|
||
<el-icon><Fold /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</el-aside>
|
||
|
||
<teleport to="body">
|
||
<div v-if="mobileOpen" class="aside-mobile-overlay" @click="closeMobile" />
|
||
</teleport>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||
import { useRouter, useRoute } from "vue-router";
|
||
import { Document, Warning, Fold } from "@element-plus/icons-vue";
|
||
import { useAllDataStore, useMenuStore } from "@/stores";
|
||
|
||
const emit = defineEmits(["menu-click"]);
|
||
|
||
const toggleMobile = () => {
|
||
store.state.isCollapse = !store.state.isCollapse;
|
||
};
|
||
|
||
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 isMobile = ref(false);
|
||
const mobileOpen = computed(() => isMobile.value && !isCollapse.value);
|
||
|
||
const currentModuleId = ref(null);
|
||
|
||
const closeMobile = () => {
|
||
if (isMobile.value) {
|
||
store.state.isCollapse = true;
|
||
}
|
||
};
|
||
|
||
defineExpose({
|
||
toggleMobile,
|
||
closeMobile,
|
||
});
|
||
|
||
const updateDeviceType = () => {
|
||
isMobile.value = window.innerWidth <= 768;
|
||
// 手机端默认收起侧边栏
|
||
if (isMobile.value) {
|
||
store.state.isCollapse = true;
|
||
}
|
||
};
|
||
|
||
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(() => {
|
||
// 侧边栏始终展示完整菜单树,不随当前路由切换为“子菜单视图”
|
||
return list.value;
|
||
});
|
||
|
||
const asideTitle = computed(() => {
|
||
if (isCollapse.value) return "管理";
|
||
return "菜单";
|
||
});
|
||
|
||
const processMenus = (menus) => {
|
||
return menus
|
||
.filter((menu) => {
|
||
// isPlatform 控制“平台端是否展示”,0 表示不在平台端显示
|
||
if (menu.isPlatform !== undefined && Number(menu.isPlatform) === 0) {
|
||
return false;
|
||
}
|
||
// 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.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;
|
||
});
|
||
|
||
// 再递归对子级排序
|
||
menus.forEach((menu) => {
|
||
if (menu.children && menu.children.length > 0) {
|
||
sortMenusRecursively(menu.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
sortMenusRecursively(allMenus);
|
||
|
||
return allMenus;
|
||
});
|
||
|
||
const handleMenuSelect = (index) => {
|
||
// 移动端点击菜单后关闭侧边栏
|
||
closeMobile();
|
||
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);
|
||
if (isMobile.value) {
|
||
store.state.isCollapse = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
const fetchMenus = async () => {
|
||
try {
|
||
await menuStore.fetchMenus();
|
||
} catch (error) {}
|
||
};
|
||
|
||
const handleCollapse = () => {
|
||
toggleMobile();
|
||
};
|
||
|
||
const handleMenuRefresh = () => {
|
||
fetchMenus();
|
||
};
|
||
|
||
watch(
|
||
() => route.path,
|
||
() => {
|
||
findCurrentMenu(list.value, route.path);
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
onMounted(() => {
|
||
updateDeviceType();
|
||
window.addEventListener("resize", updateDeviceType);
|
||
|
||
if (!menuStore.menus || menuStore.menus.length === 0) {
|
||
setTimeout(() => {
|
||
fetchMenus();
|
||
}, 100);
|
||
}
|
||
|
||
window.addEventListener("menu-cache-refreshed", handleMenuRefresh);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener("resize", updateDeviceType);
|
||
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);
|
||
}
|
||
}
|
||
|
||
.common-aside.mobile-open {
|
||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35), 2px 0 12px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.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;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.mobile-close-btn {
|
||
display: none;
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 28px;
|
||
height: 28px;
|
||
line-height: 28px;
|
||
text-align: center;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
background: rgba(255, 255, 255, 0.15);
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.mobile-close-btn {
|
||
display: block;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 菜单样式
|
||
:deep(.el-menu) {
|
||
border-right: none;
|
||
height: calc(100% - 128px);
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
.aside-toggle-bottom {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 12px 8px 14px;
|
||
background: linear-gradient(to top, rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0));
|
||
}
|
||
|
||
.aside-toggle-btn {
|
||
width: 100%;
|
||
max-width: 180px;
|
||
background-color: rgba(255, 255, 255, 0.18);
|
||
border-color: rgba(255, 255, 255, 0.3);
|
||
color: #fff;
|
||
}
|
||
|
||
.aside-toggle-btn:hover {
|
||
background-color: rgba(255, 255, 255, 0.28);
|
||
border-color: rgba(255, 255, 255, 0.45);
|
||
color: #fff;
|
||
}
|
||
|
||
// 响应式设计
|
||
@media (max-width: 768px) {
|
||
.common-aside {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
bottom: 0;
|
||
width: 240px !important;
|
||
max-width: 80vw;
|
||
z-index: 1000;
|
||
transform: translateX(-100%);
|
||
transition:
|
||
transform 0.3s ease,
|
||
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||
background-color 0.3s ease;
|
||
}
|
||
|
||
.common-aside.mobile-open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
:deep(.el-menu) {
|
||
padding: 12px 4px;
|
||
}
|
||
|
||
.aside-toggle-bottom {
|
||
padding: 10px 8px 12px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
.aside-mobile-overlay {
|
||
display: none;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.aside-mobile-overlay {
|
||
display: block;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 999;
|
||
}
|
||
}
|
||
</style>
|