307 lines
10 KiB
Markdown
307 lines
10 KiB
Markdown
# 路由与菜单
|
||
|
||
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
|
||
|
||
:::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` 字段值 `tool/example/index` 会匹配 `../views/tool/example/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` 管理路由状态和组件缓存。
|