first commit

This commit is contained in:
2026-01-26 09:32:17 +08:00
commit 9429a700d0
2928 changed files with 30330 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
<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 ? '管理' : '管理后台' }}</h3>
<!-- 固定仪表盘 -->
<el-menu-item index="/dashboard">
<i class="fa-solid fa-gauge"></i>
<template #title>仪表盘</template>
</el-menu-item>
<!-- 动态菜单项 -->
<template v-for="item in list" :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 } 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('#409EFF');
const activeBgColor = ref('#409EFF');
const list = computed(() => {
const menuData = menuStore.menus;
if (!menuData || menuData.length === 0) {
return [];
}
const processMenus = (menus) => {
return menus
.filter((menu) => {
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 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 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 handleMenuSelect = (index) => {
if (index === "/dashboard") {
emit("menu-click", {
path: "/dashboard",
title: "仪表盘",
icon: "fa-solid fa-gauge",
component_path: "/dashboard"
});
return;
}
const menuItem = findMenuItem(list.value, index);
if (menuItem) {
emit("menu-click", menuItem);
}
};
const fetchMenus = async () => {
try {
await menuStore.fetchMenus();
} catch (error) {
// 错误已在 menuStore 中处理
}
};
const handleMenuRefresh = () => {
fetchMenus();
};
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: linear-gradient(135deg, #062da3 0%, #0a4a8a 100%);
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 {
background-color: rgba(79, 132, 255, 0.2) !important;
color: #ffffff !important;
border-left: 3px solid #4f84ff;
margin-left: -3px;
.menu-icon {
color: #4f84ff;
}
}
// 悬浮样式
.el-menu-item:hover:not(.is-active),
.el-sub-menu__title:hover {
background-color: rgba(255, 255, 255, 0.1) !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(64, 158, 255, 0.1) !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>
+481
View File
@@ -0,0 +1,481 @@
<template>
<div class="header">
<div class="l-content">
<el-button size="small" @click="handleCollapse">
<i class="fa fa-bars"></i>
</el-button>
<!-- <el-breadcrumb separator="/" class="bread">
<el-breadcrumb-item :to="{ path: '/' }"
style="position: relative !important; top: 1px !important;">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index"
:to="(item.label === '首页' || index === breadcrumbs.length - 1) ? { path: item.path } : null"
style="position: relative !important; top: 1px !important;">
{{ item.label }}
</el-breadcrumb-item>
</el-breadcrumb> -->
</div>
<div class="r-content">
<!-- 更新缓存按钮 -->
<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>
</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";
import { useAllDataStore, useMenuStore } from "@/stores";
import { useAuthStore } from "@/stores/auth";
import { logout } from "@/api/login";
import { User, SwitchButton, Sunny, Moon, Refresh, Bell } 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 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(loadMenu);
// 根据菜单列表和当前路径计算出的面包屑导航
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 || '';
});
const handleCollapse = () => {
store.state.isCollapse = !store.state.isCollapse;
};
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');
// 清除菜单缓存
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_cache_')) {
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_cache_')) {
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;
// 亮色主题下使用 #062da3 背景
html:not(.dark) & {
background-color: #062da3;
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,
.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;
}
}
}
}
// 下拉菜单样式 - 使用全局样式覆盖
: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>