feat: 优化路由 搭建赛事管理页面基础 处理部分样式问题 完成赛事列表页面搭建

This commit is contained in:
ZhuRui
2026-07-24 15:57:20 +08:00
parent 5523695518
commit 03f1b6c220
35 changed files with 6599 additions and 117 deletions
+107 -73
View File
@@ -4,6 +4,7 @@ import type { MenuNode, MenuItemRaw } from '@/types';
import router, { resetRouter } from '@/router';
import { fetchMenuTree } from '@/api/menu';
import { pageModules } from '@/router/glob';
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
/**
* 使用 import.meta.glob 动态收集 src/pages/ 下所有页面组件
@@ -24,15 +25,11 @@ import { pageModules } from '@/router/glob';
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];
@@ -60,43 +57,94 @@ const state = reactive<MenuState>({
homePath: '/dashboard',
});
// ============================================================
// 辅助函数
// ============================================================
/**
* 获取节点的可用子节点(排除 disabled 和 externalLink
*/
function getEnabledChildren(node: MenuNode): MenuNode[] {
if (!node.children?.length) return [];
return node.children.filter((child) => !child.disabled && !child.externalLink);
}
/**
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
*
* 规则:
* - 过滤 disabled / hideInMenu / externalLink 节点
* - 父节点有 enabledChildren 时递归转换;若所有子节点都被过滤且父节点无 component,则父节点也不显示
* - key 统一加 '/' 前缀,与 route.path 匹配(如 '/dashboard'、'/events/list'
*/
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,
};
const result: MenuItemRaw[] = [];
if (node.children?.length) {
item.children = transformMenuNode(node.children);
for (const node of nodes) {
if (node.disabled || node.hideInMenu || node.externalLink) continue;
const enabledChildren = getEnabledChildren(node);
const item: MenuItemRaw = {
key: '/' + node.path,
label: node.name,
iconName: node.icon,
};
if (enabledChildren.length > 0) {
const childItems = transformMenuNode(enabledChildren);
if (childItems.length > 0) {
item.children = childItems;
}
// 子节点全部被过滤 → 父节点也不显示
else {
continue;
}
}
return item;
});
result.push(item);
}
// 排序
result.sort((a, b) => {
const aNode = nodes.find((n) => '/' + n.path === a.key);
const bNode = nodes.find((n) => '/' + n.path === b.key);
return (aNode?.sort ?? 0) - (bNode?.sort ?? 0);
});
return result;
}
/**
* 将后端 MenuNode 递归转为 vue-router RouteRecordRaw
* component 字段通过 resolveComponent() 动态解析
*
* node.path 存储完整路径(如 'events/list'),
* 但 Vue Router 嵌套路由下子节点的 path 必须相对于父节点(如 'list')。
* 通过 parentPath 参数实现自动剥离前缀。
*
* 规则:
* - 跳过 disabled / externalLink 节点
* - 父节点无 component 且所有子节点被过滤 → 跳过
* - 父节点无 component 时,redirect 自动指向第一个可用子路由(相对路径)
*/
function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
function transformMenuToRoutes(nodes: MenuNode[], parentPath = ''): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
for (const node of nodes) {
// 外链节点不注册路由
if (node.externalLink) continue;
if (node.disabled || node.externalLink) continue;
const enabledChildren = getEnabledChildren(node);
const componentLoader = resolveComponent(node.component);
// 无组件且无可用子节点 → 跳过
if (!componentLoader && enabledChildren.length === 0) continue;
// 计算 Vue Router 路由 path: 根节点用完整路径,子节点剥离父级前缀
const routePath = parentPath
? node.path.slice(parentPath.length + 1) // 'events/list' → 'list'
: node.path;
const route: RouteRecordRaw = {
path: node.path,
path: routePath,
name: node.name,
meta: {
title: node.name,
@@ -111,11 +159,13 @@ function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
route.component = componentLoader;
}
if (node.children?.length) {
route.children = transformMenuToRoutes(node.children);
// 没有组件的父节点自动重定向到第一个子节点
if (!componentLoader && node.children[0]) {
route.redirect = node.children[0].path;
if (enabledChildren.length > 0) {
// 递归时传入当前节点的完整路径作为父级前缀
route.children = transformMenuToRoutes(enabledChildren, node.path);
// 无组件的父节点 → redirect 指向第一个可用子路由(相对路径)
if (!componentLoader) {
const firstChildRelativePath = enabledChildren[0].path.slice(node.path.length + 1);
route.redirect = firstChildRelativePath;
}
}
@@ -127,14 +177,19 @@ function transformMenuToRoutes(nodes: MenuNode[]): RouteRecordRaw[] {
/**
* 获取第一个可见菜单的路径(用作默认首页)
* 跳过 disabled / hideInMenu / externalLink 节点
* 返回带 '/' 前缀的完整路径
*/
function getFirstMenuPath(nodes: MenuNode[]): string {
for (const node of nodes) {
if (!node.hideInMenu && !node.externalLink && !node.children?.length) {
return node.path;
if (node.disabled || node.externalLink) continue;
const enabledChildren = getEnabledChildren(node);
if (!node.hideInMenu && enabledChildren.length === 0) {
return '/' + node.path;
}
if (node.children?.length) {
const childPath = getFirstMenuPath(node.children);
if (enabledChildren.length > 0) {
const childPath = getFirstMenuPath(enabledChildren);
if (childPath) return childPath;
}
}
@@ -146,8 +201,7 @@ function getFirstMenuPath(nodes: MenuNode[]): string {
* 动态路由作为其子路由挂载
*/
function ensureLayoutRoute() {
const existing = router.hasRoute('BasicLayout');
if (!existing) {
if (!router.hasRoute('BasicLayout')) {
router.addRoute({
path: '/',
name: 'BasicLayout',
@@ -159,61 +213,41 @@ function ensureLayoutRoute() {
}
/**
* 注册兜底菜单和路由(后端接口不可用时使用)
* 通用"注入路由 + 菜单"流程
* 无论是后端数据还是兜底数据,都走同一套 logic
*/
function registerFallbackRoutes() {
function applyMenuTree(menuTree: MenuNode[]) {
state.menuTree = menuTree;
state.menuItems = transformMenuNode(menuTree);
state.homePath = getFirstMenuPath(menuTree);
ensureLayoutRoute();
router.addRoute('BasicLayout', {
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/pages/dashboard'),
meta: { title: '工作台', icon: 'DashboardOutlined' },
const dynamicRoutes = transformMenuToRoutes(menuTree);
dynamicRoutes.forEach((route) => {
router.addRoute('BasicLayout', route);
});
router.addRoute('BasicLayout', {
path: 'about',
name: 'About',
component: () => import('@/pages/about'),
meta: { title: '关于', icon: 'InfoCircleOutlined' },
});
router.addRoute('BasicLayout', { path: '', redirect: '/dashboard' });
// 默认重定向到首页
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
}
// ============================================================
// 暴露给组件使用的 composable
// ============================================================
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 布局路由已注册
ensureLayoutRoute();
// 动态注册路由:将后端路由配置注入到 BasicLayout 布局下
const dynamicRoutes = transformMenuToRoutes(menuTree);
dynamicRoutes.forEach((route) => {
router.addRoute('BasicLayout', route);
});
// 默认重定向到首页(BasicLayout 的根路径)
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
applyMenuTree(menuTree);
state.loaded = true;
} catch (err) {
console.error('加载菜单失败:', err);
// 降级:使用兜底菜单
state.menuItems = [
{ key: '/dashboard', label: '工作台', iconName: 'DashboardOutlined' },
{ key: '/about', label: '关于', iconName: 'InfoCircleOutlined' },
];
state.homePath = '/dashboard';
// 兜底路由
registerFallbackRoutes();
// 降级:使用本地兜底菜单
applyMenuTree(FALLBACK_MENU_NODES);
state.loaded = true;
}
};