/** * @description 动态路由生成器工具类 * @author yslg * @since 2025-12-12 * * 说明:此文件提供路由生成的通用方法,各个 web 服务可以使用这些方法生成自己的路由 */ import type { RouteRecordRaw } from 'vue-router' import type { TbSysViewDTO } from '@/types' // 为了代码可读性,创建类型别名 type SysMenu = TbSysViewDTO // 视图类型常量(对应后端的 type 字段) const ViewType = { MENU: 1, // 菜单 PAGE: 2, // 页面 BUTTON: 3 // 按钮 } as const /** * 路由生成器配置 */ export interface RouteGeneratorConfig { /** * 布局组件映射表 * key: 布局名称,value: 组件加载函数 */ layoutMap: Record Promise> /** * 视图组件加载器 * 用于动态加载视图组件 */ viewLoader: (componentPath: string) => Promise | null /** * 静态路由列表(可选) * 用于将静态路由转换为菜单项 */ staticRoutes?: RouteRecordRaw[] /** * 404 组件路径(可选) */ notFoundComponent?: () => Promise } /** * 根据菜单生成路由配置 * @param menus 用户菜单列表 * @param config 路由生成器配置 * @returns Vue Router路由配置数组 */ export function generateRoutes( menus: SysMenu[], config: RouteGeneratorConfig ): RouteRecordRaw[] { if (!menus || menus.length === 0) { return [] } const routes: RouteRecordRaw[] = [] const pageRoutes: RouteRecordRaw[] = [] // 构建菜单树 const menuTree = buildMenuTree(menus, config.staticRoutes) // 生成路由 menuTree.forEach(menu => { const route = generateRouteFromMenu(menu, config, true) if (route) { routes.push(route) // 递归提取所有 PAGE 类型的子菜单 extractPageChildren(route, pageRoutes, config) } }) // 将 PAGE 类型的路由添加到路由列表 routes.push(...pageRoutes) return routes } /** * 递归提取路由中的 PAGE 类型子菜单 */ function extractPageChildren( route: any, pageRoutes: RouteRecordRaw[], config: RouteGeneratorConfig ) { // 检查当前路由是否有 PAGE 类型的子菜单 if (route.meta?.pageChildren && Array.isArray(route.meta.pageChildren)) { route.meta.pageChildren.forEach((pageMenu: SysMenu) => { const pageRoute = generateRouteFromMenu(pageMenu, config, 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, config) }) } } /** * 根据单个菜单生成路由 * @param menu 菜单对象 * @param config 路由生成器配置 * @param isTopLevel 是否是顶层菜单 * @returns 路由配置 */ function generateRouteFromMenu( menu: SysMenu, config: RouteGeneratorConfig, isTopLevel = true ): RouteRecordRaw | null { // 跳过按钮类型 if (menu.type === ViewType.BUTTON) { return null } // 跳过静态路由(已经在 router 中定义,不需要再次添加) if (menu.component === '__STATIC_ROUTE__') { return null } const route: any = { path: menu.url || `/${menu.viewId}`, name: menu.viewId, meta: { title: menu.name, icon: menu.icon, menuId: menu.viewId, 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 是否是布局组件 const isComponentLayout = menu.component && ( config.layoutMap[menu.component] || (typeof menu.component === 'string' && menu.component.includes('Layout')) ) // 确定路由组件 if (layout && config.layoutMap[layout]) { // 如果指定了布局,使用指定的布局 route.component = config.layoutMap[layout] } else if (isComponentLayout && hasChildren && isTopLevel && menu.component) { // 如果 component 是布局组件且有子菜单,使用该布局组件作为父路由组件 route.component = config.layoutMap[menu.component] } else if (hasChildren && isTopLevel) { // 如果有子菜单但没有指定布局,根据菜单类型选择默认布局 if (menu.type === ViewType.MENU && !menu.parentId) { route.component = config.layoutMap['SidebarLayout'] } else { route.component = config.layoutMap['BasicLayout'] } } else { // 没有子菜单,也没有指定布局,使用具体的页面组件 if (menu.component) { const component = config.viewLoader(menu.component) if (component) { route.component = component } else { // 组件加载失败,使用 404 route.component = config.notFoundComponent || (() => import('vue').then(({ h }) => ({ default: { render() { return h('div', '404') } } }))) } } else { // 使用路由占位组件 route.component = () => import('vue').then(({ h, resolveComponent }) => ({ default: { render() { const RouterView = resolveComponent('RouterView') return h(RouterView) } } })) } } // 处理子路由 if (layout && config.layoutMap[layout] && menu.component && isTopLevel) { // 如果指定了布局,将页面组件作为子路由 const component = config.viewLoader(menu.component) route.children = [{ path: '', name: `${menu.viewId}_page`, component: component || route.component, meta: route.meta }] // 如果还有其他子菜单,继续添加 if (hasChildren) { const pageChildren: SysMenu[] = [] const normalChildren: SysMenu[] = [] menu.children!.forEach((child: SysMenu) => { if (child.type === ViewType.PAGE) { pageChildren.push(child) } else { normalChildren.push(child) } }) // 添加普通子菜单 normalChildren.forEach(child => { const childRoute = generateRouteFromMenu(child, config, 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: SysMenu) => { if (child.type === ViewType.PAGE) { pageChildren.push(child) } else { normalChildren.push(child) } }) // 如果当前菜单有组件且有普通子菜单,创建默认子路由 if (menu.component && !isComponentLayout && normalChildren.length > 0) { const component = config.viewLoader(menu.component) route.children!.push({ path: '', name: `${menu.viewId}_page`, component: component || route.component, meta: { ...route.meta, } }) } // 只将普通子菜单加入 children normalChildren.forEach(child => { const childRoute = generateRouteFromMenu(child, config, false) if (childRoute) { route.children!.push(childRoute) } }) // PAGE 类型的菜单保存到 meta if (pageChildren.length > 0) { 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的菜单 */ function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null { for (const menu of menus) { if (menu.type !== ViewType.BUTTON) { if (menu.url) { return menu } if (menu.children && menu.children.length > 0) { const found = findFirstMenuWithUrl(menu.children) if (found) return found } } } return null } /** * 将静态路由转换为菜单项 */ function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] { const menus: SysMenu[] = [] routes.forEach(route => { if (route.children && route.children.length > 0) { route.children.forEach(child => { if (child.meta?.menuType !== undefined) { const menu: SysMenu = { viewId: 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 number, orderNum: (child.meta.orderNum as number) || -1, component: '__STATIC_ROUTE__', } menus.push(menu) } }) } else if (route.meta?.menuType !== undefined) { const menu: SysMenu = { viewId: 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 number, orderNum: (route.meta.orderNum as number) || -1, component: '__STATIC_ROUTE__', } menus.push(menu) } }) return menus } /** * 构建菜单树结构 * @param menus 菜单列表 * @param staticRoutes 静态路由列表 * @returns 菜单树 */ export function buildMenuTree( menus: SysMenu[], staticRoutes?: RouteRecordRaw[] ): SysMenu[] { // 将静态路由转换为菜单项 const staticMenus = staticRoutes ? convertRoutesToMenus(staticRoutes) : [] // 合并动态菜单和静态菜单 const allMenus = [...staticMenus, ...menus] if (allMenus.length === 0) { return [] } const menuMap = new Map() const rootMenus: SysMenu[] = [] const maxDepth = allMenus.length // 创建菜单映射 allMenus.forEach(menu => { if (menu.viewId) { menuMap.set(menu.viewId, { ...menu, children: [] }) } }) // 循环构建树结构 for (let depth = 0; depth < maxDepth; depth++) { let hasChanges = false allMenus.forEach(menu => { if (!menu.viewId) return const menuNode = menuMap.get(menu.viewId) 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 } } // 排序 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.viewId === node.viewId) { return true } if (treeNode.children && isNodeInTree(node, treeNode.children)) { return true } } return false } /** * 根据权限过滤菜单 */ export function filterMenusByPermissions( menus: SysMenu[], permissions: string[] ): SysMenu[] { if (!menus || menus.length === 0) { return [] } return menus .filter(() => true) // 暂时返回true,后续可根据实际需求过滤 .map(menu => { if (menu.children && menu.children.length > 0) { return { ...menu, children: filterMenusByPermissions(menu.children, permissions) } } return menu }) } /** * 查找路由路径对应的菜单 */ 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 } /** * 获取菜单路径数组(面包屑导航用) */ 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.viewId === 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(用于登录后跳转) */ export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null { if (!menus || menus.length === 0) { return null } const firstMenu = findFirstMenuWithUrl(menus) return firstMenu?.url || '/home' } /** * 从 LocalStorage 加载用户视图数据 * @param storageKey localStorage 的 key,默认为 'loginDomain' * @param viewsPath 视图数据在对象中的路径,默认为 'userViews' * @returns 视图列表,如果不存在返回 null */ export function loadViewsFromStorage( storageKey: string = 'loginDomain', viewsPath: string = 'userViews' ): TbSysViewDTO[] | null { try { const dataStr = localStorage.getItem(storageKey) if (!dataStr) { console.log(`[路由工具] LocalStorage 中没有 ${storageKey}`) return null } const data = JSON.parse(dataStr) // 支持嵌套路径,如 'user.views' const paths = viewsPath.split('.') let views = data for (const path of paths) { views = views?.[path] } if (views && Array.isArray(views) && views.length > 0) { console.log(`[路由工具] 从 LocalStorage 加载视图,数量: ${views.length}`) return views } console.log(`[路由工具] ${storageKey} 中没有 ${viewsPath} 或数据为空`) return null } catch (error) { console.error('[路由工具] 从 LocalStorage 加载视图失败:', error) return null } } /** * 生成简化的路由配置(用于直接添加到 router) * 相比 generateRoutes,这个方法生成的路由更适合动态添加到现有路由树 * * @param views 视图列表 * @param config 路由生成器配置 * @param options 额外选项 * @returns 路由配置数组 */ export interface GenerateSimpleRoutesOptions { /** * 是否作为根路由的子路由(路径去掉前导 /) */ asRootChildren?: boolean /** * iframe 类型视图的占位组件 */ iframePlaceholder?: () => Promise /** * 是否启用详细日志 */ verbose?: boolean } export function generateSimpleRoutes( views: TbSysViewDTO[], config: RouteGeneratorConfig, options: GenerateSimpleRoutesOptions = {} ): RouteRecordRaw[] { const { asRootChildren = false, iframePlaceholder, verbose = false } = options if (!views || views.length === 0) { if (verbose) console.warn('[路由生成] 视图列表为空') return [] } if (verbose) { console.log('[路由生成] 开始生成路由,视图数量:', views.length) } // 构建视图树 const viewTree = buildMenuTree(views) if (verbose) { console.log('[路由生成] 构建视图树,根节点数量:', viewTree.length) } const routes: RouteRecordRaw[] = [] // 遍历根节点,生成路由 viewTree.forEach(view => { const route = generateSimpleRoute(view, config, { asRootChild: asRootChildren, iframePlaceholder, verbose }) if (route) { routes.push(route) if (verbose) { console.log('[路由生成] 已生成路由:', { path: route.path, name: route.name, hasComponent: !!route.component, childrenCount: route.children?.length || 0 }) } } else if (verbose) { console.warn('[路由生成] 跳过无效视图:', view.name) } }) return routes } /** * 从单个视图生成简化路由 */ function generateSimpleRoute( view: TbSysViewDTO, config: RouteGeneratorConfig, options: { asRootChild?: boolean iframePlaceholder?: () => Promise verbose?: boolean } = {} ): RouteRecordRaw | null { const { asRootChild = false, iframePlaceholder, verbose = false } = options // 验证必要字段 if (!view.viewId) { if (verbose) console.error('[路由生成] 视图缺少 viewId:', view) return null } // 判断是否是 iframe 类型 const isIframe = (view as any).viewType === 'iframe' || !!(view as any).iframeUrl // 处理路径和组件 let routePath = view.url || `/${view.viewId}` let component: any if (isIframe) { // iframe 类型:使用占位组件(用于显示iframe内容) // 路由路径使用 url 字段(应该设置为不冲突的路径,如 /app/workcase) component = iframePlaceholder || (() => import('vue').then(({ h }) => ({ default: { render() { return h('div', { class: 'iframe-placeholder' }, 'Loading...') } } }))) } else if (view.component) { // route 类型:加载实际组件 component = config.viewLoader(view.component) if (!component) { if (verbose) console.warn('[路由生成] 组件加载失败:', view.component, '使用占位组件') // 使用占位组件,避免路由无效 const errorMsg = `组件加载失败: ${view.component}` component = () => import('vue').then(({ h }) => ({ default: { render() { return h('div', { style: { padding: '20px', color: 'red' } }, errorMsg) } } })) } } // 根路径的子路由去掉前导斜杠 if (asRootChild && routePath.startsWith('/')) { routePath = routePath.substring(1) } const hasChildren = view.children && view.children.length > 0 if (verbose) { console.log('[路由生成] 视图信息:', { viewId: view.viewId, name: view.name, url: view.url, component: view.component, isIframe, hasChildren, childrenCount: view.children?.length || 0 }) } const route: any = { path: routePath, name: view.viewId, meta: { title: view.name || view.viewId, icon: view.icon, menuId: view.viewId, orderNum: view.orderNum, requiresAuth: true, isIframe, iframeUrl: (view as any).iframeUrl } } // 根据 component 和 children 的情况处理 if (component && hasChildren) { // 有组件且有子视图:组件作为空路径子路由 route.component = component route.children = [ { path: '', name: `${view.viewId}_page`, component: component, meta: route.meta } ] // 添加其他子路由 view.children!.forEach(childView => { const childRoute = generateSimpleRoute(childView, config, { asRootChild: false, iframePlaceholder, verbose }) if (childRoute) { route.children.push(childRoute) } }) } else if (component && !hasChildren) { // 只有组件,没有子视图 route.component = component } else if (!component && hasChildren) { // 没有组件,只有子视图(路由容器) route.component = () => import('vue').then(({ h, resolveComponent }) => ({ default: { render() { const RouterView = resolveComponent('RouterView') return h(RouterView) } } })) route.children = [] // 添加子路由 view.children!.forEach(childView => { const childRoute = generateSimpleRoute(childView, config, { asRootChild: false, iframePlaceholder, verbose }) if (childRoute) { route.children.push(childRoute) } }) // 重定向到第一个子路由 if (route.children.length > 0) { const firstChild = route.children[0] route.redirect = firstChild.path } } else { // 既没有组件也没有子视图 if (verbose) { console.warn('[路由生成] 视图既无组件也无子视图:', view.name) } return null } // 处理layout:如果视图指定了layout,且不是作为Root的子路由,且有有效组件,需要包裹layout const viewLayout = (view as any).layout if (viewLayout && !asRootChild && route.component && config.layoutMap[viewLayout]) { if (verbose) { console.log('[路由生成] 为视图添加布局:', view.name, '布局:', viewLayout, '路径:', routePath) } // 创建layout路由,将原路由的组件作为其子路由 const layoutRoute: RouteRecordRaw = { path: routePath, name: view.viewId, component: config.layoutMap[viewLayout], meta: { ...route.meta, layout: viewLayout // 标记使用的布局 }, children: [ { path: '', name: `${view.viewId}_content`, component: route.component, meta: route.meta } ] } // 如果原路由有其他children(子视图),也添加到layout路由的children中 if (route.children && route.children.length > 0) { // 跳过第一个空路径的子路由(如果存在) const otherChildren = route.children.filter((child: any) => child.path !== '') if (otherChildren.length > 0) { layoutRoute.children!.push(...otherChildren) } } if (verbose) { console.log('[路由生成] Layout路由生成完成:', { path: layoutRoute.path, name: layoutRoute.name, childrenCount: layoutRoute.children?.length }) } return layoutRoute } return route }