/** * 菜单处理器 * * 负责菜单数据的获取、过滤和处理 * * @module router/core/MenuProcessor * @author Art Design Pro Team */ import type { AppRouteRecord } from '@/types/router' import { useUserStore } from '@/store/modules/user' import { useAppMode } from '@/hooks/core/useAppMode' import { fetchGetMenuList } from '@/api/admin' import { fetchGetMenuCodes } from '@/api/auth' import { asyncRoutes } from '../routes/asyncRoutes' import { RoutesAlias } from '../routesAlias' import { formatMenuTitle } from '@/utils' import { isDashboardMenuItem } from '@/utils/router/portal-menu' export class MenuProcessor { /** * 将路由上的“菜单标识”规范化为数据库/权限体系使用的 code 形态。 * 例如:DeliveryList -> delivery_list */ private normalizeMenuCode(input: unknown): string { const raw = String(input ?? '').trim() if (!raw) return '' // 兼容已是 snake_case 的情况 const withUnderscore = raw .replace(/[\s-]+/g, '_') // PascalCase / camelCase -> snake_case .replace(/([a-z0-9])([A-Z])/g, '$1_$2') return withUnderscore.toLowerCase() } /** * 获取菜单数据 */ async getMenuList(): Promise { const { isFrontendMode } = useAppMode() let menuList: AppRouteRecord[] if (isFrontendMode.value) { menuList = await this.processFrontendMenu() } else { menuList = await this.processBackendMenu() } // 在规范化路径之前,验证原始路径配置 this.validateMenuPaths(menuList) // 规范化路径(将相对路径转换为完整路径) const normalized = this.normalizeMenuPaths(this.filterDashboardMenus(menuList)) // 门户启动器首页:始终注入(不依赖后端 menuCodes) return this.ensurePortalHomeRoute(normalized) } /** 确保 /home 启动器路由存在于菜单树(供动态注册) */ private ensurePortalHomeRoute(menuList: AppRouteRecord[]): AppRouteRecord[] { const hasHome = menuList.some( (m) => m.path === '/home-root' || m.name === 'PortalHomeRoot' || m.children?.some((c) => c.path === '/home' || c.name === 'PortalHome') ) if (hasHome) { return menuList } const homeRoute: AppRouteRecord = { id: 'HOME00000000000000000000000000001', name: 'PortalHomeRoot', path: '/home-root', component: '/index/index', meta: { title: '首页', keepAlive: false, isHide: true }, children: [ { id: 'HOME00000000000000000000000000002', name: 'PortalHome', path: '/home', component: '/portal/home/index', meta: { title: '首页', keepAlive: true, fixedTab: true, isHide: true } } ] } return [homeRoute, ...menuList] } /** * 移除控制台/Dashboard 一级菜单(门户布局不在菜单中展示) */ private filterDashboardMenus(menuList: AppRouteRecord[]): AppRouteRecord[] { return menuList .filter((item) => !isDashboardMenuItem(item)) .map((item) => { if (!item.children?.length) { return item } return { ...item, children: this.filterDashboardMenus(item.children) } }) } /** * 解析 Web 端角色(优先使用后端 current-user/login 的 role 字段)。 * 避免 Pinia 持久化的旧 roles(模板/demo 遗留)覆盖真实 role 导致菜单被滤空。 */ private resolveWebRoles(info: Api.Auth.UserInfo | Record | undefined): string[] | undefined { const webRole = String(info?.role ?? '').trim() if (webRole) { return [webRole] } if (info?.roles?.length) { return info.roles } return undefined } /** * 是否超级管理员(跳过后端 menuCodes 过滤) */ private isSuperAdminRole(info: Api.Auth.UserInfo | Record | undefined): boolean { const webRole = String(info?.role ?? '').trim() if (webRole === 'super_admin') return true return Array.isArray(info?.roles) && info.roles.includes('super_admin') } /** * 处理前端控制模式的菜单 */ private async processFrontendMenu(): Promise { const userStore = useUserStore() const info = userStore.info const roles = this.resolveWebRoles(info) let menuList = [...asyncRoutes] // 根据角色过滤菜单 if (roles && roles.length > 0) { // super_admin:前端静态菜单全量展示,不依赖 meta.roles 的维护 if (!roles.includes('super_admin')) { menuList = this.filterMenuByRoles(menuList, roles) } } // 静态菜单模式下,非 super_admin 按后端返回的 menuCodes 过滤菜单 if (!this.isSuperAdminRole(info)) { const perm = await fetchGetMenuCodes() const codeSet = new Set( (perm?.menuCodes || []).map((x) => this.normalizeMenuCode(x)).filter(Boolean) ) if (!perm?.all) { const filtered = this.filterMenuByCodes(menuList, codeSet) // 有 menuCodes 但过滤后为空,多为 roles/menuCode 配置异常,保留角色过滤结果便于排查 if (filtered.length > 0 || codeSet.size === 0) { menuList = filtered } else { console.warn( '[MenuProcessor] menuCodes 非空但过滤后菜单为空,保留角色过滤后的菜单树', codeSet.size ) } } } return this.filterEmptyMenus(menuList) } /** * 处理后端控制模式的菜单 */ private async processBackendMenu(): Promise { const list = await fetchGetMenuList() return this.filterEmptyMenus(list) } /** * 根据角色过滤菜单 */ private filterMenuByRoles(menu: AppRouteRecord[], roles: string[]): AppRouteRecord[] { return menu.reduce((acc: AppRouteRecord[], item) => { const itemRoles = item.meta?.roles const hasPermission = !itemRoles || itemRoles.some((role) => roles?.includes(role)) if (hasPermission) { const filteredItem = { ...item } if (filteredItem.children?.length) { filteredItem.children = this.filterMenuByRoles(filteredItem.children, roles) } acc.push(filteredItem) } return acc }, []) } /** * 根据 menu code 过滤菜单(目录节点在有可见子菜单时保留) */ private filterMenuByCodes( menu: AppRouteRecord[], codes: Set, level = 1 ): AppRouteRecord[] { return menu.reduce((acc: AppRouteRecord[], item) => { // 门户启动器首页不参与 menuCodes 过滤 if ( item.path === '/home-root' || item.path === '/home' || item.name === 'PortalHomeRoot' || item.name === 'PortalHome' ) { acc.push({ ...item }) return acc } const rawCode = (item.meta as any)?.menuCode ?? item.name const code = this.normalizeMenuCode(rawCode) const hasSelfPermission = !!code && codes.has(code) const filteredChildren = item.children?.length ? this.filterMenuByCodes(item.children, codes, level + 1) : item.children const shouldKeep = hasSelfPermission || (Array.isArray(filteredChildren) && filteredChildren.length > 0) if (shouldKeep) { acc.push({ ...item, children: filteredChildren }) } return acc }, []) } /** * 递归过滤空菜单项 */ private filterEmptyMenus(menuList: AppRouteRecord[]): AppRouteRecord[] { return menuList .map((item) => { // 如果有子菜单,先递归过滤子菜单 if (item.children && item.children.length > 0) { const filteredChildren = this.filterEmptyMenus(item.children) return { ...item, children: filteredChildren } } return item }) .filter((item) => { // 仅当 children 为数组时视为目录;勿用 `'children' in item`(spread 后叶子常为 children: undefined) if (Array.isArray(item.children)) { return item.children.length > 0 } // 如果有外链或 iframe,保留 if (item.meta?.isIframe === true || item.meta?.link) { return true } // 如果有有效的 component,保留 if (item.component && item.component !== '' && item.component !== RoutesAlias.Layout) { return true } // 其他情况过滤掉 return false }) } /** * 验证菜单列表是否有效 */ validateMenuList(menuList: AppRouteRecord[]): boolean { return Array.isArray(menuList) && menuList.length > 0 } /** * 规范化菜单路径 * 将相对路径转换为完整路径,确保菜单跳转正确 */ private normalizeMenuPaths(menuList: AppRouteRecord[], parentPath = ''): AppRouteRecord[] { return menuList.map((item) => { // 构建完整路径 const fullPath = this.buildFullPath(item.path || '', parentPath) // 递归处理子菜单 const children = item.children?.length ? this.normalizeMenuPaths(item.children, fullPath) : item.children const redirect = item.redirect || this.resolveDefaultRedirect(children) return { ...item, path: fullPath, redirect, children } }) } /** * 为目录型菜单推导默认跳转地址 */ private resolveDefaultRedirect(children?: AppRouteRecord[]): string | undefined { if (!children?.length) { return undefined } for (const child of children) { if (this.isNavigableRoute(child)) { return child.path } const nestedRedirect = this.resolveDefaultRedirect(child.children) if (nestedRedirect) { return nestedRedirect } } return undefined } /** * 判断子路由是否可以作为默认落点 */ private isNavigableRoute(route: AppRouteRecord): boolean { return Boolean( route.path && route.path !== '/' && !route.meta?.isHide && !route.meta?.link && route.meta?.isIframe !== true && route.component && route.component !== '' ) } /** * 验证菜单路径配置 * 检测非一级菜单是否错误使用了 / 开头的路径 */ /** * 验证菜单路径配置 * 检测非一级菜单是否错误使用了 / 开头的路径 */ private validateMenuPaths(menuList: AppRouteRecord[], level = 1): void { menuList.forEach((route) => { if (!route.children?.length) return const parentName = String(route.name || route.path || '未知路由') route.children.forEach((child) => { const childPath = child.path || '' // 跳过合法的绝对路径:外部链接和 iframe 路由 if (this.isValidAbsolutePath(childPath)) return // 检测非法的绝对路径 if (childPath.startsWith('/')) { this.logPathError(child, childPath, parentName, level) } }) // 递归检查更深层级的子路由 this.validateMenuPaths(route.children, level + 1) }) } /** * 判断是否为合法的绝对路径 */ private isValidAbsolutePath(path: string): boolean { return ( path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/outside/iframe/') ) } /** * 输出路径配置错误日志 */ private logPathError( route: AppRouteRecord, path: string, parentName: string, level: number ): void { const routeName = String(route.name || path || '未知路由') const menuTitle = route.meta?.title || routeName const suggestedPath = path.split('/').pop() || path.slice(1) console.error( `[路由配置错误] 菜单 "${formatMenuTitle(menuTitle)}" (name: ${routeName}, path: ${path}) 配置错误\n` + ` 位置: ${parentName} > ${routeName}\n` + ` 问题: ${level + 1}级菜单的 path 不能以 / 开头\n` + ` 当前配置: path: '${path}'\n` + ` 应该改为: path: '${suggestedPath}'` ) } /** * 构建完整路径 */ private buildFullPath(path: string, parentPath: string): string { if (!path) return '' // 外部链接直接返回 if (path.startsWith('http://') || path.startsWith('https://')) { return path } // 如果已经是绝对路径,直接返回 if (path.startsWith('/')) { return path } // 拼接父路径和当前路径 if (parentPath) { // 移除父路径末尾的斜杠,移除子路径开头的斜杠,然后拼接 const cleanParent = parentPath.replace(/\/$/, '') const cleanChild = path.replace(/^\//, '') return `${cleanParent}/${cleanChild}` } // 没有父路径,添加前导斜杠 return `/${path}` } }