Files
front/src/router/menu-data.ts
2026-03-21 17:13:47 +08:00

301 lines
9.3 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.
import { DEFAULT_LAYOUT, STANDALONE_LAYOUT } from './routes/base'
import type { AppRouteRecordRaw } from './routes/types'
import type { TreeNodeBase } from '@/utils/tree'
/**
* 服务器返回的菜单数据结构
*/
export interface ServerMenuItem extends TreeNodeBase {
id: number | string
parent_id: number | string | null
name?: string
title?: string // 菜单标题
title_en?: string // 英文标题
code?: string // 菜单编码
menu_path?: string // 菜单路径,如 '/overview'
component?: string // 组件路径,如 'ops/pages/overview'
icon?: string
locale?: string
sort_key?: number // 排序字段
order?: number
hideInMenu?: boolean
hideChildrenInMenu?: boolean
requiresAuth?: boolean
roles?: string[]
children?: ServerMenuItem[]
is_full?: boolean // 是否为独立页面(不包含菜单栏)
hide_menu?: boolean // 是否隐藏菜单栏
[key: string]: any
}
// 预定义的视图模块映射(用于 Vite 动态导入)
const viewModules = import.meta.glob('@/views/**/*.vue')
console.log('viewModules', viewModules)
/**
* 动态加载视图组件
* @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index'
* @returns 动态导入的组件
*/
export function loadViewComponent(componentPath: string) {
// 将路径转换为完整的视图路径
// 如果路径不以 /index 结尾且不以 .vue 结尾,自动补全
let normalizedPath = componentPath
if (!normalizedPath.endsWith('/index') && !normalizedPath.endsWith('.vue')) {
normalizedPath = `${normalizedPath}/index`
}
// 构建完整的文件路径
const filePath = `/src/views/${normalizedPath}.vue`
// 从预加载的模块中查找
const modulePath = Object.keys(viewModules).find((path) => path.endsWith(filePath) || path === filePath)
if (modulePath && viewModules[modulePath]) {
return viewModules[modulePath]
}
// 如果找不到,尝试不带 /index 的路径
const directFilePath = `/src/views/${componentPath}.vue`
const directModulePath = Object.keys(viewModules).find((path) => path.endsWith(directFilePath) || path === directFilePath)
if (directModulePath && viewModules[directModulePath]) {
return viewModules[directModulePath]
}
// 如果都找不到,返回一个默认组件或抛出错误
console.warn(`View component not found: ${filePath} or ${directFilePath}`)
return () => import('@/views/redirect/index.vue')
}
/**
* 将服务器菜单数据转换为路由配置
* @param menuItems 服务器返回的菜单项(树状结构)
* @returns 路由配置数组
*/
export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteRecordRaw[] {
const routes: AppRouteRecordRaw[] = []
for (const item of menuItems) {
// 根据 is_full 决定如何设置 component
let routeComponent: AppRouteRecordRaw['component']
if (item.is_full) {
// 独立页面:直接加载视图组件,不使用布局
if (item.component) {
routeComponent = loadViewComponent(item.component)
} else {
// 如果没有 component使用默认重定向页面
routeComponent = () => import('@/views/redirect/index.vue')
}
} else {
// 非独立页面:使用默认布局
routeComponent = DEFAULT_LAYOUT
}
const route: AppRouteRecordRaw = {
path: item.menu_path || '',
name: item.title || item.name || `menu_${item.id}`,
component: routeComponent,
meta: {
// ...item,
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
icon: item.icon || item?.menu_icon,
order: item.sort_key ?? item.order,
hideInMenu: item.hideInMenu || item.hide_menu,
hideChildrenInMenu: item.hideChildrenInMenu,
roles: item.roles,
isNewTab: item.is_new_tab,
},
}
// 非独立页面需要处理子菜单
if (!item.is_full) {
if (item.children && item.children.length > 0) {
// 传递父级的 component 和 path 给子路由处理函数
route.children = transformChildRoutes(item.children, item.component, item.menu_path, false)
} else if (item.component) {
// 一级菜单没有 children 但有 component创建一个空路径的子路由
const routeName = route.name
route.children = [
{
path: item.menu_path || '',
name: typeof routeName === 'string' ? `${routeName}Index` : `menu_${item.id}_index`,
component: loadViewComponent(item.component),
meta: {
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
isNewTab: item.is_new_tab,
},
},
]
}
}
routes.push(route)
}
return routes
}
/**
* 将路径转换为相对路径(去掉开头的 /
* @param path 路径
* @returns 相对路径
*/
function toRelativePath(path: string): string {
if (!path) return ''
// 去掉开头的 /
return path.startsWith('/') ? path.slice(1) : path
}
/**
* 从完整路径中提取子路由的相对路径
* 例如:父路径 '/dashboard',子路径 '/dashboard/workplace' -> 'workplace'
* @param childPath 子菜单的完整路径
* @param parentPath 父菜单的路径
* @returns 相对路径
*/
function extractRelativePath(childPath: string, parentPath: string): string {
if (!childPath) return ''
// 如果子路径以父路径开头,提取相对部分
if (parentPath && childPath.startsWith(parentPath)) {
let relativePath = childPath.slice(parentPath.length)
// 去掉开头的 /
if (relativePath.startsWith('/')) {
relativePath = relativePath.slice(1)
}
return relativePath
}
// 否则转换为相对路径
return toRelativePath(childPath)
}
/**
* 转换子路由配置
* @param children 子菜单项
* @param parentComponent 父级菜单的 component 字段(用于子菜单没有 component 时继承)
* @param parentPath 父级菜单的路径(用于计算相对路径)
* @param parentIsFull 父级菜单的 is_full 字段
* @returns 子路由配置数组
*/
/** 许可授权中心菜单路径(严格匹配末段,避免 menu_path 中含 query 等导致误伤其它菜单如用户管理) */
const LICENSE_CENTER_VIEW = 'ops/pages/system-settings/license-center'
function isLicenseCenterMenuPath(fullPath: string): boolean {
const path = String(fullPath ?? '')
.trim()
.split('?')[0]
.replace(/\/+$/, '')
return path.endsWith('/license-center') || path === 'license-center'
}
function transformChildRoutes(
children: ServerMenuItem[],
parentComponent?: string,
parentPath?: string,
parentIsFull?: boolean
): AppRouteRecordRaw[] {
return children.map((child) => {
const childFullPath = String(child.menu_path ?? child.path ?? '').trim()
// 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配
let componentPath = child.component || parentComponent
if (
!child.component &&
(isLicenseCenterMenuPath(childFullPath) || child.code === 'LicenseCenter')
) {
componentPath = LICENSE_CENTER_VIEW
}
const relativePath = extractRelativePath(childFullPath, parentPath || '')
const route: AppRouteRecordRaw = {
path: relativePath,
name: child.title || child.name || `menu_${child.id}`,
meta: {
...child,
locale: child.locale || child.title,
requiresAuth: child.requiresAuth !== false,
roles: child.roles,
hideInMenu: child.hideInMenu || child.hide_menu,
},
component: componentPath
? loadViewComponent(componentPath)
: () => import('@/views/redirect/index.vue'),
}
// 递归处理子菜单的子菜单
if (child.children && child.children.length > 0) {
route.children = transformChildRoutes(
child.children,
child.component || parentComponent,
childFullPath, // 传递当前子菜单的完整路径作为下一层的父路径
child.is_full || parentIsFull // 传递 is_full 标志
)
}
return route
})
}
// 本地菜单数据 - 接口未准备好时使用
export const localMenuData: AppRouteRecordRaw[] = [{
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
meta: {
locale: '仪表盘',
requiresAuth: true,
icon: 'icon-dashboard',
order: 0,
},
children: [
{
path: 'workplace',
name: 'Workplace',
component: () => import('@/views/dashboard/workplace/index.vue'),
meta: {
locale: 'menu.dashboard.workplace',
requiresAuth: true,
},
},
{
path: 'monitor',
name: 'Monitor',
component: () => import('@/views/dashboard/monitor/index.vue'),
meta: {
locale: 'menu.dashboard.monitor',
requiresAuth: true,
},
},
],
},
{
path: '/overview',
name: 'Overview',
component: DEFAULT_LAYOUT,
meta: {
locale: '系统概况',
requiresAuth: true,
icon: 'icon-home',
order: 1,
},
children: [
{
path: '',
name: 'OverviewIndex',
component: () => import('@/views/ops/pages/overview/index.vue'),
meta: {
locale: '系统概况',
requiresAuth: true,
},
},
],
},
]
export default localMenuData