562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
|
||
import NProgress from 'nprogress'
|
||
import { useSettingStore } from '@/store/modules/setting'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import { useMenuStore } from '@/store/modules/menu'
|
||
import { setWorktab } from '@/utils/navigation'
|
||
import { setPageTitle, setSystemTheme } from '../utils/utils'
|
||
import { registerDynamicRoutes } from '../utils/registerRoutes'
|
||
import { AppRouteRecord } from '@/types/router'
|
||
import { RoutesAlias } from '../routesAlias'
|
||
import { menuDataToRouter } from '../utils/menuToRouter'
|
||
import { asyncRoutes } from '../routes/asyncRoutes'
|
||
import { loadingService } from '@/utils/ui'
|
||
import { useCommon } from '@/composables/useCommon'
|
||
import { useWorktabStore } from '@/store/modules/worktab'
|
||
import { isInWhiteList, hasRoutePermission, isTokenValid, buildLoginRedirect } from './permission'
|
||
|
||
// 是否已注册动态路由
|
||
const isRouteRegistered = ref(false)
|
||
|
||
// 临时开发模式:跳过所有权限验证(开发静态页面时使用)
|
||
const DEV_MODE_SKIP_AUTH = false
|
||
|
||
/**
|
||
* 路由全局前置守卫
|
||
* 处理进度条、获取菜单列表、动态路由注册、404 检查、工作标签页及页面标题设置
|
||
*/
|
||
export function setupBeforeEachGuard(router: Router): void {
|
||
router.beforeEach(
|
||
async (
|
||
to: RouteLocationNormalized,
|
||
from: RouteLocationNormalized,
|
||
next: NavigationGuardNext
|
||
) => {
|
||
try {
|
||
// 开发模式:直接放行,无需登录
|
||
if (DEV_MODE_SKIP_AUTH) {
|
||
const settingStore = useSettingStore()
|
||
if (settingStore.showNprogress) {
|
||
NProgress.start()
|
||
}
|
||
setSystemTheme(to)
|
||
setPageTitle(to)
|
||
|
||
// 开发模式下,首次访问时注册所有路由
|
||
if (!isRouteRegistered.value) {
|
||
const menuList = asyncRoutes.map((route) => menuDataToRouter(route))
|
||
const menuStore = useMenuStore()
|
||
menuStore.setMenuList(menuList)
|
||
registerDynamicRoutes(router, menuList)
|
||
isRouteRegistered.value = true
|
||
|
||
// 重新导航到目标路由
|
||
next({ path: to.path, query: to.query, hash: to.hash, replace: true })
|
||
return
|
||
}
|
||
|
||
// 设置工作标签页
|
||
setWorktab(to)
|
||
next()
|
||
return
|
||
}
|
||
|
||
await handleRouteGuard(to, from, next, router)
|
||
} catch (error) {
|
||
console.error('路由守卫处理失败:', error)
|
||
next('/exception/500')
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 处理路由守卫逻辑
|
||
*/
|
||
async function handleRouteGuard(
|
||
to: RouteLocationNormalized,
|
||
from: RouteLocationNormalized,
|
||
next: NavigationGuardNext,
|
||
router: Router
|
||
): Promise<void> {
|
||
const settingStore = useSettingStore()
|
||
const userStore = useUserStore()
|
||
|
||
// 处理进度条
|
||
if (settingStore.showNprogress) {
|
||
NProgress.start()
|
||
}
|
||
|
||
// 设置系统主题
|
||
setSystemTheme(to)
|
||
|
||
// 处理登录状态
|
||
if (!(await handleLoginStatus(to, userStore, next))) {
|
||
return
|
||
}
|
||
|
||
// 处理动态路由注册
|
||
if (!isRouteRegistered.value && userStore.isLogin) {
|
||
await handleDynamicRoutes(to, router, next)
|
||
return
|
||
}
|
||
|
||
// 处理已知的匹配路由
|
||
if (to.matched.length > 0) {
|
||
setWorktab(to)
|
||
setPageTitle(to)
|
||
next()
|
||
return
|
||
}
|
||
|
||
// 路由未匹配时,判断是 404 还是 403
|
||
if (userStore.isLogin) {
|
||
// 检查路由是否存在于 asyncRoutes 中
|
||
const routeExistsInAsync = checkRouteExistsInAsyncRoutes(to.path, asyncRoutes)
|
||
console.log(`[路由检查] 目标路径: ${to.path}, 是否存在于asyncRoutes: ${routeExistsInAsync}`)
|
||
|
||
if (routeExistsInAsync) {
|
||
// 路由存在于 asyncRoutes 但未注册,说明用户没有权限
|
||
console.warn('路由存在但用户无权限访问:', to.path)
|
||
next(RoutesAlias.Exception403 || '/exception/403')
|
||
return
|
||
}
|
||
}
|
||
|
||
// 路由不存在,跳转到 404
|
||
console.log(`[路由检查] 路径 ${to.path} 不存在于asyncRoutes,跳转404`)
|
||
next(RoutesAlias.Exception404)
|
||
}
|
||
|
||
/**
|
||
* 处理登录状态
|
||
*/
|
||
async function handleLoginStatus(
|
||
to: RouteLocationNormalized,
|
||
userStore: ReturnType<typeof useUserStore>,
|
||
next: NavigationGuardNext
|
||
): Promise<boolean> {
|
||
const { isLogin, accessToken, info } = userStore
|
||
|
||
// 如果访问的是白名单路由,直接放行
|
||
if (isInWhiteList(to.path) || to.meta.noLogin) {
|
||
// 如果已登录且访问登录页,重定向到首页
|
||
if (isLogin && to.path === RoutesAlias.Login) {
|
||
next(RoutesAlias.Home || '/')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 检查是否已登录
|
||
if (!isLogin || !accessToken) {
|
||
userStore.logOut()
|
||
next(buildLoginRedirect(to.fullPath))
|
||
return false
|
||
}
|
||
|
||
// 检查 Token 是否有效
|
||
if (!isTokenValid(accessToken)) {
|
||
console.warn('Token 已过期,需要重新登录')
|
||
userStore.logOut()
|
||
next(buildLoginRedirect(to.fullPath))
|
||
return false
|
||
}
|
||
|
||
// 检查页面级权限
|
||
if (!hasRoutePermission(to, info)) {
|
||
console.warn('无权限访问该页面:', to.path)
|
||
next(RoutesAlias.Exception403 || '/exception/403')
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 处理动态路由注册
|
||
*/
|
||
async function handleDynamicRoutes(
|
||
to: RouteLocationNormalized,
|
||
router: Router,
|
||
next: NavigationGuardNext
|
||
): Promise<void> {
|
||
try {
|
||
await getMenuData(router)
|
||
next({
|
||
path: to.path,
|
||
query: to.query,
|
||
hash: to.hash,
|
||
replace: true
|
||
})
|
||
} catch (error) {
|
||
console.error('动态路由注册失败:', error)
|
||
next('/exception/500')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取菜单数据
|
||
* @param router 路由实例
|
||
*/
|
||
async function getMenuData(router: Router): Promise<void> {
|
||
try {
|
||
if (useCommon().isFrontendMode.value) {
|
||
await processFrontendMenu(router) // 前端控制模式
|
||
} else {
|
||
await processBackendMenu(router) // 后端控制模式
|
||
}
|
||
} catch (error) {
|
||
handleMenuError(error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理前端控制模式的菜单逻辑
|
||
*/
|
||
async function processFrontendMenu(router: Router): Promise<void> {
|
||
const closeLoading = loadingService.showLoading()
|
||
|
||
try {
|
||
const menuList = asyncRoutes.map((route) => menuDataToRouter(route))
|
||
const userStore = useUserStore()
|
||
const roles = userStore.info.roles
|
||
|
||
if (!roles || roles.length === 0) {
|
||
console.warn('用户角色信息不存在,清除登录状态')
|
||
closeLoading()
|
||
userStore.logOut()
|
||
throw new Error('获取用户角色失败')
|
||
}
|
||
|
||
const filteredMenuList = filterMenuByRoles(menuList, roles)
|
||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||
await registerAndStoreMenu(router, filteredMenuList, closeLoading)
|
||
} catch (error) {
|
||
closeLoading()
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理后端控制模式的菜单
|
||
*/
|
||
async function processBackendMenu(router: Router): Promise<void> {
|
||
const closeLoading = loadingService.showLoading()
|
||
|
||
try {
|
||
const userStore = useUserStore()
|
||
|
||
// 如果是超级管理员(user_type === 1),直接使用所有 asyncRoutes
|
||
if (userStore.isSuperAdmin) {
|
||
const menuList = asyncRoutes.map((route) => menuDataToRouter(route))
|
||
await registerAndStoreMenu(router, menuList, closeLoading)
|
||
return
|
||
}
|
||
|
||
// 普通用户:使用后端返回的菜单
|
||
const backendMenus = userStore.menus || []
|
||
const routeMap = buildRouteMap(asyncRoutes)
|
||
|
||
const menuList = backendMenus
|
||
.map((menu) => convertBackendMenuToRoute(menu, routeMap))
|
||
.filter((route) => route !== null)
|
||
|
||
const finalMenuList = mergeDefaultMenus(menuList, routeMap)
|
||
await registerAndStoreMenu(router, finalMenuList, closeLoading)
|
||
} catch (error) {
|
||
closeLoading()
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 合并默认菜单
|
||
* 将配置的默认菜单合并到后端返回的菜单中
|
||
* 注意: /dashboard 及其子路由(console/analysis/ecommerce)不再作为默认菜单强制显示
|
||
* 这些路由只有在后端 menus 中返回时才会显示
|
||
*/
|
||
function mergeDefaultMenus(
|
||
backendMenus: AppRouteRecord[],
|
||
routeMap: Map<string, AppRouteRecord>
|
||
): AppRouteRecord[] {
|
||
// 移除 /dashboard 作为默认菜单,现在仪表台路由完全由后端控制
|
||
const defaultMenuPaths: string[] = []
|
||
|
||
const defaultMenus: AppRouteRecord[] = defaultMenuPaths
|
||
.map((path) => {
|
||
const route = routeMap.get(path)
|
||
return route ? menuDataToRouter(route) : null
|
||
})
|
||
.filter((menu): menu is AppRouteRecord => menu !== null)
|
||
|
||
const backendPaths = new Set(backendMenus.map((m) => m.path))
|
||
const filteredDefaultMenus = defaultMenus.filter((m) => !backendPaths.has(m.path))
|
||
|
||
return [...filteredDefaultMenus, ...backendMenus]
|
||
}
|
||
|
||
/**
|
||
* 构建 URL 到路由的映射表
|
||
*/
|
||
function buildRouteMap(routes: AppRouteRecord[], parentPath = ''): Map<string, AppRouteRecord> {
|
||
const map = new Map<string, AppRouteRecord>()
|
||
|
||
routes.forEach((route) => {
|
||
// 构建完整路径
|
||
const fullPath = route.path.startsWith('/')
|
||
? route.path
|
||
: parentPath
|
||
? `${parentPath}/${route.path}`.replace(/\/+/g, '/')
|
||
: `/${route.path}`
|
||
|
||
// 存储路由映射
|
||
map.set(fullPath, route)
|
||
|
||
// 递归处理子路由
|
||
if (route.children && route.children.length > 0) {
|
||
const childMap = buildRouteMap(route.children, fullPath)
|
||
childMap.forEach((childRoute, childPath) => {
|
||
map.set(childPath, childRoute)
|
||
})
|
||
}
|
||
})
|
||
|
||
return map
|
||
}
|
||
|
||
/**
|
||
* 将后端菜单数据转换为路由格式
|
||
* 递归处理所有层级的 children
|
||
*/
|
||
function convertBackendMenuToRoute(
|
||
menu: any,
|
||
routeMap: Map<string, AppRouteRecord>
|
||
): AppRouteRecord | null {
|
||
const menuUrl = menu.url || '/'
|
||
const matchedRoute = routeMap.get(menuUrl)
|
||
|
||
if (!matchedRoute) {
|
||
console.warn(`未找到与菜单 URL "${menuUrl}" 匹配的路由定义: ${menu.name}`)
|
||
|
||
// 如果当前菜单没有匹配的路由,但有 children,尝试递归处理 children
|
||
if (menu.children && menu.children.length > 0) {
|
||
const children = menu.children
|
||
.map((child: any) => convertBackendMenuToRoute(child, routeMap))
|
||
.filter((child: AppRouteRecord | null) => child !== null)
|
||
|
||
// 如果子菜单有有效的路由,返回一个包含子菜单的占位路由
|
||
if (children.length > 0) {
|
||
return {
|
||
path: menuUrl,
|
||
name: menu.name || menuUrl.replace(/\//g, '_'),
|
||
meta: {
|
||
title: menu.name,
|
||
permission: menu.perm_code,
|
||
sort: menu.sort || 0,
|
||
icon: menu.icon,
|
||
isHide: false,
|
||
roles: undefined,
|
||
permissions: undefined
|
||
},
|
||
children: children
|
||
} as AppRouteRecord
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const route: AppRouteRecord = {
|
||
path: menuUrl,
|
||
name: matchedRoute.name,
|
||
component: matchedRoute.component,
|
||
meta: {
|
||
...matchedRoute.meta,
|
||
title: menu.name,
|
||
permission: menu.perm_code,
|
||
sort: menu.sort || matchedRoute.meta?.sort || 0,
|
||
icon: menu.icon || matchedRoute.meta?.icon,
|
||
// 清除前端定义的 roles 和 permissions,使用后端权限控制
|
||
roles: undefined,
|
||
permissions: undefined
|
||
}
|
||
}
|
||
|
||
// 递归处理后端返回的 children
|
||
if (menu.children && menu.children.length > 0) {
|
||
const children = menu.children
|
||
.map((child: any) => convertBackendMenuToRoute(child, routeMap))
|
||
.filter((child: AppRouteRecord | null) => child !== null)
|
||
|
||
if (children.length > 0) {
|
||
route.children = children
|
||
}
|
||
}
|
||
|
||
return route
|
||
}
|
||
|
||
/**
|
||
* 注册路由并存储菜单数据
|
||
*/
|
||
async function registerAndStoreMenu(
|
||
router: Router,
|
||
menuList: AppRouteRecord[],
|
||
closeLoading: () => void
|
||
): Promise<void> {
|
||
if (!isValidMenuList(menuList)) {
|
||
closeLoading()
|
||
throw new Error('获取菜单列表失败,请重新登录')
|
||
}
|
||
|
||
const menuStore = useMenuStore()
|
||
menuStore.setMenuList(menuList)
|
||
registerDynamicRoutes(router, menuList)
|
||
isRouteRegistered.value = true
|
||
|
||
const worktabStore = useWorktabStore()
|
||
worktabStore.validateWorktabs(router)
|
||
|
||
// 刷新后只保留固定标签页和当前标签页
|
||
worktabStore.initializeAfterRefresh()
|
||
|
||
closeLoading()
|
||
}
|
||
|
||
/**
|
||
* 处理菜单相关错误
|
||
*/
|
||
function handleMenuError(error: unknown): void {
|
||
console.error('菜单处理失败:', error)
|
||
// 确保 loading 被关闭
|
||
loadingService.hideLoading()
|
||
useUserStore().logOut()
|
||
throw error instanceof Error ? error : new Error('获取菜单列表失败,请重新登录')
|
||
}
|
||
|
||
/**
|
||
* 根据角色过滤菜单
|
||
*/
|
||
const 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 = filterMenuByRoles(filteredItem.children, roles)
|
||
}
|
||
acc.push(filteredItem)
|
||
}
|
||
|
||
return acc
|
||
}, [])
|
||
}
|
||
|
||
/**
|
||
* 验证菜单列表是否有效
|
||
*/
|
||
function isValidMenuList(menuList: AppRouteRecord[]): boolean {
|
||
return Array.isArray(menuList) && menuList.length > 0
|
||
}
|
||
|
||
/**
|
||
* 检查路由是否存在于 asyncRoutes 中
|
||
* 支持动态参数匹配,如 /account-management/enterprise-customer/customer-accounts/3000 匹配 enterprise-customer/customer-accounts/:id
|
||
* @param targetPath 目标路由路径,如 /account-management/enterprise-customer/customer-accounts/3000
|
||
* @param routes 路由配置数组
|
||
* @param parentPath 父路由路径
|
||
*/
|
||
function checkRouteExistsInAsyncRoutes(
|
||
targetPath: string,
|
||
routes: AppRouteRecord[],
|
||
parentPath = ''
|
||
): boolean {
|
||
for (const route of routes) {
|
||
// 构建完整路径
|
||
const fullPath = route.path.startsWith('/')
|
||
? route.path
|
||
: parentPath
|
||
? `${parentPath}/${route.path}`.replace(/\/+/g, '/')
|
||
: `/${route.path}`
|
||
|
||
// 检查当前路由是否匹配(支持动态参数)
|
||
const isMatch = matchRoutePath(targetPath, fullPath)
|
||
|
||
if (isMatch) {
|
||
console.log(`[路由匹配成功] 目标: ${targetPath}, 匹配到: ${fullPath}`)
|
||
return true
|
||
}
|
||
|
||
// 递归检查子路由
|
||
if (route.children && route.children.length > 0) {
|
||
if (checkRouteExistsInAsyncRoutes(targetPath, route.children, fullPath)) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 匹配路由路径,支持动态参数
|
||
* @param targetPath 实际路径,如 /account-management/enterprise-customer/customer-accounts/3000
|
||
* @param routePath 路由定义路径,如 /account-management/enterprise-customer/customer-accounts/:id
|
||
*/
|
||
function matchRoutePath(targetPath: string, routePath: string): boolean {
|
||
// 移除查询参数
|
||
const cleanTargetPath = targetPath.split('?')[0]
|
||
const cleanRoutePath = routePath.split('?')[0]
|
||
|
||
// 如果完全匹配,直接返回
|
||
if (cleanTargetPath === cleanRoutePath) {
|
||
return true
|
||
}
|
||
|
||
// 将路径分割成段
|
||
const targetSegments = cleanTargetPath.split('/').filter(Boolean)
|
||
const routeSegments = cleanRoutePath.split('/').filter(Boolean)
|
||
|
||
// 段数必须相同
|
||
if (targetSegments.length !== routeSegments.length) {
|
||
return false
|
||
}
|
||
|
||
// 逐段比较
|
||
for (let i = 0; i < routeSegments.length; i++) {
|
||
const routeSegment = routeSegments[i]
|
||
const targetSegment = targetSegments[i]
|
||
|
||
// 如果是动态参数(以 : 开头),跳过比较
|
||
if (routeSegment.startsWith(':')) {
|
||
continue
|
||
}
|
||
|
||
// 如果不是动态参数,必须完全匹配
|
||
if (routeSegment !== targetSegment) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 重置路由相关状态
|
||
*/
|
||
export function resetRouterState(router: Router): void {
|
||
isRouteRegistered.value = false
|
||
// 清理动态注册的路由
|
||
router.getRoutes().forEach((route) => {
|
||
if (route.meta?.dynamic) {
|
||
router.removeRoute(route.name as string)
|
||
}
|
||
})
|
||
// 清空菜单数据
|
||
const menuStore = useMenuStore()
|
||
menuStore.setMenuList([])
|
||
}
|