Skip to content

9.5 路由与动态菜单 ​

概述

前端路由采用动态生成机制,从后端 /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<RouteRecordRaw[]> => {
  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;
  });
};

路由守卫 ​

动态路由流程

详见上方 动态路由生成流程 章节,路由守卫中实现了该流程的第 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"
            }
        ]
    }
]

小蚂蚁云团队 · 提供技术支持