Files
djangoadmin-wiki/DjangoAdmin_FastAPI_EleVue/zh/volume4/frontend/router.md
T
2026-09-12 23:12:15 +08:00

305 lines
10 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.
# 路由与菜单
:::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<string, () => Promise<typeof import('*.vue')>>();
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` 管理路由状态和组件缓存。