feat(admin): 模板精简+请求层适配 Django JWT
- 克隆 art-design-pro v3.0.2(GitHub depth1),rm .git,pnpm clean:dev 精简演示页(92 文件) - .env.development/.env.production 均设 VITE_API_URL=/api,代理目标 127.0.0.1:8000(移除 Apifox Mock) - http 封装信封适配:code===200 解包 data;msg/message 字段兼容(拦截器/error/类型) - /api/admin/auth/token 登录适配(access->Bearer token, refresh->refreshToken);/api/admin/auth/me 映射 UserInfo(userName/roles/is_admin) - 登录页演示账号替换为真实 admin;logOut 按路由名判登录页修复 redirect 嵌套 - 根 .gitignore 补 !admin/.env(模板公共 env 非密钥,需入库) - 验证:Playwright 全链路(错误密码 401 留登页/正确登录进工作台/登出/受保护路由重定向/redirect 回跳)+ pnpm build(vue-tsc 通过,产物无 mock 域名) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 组件加载器
|
||||
*
|
||||
* 负责动态加载 Vue 组件
|
||||
*
|
||||
* @module router/core/ComponentLoader
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import { h } from 'vue'
|
||||
|
||||
export class ComponentLoader {
|
||||
private modules: Record<string, () => Promise<any>>
|
||||
|
||||
constructor() {
|
||||
// 动态导入 views 目录下所有 .vue 组件
|
||||
this.modules = import.meta.glob('../../views/**/*.vue')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载组件
|
||||
*/
|
||||
load(componentPath: string): () => Promise<any> {
|
||||
if (!componentPath) {
|
||||
return this.createEmptyComponent()
|
||||
}
|
||||
|
||||
// 构建可能的路径
|
||||
const fullPath = `../../views${componentPath}.vue`
|
||||
const fullPathWithIndex = `../../views${componentPath}/index.vue`
|
||||
|
||||
// 先尝试直接路径,再尝试添加/index的路径
|
||||
const module = this.modules[fullPath] || this.modules[fullPathWithIndex]
|
||||
|
||||
if (!module) {
|
||||
console.error(
|
||||
`[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}`
|
||||
)
|
||||
return this.createErrorComponent(componentPath)
|
||||
}
|
||||
|
||||
return module
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载布局组件
|
||||
*/
|
||||
loadLayout(): () => Promise<any> {
|
||||
return () => import('@/views/index/index.vue')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 iframe 组件
|
||||
*/
|
||||
loadIframe(): () => Promise<any> {
|
||||
return () => import('@/views/outside/Iframe.vue')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空组件
|
||||
*/
|
||||
private createEmptyComponent(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h('div', {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误提示组件
|
||||
*/
|
||||
private createErrorComponent(componentPath: string): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h('div', { class: 'route-error' }, `组件未找到: ${componentPath}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Iframe 路由管理器
|
||||
*
|
||||
* 负责管理 iframe 类型的路由
|
||||
*
|
||||
* @module router/core/IframeRouteManager
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
export class IframeRouteManager {
|
||||
private static instance: IframeRouteManager
|
||||
private iframeRoutes: AppRouteRecord[] = []
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): IframeRouteManager {
|
||||
if (!IframeRouteManager.instance) {
|
||||
IframeRouteManager.instance = new IframeRouteManager()
|
||||
}
|
||||
return IframeRouteManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 iframe 路由
|
||||
*/
|
||||
add(route: AppRouteRecord): void {
|
||||
if (!this.iframeRoutes.find((r) => r.path === route.path)) {
|
||||
this.iframeRoutes.push(route)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 iframe 路由
|
||||
*/
|
||||
getAll(): AppRouteRecord[] {
|
||||
return this.iframeRoutes
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径查找 iframe 路由
|
||||
*/
|
||||
findByPath(path: string): AppRouteRecord | undefined {
|
||||
return this.iframeRoutes.find((route) => route.path === path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有 iframe 路由
|
||||
*/
|
||||
clear(): void {
|
||||
this.iframeRoutes = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到 sessionStorage
|
||||
*/
|
||||
save(): void {
|
||||
if (this.iframeRoutes.length > 0) {
|
||||
sessionStorage.setItem('iframeRoutes', JSON.stringify(this.iframeRoutes))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 sessionStorage 加载
|
||||
*/
|
||||
load(): void {
|
||||
try {
|
||||
const data = sessionStorage.getItem('iframeRoutes')
|
||||
if (data) {
|
||||
this.iframeRoutes = JSON.parse(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[IframeRouteManager] 加载 iframe 路由失败:', error)
|
||||
this.iframeRoutes = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 菜单处理器
|
||||
*
|
||||
* 负责菜单数据的获取、过滤和处理
|
||||
*
|
||||
* @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/system-manage'
|
||||
import { asyncRoutes } from '../routes/asyncRoutes'
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
import { formatMenuTitle } from '@/utils'
|
||||
|
||||
export class MenuProcessor {
|
||||
/**
|
||||
* 获取菜单数据
|
||||
*/
|
||||
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)
|
||||
|
||||
// 规范化路径(将相对路径转换为完整路径)
|
||||
return this.normalizeMenuPaths(menuList)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理前端控制模式的菜单
|
||||
*/
|
||||
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 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
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归过滤空菜单项
|
||||
*/
|
||||
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}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 路由权限验证模块
|
||||
*
|
||||
* 提供路由权限验证和路径检查功能
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 验证路径是否在用户菜单权限中
|
||||
* - 构建菜单路径集合(扁平化处理)
|
||||
* - 支持动态路由参数匹配
|
||||
* - 路径前缀匹配
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 路由守卫中验证用户权限
|
||||
* - 动态路由注册后的权限检查
|
||||
* - 防止用户访问无权限的页面
|
||||
*
|
||||
* @module router/core/RoutePermissionValidator
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
/**
|
||||
* 路由权限验证器
|
||||
*/
|
||||
export class RoutePermissionValidator {
|
||||
/**
|
||||
* 验证路径是否在用户菜单权限中
|
||||
* @param targetPath 目标路径
|
||||
* @param menuList 菜单列表
|
||||
* @returns 是否有权限访问
|
||||
*/
|
||||
static hasPermission(targetPath: string, menuList: AppRouteRecord[]): boolean {
|
||||
// 根路径始终允许访问
|
||||
if (targetPath === '/') {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.matchRoute(targetPath, menuList)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建菜单路径集合(扁平化处理)
|
||||
* @param menuList 菜单列表
|
||||
* @param pathSet 路径集合
|
||||
* @returns 路径集合
|
||||
*/
|
||||
static buildMenuPathSet(
|
||||
menuList: AppRouteRecord[],
|
||||
pathSet: Set<string> = new Set()
|
||||
): Set<string> {
|
||||
if (!Array.isArray(menuList) || menuList.length === 0) {
|
||||
return pathSet
|
||||
}
|
||||
|
||||
for (const menuItem of menuList) {
|
||||
if (!menuItem.path) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 标准化路径并添加到集合
|
||||
const menuPath = menuItem.path.startsWith('/') ? menuItem.path : `/${menuItem.path}`
|
||||
pathSet.add(menuPath)
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menuItem.children?.length) {
|
||||
this.buildMenuPathSet(menuItem.children, pathSet)
|
||||
}
|
||||
}
|
||||
|
||||
return pathSet
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目标路径是否匹配集合中的某个路径前缀
|
||||
* 用于支持动态路由参数匹配,如 /user/123 匹配 /user
|
||||
* @param targetPath 目标路径
|
||||
* @param pathSet 路径集合
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
static checkPathPrefix(targetPath: string, pathSet: Set<string>): boolean {
|
||||
// 遍历路径集合,检查是否有前缀匹配
|
||||
for (const menuPath of pathSet) {
|
||||
if (targetPath.startsWith(`${menuPath}/`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归匹配路由配置,支持隐藏路由和动态参数路由
|
||||
*/
|
||||
static matchRoute(targetPath: string, routes: AppRouteRecord[]): boolean {
|
||||
if (!Array.isArray(routes) || routes.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
if (!route.path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const routePath = route.path.startsWith('/') ? route.path : `/${route.path}`
|
||||
|
||||
if (
|
||||
routePath === targetPath ||
|
||||
this.isDynamicRouteMatch(targetPath, routePath) ||
|
||||
targetPath.startsWith(`${routePath}/`)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (route.children?.length && this.matchRoute(targetPath, route.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目标路径是否匹配动态参数路由,如 /demo/123 匹配 /demo/:id
|
||||
*/
|
||||
static isDynamicRouteMatch(targetPath: string, routePath: string): boolean {
|
||||
if (!routePath.includes(':')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pattern = routePath
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/:([^/]+)/g, '[^/]+')
|
||||
.replace(/\\\*/g, '.*')
|
||||
|
||||
return new RegExp(`^${pattern}$`).test(targetPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并返回有效的路径
|
||||
* 如果目标路径无权限,返回首页路径
|
||||
* @param targetPath 目标路径
|
||||
* @param menuList 菜单列表
|
||||
* @param homePath 首页路径
|
||||
* @returns 验证后的路径
|
||||
*/
|
||||
static validatePath(
|
||||
targetPath: string,
|
||||
menuList: AppRouteRecord[],
|
||||
homePath: string = '/'
|
||||
): { path: string; hasPermission: boolean } {
|
||||
const hasPermission = this.hasPermission(targetPath, menuList)
|
||||
|
||||
if (hasPermission) {
|
||||
return { path: targetPath, hasPermission: true }
|
||||
}
|
||||
|
||||
return { path: homePath, hasPermission: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 路由注册核心类
|
||||
*
|
||||
* 负责动态路由的注册、验证和管理
|
||||
*
|
||||
* @module router/core/RouteRegistry
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import type { Router, RouteRecordRaw } from 'vue-router'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { ComponentLoader } from './ComponentLoader'
|
||||
import { RouteValidator } from './RouteValidator'
|
||||
import { RouteTransformer } from './RouteTransformer'
|
||||
|
||||
export class RouteRegistry {
|
||||
private router: Router
|
||||
private componentLoader: ComponentLoader
|
||||
private validator: RouteValidator
|
||||
private transformer: RouteTransformer
|
||||
private removeRouteFns: (() => void)[] = []
|
||||
private registered = false
|
||||
|
||||
constructor(router: Router) {
|
||||
this.router = router
|
||||
this.componentLoader = new ComponentLoader()
|
||||
this.validator = new RouteValidator()
|
||||
this.transformer = new RouteTransformer(this.componentLoader)
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册动态路由
|
||||
*/
|
||||
register(menuList: AppRouteRecord[]): void {
|
||||
if (this.registered) {
|
||||
console.warn('[RouteRegistry] 路由已注册,跳过重复注册')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证路由配置
|
||||
const validationResult = this.validator.validate(menuList)
|
||||
if (!validationResult.valid) {
|
||||
throw new Error(`路由配置验证失败: ${validationResult.errors.join(', ')}`)
|
||||
}
|
||||
|
||||
// 转换并注册路由
|
||||
const removeRouteFns: (() => void)[] = []
|
||||
|
||||
menuList.forEach((route) => {
|
||||
if (route.name && !this.router.hasRoute(route.name)) {
|
||||
const routeConfig = this.transformer.transform(route)
|
||||
const removeRouteFn = this.router.addRoute(routeConfig as RouteRecordRaw)
|
||||
removeRouteFns.push(removeRouteFn)
|
||||
}
|
||||
})
|
||||
|
||||
this.removeRouteFns = removeRouteFns
|
||||
this.registered = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除所有动态路由
|
||||
*/
|
||||
unregister(): void {
|
||||
this.removeRouteFns.forEach((fn) => fn())
|
||||
this.removeRouteFns = []
|
||||
this.registered = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已注册
|
||||
*/
|
||||
isRegistered(): boolean {
|
||||
return this.registered
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取移除函数列表(用于 store 管理)
|
||||
*/
|
||||
getRemoveRouteFns(): (() => void)[] {
|
||||
return this.removeRouteFns
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已注册(用于错误处理场景,避免重复请求)
|
||||
*/
|
||||
markAsRegistered(): void {
|
||||
this.registered = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 路由转换器
|
||||
*
|
||||
* 负责将菜单数据转换为 Vue Router 路由配置
|
||||
*
|
||||
* @module router/core/RouteTransformer
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { ComponentLoader } from './ComponentLoader'
|
||||
import { IframeRouteManager } from './IframeRouteManager'
|
||||
|
||||
interface ConvertedRoute extends Omit<RouteRecordRaw, 'children'> {
|
||||
id?: number
|
||||
children?: ConvertedRoute[]
|
||||
component?: RouteRecordRaw['component'] | (() => Promise<any>)
|
||||
}
|
||||
|
||||
export class RouteTransformer {
|
||||
private componentLoader: ComponentLoader
|
||||
private iframeManager: IframeRouteManager
|
||||
|
||||
constructor(componentLoader: ComponentLoader) {
|
||||
this.componentLoader = componentLoader
|
||||
this.iframeManager = IframeRouteManager.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换路由配置
|
||||
*/
|
||||
transform(route: AppRouteRecord, depth = 0): ConvertedRoute {
|
||||
const { component, children, ...routeConfig } = route
|
||||
|
||||
// 基础路由配置
|
||||
const converted: ConvertedRoute = {
|
||||
...routeConfig,
|
||||
component: undefined
|
||||
}
|
||||
|
||||
// 处理不同类型的路由
|
||||
if (route.meta.isIframe) {
|
||||
this.handleIframeRoute(converted, route, depth)
|
||||
} else if (this.isFirstLevelRoute(route, depth)) {
|
||||
this.handleFirstLevelRoute(converted, route, component as string)
|
||||
} else {
|
||||
this.handleNormalRoute(converted, component as string)
|
||||
}
|
||||
|
||||
// 递归处理子路由
|
||||
if (children?.length) {
|
||||
converted.children = children.map((child) => this.transform(child, depth + 1))
|
||||
}
|
||||
|
||||
return converted
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为一级路由(需要 Layout 包裹)
|
||||
*/
|
||||
private isFirstLevelRoute(route: AppRouteRecord, depth: number): boolean {
|
||||
return depth === 0 && (!route.children || route.children.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 iframe 类型路由
|
||||
*/
|
||||
private handleIframeRoute(
|
||||
targetRoute: ConvertedRoute,
|
||||
sourceRoute: AppRouteRecord,
|
||||
depth: number
|
||||
): void {
|
||||
if (depth === 0) {
|
||||
// 顶级 iframe:用 Layout 包裹
|
||||
targetRoute.component = this.componentLoader.loadLayout()
|
||||
targetRoute.path = this.extractFirstSegment(sourceRoute.path || '')
|
||||
targetRoute.name = ''
|
||||
|
||||
targetRoute.children = [
|
||||
{
|
||||
...sourceRoute,
|
||||
component: this.componentLoader.loadIframe()
|
||||
} as ConvertedRoute
|
||||
]
|
||||
} else {
|
||||
// 非顶级(嵌套)iframe:直接使用 Iframe.vue
|
||||
targetRoute.component = this.componentLoader.loadIframe()
|
||||
}
|
||||
|
||||
// 记录 iframe 路由
|
||||
this.iframeManager.add(sourceRoute)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理一级菜单路由
|
||||
*/
|
||||
private handleFirstLevelRoute(
|
||||
converted: ConvertedRoute,
|
||||
route: AppRouteRecord,
|
||||
component: string | undefined
|
||||
): void {
|
||||
converted.component = this.componentLoader.loadLayout()
|
||||
converted.path = this.extractFirstSegment(route.path || '')
|
||||
converted.name = ''
|
||||
route.meta.isFirstLevel = true
|
||||
|
||||
converted.children = [
|
||||
{
|
||||
...route,
|
||||
component: component ? this.componentLoader.load(component) : undefined
|
||||
} as ConvertedRoute
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理普通路由
|
||||
*/
|
||||
private handleNormalRoute(converted: ConvertedRoute, component: string | undefined): void {
|
||||
if (component) {
|
||||
converted.component = this.componentLoader.load(component)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取路径的第一段
|
||||
*/
|
||||
private extractFirstSegment(path: string): string {
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
return segments.length > 0 ? `/${segments[0]}` : '/'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 路由验证器
|
||||
*
|
||||
* 负责验证路由配置的合法性
|
||||
*
|
||||
* @module router/core/RouteValidator
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export class RouteValidator {
|
||||
// 用于记录已经提示过的路由,避免重复提示
|
||||
private warnedRoutes = new Set<string>()
|
||||
|
||||
/**
|
||||
* 验证路由配置
|
||||
*/
|
||||
validate(routes: AppRouteRecord[]): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
// 检测重复路由
|
||||
this.checkDuplicates(routes, errors, warnings)
|
||||
|
||||
// 检测组件配置
|
||||
this.checkComponents(routes, errors, warnings)
|
||||
|
||||
// 检测嵌套菜单的 /index/index 配置
|
||||
this.checkNestedIndexComponent(routes)
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测重复路由
|
||||
*/
|
||||
private checkDuplicates(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ''
|
||||
): void {
|
||||
const routeNameMap = new Map<string, string>()
|
||||
const componentPathMap = new Map<string, string>()
|
||||
|
||||
const checkRoutes = (routes: AppRouteRecord[], parentPath = '') => {
|
||||
routes.forEach((route) => {
|
||||
const currentPath = route.path || ''
|
||||
const fullPath = this.resolvePath(parentPath, currentPath)
|
||||
|
||||
// 名称重复检测
|
||||
if (route.name) {
|
||||
const routeName = String(route.name)
|
||||
if (routeNameMap.has(routeName)) {
|
||||
warnings.push(`路由名称重复: "${routeName}" (${fullPath})`)
|
||||
} else {
|
||||
routeNameMap.set(routeName, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件路径重复检测
|
||||
if (route.component && typeof route.component === 'string') {
|
||||
const componentPath = route.component
|
||||
if (componentPath !== RoutesAlias.Layout) {
|
||||
const componentKey = `${parentPath}:${componentPath}`
|
||||
if (componentPathMap.has(componentKey)) {
|
||||
warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`)
|
||||
} else {
|
||||
componentPathMap.set(componentKey, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子路由
|
||||
if (route.children?.length) {
|
||||
checkRoutes(route.children, fullPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
checkRoutes(routes, parentPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测组件配置
|
||||
*/
|
||||
private checkComponents(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ''
|
||||
): void {
|
||||
routes.forEach((route) => {
|
||||
const hasExternalLink = !!route.meta?.link?.trim()
|
||||
const hasChildren = Array.isArray(route.children) && route.children.length > 0
|
||||
const routePath = route.path || '[未定义路径]'
|
||||
const isIframe = route.meta?.isIframe
|
||||
|
||||
// 如果配置了 component,则无需校验
|
||||
if (route.component) {
|
||||
// 递归检查子路由
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || '')
|
||||
this.checkComponents(route.children, errors, warnings, fullPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 一级菜单:必须指定 Layout,除非是外链或 iframe
|
||||
if (parentPath === '' && !hasExternalLink && !isIframe) {
|
||||
errors.push(`一级菜单(${routePath}) 缺少 component,必须指向 ${RoutesAlias.Layout}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 非一级菜单:如果既不是外链、iframe,也没有子路由,则必须配置 component
|
||||
if (!hasExternalLink && !isIframe && !hasChildren) {
|
||||
errors.push(`路由(${routePath}) 缺少 component 配置`)
|
||||
}
|
||||
|
||||
// 递归检查子路由
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || '')
|
||||
this.checkComponents(route.children, errors, warnings, fullPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测嵌套菜单的 Layout 组件配置
|
||||
* 只有一级菜单才能使用 Layout,二级及以下菜单不能使用
|
||||
*/
|
||||
private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void {
|
||||
routes.forEach((route) => {
|
||||
// 检查二级及以下菜单是否错误使用了 Layout
|
||||
if (level > 1 && route.component === RoutesAlias.Layout) {
|
||||
this.logLayoutError(route, level)
|
||||
}
|
||||
|
||||
// 递归检查子路由
|
||||
if (route.children?.length) {
|
||||
this.checkNestedIndexComponent(route.children, level + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出 Layout 组件配置错误日志
|
||||
*/
|
||||
private logLayoutError(route: AppRouteRecord, level: number): void {
|
||||
const routeName = String(route.name || route.path || '未知路由')
|
||||
const routeKey = `${routeName}_${route.path}`
|
||||
|
||||
// 避免重复提示
|
||||
if (this.warnedRoutes.has(routeKey)) return
|
||||
this.warnedRoutes.add(routeKey)
|
||||
|
||||
const menuTitle = route.meta?.title || routeName
|
||||
const routePath = route.path || '/'
|
||||
|
||||
console.error(
|
||||
`[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` +
|
||||
` 问题: ${level}级菜单不能使用 ${RoutesAlias.Layout} 作为 component\n` +
|
||||
` 说明: 只有一级菜单才能使用 ${RoutesAlias.Layout},二级及以下菜单应该指向具体的组件路径\n` +
|
||||
` 当前配置: component: '${RoutesAlias.Layout}'\n` +
|
||||
` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径解析
|
||||
*/
|
||||
private resolvePath(parent: string, child: string): string {
|
||||
return [parent.replace(/\/$/, ''), child.replace(/^\//, '')].filter(Boolean).join('/')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 路由核心模块导出
|
||||
*
|
||||
* @module router/core
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
export { RouteRegistry } from './RouteRegistry'
|
||||
export { ComponentLoader } from './ComponentLoader'
|
||||
export { RouteValidator } from './RouteValidator'
|
||||
export { RouteTransformer } from './RouteTransformer'
|
||||
export { IframeRouteManager } from './IframeRouteManager'
|
||||
export { MenuProcessor } from './MenuProcessor'
|
||||
export { RoutePermissionValidator } from './RoutePermissionValidator'
|
||||
Reference in New Issue
Block a user