初始化

This commit is contained in:
2026-03-03 09:53:54 +08:00
commit 3f349a35a4
437 changed files with 65639 additions and 0 deletions

View File

@@ -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}`)
}
})
}
}

View File

@@ -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 = []
}
}
}

View File

@@ -0,0 +1,173 @@
/**
* 菜单处理器
*
* 负责菜单数据的获取、过滤和处理
*
* @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/auth'
import { asyncRoutes } from '../routes/asyncRoutes'
import { RoutesAlias } from '../routesAlias'
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()
}
// 规范化路径(将相对路径转换为完整路径)
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
return {
...item,
path: fullPath,
children
}
})
}
/**
* 构建完整路径
*/
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}`
}
}

View File

@@ -0,0 +1,119 @@
/**
* 路由权限验证模块
*
* 提供路由权限验证和路径检查功能
*
* ## 主要功能
*
* - 验证路径是否在用户菜单权限中
* - 构建菜单路径集合(扁平化处理)
* - 支持动态路由参数匹配
* - 路径前缀匹配
*
* ## 使用场景
*
* - 路由守卫中验证用户权限
* - 动态路由注册后的权限检查
* - 防止用户访问无权限的页面
*
* @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
}
// 构建路径集合
const pathSet = this.buildMenuPathSet(menuList)
// 检查路径是否在集合中(精确匹配或前缀匹配)
return pathSet.has(targetPath) || this.checkPathPrefix(targetPath, pathSet)
}
/**
* 构建菜单路径集合(扁平化处理)
* @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.meta?.isHide || !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
}
/**
* 验证并返回有效的路径
* 如果目标路径无权限,返回首页路径
* @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 }
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,144 @@
/**
* 路由转换器
*
* 负责将菜单数据转换为 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 (route.meta.isFullPage) {
// 全屏页面:不继承 layout直接使用组件
this.handleFullPageRoute(converted, component as string)
} 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) && !route.meta.isFullPage
}
/**
* 处理 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)
}
}
/**
* 处理全屏页面路由(不继承 layout
*/
private handleFullPageRoute(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]}` : '/'
}
}

View File

@@ -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('/')
}
}

View File

@@ -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'

View File

@@ -0,0 +1,34 @@
import { nextTick } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
import { Router } from 'vue-router'
import NProgress from 'nprogress'
import { useCommon } from '@/hooks/core/useCommon'
import { loadingService } from '@/utils/ui'
import { getPendingLoading, resetPendingLoading } from './beforeEach'
/** 路由全局后置守卫 */
export function setupAfterEachGuard(router: Router) {
const { scrollToTop } = useCommon()
router.afterEach(() => {
scrollToTop()
// 关闭进度条
const settingStore = useSettingStore()
if (settingStore.showNprogress) {
NProgress.done()
// 确保进度条完全移除,避免残影
setTimeout(() => {
NProgress.remove()
}, 600)
}
// 关闭 loading 效果
if (getPendingLoading()) {
nextTick(() => {
loadingService.hideLoading()
resetPendingLoading()
})
}
})
}

View File

@@ -0,0 +1,413 @@
/**
* 路由全局前置守卫模块
*
* 提供完整的路由导航守卫功能
*
* ## 主要功能
*
* - 登录状态验证和重定向
* - 动态路由注册和权限控制
* - 菜单数据获取和处理(前端/后端模式)
* - 用户信息获取和缓存
* - 页面标题设置
* - 工作标签页管理
* - 进度条和加载动画控制
* - 静态路由识别和处理
* - 错误处理和异常跳转
*
* ## 使用场景
*
* - 路由跳转前的权限验证
* - 动态菜单加载和路由注册
* - 用户登录状态管理
* - 页面访问控制
* - 路由级别的加载状态管理
*
* ## 工作流程
*
* 1. 检查登录状态,未登录跳转到登录页
* 2. 首次访问时获取用户信息和菜单数据
* 3. 根据权限动态注册路由
* 4. 设置页面标题和工作标签页
* 5. 处理根路径重定向到首页
* 6. 未匹配路由跳转到 404 页面
*
* @module router/guards/beforeEach
* @author Art Design Pro Team
*/
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { nextTick } from 'vue'
import NProgress from 'nprogress'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { useDictStore } from '@/store/modules/dict'
import { setWorktab } from '@/utils/navigation'
import { setPageTitle } from '@/utils/router'
import { RoutesAlias } from '../routesAlias'
import { staticRoutes } from '../routes/staticRoutes'
import { loadingService } from '@/utils/ui'
import { useCommon } from '@/hooks/core/useCommon'
import { useWorktabStore } from '@/store/modules/worktab'
import { fetchGetUserInfo, fetchGetDictList } from '@/api/auth'
import { ApiStatus } from '@/utils/http/status'
import { isHttpError } from '@/utils/http/error'
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
// 路由注册器实例
let routeRegistry: RouteRegistry | null = null
// 菜单处理器实例
const menuProcessor = new MenuProcessor()
// 跟踪是否需要关闭 loading
let pendingLoading = false
// 路由初始化失败标记,防止死循环
// 一旦设置为 true只有刷新页面或重新登录才能重置
let routeInitFailed = false
// 路由初始化进行中标记,防止并发请求
let routeInitInProgress = false
/**
* 获取 pendingLoading 状态
*/
export function getPendingLoading(): boolean {
return pendingLoading
}
/**
* 重置 pendingLoading 状态
*/
export function resetPendingLoading(): void {
pendingLoading = false
}
/**
* 获取路由初始化失败状态
*/
export function getRouteInitFailed(): boolean {
return routeInitFailed
}
/**
* 重置路由初始化状态(用于重新登录场景)
*/
export function resetRouteInitState(): void {
routeInitFailed = false
routeInitInProgress = false
}
/**
* 设置路由全局前置守卫
*/
export function setupBeforeEachGuard(router: Router): void {
// 初始化路由注册器
routeRegistry = new RouteRegistry(router)
router.beforeEach(
async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
try {
await handleRouteGuard(to, from, next, router)
} catch (error) {
console.error('[RouteGuard] 路由守卫处理失败:', error)
closeLoading()
next({ name: 'Exception500' })
}
}
)
}
/**
* 关闭 loading 效果
*/
function closeLoading(): void {
if (pendingLoading) {
nextTick(() => {
loadingService.hideLoading()
pendingLoading = false
})
}
}
/**
* 处理路由守卫逻辑
*/
async function handleRouteGuard(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
const settingStore = useSettingStore()
const userStore = useUserStore()
// 启动进度条
if (settingStore.showNprogress) {
NProgress.start()
}
// 1. 检查登录状态
if (!handleLoginStatus(to, userStore, next)) {
return
}
// 2. 检查路由初始化是否已失败(防止死循环)
if (routeInitFailed) {
// 已经失败过,直接放行到错误页面,不再重试
if (to.matched.length > 0) {
next()
} else {
// 未匹配到路由,跳转到 500 页面
next({ name: 'Exception500', replace: true })
}
return
}
// 3. 处理动态路由注册
if (!routeRegistry?.isRegistered() && userStore.isLogin) {
// 防止并发请求(快速连续导航场景)
if (routeInitInProgress) {
// 正在初始化中,等待完成后重新导航
next(false)
return
}
await handleDynamicRoutes(to, next, router)
return
}
// 4. 处理根路径重定向
if (handleRootPathRedirect(to, next)) {
return
}
// 5. 处理已匹配的路由
if (to.matched.length > 0) {
setWorktab(to)
setPageTitle(to)
next()
return
}
// 6. 未匹配到路由,跳转到 404
next({ name: 'Exception404' })
}
/**
* 处理登录状态
* @returns true 表示可以继续false 表示已处理跳转
*/
function handleLoginStatus(
to: RouteLocationNormalized,
userStore: ReturnType<typeof useUserStore>,
next: NavigationGuardNext
): boolean {
// 已登录或访问登录页或静态路由,直接放行
if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) {
return true
}
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
userStore.logOut()
next({
name: 'Login',
query: { redirect: to.fullPath }
})
return false
}
/**
* 检查路由是否为静态路由
*/
function isStaticRoute(path: string): boolean {
const checkRoute = (routes: any[], targetPath: string): boolean => {
return routes.some((route) => {
// 处理动态路由参数匹配
const routePath = route.path
const pattern = routePath.replace(/:[^/]+/g, '[^/]+').replace(/\*/g, '.*')
const regex = new RegExp(`^${pattern}$`)
if (regex.test(targetPath)) {
return true
}
if (route.children && route.children.length > 0) {
return checkRoute(route.children, targetPath)
}
return false
})
}
return checkRoute(staticRoutes, path)
}
/**
* 处理动态路由注册
*/
async function handleDynamicRoutes(
to: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
// 标记初始化进行中
routeInitInProgress = true
// 显示 loading
pendingLoading = true
loadingService.showLoading()
try {
// 1. 获取用户信息
await fetchUserInfo()
// + 获取字典数据
await fetchDictList()
// 2. 获取菜单数据
const menuList = await menuProcessor.getMenuList()
// 3. 验证菜单数据
if (!menuProcessor.validateMenuList(menuList)) {
throw new Error('获取菜单列表失败,请重新登录')
}
// 4. 注册动态路由
routeRegistry?.register(menuList)
// 5. 保存菜单数据到 store
const menuStore = useMenuStore()
menuStore.setMenuList(menuList)
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || [])
// 6. 保存 iframe 路由
IframeRouteManager.getInstance().save()
// 7. 验证工作标签页
useWorktabStore().validateWorktabs(router)
// 8. 验证目标路径权限
const { homePath } = useCommon()
const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath(
to.path,
menuList,
homePath.value || '/'
)
// 初始化成功,重置进行中标记
routeInitInProgress = false
// 9. 重新导航到目标路由
if (!hasPermission) {
// 无权限访问,跳转到首页
closeLoading()
// 输出警告信息
console.warn(`[RouteGuard] 用户无权限访问路径: ${to.path},已跳转到首页`)
// 直接跳转到首页
next({
path: validatedPath,
replace: true
})
} else {
// 有权限,正常导航
next({
path: to.path,
query: to.query,
hash: to.hash,
replace: true
})
}
} catch (error) {
console.error('[RouteGuard] 动态路由注册失败:', error)
// 关闭 loading
closeLoading()
// 401 错误axios 拦截器已处理退出登录,取消当前导航
if (isUnauthorizedError(error)) {
// 重置状态,允许重新登录后再次初始化
routeInitInProgress = false
next(false)
return
}
// 标记初始化失败,防止死循环
routeInitFailed = true
routeInitInProgress = false
// 输出详细错误信息,便于排查
if (isHttpError(error)) {
console.error(`[RouteGuard] 错误码: ${error.code}, 消息: ${error.message}`)
}
// 跳转到 500 页面,使用 replace 避免产生历史记录
next({ name: 'Exception500', replace: true })
}
}
/**
* 获取用户信息
*/
async function fetchUserInfo(): Promise<void> {
const userStore = useUserStore()
const data = await fetchGetUserInfo()
userStore.setUserInfo(data)
// 检查并清理工作台标签页(如果是不同用户登录)
userStore.checkAndClearWorktabs()
}
/**
* 获取字典数据
*/
async function fetchDictList(): Promise<void> {
const dictStore = useDictStore()
const data = await fetchGetDictList()
dictStore.setDictList(data)
}
/**
* 重置路由相关状态
*/
export function resetRouterState(delay: number): void {
setTimeout(() => {
routeRegistry?.unregister()
IframeRouteManager.getInstance().clear()
const menuStore = useMenuStore()
menuStore.removeAllDynamicRoutes()
menuStore.setMenuList([])
// 重置路由初始化状态,允许重新登录后再次初始化
resetRouteInitState()
}, delay)
}
/**
* 处理根路径重定向到首页
* @returns true 表示已处理跳转false 表示无需跳转
*/
function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGuardNext): boolean {
if (to.path !== '/') {
return false
}
const { homePath } = useCommon()
if (homePath.value && homePath.value !== '/') {
next({ path: homePath.value, replace: true })
return true
}
return false
}
/**
* 判断是否为未授权错误401
*/
function isUnauthorizedError(error: unknown): boolean {
return isHttpError(error) && error.code === ApiStatus.unauthorized
}

View File

@@ -0,0 +1,23 @@
import type { App } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { staticRoutes } from './routes/staticRoutes'
import { configureNProgress } from '@/utils/router'
import { setupBeforeEachGuard } from './guards/beforeEach'
import { setupAfterEachGuard } from './guards/afterEach'
// 创建路由实例
export const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes // 静态路由
})
// 初始化路由
export function initRouter(app: App<Element>): void {
configureNProgress() // 顶部进度条
setupBeforeEachGuard(router) // 路由前置守卫
setupAfterEachGuard(router) // 路由后置守卫
app.use(router)
}
// 主页路径,默认使用菜单第一个有效路径,配置后使用此路径
export const HOME_PAGE_PATH = ''

View File

@@ -0,0 +1,29 @@
import { AppRouteRecord } from '@/types/router'
export const dashboardRoutes: AppRouteRecord = {
name: 'Dashboard',
path: '/dashboard',
component: '/index/index',
meta: {
title: 'menus.dashboard.title',
icon: 'ri:pie-chart-line'
},
children: [
{
path: 'console',
name: 'Console',
component: '/dashboard/console',
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center/index.vue',
meta: { title: 'menus.userCenter.title', isHideTab: true }
}
]
}

View File

@@ -0,0 +1,46 @@
import { AppRouteRecord } from '@/types/router'
export const exceptionRoutes: AppRouteRecord = {
path: '/exception',
name: 'Exception',
component: '/index/index',
meta: {
title: 'menus.exception.title',
icon: 'ri:error-warning-line'
},
children: [
{
path: '403',
name: 'Exception403',
component: '/exception/403',
meta: {
title: 'menus.exception.forbidden',
keepAlive: true,
isHideTab: true,
isFullPage: true
}
},
{
path: '404',
name: 'Exception404',
component: '/exception/404',
meta: {
title: 'menus.exception.notFound',
keepAlive: true,
isHideTab: true,
isFullPage: true
}
},
{
path: '500',
name: 'Exception500',
component: '/exception/500',
meta: {
title: 'menus.exception.serverError',
keepAlive: true,
isHideTab: true,
isFullPage: true
}
}
]
}

View File

@@ -0,0 +1,15 @@
import { AppRouteRecord } from '@/types/router'
import { dashboardRoutes } from './dashboard'
import { systemRoutes } from './system'
import { resultRoutes } from './result'
import { exceptionRoutes } from './exception'
/**
* 导出所有模块化路由
*/
export const routeModules: AppRouteRecord[] = [
dashboardRoutes,
systemRoutes,
resultRoutes,
exceptionRoutes
]

View File

@@ -0,0 +1,33 @@
import { AppRouteRecord } from '@/types/router'
export const resultRoutes: AppRouteRecord = {
path: '/result',
name: 'Result',
component: '/index/index',
meta: {
title: 'menus.result.title',
icon: 'ri:checkbox-circle-line'
},
children: [
{
path: 'success',
name: 'ResultSuccess',
component: '/result/success',
meta: {
title: 'menus.result.success',
icon: 'ri:checkbox-circle-line',
keepAlive: true
}
},
{
path: 'fail',
name: 'ResultFail',
component: '/result/fail',
meta: {
title: 'menus.result.fail',
icon: 'ri:close-circle-line',
keepAlive: true
}
}
]
}

View File

@@ -0,0 +1,60 @@
import { AppRouteRecord } from '@/types/router'
export const systemRoutes: AppRouteRecord = {
path: '/system',
name: 'System',
component: '/index/index',
meta: {
title: 'menus.system.title',
icon: 'ri:user-3-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: '/system/user',
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: '/system/role',
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center',
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: '/system/menu',
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{ title: '新增', authMark: 'add' },
{ title: '编辑', authMark: 'edit' },
{ title: '删除', authMark: 'delete' }
]
}
}
]
}

View File

@@ -0,0 +1,9 @@
// 权限文档https://www.artd.pro/docs/zh/guide/in-depth/permission.html
import { AppRouteRecord } from '@/types/router'
import { routeModules } from '../modules'
/**
* 动态路由(需要权限才能访问的路由)
* 用于渲染菜单以及根据菜单权限动态加载路由,如果没有权限无法访问
*/
export const asyncRoutes: AppRouteRecord[] = routeModules

View File

@@ -0,0 +1,72 @@
import { AppRouteRecordRaw } from '@/utils/router'
/**
* 静态路由配置(不需要权限就能访问的路由)
*
* 属性说明:
* isHideTab: true 表示不在标签页中显示
*
* 注意事项:
* 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问
* 2、静态路由不管是否登录都可以访问
*/
export const staticRoutes: AppRouteRecordRaw[] = [
// 不需要登录就能访问的路由示例
// {
// path: '/welcome',
// name: 'WelcomeStatic',
// component: () => import('@views/dashboard/console/index.vue'),
// meta: { title: 'menus.dashboard.title' }
// },
{
path: '/auth/login',
name: 'Login',
component: () => import('@views/auth/login/index.vue'),
meta: { title: 'menus.login.title', isHideTab: true }
},
{
path: '/auth/register',
name: 'Register',
component: () => import('@views/auth/register/index.vue'),
meta: { title: 'menus.register.title', isHideTab: true }
},
{
path: '/auth/forget-password',
name: 'ForgetPassword',
component: () => import('@views/auth/forget-password/index.vue'),
meta: { title: 'menus.forgetPassword.title', isHideTab: true }
},
{
path: '/403',
name: 'Exception403',
component: () => import('@views/exception/403/index.vue'),
meta: { title: '403', isHideTab: true }
},
{
path: '/:pathMatch(.*)*',
name: 'Exception404',
component: () => import('@views/exception/404/index.vue'),
meta: { title: '404', isHideTab: true }
},
{
path: '/500',
name: 'Exception500',
component: () => import('@views/exception/500/index.vue'),
meta: { title: '500', isHideTab: true }
},
{
path: '/outside',
component: () => import('@views/index/index.vue'),
name: 'Outside',
meta: { title: 'menus.outside.title' },
children: [
// iframe 内嵌页面
{
path: '/outside/iframe/:path',
name: 'Iframe',
component: () => import('@/views/outside/Iframe.vue'),
meta: { title: 'iframe' }
}
]
}
]

View File

@@ -0,0 +1,8 @@
/**
* 公共路由别名
# 存放系统级公共路由路径,如布局容器、登录页等
*/
export enum RoutesAlias {
Layout = '/index/index', // 布局容器
Login = '/auth/login' // 登录页
}