platform-vue/src/components/CommonAside.vue
2026-05-05 16:56:12 +08:00

680 lines
18 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', { '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>