library-picturebook-activity/frontend/src/utils/menu.ts
2026-01-16 16:35:43 +08:00

334 lines
12 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 { h } from "vue"
import type { RouteRecordRaw } from "vue-router"
import type { MenuProps } from "ant-design-vue"
import type { Menu } from "@/api/menus"
import * as Icons from "@ant-design/icons-vue"
/**
* 组件路径映射
* 将数据库中的组件路径映射到实际的导入函数
* 注意Vite 的动态 import() 不支持路径别名,所以需要在这里预先定义所有组件
*/
// 空布局组件,用于父级菜单
const EmptyLayout = () => import("@/layouts/EmptyLayout.vue")
const componentMap: Record<string, () => Promise<any>> = {
"workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"),
// 学校管理模块
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
"school/departments/Index": () =>
import("@/views/school/departments/Index.vue"),
"school/grades/Index": () => import("@/views/school/grades/Index.vue"),
"school/classes/Index": () => import("@/views/school/classes/Index.vue"),
"school/teachers/Index": () => import("@/views/school/teachers/Index.vue"),
"school/students/Index": () => import("@/views/school/students/Index.vue"),
// 赛事管理模块
"contests/Index": () => import("@/views/contests/Index.vue"),
"contests/Activities": () => import("@/views/contests/Activities.vue"),
"contests/Create": () => import("@/views/contests/Create.vue"),
"contests/Detail": () => import("@/views/contests/Detail.vue"),
"contests/registrations/Index": () =>
import("@/views/contests/registrations/Index.vue"),
"contests/works/Index": () => import("@/views/contests/works/Index.vue"),
"contests/works/WorksDetail": () =>
import("@/views/contests/works/WorksDetail.vue"),
"contests/reviews/Index": () => import("@/views/contests/reviews/Index.vue"),
"contests/reviews/Tasks": () => import("@/views/contests/reviews/Tasks.vue"),
"contests/reviews/Progress": () =>
import("@/views/contests/reviews/Progress.vue"),
"contests/reviews/ProgressDetail": () =>
import("@/views/contests/reviews/ProgressDetail.vue"),
"contests/judges/Index": () => import("@/views/contests/judges/Index.vue"),
"contests/results/Index": () => import("@/views/contests/results/Index.vue"),
"contests/notices/Index": () => import("@/views/contests/notices/Index.vue"),
// 作业管理模块
"homework/Index": () => import("@/views/homework/Index.vue"),
"homework/Submissions": () => import("@/views/homework/Submissions.vue"),
"homework/ReviewRules": () => import("@/views/homework/ReviewRules.vue"),
// 学生端作业模块
"homework/StudentList": () => import("@/views/homework/StudentList.vue"),
"homework/StudentDetail": () => import("@/views/homework/StudentDetail.vue"),
// 赛事活动模块(教师/评委)
"activities/Guidance": () => import("@/views/activities/Guidance.vue"),
"activities/Review": () => import("@/views/activities/Review.vue"),
"activities/ReviewDetail": () => import("@/views/activities/ReviewDetail.vue"),
"activities/Comments": () => import("@/views/activities/Comments.vue"),
// 系统管理模块
"system/users/Index": () => import("@/views/system/users/Index.vue"),
"system/roles/Index": () => import("@/views/system/roles/Index.vue"),
"system/permissions/Index": () =>
import("@/views/system/permissions/Index.vue"),
"system/menus/Index": () => import("@/views/system/menus/Index.vue"),
"system/tenants/Index": () => import("@/views/system/tenants/Index.vue"),
"system/dict/Index": () => import("@/views/system/dict/Index.vue"),
"system/config/Index": () => import("@/views/system/config/Index.vue"),
"system/logs/Index": () => import("@/views/system/logs/Index.vue"),
}
/**
* 获取图标组件
*/
export function getIconComponent(iconName: string | null | undefined) {
if (!iconName) return null
const IconComponent = (Icons as any)[iconName]
if (IconComponent) {
return () => h(IconComponent)
}
return null
}
/**
* 从菜单路径生成路由名称(与 convertMenusToRoutes 中的逻辑一致)
* 如果路径相同使用菜单ID来区分避免路由名称冲突
*/
function getRouteNameFromPath(
path: string | null | undefined,
menuId: number,
isChild: boolean = false
): string {
if (path) {
const baseName = path
.split("/")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("")
// 如果是子菜单,添加后缀以避免与父菜单路由名称冲突
return isChild ? `${baseName}${menuId}` : baseName
}
return `Menu${menuId}`
}
/**
* 将数据库菜单转换为 Ant Design Vue Menu 的 items 格式
* key 使用路由名称而不是路径
* 当父菜单只有一个子菜单时,直接显示子菜单,不显示父级折叠结构
*/
export function convertMenusToMenuItems(
menus: Menu[],
isChild: boolean = false
): MenuProps["items"] {
const result: any[] = []
menus.forEach((menu) => {
// 如果只有一个子菜单,直接提升子菜单到当前层级
if (menu.children && menu.children.length === 1) {
const onlyChild = menu.children[0]
const childRouteName = getRouteNameFromPath(onlyChild.path, onlyChild.id, true)
const item: any = {
key: childRouteName,
label: onlyChild.name,
title: onlyChild.name,
}
// 优先使用父菜单的图标,如果没有则使用子菜单的图标
const iconName = menu.icon || onlyChild.icon
if (iconName) {
const IconComponent = getIconComponent(iconName)
if (IconComponent) {
item.icon = IconComponent
}
}
// 如果这个唯一的子菜单还有子菜单,继续递归处理
if (onlyChild.children && onlyChild.children.length > 0) {
item.children = convertMenusToMenuItems(onlyChild.children, true)
}
result.push(item)
return
}
// 正常处理:使用路由名称作为 key
const routeName = getRouteNameFromPath(menu.path, menu.id, isChild)
const item: any = {
key: routeName,
label: menu.name,
title: menu.name,
}
// 添加图标
if (menu.icon) {
const IconComponent = getIconComponent(menu.icon)
if (IconComponent) {
item.icon = IconComponent
}
}
// 如果有多个子菜单,递归处理
if (menu.children && menu.children.length > 1) {
item.children = convertMenusToMenuItems(menu.children, true)
}
result.push(item)
})
return result
}
/**
* 将数据库菜单转换为 Vue Router 的路由配置
* 注意:这些路由会被添加到 /:tenantCode 父路由下,所以不需要再添加 tenantCode 前缀
*/
/**
* 移除路径中与父路径重合的部分
*/
function removeParentPathFromRoutePath(
routePath: string,
parentPath: string
): string {
if (!parentPath) {
return routePath
}
// 标准化路径:移除开头的斜杠
const normalizedRoutePath = routePath.startsWith("/")
? routePath.slice(1)
: routePath
const normalizedParentPath = parentPath.startsWith("/")
? parentPath.slice(1)
: parentPath
// 如果子路径以父路径开头,移除父路径部分
if (normalizedRoutePath.startsWith(normalizedParentPath + "/")) {
return normalizedRoutePath.slice(normalizedParentPath.length + 1)
} else if (normalizedRoutePath === normalizedParentPath) {
// 如果路径完全相同,返回空字符串(表示当前路由)
return ""
}
return normalizedRoutePath
}
export function convertMenusToRoutes(
menus: Menu[],
parentPath: string = "",
isChild: boolean = false
): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
menus.forEach((menu) => {
// 构建路由路径
// 注意:这些路由会被添加到 /:tenantCode 父路由下,所以路径应该是相对路径
let routePath = menu.path
? menu.path.startsWith("/")
? menu.path.slice(1) // 移除开头的斜杠,因为这是相对路径
: menu.path
: `menu-${menu.id}`
// 如果有父路径,移除与父路径重合的部分
if (parentPath) {
routePath = removeParentPathFromRoutePath(routePath, parentPath)
}
// 构建路由名称(与 convertMenusToMenuItems 中的逻辑一致)
const routeName = getRouteNameFromPath(menu.path, menu.id, isChild)
// 确定组件加载器
let componentLoader: (() => Promise<any>) | undefined
if (menu.component) {
// 从组件映射中获取导入函数
// 如果组件路径以 @/ 开头,说明是完整路径,需要去掉 @/views/ 前缀和 .vue 后缀来匹配
let componentKey = menu.component
if (componentKey.startsWith("@/views/")) {
componentKey = componentKey.replace("@/views/", "").replace(".vue", "")
} else if (componentKey.endsWith(".vue")) {
componentKey = componentKey.replace(".vue", "")
}
// 从映射中获取组件导入函数
const mappedLoader = componentMap[componentKey]
if (mappedLoader) {
componentLoader = mappedLoader
} else {
const componentPath = menu.component
console.warn(
`组件路径 "${componentPath}" (key: "${componentKey}") 未在 componentMap 中定义,请添加到 menu.ts 的 componentMap 中`
)
// 如果找不到映射,尝试直接导入(可能会失败,但至少不会阻塞)
componentLoader = () =>
import(
/* @vite-ignore */ componentPath.startsWith("@/")
? componentPath
: `@/views/${componentPath}.vue`
)
}
} else if (menu.children && menu.children.length > 0) {
// 如果没有 component 但有子菜单,使用空布局组件来渲染子路由
componentLoader = EmptyLayout
}
// 如果有子菜单,先处理子菜单
let childrenRoutes: RouteRecordRaw[] | undefined
let redirectToDefaultChild: string | undefined
if (menu.children && menu.children.length > 0) {
childrenRoutes = convertMenusToRoutes(
menu.children,
menu.path
? menu.path.startsWith("/")
? menu.path.slice(1)
: menu.path
: parentPath,
true // 标记为子菜单
)
// 如果父菜单没有 component但子菜单中有路径为空字符串的路由默认子路由
// 应该让父路由重定向到该子路由
if (!menu.component && childrenRoutes.length > 0) {
const defaultChild = childrenRoutes.find((child) => child.path === "")
if (defaultChild && defaultChild.name) {
// 重定向到默认子路由(使用路由名称)
redirectToDefaultChild = defaultChild.name as string
}
}
}
// 如果既没有组件也没有子路由,跳过这个菜单(无法渲染)
if (!componentLoader && (!childrenRoutes || childrenRoutes.length === 0)) {
return
}
const route: RouteRecordRaw = {
path: routePath,
name: routeName,
meta: {
title: menu.name,
requiresAuth: true,
// 如果菜单有权限要求添加到路由meta中
...(menu.permission && { permissions: [menu.permission] }),
},
...(componentLoader && { component: componentLoader }),
// 如果有重定向,添加重定向
...(redirectToDefaultChild && {
redirect: { name: redirectToDefaultChild },
}),
// 如果有子菜单,添加子路由
...(childrenRoutes &&
childrenRoutes.length > 0 && {
children: childrenRoutes,
}),
} as RouteRecordRaw
routes.push(route)
})
return routes
}
/**
* 扁平化菜单树,用于查找特定路径的菜单
*/
export function flattenMenus(menus: Menu[]): Menu[] {
const result: Menu[] = []
menus.forEach((menu) => {
result.push(menu)
if (menu.children && menu.children.length > 0) {
result.push(...flattenMenus(menu.children))
}
})
return result
}