/** * @description 动态路由生成器 * @author yslg * @since 2025-10-07 */ import type { RouteRecordRaw } from 'vue-router'; import type { SysMenu } from '@/types'; import { MenuType } from '@/types/enums'; import { routes } from '@/router'; import { getResponsiveLayout, createResponsiveRoute, type RouteAdapter } from './routeAdapter'; // 预注册所有视图组件,构建时由 Vite 解析并生成按需加载的 chunk const VIEW_MODULES = import.meta.glob('../views/**/*.vue'); /** * 布局组件映射 - 使用响应式布局适配 */ const LAYOUT_MAP: Record Promise> = { // 基础布局(旧版,带侧边栏) 'BasicLayout': getResponsiveLayout('BasicLayout'), // 导航布局(新版,顶部导航+动态侧边栏) 'NavigationLayout': getResponsiveLayout('NavigationLayout'), // 侧边栏布局(管理后台专用,顶层SIDEBAR菜单) 'SidebarLayout': getResponsiveLayout('SidebarLayout'), // 空白布局 'BlankLayout': getResponsiveLayout('BlankLayout'), // 页面布局 'PageLayout': getResponsiveLayout('PageLayout'), // 路由占位组件(用于没有组件的子路由) 'RoutePlaceholder': () => import('@/layouts/RoutePlaceholder.vue'), // 用户中心布局(有共用区域,避免重复查询) 'UserCenterLayout': () => import('@/views/user/user-center/UserCenterLayout.vue'), }; /** * 根据菜单生成路由配置 * @param menus 用户菜单列表 * @returns Vue Router路由配置数组 */ export function generateRoutes(menus: SysMenu[]): RouteRecordRaw[] { if (!menus || menus.length === 0) { return []; } const routes: RouteRecordRaw[] = []; const pageRoutes: RouteRecordRaw[] = []; // 构建菜单树 const menuTree = buildMenuTree(menus); // 生成路由 menuTree.forEach(menu => { const route = generateRouteFromMenu(menu); if (route) { routes.push(route); // 递归提取所有 PAGE 类型的子菜单 extractPageChildren(route, pageRoutes); } }); // 将 PAGE 类型的路由添加到路由列表 routes.push(...pageRoutes); return routes; } /** * 递归提取路由中的 PAGE 类型子菜单 */ function extractPageChildren(route: any, pageRoutes: RouteRecordRaw[]) { // 检查当前路由是否有 PAGE 类型的子菜单 if (route.meta?.pageChildren && Array.isArray(route.meta.pageChildren)) { route.meta.pageChildren.forEach((pageMenu: SysMenu) => { const pageRoute = generateRouteFromMenu(pageMenu, true); // 作为顶层路由生成 if (pageRoute) { pageRoutes.push(pageRoute); } else { console.error(`[路由生成] 生成独立PAGE路由失败: ${pageMenu.name}`); } }); // 清理临时数据 delete route.meta.pageChildren; } // 递归检查子路由 if (route.children && Array.isArray(route.children)) { route.children.forEach((childRoute: any) => { extractPageChildren(childRoute, pageRoutes); }); } } /** * 根据单个菜单生成路由 * @param menu 菜单对象 * @param isTopLevel 是否是顶层菜单 * @returns 路由配置 */ function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw | null { // 跳过按钮类型 if (menu.type === MenuType.BUTTON) { return null; } // 跳过静态路由(已经在 router 中定义,不需要再次添加) if (menu.component === '__STATIC_ROUTE__') { return null; } const route: any = { path: menu.url || `/${menu.menuID}`, name: menu.menuID, meta: { title: menu.name, icon: menu.icon, menuId: menu.menuID, parentId: menu.parentID, orderNum: menu.orderNum, type: menu.type, hideInMenu: false, requiresAuth: true, } }; // 检查是否指定了布局(只有顶层菜单才使用布局) const layout = isTopLevel ? (menu as any).layout : null; const hasChildren = menu.children && menu.children.length > 0; // 检查 component 是否是布局组件(优先检查 LAYOUT_MAP,再检查名称) const isComponentLayout = menu.component && ( LAYOUT_MAP[menu.component] || (typeof menu.component === 'string' && menu.component.includes('Layout')) ); // 确定路由组件 if (layout && LAYOUT_MAP[layout]) { // 如果指定了布局,使用指定的布局 route.component = getComponent(layout); } else if (isComponentLayout && hasChildren && isTopLevel && menu.component) { // 如果 component 是布局组件且有子菜单,使用该布局组件作为父路由组件 // 这样可以确保布局只渲染一次,共用区域的数据也只查询一次 route.component = getComponent(menu.component); } else if (hasChildren && isTopLevel) { // 如果有子菜单但没有指定布局,根据菜单类型选择默认布局 if (menu.type === MenuType.NAVIGATION) { route.component = getComponent('NavigationLayout'); } else if (menu.type === MenuType.SIDEBAR && !menu.parentID) { // 顶层SIDEBAR菜单(管理后台)默认使用SidebarLayout route.component = getComponent('SidebarLayout'); } else { route.component = getComponent('BasicLayout'); } } else { // 没有子菜单,也没有指定布局,使用具体的页面组件 if (menu.component) { route.component = getComponent(menu.component); } else { // 非顶层菜单没有组件时,使用路由占位组件(不影响布局样式) route.component = getComponent('RoutePlaceholder'); } } // 处理子路由 if (layout && LAYOUT_MAP[layout] && menu.component && isTopLevel) { // 如果指定了布局,将页面组件作为子路由 route.children = [{ path: '', name: `${menu.menuID}_page`, component: getComponent(menu.component), meta: route.meta }]; // 如果还有其他子菜单,继续添加 if (hasChildren) { const pageChildren: SysMenu[] = []; const normalChildren: SysMenu[] = []; menu.children!.forEach(child => { if (child.type === MenuType.PAGE) { pageChildren.push(child); } else { normalChildren.push(child); } }); // 添加普通子菜单 normalChildren.forEach(child => { const childRoute = generateRouteFromMenu(child, false); if (childRoute) { route.children!.push(childRoute); } }); // PAGE 类型的菜单保存到 meta if (pageChildren.length > 0) { route.meta.pageChildren = pageChildren; } } } else if (hasChildren) { // 处理有子菜单的情况 route.children = []; // 分离 PAGE 类型的子菜单和普通子菜单 const pageChildren: SysMenu[] = []; const normalChildren: SysMenu[] = []; menu.children!.forEach(child => { if (child.type === MenuType.PAGE) { // PAGE 类型的菜单作为独立路由,不作为子路由 pageChildren.push(child); } else { normalChildren.push(child); } }); // 当前菜单指定了页面组件时,如果存在“普通子菜单”(非 PAGE 类型) // 则需要创建一个默认子路由来承载当前菜单的页面组件, // 这样父级既能作为分组,又能渲染自己的页面。 // 如果只有 PAGE 类型子菜单,则直接使用当前路由组件,而不再包一层 `_page`, // 避免多出一层嵌套导致 matched 结构过深。 if (menu.component && !isComponentLayout && normalChildren.length > 0) { route.children!.push({ path: '', name: `${menu.menuID}_page`, component: getComponent(menu.component), meta: { ...route.meta, } }); } // 只将普通子菜单加入 children normalChildren.forEach(child => { const childRoute = generateRouteFromMenu(child, false); if (childRoute) { route.children!.push(childRoute); } }); // PAGE 类型的菜单需要在外层单独处理(不管是哪一层的菜单) if (pageChildren.length > 0) { // 将 PAGE 类型的子菜单保存到路由的 meta 中,稍后在外层生成 route.meta.pageChildren = pageChildren; } // 如果没有设置重定向,自动重定向到第一个有URL的子菜单 if (!route.redirect && route.children.length > 0) { const firstChildWithUrl = findFirstMenuWithUrl(normalChildren); if (firstChildWithUrl?.url) { route.redirect = firstChildWithUrl.url; } } } return route; } /** * 查找第一个有URL的菜单 * @param menus 菜单列表 * @returns 第一个有URL的菜单 */ function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null { for (const menu of menus) { if (menu.type !== MenuType.BUTTON) { if (menu.url) { return menu; } if (menu.children && menu.children.length > 0) { const found = findFirstMenuWithUrl(menu.children); if (found) return found; } } } return null; } /** * 根据组件名称获取组件(支持响应式组件) * @param componentName 组件名称/路径 * @returns 组件异步加载函数 */ function getComponent(componentName: string) { // 1. 若是布局组件,直接返回预定义映射 if (LAYOUT_MAP[componentName]) { return LAYOUT_MAP[componentName]; } // 2. 将后台给的 component 字段转换为 ../views/**.vue 形式的 key let componentPath = componentName; // 如果不是以 @/ 开头,则认为是相对 views 根目录的路径,例如 "user/home/HomeView" if (!componentPath.startsWith('@/')) { if (!componentPath.startsWith('/')) { componentPath = '/' + componentPath; } componentPath = '@/views' + componentPath; // => '@/views/user/home/HomeView' } // 将别名 @/ 转为相对于当前文件的路径,必须与 import.meta.glob 中的模式一致 const originalPath = componentPath.replace(/^@\//, '../'); // => '../views/user/home/HomeView' // 补全 .vue 后缀 if (!originalPath.endsWith('.vue')) { componentPath = originalPath + '.vue'; } else { componentPath = originalPath; } // 3. 检查是否有移动端版本 const mobileComponentPath = componentPath.replace('.vue', '.mobile.vue'); // 从 VIEW_MODULES 中查找对应的 loader const originalLoader = VIEW_MODULES[componentPath]; const mobileLoader = VIEW_MODULES[mobileComponentPath]; if (!originalLoader) { console.error('[路由生成] 未找到组件模块', { 原始组件名: componentName, 期望路径: componentPath, 可用模块: Object.keys(VIEW_MODULES) }); // 找不到时退回到 404 组件 return () => import('@/views/public/error/404.vue'); } // 4. 如果有移动端版本,创建响应式路由适配器 if (mobileLoader) { const adapter: RouteAdapter = { original: originalLoader as () => Promise, mobile: mobileLoader as () => Promise }; return createResponsiveRoute(adapter); } // 5. 没有移动端版本,直接返回原始组件 return originalLoader as () => Promise; } /** * 将静态路由转换为菜单项 * @param routes 静态路由数组 * @returns 菜单项数组 */ function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] { const menus: SysMenu[] = []; routes.forEach(route => { // 处理有子路由的情况(现在静态路由都有布局组件) if (route.children && route.children.length > 0) { route.children.forEach(child => { // 只处理有 meta.menuType 的子路由 if (child.meta?.menuType !== undefined) { const menu: SysMenu = { menuID: child.name as string || child.path.replace(/\//g, '-'), parentID: '0', name: child.meta.title as string || child.name as string, url: route.path, // 使用父路由的路径 type: child.meta.menuType as MenuType, orderNum: (child.meta.orderNum as number) || -1, // 标记为静态路由,避免重复生成路由 component: '__STATIC_ROUTE__', // 特殊标记 }; menus.push(menu); } }); } // 处理没有子路由的情况(兼容性保留) else if (route.meta?.menuType !== undefined) { const menu: SysMenu = { menuID: route.name as string || route.path.replace(/\//g, '-'), parentID: '0', name: route.meta.title as string || route.name as string, url: route.path, type: route.meta.menuType as MenuType, orderNum: (route.meta.orderNum as number) || -1, // 标记为静态路由,避免重复生成路由 component: '__STATIC_ROUTE__', // 特殊标记 }; menus.push(menu); } }); return menus; } /** * 构建菜单树结构 * @param menus 菜单列表 * @returns 菜单树 */ export function buildMenuTree(menus: SysMenu[]): SysMenu[] { // 将静态路由转换为菜单项 const staticMenus = convertRoutesToMenus(routes); // 合并动态菜单和静态菜单 const allMenus = [...staticMenus, ...menus]; if (allMenus.length === 0) { return []; } const menuMap = new Map(); const rootMenus: SysMenu[] = []; const maxDepth = allMenus.length; // 最多遍历len层 // 创建菜单映射 allMenus.forEach(menu => { if (menu.menuID) { menuMap.set(menu.menuID, { ...menu, children: [] }); } }); // 循环构建树结构,最多遍历maxDepth次 for (let depth = 0; depth < maxDepth; depth++) { let hasChanges = false; allMenus.forEach(menu => { if (!menu.menuID) return; const menuNode = menuMap.get(menu.menuID); if (!menuNode) return; // 如果节点已经在树中,跳过 if (isNodeInTree(menuNode, rootMenus)) { return; } if (!menu.parentID || menu.parentID === '0' || menu.parentID === '') { // 根菜单 if (!isNodeInTree(menuNode, rootMenus)) { rootMenus.push(menuNode); hasChanges = true; } } else { // 子菜单 const parent = menuMap.get(menu.parentID); if (parent && isNodeInTree(parent, rootMenus)) { if (!parent.children) { parent.children = []; } if (!parent.children.includes(menuNode)) { parent.children.push(menuNode); hasChanges = true; } } } }); // 如果没有变化,说明树构建完成 if (!hasChanges) { break; } } // 按orderNum排序 const sortMenus = (menus: SysMenu[]): SysMenu[] => { return menus .sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0)) .map(menu => ({ ...menu, children: menu.children ? sortMenus(menu.children) : [] })); }; return sortMenus(rootMenus); } // 检查节点是否已经在树中 function isNodeInTree(node: SysMenu, tree: SysMenu[]): boolean { for (const treeNode of tree) { if (treeNode.menuID === node.menuID) { return true; } if (treeNode.children && isNodeInTree(node, treeNode.children)) { return true; } } return false; } /** * 根据权限过滤菜单 * @param menus 菜单列表 * @param permissions 用户权限列表 * @returns 过滤后的菜单列表 */ export function filterMenusByPermissions( menus: SysMenu[], permissions: string[] ): SysMenu[] { if (!menus || menus.length === 0) { return []; } return menus .filter(() => { // 如果菜单没有设置权限要求,则默认显示 // 这里可以根据实际业务需求调整权限检查逻辑 return true; // 暂时返回true,后续可以根据菜单的权限字段进行过滤 }) .map(menu => { if (menu.children && menu.children.length > 0) { return { ...menu, children: filterMenusByPermissions(menu.children, permissions) }; } return menu; }); } /** * 查找路由路径对应的菜单 * @param menus 菜单树 * @param path 路由路径 * @returns 匹配的菜单 */ export function findMenuByPath(menus: SysMenu[], path: string): SysMenu | null { for (const menu of menus) { if (menu.url === path) { return menu; } if (menu.children && menu.children.length > 0) { const found = findMenuByPath(menu.children, path); if (found) { return found; } } } return null; } /** * 获取菜单路径数组(面包屑导航用) * @param menus 菜单树 * @param targetMenuId 目标菜单ID * @returns 菜单路径数组 */ export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] { const path: SysMenu[] = []; function findPath(menuList: SysMenu[]): boolean { for (const menu of menuList) { path.push(menu); if (menu.menuID === targetMenuId) { return true; } if (menu.children && menu.children.length > 0) { if (findPath(menu.children)) { return true; } } path.pop(); } return false; } findPath(menus); return path; } /** * 获取第一个可访问的菜单URL(用于登录后跳转) * @param menus 菜单树 * @returns 第一个可访问的菜单URL,如果没有则返回 null */ export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null { if (!menus || menus.length === 0) { return null; } return "/home"; }