first commit
This commit is contained in:
@@ -0,0 +1,679 @@
|
||||
<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>
|
||||
@@ -0,0 +1,568 @@
|
||||
<template>
|
||||
<div class="header">
|
||||
<div class="l-content">
|
||||
<el-button v-if="showTopToggle" size="small" @click="handleCollapse">
|
||||
<el-icon><Expand /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="r-content">
|
||||
<!-- 返回首页按钮 -->
|
||||
<el-tooltip content="返回首页" placement="top">
|
||||
<el-button circle @click="goHome" class="home-btn" title="返回首页">
|
||||
<el-icon>
|
||||
<HomeFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 更新缓存按钮 -->
|
||||
<el-button circle :icon="Refresh" @click="refreshCache" class="refresh-cache-btn" :loading="cacheLoading"
|
||||
title="更新菜单缓存" />
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<el-button circle :icon="themeIcon" @click="toggleTheme" class="theme-toggle-btn"
|
||||
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" />
|
||||
|
||||
<!-- 消息中心 -->
|
||||
<el-dropdown trigger="click">
|
||||
<span class="el-dropdown-link" style="cursor: pointer;">
|
||||
<el-button circle class="message-btn" title="消息中心">
|
||||
<el-icon>
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="message-menu" style="width: 260px;">
|
||||
<el-dropdown-item disabled>暂无新消息</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="el-dropdown-link" style="cursor: pointer;">
|
||||
<img :src="getImageUrl('user')" class="user" />
|
||||
<span class="user-name">{{ displayName }}</span>
|
||||
<el-tag v-if="roleLabel" size="small" effect="plain" class="user-role-tag">{{ roleLabel }}</el-tag>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon>
|
||||
<User />
|
||||
</el-icon>
|
||||
<span>个人中心</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon>
|
||||
<SwitchButton />
|
||||
</el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-center">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
|
||||
const emit = defineEmits(['collapse']);
|
||||
import { useAllDataStore, useMenuStore, useTabsStore } from "@/stores";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { logout, getCurrentUser } from "@/api/login";
|
||||
import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Expand } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
interface Menu {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
parentId: number;
|
||||
}
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
const tabsStore = useTabsStore();
|
||||
const cacheLoading = ref(false);
|
||||
// 使用 store 中的菜单数据
|
||||
const menuList = computed(() => menuStore.menus);
|
||||
|
||||
// 加载菜单(从 store)
|
||||
async function loadMenu() {
|
||||
await menuStore.fetchMenus();
|
||||
}
|
||||
|
||||
// 更新缓存(手动刷新)
|
||||
async function refreshCache() {
|
||||
cacheLoading.value = true;
|
||||
try {
|
||||
await menuStore.refreshMenus();
|
||||
|
||||
// 重新加载动态路由
|
||||
const { loadAndAddDynamicRoutes, resetDynamicRoutes } = await import('@/router/index');
|
||||
// 重置路由加载状态,强制重新加载
|
||||
resetDynamicRoutes();
|
||||
await loadAndAddDynamicRoutes();
|
||||
|
||||
// 等待路由完全加载(给Vue Router一些时间更新路由表)
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// 触发菜单刷新事件,通知CommonAside组件刷新菜单
|
||||
window.dispatchEvent(new CustomEvent('menu-cache-refreshed'));
|
||||
|
||||
ElMessage.success('更新成功');
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh cache', error);
|
||||
ElMessage.error('更新缓存失败,请检查网络连接');
|
||||
} finally {
|
||||
cacheLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMenu();
|
||||
if (!authStore.token) return;
|
||||
try {
|
||||
const res = await getCurrentUser();
|
||||
if (res && res.code === 200 && res.data) {
|
||||
authStore.updateUserInfo({ ...authStore.user, ...res.data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("getCurrentUser failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 根据菜单列表和当前路径计算出的面包屑导航
|
||||
const breadcrumbs = computed(() => {
|
||||
let chain: Breadcrumb[] = [];
|
||||
let currentPath = route.path || '/';
|
||||
if (currentPath === '/' || currentPath === '') {
|
||||
return [{ label: '仪表盘', path: '/' }];
|
||||
}
|
||||
let current = menuList.value.find(m => m.path === currentPath);
|
||||
if (!current) {
|
||||
const candidates = menuList.value.filter(m => currentPath.startsWith(m.path));
|
||||
current = candidates.sort((a, b) => b.path.length - a.path.length)[0];
|
||||
}
|
||||
if (!current) return [];
|
||||
chain.push({ label: current.name || 'Unknown', path: current.path });
|
||||
let parentId = current.parentId;
|
||||
while (parentId > 0) {
|
||||
let parent = menuList.value.find(m => m.id === parentId);
|
||||
if (parent && !chain.some(c => c.label === parent.name)) {
|
||||
chain.push({ label: parent.name || 'Unknown', path: parent.path });
|
||||
parentId = parent.parentId;
|
||||
} else break;
|
||||
}
|
||||
chain = chain.reverse();
|
||||
return chain;
|
||||
});
|
||||
|
||||
const store = useAllDataStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const getImageUrl = (user) => {
|
||||
return new URL(`/src/assets/images/default_avatar.png`, import.meta.url).href;
|
||||
};
|
||||
|
||||
// 计算显示名称:优先显示昵称(用户)或姓名(员工),否则显示用户名
|
||||
const displayName = computed(() => {
|
||||
const user = authStore.user;
|
||||
if (!user) return '';
|
||||
|
||||
// 如果是用户登录,优先显示name
|
||||
if (user.name) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// 最后显示account
|
||||
return user.account || '';
|
||||
});
|
||||
|
||||
/** 角色展示名(来自 yz_admin_role.name) */
|
||||
const roleLabel = computed(() => {
|
||||
const n = authStore.user?.role_name;
|
||||
return typeof n === "string" && n.trim() ? n.trim() : "";
|
||||
});
|
||||
|
||||
const handleCollapse = () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
emit('collapse');
|
||||
} else {
|
||||
store.state.isCollapse = !store.state.isCollapse;
|
||||
}
|
||||
};
|
||||
|
||||
const showTopToggle = computed(() => store.state.isCollapse);
|
||||
|
||||
const goHome = () => {
|
||||
tabsStore.closeAll();
|
||||
router.push('/home');
|
||||
};
|
||||
|
||||
const handleCommand = async (command) => {
|
||||
if (command === 'profile') {
|
||||
router.push('/user/userProfile');
|
||||
} else if (command === 'logout') {
|
||||
try {
|
||||
// 从 localStorage 获取用户信息,传递给后端
|
||||
const userInfo = authStore.user;
|
||||
// 先调用后端退出登录接口(记录日志)
|
||||
await logout(userInfo);
|
||||
} catch (error) {
|
||||
// 即使后端接口失败,也继续执行前端退出逻辑
|
||||
console.error('退出登录接口调用失败:', error);
|
||||
}
|
||||
|
||||
// 清除前端存储
|
||||
authStore.clearToken();
|
||||
// 清除缓存中的user数据
|
||||
localStorage.removeItem('user');
|
||||
sessionStorage.removeItem('user');
|
||||
//清除租户数据
|
||||
localStorage.removeItem('tenant');
|
||||
sessionStorage.removeItem('tenant');
|
||||
//清除tabs_list缓存
|
||||
localStorage.removeItem('tabs_list');
|
||||
localStorage.removeItem('active_tab');
|
||||
sessionStorage.removeItem('tabs_list');
|
||||
|
||||
localStorage.removeItem('tabs_list');
|
||||
// 清除菜单缓存
|
||||
menuStore.resetMenus();
|
||||
|
||||
// 清除所有以 menu_cache_ 开头的本地存储项
|
||||
const menuCacheKeys: string[] = [];
|
||||
// 遍历 localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('menu_')) {
|
||||
menuCacheKeys.push(key);
|
||||
}
|
||||
}
|
||||
// 删除 localStorage 中的 menu_cache_ 项
|
||||
menuCacheKeys.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// 遍历 sessionStorage
|
||||
const sessionMenuCacheKeys: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && key.startsWith('menu_')) {
|
||||
sessionMenuCacheKeys.push(key);
|
||||
}
|
||||
}
|
||||
// 删除 sessionStorage 中的 menu_cache_ 项
|
||||
sessionMenuCacheKeys.forEach(key => {
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// 重置 tabs store 状态
|
||||
const { useTabsStore } = await import('@/stores');
|
||||
const tabsStore = useTabsStore();
|
||||
tabsStore.resetTabs();
|
||||
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
// Element Plus 主题切换
|
||||
const THEME_STORAGE_KEY = 'element-plus-theme';
|
||||
const isDark = ref(false);
|
||||
|
||||
// 初始化主题:从 localStorage 读取或检测系统偏好
|
||||
const initTheme = () => {
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (savedTheme) {
|
||||
isDark.value = savedTheme === 'dark';
|
||||
} else {
|
||||
// 检测系统偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
isDark.value = prefersDark;
|
||||
}
|
||||
applyTheme();
|
||||
};
|
||||
|
||||
// 应用主题:在 html 元素上添加/移除 dark 类
|
||||
const applyTheme = () => {
|
||||
const htmlElement = document.documentElement;
|
||||
if (isDark.value) {
|
||||
htmlElement.classList.add('dark');
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'dark');
|
||||
} else {
|
||||
htmlElement.classList.remove('dark');
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'light');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value;
|
||||
applyTheme();
|
||||
};
|
||||
|
||||
// 计算当前主题
|
||||
const currentTheme = computed(() => isDark.value ? 'dark' : 'light');
|
||||
|
||||
// 计算主题图标
|
||||
const themeIcon = computed(() => isDark.value ? Sunny : Moon);
|
||||
|
||||
// 组件挂载时初始化主题
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let handleChange: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
initTheme();
|
||||
|
||||
// 监听系统主题变化
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
handleChange = (e: MediaQueryListEvent) => {
|
||||
// 如果用户没有手动设置主题,则跟随系统
|
||||
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
|
||||
isDark.value = e.matches;
|
||||
applyTheme();
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery && handleChange) {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 40px;
|
||||
// 使用 Element Plus 的背景色变量,暗黑模式下自动适配
|
||||
background-color: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
// 亮色主题下使用 #3973ff 背景
|
||||
html:not(.dark) & {
|
||||
background-color: #3973ff;
|
||||
border-bottom-color: rgba(79, 132, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.icons {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.l-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.el-button {
|
||||
margin-right: 20px;
|
||||
// 使用 Element Plus 的填充色变量
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
border-color: var(--el-border-color-dark);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.r-content {
|
||||
position: relative;
|
||||
// z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.refresh-cache-btn,
|
||||
.home-btn,
|
||||
.theme-toggle-btn {
|
||||
// 使用 Element Plus 的填充色变量
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
color: var(--el-text-color-primary);
|
||||
margin-left: 0 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
border-color: var(--el-border-color-dark);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
// 使用 Element Plus 的边框颜色变量
|
||||
// border: 2px solid var(--el-border-color);
|
||||
transition: border-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
// 亮色主题下使用白色
|
||||
html:not(.dark) & {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.user-role-tag {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html:not(.dark) & .user-role-tag {
|
||||
color: #ffffff;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.l-content .el-button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.r-content {
|
||||
gap: 6px;
|
||||
|
||||
.refresh-cache-btn,
|
||||
.home-btn,
|
||||
.theme-toggle-btn,
|
||||
.message-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
gap: 6px;
|
||||
|
||||
.user {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.user-name,
|
||||
.user-role-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉菜单样式 - 使用全局样式覆盖
|
||||
:deep(.el-dropdown) {
|
||||
.el-dropdown__popper {
|
||||
.el-dropdown-menu {
|
||||
background-color: var(--bg-color-overlay) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
padding: 4px 0;
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-color-primary) !important;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
span {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
&:not(.is-disabled):hover {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
&.is-divided {
|
||||
border-top-color: var(--border-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.bread) {
|
||||
|
||||
// 面包屑使用白色
|
||||
.el-breadcrumb__inner {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-breadcrumb__inner.is-link {
|
||||
color: #ffffff !important;
|
||||
cursor: pointer !important;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav) {
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user