Files
shanxia2.0/web-admin/src/router/core/MenuProcessor.ts
T
zlwuandCursor 2df2ef0808 feat: add portal home launcher, favorites UI, and WorkTab overflow eviction
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>
2026-08-08 12:06:49 +08:00

449 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 菜单处理器
*
* 负责菜单数据的获取、过滤和处理
*
* @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}`
}
}