Files
ai-kefu-admin/admin/src/router/core/MenuProcessor.ts
T
浅念 78ff060420 feat: art-design-pro 脚手架落地与品牌化(八页骨架/代理配置/权限菜单)
- 模板 art-design-pro(MIT)GitHub 直连克隆后裁剪落地 admin/:删除演示页面/示例数据/mock/未用组件,保留构建与依赖清单
- 品牌化:应用名/标题/侧栏与登录页 logo/浏览器 favicon 全部替换为 all-kefu 自有资源(static/kefu-small.png),中文界面
- 八页骨架:login / force-change-password / dashboard / tenants / conversations / config / stats / settings 占位页 + 6 组路由模块
- 权限菜单:meta.roles 前端过滤,super 全量,tenant 隐藏 租户管理/平台设置(角色值与后端一致)
- 开发代理:/api 直转 + /api/public 与 /static 携带租户 Host 改写(VITE_TENANT_PREVIEW_HOST),适配后端 TenantHostMiddleware
- 认证 API 为开发桩(角色调试开关),Task 3 对接真实登录

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 14:51:24 +08:00

265 lines
7.1 KiB
TypeScript

/**
* 菜单处理器
*
* 负责菜单数据的获取、过滤和处理
*
* @module router/core/MenuProcessor
* @author Art Design Pro Team
*/
import type { AppRouteRecord } from '@/types/router'
import { useUserStore } from '@/store/modules/user'
import { asyncRoutes } from '../routes/asyncRoutes'
import { RoutesAlias } from '../routesAlias'
import { formatMenuTitle } from '@/utils'
export class MenuProcessor {
/**
* 获取菜单数据
*/
async getMenuList(): Promise<AppRouteRecord[]> {
const menuList = await this.processFrontendMenu()
// 在规范化路径之前,验证原始路径配置
this.validateMenuPaths(menuList)
// 规范化路径(将相对路径转换为完整路径)
return this.normalizeMenuPaths(menuList)
}
/**
* 处理前端控制模式的菜单(本平台固定前端权限模式:
* 菜单来自本地路由模块,按登录角色 meta.roles 过滤)
*/
private async processFrontendMenu(): Promise<AppRouteRecord[]> {
const userStore = useUserStore()
const roles = userStore.info?.roles
let menuList = [...asyncRoutes]
// 根据角色过滤菜单
if (roles && roles.length > 0) {
menuList = this.filterMenuByRoles(menuList, roles)
}
return this.filterEmptyMenus(menuList)
}
/**
* 根据角色过滤菜单
*/
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
}, [])
}
/**
* 递归过滤空菜单项
*/
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 属性(即使是空数组),说明这是一个目录菜单,应该保留
if ('children' in item) {
return true
}
// 如果有外链或 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?.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}`
}
}