Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_EleVue/zh/frontend/router.md
T
2026-10-08 17:50:29 +08:00

243 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 9.5 路由与动态菜单
:::tip 概述
前端路由采用动态生成机制,从后端 `/api/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。菜单数据由后端按用户权限过滤——**用户只能看到有权限的菜单**。
:::
## 路由架构
```text
静态路由(固定,ui/src/router/index.ts + ui/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. 调用 GET /api/index/getMenus 获取菜单树
(后端过滤:type=0 可导航菜单、status=0 启用、hide=0 可见、is_delete=0)
4. generator-routers.ts 将菜单树转换为路由表
5. router.addRoute() 动态注入路由
6. 渲染侧边栏菜单
```
## 核心代码
```typescript
// ui/src/router/generator-routers.ts
/**
* 动态生成菜单
* 调用后端 /api/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;
});
};
```
:::tip component 映射
后端菜单的 `component` 字段(如 `system/user/index`)由前端映射到 `views/system/user/index.vue`——菜单管理中组件路径写错会导致页面空白。见 [6.3 菜单管理](/zh/modules/menu.html)。
:::
## 路由守卫
:::tip 动态路由流程
上方[动态路由生成流程](#动态路由生成流程)的第 2~6 步均在路由守卫中实现。
:::
```typescript
// ui/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();
});
}
```
## 后端菜单数据结构
`GET /api/index/getMenus` 返回的菜单树(仅 `type=0` 可导航节点):
```json
[
{
"id": 1,
"name": "系统管理",
"path": "/system",
"icon": "SettingOutlined",
"component": "LAYOUT",
"children": [
{
"id": 11,
"name": "用户管理",
"path": "/user",
"component": "system/user/index",
"permission": "sys:user:page"
}
]
}
]
```
:::info 权限节点不进菜单树
`type=1` 的权限节点(`permission` 非空)只参与后端接口鉴权(`Authenticate` 中间件),不会出现在 `getMenus` 返回中——前端菜单树与后端权限表是同一份 `laravel_menu` 数据的两个视图。
:::
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 登录后菜单为空 | 用户无角色或角色无菜单 | 角色管理分配菜单 |
| 点击菜单白屏 | `component` 路径错误 / views 下无对应文件 | 核对组件路径 |
| 改了菜单不生效 | 动态路由已缓存 | 重新登录或刷新页面 |
| 404 页覆盖真实路由 | 动态路由未加载完成就跳转 | 检查守卫第 5 步是否执行 |