Replace top-bar module navigation with a card home page, persist favorites via API, reopen favorite tabs on cold login, and enable portal multi-tabs with overflow-based eviction. Co-authored-by: Cursor <cursoragent@cursor.com>
449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
/**
|
||
* 菜单处理器
|
||
*
|
||
* 负责菜单数据的获取、过滤和处理
|
||
*
|
||
* @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<AppRouteRecord[]> {
|
||
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<string, any> | 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<string, any> | 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<AppRouteRecord[]> {
|
||
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<AppRouteRecord[]> {
|
||
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<string>,
|
||
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}`
|
||
}
|
||
}
|