Files
yunzerwebsiteallinone/backend/src/components/CommonAside.vue
T

640 lines
17 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 && list.length === 0" 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 fixedCmsMenu = {
id: -200,
path: "/apps/cms",
title: "内容管理",
icon: "Document",
order: -200,
children: [
{
id: -2001,
path: "/apps/cms/analytics/content",
title: "内容统计",
icon: "DataAnalysis",
order: 0,
children: []
},
{
id: -201,
path: "/apps/cms/article",
title: "文章管理",
icon: "Document",
order: 1,
children: []
},
{
id: -202,
path: "/apps/cms/article/type",
title: "文章分类",
icon: "Folder",
order: 2,
children: []
},
{
id: -203,
path: "/apps/cms/products",
title: "产品管理",
icon: "Goods",
order: 3,
children: []
},
{
id: -204,
path: "/apps/cms/products/types",
title: "产品分类",
icon: "Folder",
order: 4,
children: []
},
{
id: -205,
path: "/apps/cms/solutions",
title: "解决方案管理",
icon: "Promotion",
order: 5,
children: []
},
{
id: -206,
path: "/apps/cms/solutions/types",
title: "解决方案分类",
icon: "Folder",
order: 6,
children: []
}
]
};
const mergeFixedCmsMenu = (menus) => {
const hasCmsMenu = menus.some((menu) => menu.path === fixedCmsMenu.path);
return hasCmsMenu ? menus : [fixedCmsMenu, ...menus];
};
const list = computed(() => {
const menuData = menuStore.menus || [];
const allMenus = processMenus(mergeFixedCmsMenu(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);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 8px;
background: transparent;
// 自定义滚动条样式
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
.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;
background: transparent;
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;
}
// 嵌套展开的子菜单容器保持透明,避免 Element Plus 注入的
// --el-menu-bg-color 在二级及以下区域形成不透明背景块;
// 二级 ul 不需要上下内边距
.el-menu--inline {
background: transparent !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
// 子菜单样式
.el-sub-menu {
.el-sub-menu__title {
position: relative;
}
// 展开的父级菜单标题不加背景,背景只留给当前激活的菜单项
.el-menu-item {
font-size: 13px;
}
}
// 二级菜单项缩进 24px
.el-menu--inline > .el-menu-item,
.el-menu--inline > .el-sub-menu > .el-sub-menu__title {
padding-left: 24px !important;
}
// 三级菜单项缩进 48px
.el-menu--inline .el-menu--inline > .el-menu-item,
.el-menu--inline .el-menu--inline > .el-sub-menu > .el-sub-menu__title {
padding-left: 48px !important;
}
// 暗色主题适配
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;
}
}
}
// 响应式设计
// 注意:手机端(≤768px)侧栏的抽屉式布局由 Main.vue 统一控制(fixed 定位 + transform 动画),
// 此处不要再对 .common-aside 设置 width,避免覆盖父级布局逻辑。
@media (max-width: 768px) {
:deep(.el-menu) {
padding: 12px 4px;
}
}
</style>