签入版本
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
# 路由与菜单
|
||||
|
||||
:::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` 管理路由状态和组件缓存。
|
||||
Reference in New Issue
Block a user