# 路由与菜单 :::tip 说明 前端路由基于 Vue Router 4 实现动态路由生成,菜单数据从后端获取,通过 `generator-routers.ts` 递归生成路由表,路由守卫控制登录状态和权限。 ::: ## 路由配置 路由配置位于 `src/router/` 目录: ``` src/router/ ├── index.ts # 路由实例创建,导出 router、setupRouter ├── router-guards.ts # 全局路由守卫(beforeEach / afterEach) ├── generator-routers.ts # 动态路由生成(后端菜单 → 路由表) ├── router-icons.ts # 菜单图标映射 ├── constant.ts # Layout / ParentLayout 组件引用 └── base.ts # 静态路由(ErrorPage、About、Setting) ``` ### 静态路由 无需权限即可访问的路由定义在 `base.ts` 中,包括登录页、404 错误页等。 ### 动态路由生成 登录后从后端获取菜单数据,由 `generator-routers.ts` 的 `routerGenerator` 递归生成路由表: ```typescript // src/router/generator-routers.ts import { adminMenus } from '@/api/common/menu'; import { Layout, ParentLayout } from '@/router/constant'; const LayoutMap = new Map Promise>(); LayoutMap.set('LAYOUT', Layout); LayoutMap.set('IFRAME', () => import('@/views/iframe/index.vue')); export const routerGenerator = (routerMap, parent?): any[] => { return routerMap.map((item) => { const names = item.target == 2 ? item.component : item.path.replaceAll('/', ''); item.meta = { title: item.parentId == 0 && item.children.length == 0 ? '' : item.name, icon: item.icon, sort: item.sort, permissions: item.permission, hidden: item.hide ? true : false, isRoot: item.parentId == 0 && item.children.length == 0, frameSrc: item.target == 1 ? item.component : '', }; let components = ''; if (item.parentId == 0 && item.children.length == 0) { components = 'LAYOUT'; } else if (item.target == 0) { components = item.component; } else if (item.target == 1) { components = 'IFRAME'; } const currentRouter: any = { path: item.target != 2 ? item.path : '', name: names, component: components, meta: { ...item.meta, label: item.meta.title, icon: item.meta.icon || null }, }; if (item.children && item.children.length > 0) { !item.redirect && (currentRouter.redirect = `${item.children[0].path}`); currentRouter.children = routerGenerator(item.children, currentRouter); } return currentRouter; }); }; ``` ### 组件动态导入 `asyncImportRoute` 函数使用 `import.meta.glob` 扫描 `views/` 目录下的所有 `.vue` 和 `.tsx` 文件,根据后端返回的 `component` 字段(如 `system/position/index`)匹配并懒加载: ```typescript export const asyncImportRoute = (routes: AppRouteRecordRaw[]): void => { viewsModules = viewsModules || import.meta.glob('../views/**/*.{vue,tsx}'); routes.forEach((item) => { const { component } = item; if (component) { const layoutFound = LayoutMap.get(component as string); if (layoutFound) { item.component = layoutFound; // LAYOUT / IFRAME } else { item.component = dynamicImport(viewsModules, component as string); } } item.children && asyncImportRoute(item.children); }); }; export const dynamicImport = (viewsModules, component: string) => { const keys = Object.keys(viewsModules); const matchKeys = keys.filter((key) => { let k = key.replace('../views', ''); k = k.substring(0, k.lastIndexOf('.')); return k === component; }); if (matchKeys?.length === 1) return viewsModules[matchKeys[0]]; }; ``` 匹配规则:后端 `component` 字段值 `system/position/index` 会匹配 `../views/system/position/index.vue`。 ## 路由守卫 `router-guards.ts` 中定义全局路由守卫,核心流程: ```typescript // src/router/router-guards.ts export function createRouterGuards(router: Router) { const userStore = useUserStoreWidthOut(); const asyncRouteStore = useAsyncRouteStoreWidthOut(); router.beforeEach(async (to, from, next) => { // 支持 URL 参数中的 token(如 SSO 回调) if (to.query && 'token' in to.query) { storage.set(ACCESS_TOKEN, String(to.query.token)); userStore.setToken(String(to.query.token)); } NProgress.start(); const token = storage.get(ACCESS_TOKEN); // 白名单(登录页)直接放行 if (whitePathList.includes(to.path) && !token) { next(); return; } // 无 token 跳转登录页 if (!token) { if (to.meta.ignoreAuth) { next(); return; } next({ path: LOGIN_PATH, replace: true, query: { redirect: to.path } }); return; } // 已登录且路由已添加,直接放行 if (asyncRouteStore.getIsDynamicAddedRoute) { next(); return; } // 首次登录:获取用户信息 → 生成动态路由 → 添加路由 asyncRouteStore.setDynamicAddedRoute(true); const userInfo = await userStore.GetInfo(); const routes = await asyncRouteStore.generateRoutes(userInfo); routes.forEach((item) => { router.addRoute(item as unknown as RouteRecordRaw); }); // 添加 404 页面 const isErrorPage = router.getRoutes().findIndex((item) => item.name === ErrorPageRoute.name); if (isErrorPage === -1) { router.addRoute(ErrorPageRoute as unknown as RouteRecordRaw); } const firstRoutePath = findFirstRoutePath(routes); storage.set(FIRST_ROUTE, firstRoutePath); // 跳转目标页或首个路由 next({ path: redirect, replace: true }); }); // afterEach:设置页面标题、管理 keepAlive 组件缓存 router.afterEach((to) => { document.title = to?.meta?.title || document.title; NProgress.done(); }); } ``` 守卫流程图: ``` 访问页面 ├── 有 token 查询参数?→ 存储 token ├── 白名单?→ 放行 ├── 无 token?→ 跳转登录页(携带 redirect 参数) ├── 路由已添加?→ 放行 └── 首次登录 → GetInfo → generateRoutes → addRoute → 跳转 ``` ## 动态路由生成(asyncRoute Store) `asyncRouteStore` 的 `generateRoutes` 方法负责从后端获取菜单并生成路由: ```typescript // src/store/modules/asyncRoute.ts async generateRoutes(data) { let accessedRouters; const permissionsList = data.permissions || []; const { getPermissionMode } = useProjectSetting(); const permissionMode = unref(getPermissionMode); if (permissionMode === 'BACK') { // 后端控制模式:调用 adminMenus() 获取菜单 accessedRouters = await generatorDynamicRouter(); } else { // 前端控制模式:根据权限过滤静态路由表 accessedRouters = filter(asyncRoutes, routeFilter); } this.setRouters(accessedRouters); this.setMenus(accessedRouters); return toRaw(accessedRouters); }, ``` 使用 `BACK` 模式(后端控制菜单),调用 `adminMenus()` 接口获取菜单数据。 ## 菜单数据结构 后端返回的菜单数据结构: ```json [ { "id": 1, "name": "系统管理", "path": "/system", "component": "", "icon": "icon-system", "target": 0, "parentId": 0, "sort": 1, "hide": false, "permission": "", "children": [ { "id": 10, "name": "岗位管理", "path": "/system/position", "component": "system/position/index", "icon": "icon-position", "target": 0, "parentId": 1, "sort": 3, "hide": false, "permission": "sys:position:page", "children": [] } ] } ] ``` | 字段 | 说明 | |------|------| | `name` | 菜单名称,同时作为 `meta.title` | | `path` | 路由地址,如 `/system/position` | | `component` | 组件路径,如 `system/position/index`,对应 `views/system/position/index.vue` | | `target` | 打开方式:0=内部组件,1=内嵌 iframe,2=外部链接 | | `parentId` | 父菜单 ID,0 表示顶级 | | `permission` | 权限节点,如 `sys:position:page` | | `hide` | 是否隐藏菜单 | | `icon` | 菜单图标 | ## 菜单渲染 菜单数据与路由数据同源,侧边栏组件递归渲染菜单树。`meta` 中的关键字段控制渲染行为: - `hidden: true`:隐藏该菜单项 - `isRoot: true`:顶级单页菜单(无子菜单的顶级项自动包裹 Layout) - `frameSrc`:iframe 内嵌地址 - `permissions`:权限字符串,用于菜单级别的权限过滤 ## 面包屑导航 根据当前路由的 `matched` 自动生成面包屑,读取每级路由的 `meta.title`。 ## KeepAlive 缓存 路由守卫的 `afterEach` 中自动管理组件缓存: ```typescript router.afterEach((to) => { const keepAliveComponents = asyncRouteStore.keepAliveComponents; const currentComName = to.matched.find((item) => item.name == to.name)?.name; if (currentComName && !keepAliveComponents.includes(currentComName) && to.meta?.keepAlive) { keepAliveComponents.push(currentComName); // 需要缓存 } else if (!to.meta?.keepAlive) { const index = keepAliveComponents.findIndex((name) => name == currentComName); if (index != -1) keepAliveComponents.splice(index, 1); // 移除缓存 } }); ``` :::tip 温馨提示 菜单的 `component` 字段对应 `src/views/` 下的组件路径,如 `system/position/index` 对应 `src/views/system/position/index.vue`。`import.meta.glob('../views/**/*.{vue,tsx}')` 会在构建时扫描所有视图文件,实现按需懒加载。 ::: ## 总结 前端路由采用动态生成方式,登录后通过 `adminMenus()` 接口获取后端菜单数据,`routerGenerator` 递归生成路由表,`asyncImportRoute` 使用 `import.meta.glob` 懒加载组件。路由守卫控制登录状态和首次路由添加,`asyncRouteStore` 管理路由状态和组件缓存。