Become a sponsor

说明
前端路由基于 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 递归生成路由表:
// 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)匹配并懒加载:
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 中定义全局路由守卫,核心流程:
// 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 → 跳转asyncRouteStore 的 generateRoutes 方法负责从后端获取菜单并生成路由:
// 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() 接口获取菜单数据。
后端返回的菜单数据结构:
[
{
"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。
路由守卫的 afterEach 中自动管理组件缓存:
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); // 移除缓存
}
});温馨提示
菜单的 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 管理路由状态和组件缓存。