feat: 架构调整

This commit is contained in:
2026-07-23 11:27:33 +08:00
parent 6f37264047
commit 3b5a908ee4
25 changed files with 653 additions and 245 deletions
+209
View File
@@ -0,0 +1,209 @@
import { reactive, computed, toRefs } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import type { MenuNode, MenuItemRaw } from '@/types';
import router, { resetRouter } from '@/router';
import { fetchMenuTree } from '@/api/menu';
import { pageModules } from '@/router/glob';
/**
* 使用 import.meta.glob 动态收集 src/pages/ 下所有页面组件
* (实际 glob 逻辑在 @/router/glob.ts 中,因为 TS 不支持 glob 语法)
*
* 后端下发 component 字段(如 'dashboard')时,
* 前端自动匹配到 '/src/pages/dashboard/index.tsx' 的懒加载函数。
*
* 扩展新页面时只需在 src/pages/ 下创建目录和 index.tsx,
* 不需要修改任何映射表。
*/
/**
* 将后端的 component 字段解析为 vite 动态 import 函数
* 'dashboard' → 匹配 '/src/pages/dashboard/index.tsx'
* 'events/list' → 匹配 '/src/pages/events/list/index.tsx'
*/
function resolveComponent(component?: string): RouteRecordRaw['component'] | undefined {
if (!component) return undefined;
// 构建 glob key
const globKey = `/src/pages/${component}/index.tsx`;
// 直接匹配
if (pageModules[globKey]) {
return pageModules[globKey];
}
// 兜底:尝试带 @ 前缀匹配(Vite alias)
const aliasKey = `@/pages/${component}/index.tsx`;
if (pageModules[aliasKey]) {
return pageModules[aliasKey];
}
console.warn(`[路由] 未找到页面组件: ${component} (globKey: ${globKey})`);
return undefined;
}
interface MenuState {
/** 后端下发的原始菜单树 */
menuTree: MenuNode[];
/** 转换后的 antd Menu items */
menuItems: MenuItemRaw[];
/** 是否已加载(登录后只加载一次) */
loaded: boolean;
/** 首页路径(第一个可见菜单的 path) */
homePath: string;
}
const state = reactive<MenuState>({
menuTree: [],
menuItems: [],
loaded: false,
homePath: '/dashboard',
});
/**
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
*/
function transformMenuNode(nodes: MenuNode[]): MenuItemRaw[] {
return nodes
.filter((node) => !node.hideInMenu && !node.externalLink)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.map((node) => {
const item: MenuItemRaw = {
key: node.path,
label: node.name,
iconName: node.icon,
};
if (node.children?.length) {
item.children = transformMenuNode(node.children);
}
return item;
});
}
/**
* 将后端 MenuNode 递归转为 vue-router RouteRecordRaw
* component 字段通过 resolveComponent() 动态解析
*/
function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
for (const node of nodes) {
// 外链节点不注册路由
if (node.externalLink) continue;
const componentLoader = resolveComponent(node.component);
const route: RouteRecordRaw = {
path: node.path,
name: node.name,
meta: {
title: node.name,
icon: node.icon,
hideInMenu: node.hideInMenu,
activeMenu: node.activeMenu,
externalLink: node.externalLink,
},
} as RouteRecordRaw;
if (componentLoader) {
route.component = componentLoader;
}
if (node.children?.length) {
route.children = transformMenuToRoutes(node.children);
// 没有组件的父节点自动重定向到第一个子节点
if (!componentLoader && node.children[0]) {
route.redirect = node.children[0].path;
}
}
routes.push(route);
}
return routes;
}
/**
* 获取第一个可见菜单的路径(用作默认首页)
*/
function getFirstMenuPath(nodes: MenuNode[]): string {
for (const node of nodes) {
if (!node.hideInMenu && !node.externalLink && !node.children?.length) {
return node.path;
}
if (node.children?.length) {
const childPath = getFirstMenuPath(node.children);
if (childPath) return childPath;
}
}
return '/dashboard';
}
export function useMenuStore() {
const loadMenu = async () => {
if (state.loaded) return;
try {
const menuTree = await fetchMenuTree();
state.menuTree = menuTree;
state.menuItems = transformMenuNode(menuTree);
state.homePath = getFirstMenuPath(menuTree);
// 动态注册路由:将后端路由配置注入到 BasicLayout 布局下
const dynamicRoutes = transformMenuToRoutes(menuTree);
dynamicRoutes.forEach((route) => {
router.addRoute('BasicLayout', route);
});
// 默认重定向到首页(BasicLayout 的根路径)
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
state.loaded = true;
} catch (err) {
console.error('加载菜单失败:', err);
// 降级:使用兜底菜单
state.menuItems = [
{ key: '/dashboard', label: '工作台', iconName: 'DashboardOutlined' },
{ key: '/about', label: '关于', iconName: 'InfoCircleOutlined' },
];
state.homePath = '/dashboard';
// 兜底路由(挂载到 BasicLayout 下)
router.addRoute('BasicLayout', {
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/pages/dashboard'),
meta: { title: '工作台', icon: 'DashboardOutlined' },
});
router.addRoute('BasicLayout', {
path: '/about',
name: 'About',
component: () => import('@/pages/about'),
meta: { title: '关于', icon: 'InfoCircleOutlined' },
});
router.addRoute('BasicLayout', { path: '', redirect: '/dashboard' });
state.loaded = true;
}
};
const clearMenu = () => {
resetRouter();
state.menuTree = [];
state.menuItems = [];
state.loaded = false;
state.homePath = '/dashboard';
};
return {
...toRefs(state),
menuTree: computed(() => state.menuTree),
menuItems: computed(() => state.menuItems),
loaded: computed(() => state.loaded),
homePath: computed(() => state.homePath),
loadMenu,
clearMenu,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { reactive, computed, toRefs } from 'vue';
import type { PermissionCode } from '@/types';
import { fetchPermissions } from '@/api/menu';
interface PermissionState {
/** 权限编码集合 */
codes: Set<PermissionCode>;
/** 是否已加载 */
loaded: boolean;
}
const state = reactive<PermissionState>({
codes: new Set(),
loaded: false,
});
export function usePermissionStore() {
/**
* 从后端加载权限编码
*/
const loadPermissions = async () => {
if (state.loaded) return;
try {
const codes = await fetchPermissions();
state.codes = new Set(codes);
state.loaded = true;
} catch (err) {
console.error('加载权限失败:', err);
state.codes = new Set();
state.loaded = true;
}
};
/**
* 清除权限(退出登录时调用)
*/
const clearPermissions = () => {
state.codes = new Set();
state.loaded = false;
};
/**
* 判断是否拥有某个权限
* @param code 权限编码,如 'user:delete'
*/
const hasPermission = (code: PermissionCode): boolean => {
return state.codes.has(code);
};
/**
* 判断是否拥有任意一个权限
*/
const hasAnyPermission = (codes: PermissionCode[]): boolean => {
return codes.some((code) => state.codes.has(code));
};
return {
...toRefs(state),
loaded: computed(() => state.loaded),
loadPermissions,
clearPermissions,
hasPermission,
hasAnyPermission,
};
}