feat: 架构调整
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user