# 9.5 路由与动态菜单 :::tip 概述 前端路由采用动态生成机制,从后端 `/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。 ::: ## 路由架构 ```text 静态路由(固定,src/router/index.ts + src/router/base.ts) ├── /login 登录页(LoginRoute) ├── / 根路径,重定向到首页(RootRoute) ├── /redirect/:path 重定向路由(RedirectRoute) ├── /:path(.*)* 404 错误页(ErrorPageRoute,动态路由加载后注入) ├── /monitorJob/log 关于项目(AboutPageRoute,动态路由加载后注入) └── /setting/profile 个人设置(SettingPageRoute,动态路由加载后注入) 动态路由(从后端菜单生成) ├── /dashboard 仪表盘 ├── /system/user 用户管理 ├── /system/role 角色管理 ├── /content/article 文章管理 └── ... ``` ## 动态路由生成流程 ```text 1. 用户登录 → 获取 Token 2. 路由守卫检测到 Token 3. 调用 /index/getMenus 获取菜单树 4. generator-routers.ts 将菜单树转换为路由表 5. router.addRoute() 动态注入路由 6. 渲染侧边栏菜单 ``` ## 核心代码 ```typescript // src/router/generator-routers.ts /** * 动态生成菜单 * 调用后端 /index/getMenus 接口获取菜单数据 */ export const generatorDynamicRouter = (): Promise => { return new Promise((resolve, reject) => { adminMenus() .then((result) => { const routeList = routerGenerator(result); asyncImportRoute(routeList); resolve(routeList); }) .catch((err) => reject(err)); }); }; /** * 格式化后端菜单为路由结构 */ export const routerGenerator = (routerMap): any[] => { return routerMap.map((item) => { const currentRouter = { path: item.path, name: item.path.replaceAll('/', ''), component: item.component, // 如 'system/user/index' meta: { title: item.name, icon: item.icon, permissions: item.permission, }, }; // 递归处理子菜单 if (item.children?.length > 0) { currentRouter.children = routerGenerator(item.children); } return currentRouter; }); }; ``` ## 路由守卫 :::tip 动态路由流程 详见上方 [动态路由生成流程](#动态路由生成流程) 章节,路由守卫中实现了该流程的第 2~6 步。 ::: ```typescript // src/router/router-guards.ts const LOGIN_PATH = PageEnum.BASE_LOGIN; const whitePathList = [LOGIN_PATH]; // no redirect whitelist export function createRouterGuards(router: Router) { const userStore = useUserStoreWidthOut(); const asyncRouteStore = useAsyncRouteStoreWidthOut(); router.beforeEach(async (to, from, next) => { // 1. Token 可能来自 URL 查询参数 ?token=xxx(SSO 等场景) if (to.query && 'token' in to.query) { const tokenVal = String(to.query.token); storage.set(ACCESS_TOKEN, tokenVal); userStore.setToken(tokenVal); } NProgress.start(); // 2. 白名单且未登录 → 直接放行 const token = storage.get(ACCESS_TOKEN); if (whitePathList.includes(to.path as PageEnum) && !token) { next(); return; } // 3. 未登录处理 if (!token) { // 路由 meta.ignoreAuth = true 时无需登录也可访问 if (to.meta.ignoreAuth) { next(); return; } // 重定向到登录页,携带当前路径作为 redirect 参数 const redirectData = { path: LOGIN_PATH, replace: true }; if (to.path) { redirectData.query = { redirect: to.path }; } next(redirectData); return; } // 4. 已登录 + 动态路由已加载 → 直接放行 if (asyncRouteStore.getIsDynamicAddedRoute) { next(); return; } // 5. 首次加载:生成动态路由 asyncRouteStore.setDynamicAddedRoute(true); const userInfo = await userStore.GetInfo(); const routes = await asyncRouteStore.generateRoutes(userInfo); await useOssConfigStore().setOssConfig(); // 动态添加可访问路由表 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); } router.addRoute(AboutPageRoute as unknown as RouteRecordRaw); router.addRoute(SettingPageRoute as unknown as RouteRecordRaw); // 保存第一个路由路径,用于默认跳转 const firstRoutePath = findFirstRoutePath(routes); storage.set(FIRST_ROUTE, firstRoutePath); // 确定重定向目标 const formPath = findTreeByPath(routes, from.query.redirect).length > 0 ? from.query.redirect : to.path; const redirect = decodeURIComponent(formPath as string); const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }; NProgress.done(); if (nextData.path === PageEnum.BASE_HOME || (token && to.path === '/login')) { next(firstRoutePath); return; } next(nextData); }); // 6. afterEach:管理 keep-alive 缓存 + 页面标题 router.afterEach((to, _, failure) => { document.title = (to?.meta?.documentTitle ? to?.meta?.documentTitle : (to?.meta?.title as string)) || document.title; if (isNavigationFailure(failure)) { // navigation failed } const asyncRouteStore = useAsyncRouteStoreWidthOut(); const keepAliveComponents = asyncRouteStore.keepAliveComponents; const currentComName: any = 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 || to.name == 'Redirect') { const index = asyncRouteStore.keepAliveComponents.findIndex((name) => name == currentComName); if (index != -1) { keepAliveComponents.splice(index, 1); } } asyncRouteStore.setKeepAliveComponents(keepAliveComponents); NProgress.done(); }); } ``` ## 后端菜单数据结构 后端 `/index/getMenus` 返回的菜单树: ```json [ { "id": 1, "name": "系统管理", "path": "/system", "icon": "SettingOutlined", "component": "LAYOUT", "children": [ { "id": 11, "name": "用户管理", "path": "/user", "component": "system/user/index", "permission": "sys:user:page" } ] } ] ```