动态路由是指根据用户权限从后端获取菜单数据,然后在前端动态生成和注册路由的过程。这样可以实现基于角色的权限控制(RBAC),不同角色的用户看到不同的菜单和页面。
用户登录
↓
路由守卫拦截 (permission.ts)
↓
检查是否有 Token
↓
检查用户信息是否已加载 (roles.length === 0?)
↓
调用后端接口获取菜单数据 (/system/menu/getRouters)
↓
后端返回菜单 JSON 数据
↓
前端转换路由格式 (filterAsyncRouter)
↓
动态加载 Vue 组件 (loadView)
↓
注册路由到 Vue Router (router.addRoute)
↓
更新侧边栏菜单 (setSidebarRouters)
↓
跳转到目标页面
src/permission.ts)作用:在每次路由跳转前进行检查,确保用户已登录且路由已加载。
// src/permission.ts
router.beforeEach(async (to, from, next) => {
NProgress.start(); // 显示加载进度条
if (getToken()) {
// ✅ 有 Token,已登录
if (to.path === '/login') {
// 已登录用户访问登录页,重定向到首页
next({ path: '/' });
} else if (isWhiteList(to.path)) {
// 白名单路径,直接放行
next();
} else {
// 🔑 关键:检查用户信息是否已加载
if (useUserStore().roles.length === 0) {
// 用户信息未加载,需要获取用户信息和路由
isRelogin.show = true;
// 1. 获取用户信息
const [err] = await tos(useUserStore().getInfo());
if (err) {
// 获取失败,退出登录
await useUserStore().logout();
next({ path: '/' });
} else {
isRelogin.show = false;
// 2. 🚀 生成动态路由(核心步骤)
const accessRoutes = await usePermissionStore().generateRoutes();
// 3. 将路由添加到 Vue Router
accessRoutes.forEach((route) => {
if (!isHttp(route.path)) {
router.addRoute(route); // 动态注册路由
}
});
// 4. 重新跳转到目标页面(确保路由已注册)
next({
path: to.path,
replace: true,
params: to.params,
query: to.query,
hash: to.hash,
name: to.name as string
});
}
} else {
// 用户信息已加载,直接放行
next();
}
}
} else {
// ❌ 没有 Token,未登录
if (isWhiteList(to.path)) {
next(); // 白名单路径
} else {
// 重定向到登录页
const redirect = encodeURIComponent(to.fullPath || '/');
next(`/login?redirect=${redirect}`);
}
}
});
src/api/menu.ts)作用:向后端请求当前用户有权限的菜单数据。
// src/api/menu.ts
export function getRouters(): AxiosPromise<RouteRecordRaw[]> {
return request({
url: '/system/menu/getRouters', // 后端接口地址
method: 'get'
});
}
后端返回的数据格式示例:
[
{
"name": "System",
"path": "/system",
"component": "Layout",
"redirect": "/system/user",
"alwaysShow": true,
"meta": {
"title": "系统管理",
"icon": "system"
},
"children": [
{
"name": "User",
"path": "user",
"component": "system/user/index",
"meta": {
"title": "用户管理",
"icon": "user"
}
},
{
"name": "Role",
"path": "role",
"component": "system/role/index",
"meta": {
"title": "角色管理",
"icon": "peoples"
}
}
]
},
{
"name": "Platform",
"path": "/platform",
"component": "Layout",
"redirect": "/platform/carousel",
"alwaysShow": true,
"meta": {
"title": "平台装修",
"icon": "system"
},
"children": [
{
"name": "Carousel",
"path": "carousel",
"component": "platform/decoration/carousel/index",
"meta": {
"title": "轮播广告",
"icon": "component"
}
}
]
}
]
src/store/modules/permission.ts)作用:将后端返回的菜单 JSON 转换为 Vue Router 可用的路由对象。
// src/store/modules/permission.ts
const generateRoutes = async (): Promise<RouteRecordRaw[]> => {
// 1. 📡 调用后端接口获取菜单数据
const res = await getRouters();
const { data } = res;
// 2. 📋 深拷贝数据(用于不同用途)
const sdata = JSON.parse(JSON.stringify(data)); // 侧边栏路由
const rdata = JSON.parse(JSON.stringify(data)); // 重写路由(扁平化)
const defaultData = JSON.parse(JSON.stringify(data)); // 默认路由
// 3. 🔄 转换路由格式
const sidebarRoutes = filterAsyncRouter(sdata); // 侧边栏菜单
const rewriteRoutes = filterAsyncRouter(rdata, undefined, true); // 扁平化路由
const defaultRoutes = filterAsyncRouter(defaultData); // 默认路由
// 4. 🔐 处理动态路由权限(如果有)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
asyncRoutes.forEach((route) => {
router.addRoute(route);
});
// 5. 💾 保存路由到 Store
setRoutes(rewriteRoutes); // 保存所有路由
setSidebarRouters(constantRoutes.concat(sidebarRoutes)); // 侧边栏路由
setDefaultRoutes(sidebarRoutes); // 默认路由
setTopbarRoutes(defaultRoutes); // 顶部栏路由
// 6. ✅ 检查路由名称是否重复
duplicateRouteChecker(asyncRoutes, sidebarRoutes);
return rewriteRoutes;
};
filterAsyncRouter)作用:将后端返回的路由字符串转换为 Vue Router 组件对象。
// src/store/modules/permission.ts
const filterAsyncRouter = (
asyncRouterMap: RouteRecordRaw[],
lastRouter?: RouteRecordRaw,
type = false
): RouteRecordRaw[] => {
return asyncRouterMap.filter((route) => {
// 1. 🔄 如果是重写路由,扁平化子路由
if (type && route.children) {
route.children = filterChildren(route.children, undefined);
}
// 2. 🎨 处理特殊组件
if (route.component?.toString() === 'Layout') {
// Layout 组件:主布局容器
route.component = Layout;
} else if (route.component?.toString() === 'ParentView') {
// ParentView 组件:父级视图容器(用于嵌套路由)
route.component = ParentView;
} else if (route.component?.toString() === 'InnerLink') {
// InnerLink 组件:内部链接
route.component = InnerLink;
} else {
// 3. 📦 动态加载 Vue 组件
// 例如:'system/user/index' → 加载 '@/views/system/user/index.vue'
route.component = loadView(route.component, route.name as string);
}
// 4. 🔁 递归处理子路由
if (route.children != null && route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, route, type);
} else {
// 没有子路由,删除空属性
delete route.children;
delete route.redirect;
}
return true;
});
};
转换示例:
转换前(后端返回):
{
"name": "User",
"path": "user",
"component": "system/user/index",
"meta": {
"title": "用户管理",
"icon": "user"
}
}
转换后(Vue Router 格式):
{
name: "User",
path: "user",
component: () => import('@/views/system/user/index.vue'), // 动态导入组件
meta: {
title: "用户管理",
icon: "user"
}
}
loadView)作用:根据组件路径字符串,动态加载对应的 Vue 组件文件。
// src/store/modules/permission.ts
// 匹配 views 目录下所有的 .vue 文件
const modules = import.meta.glob('./../../views/**/*.vue');
export const loadView = (view: any, name: string) => {
let res;
// 遍历所有已匹配的组件文件
for (const path in modules) {
// 例如:path = './../../views/system/user/index.vue'
const viewsIndex = path.indexOf('/views/');
let dir = path.substring(viewsIndex + 7); // 'system/user/index.vue'
dir = dir.substring(0, dir.lastIndexOf('.vue')); // 'system/user/index'
// 匹配组件路径
if (dir === view) {
// 创建自定义名称组件
res = createCustomNameComponent(modules[path], { name });
return res;
}
}
return res;
};
工作原理:
import.meta.glob() 在构建时扫描所有 .vue 文件,生成一个映射对象'system/user/index'),在映射中查找对应的文件modules 对象示例:
{
'./../../views/system/user/index.vue': () => import('./../../views/system/user/index.vue'),
'./../../views/system/role/index.vue': () => import('./../../views/system/role/index.vue'),
'./../../views/platform/decoration/carousel/index.vue': () => import('./../../views/platform/decoration/carousel/index.vue'),
// ... 更多组件
}
filterChildren)作用:将嵌套的子路由扁平化,用于路由重写。
// src/store/modules/permission.ts
const filterChildren = (
childrenMap: RouteRecordRaw[],
lastRouter?: RouteRecordRaw
): RouteRecordRaw[] => {
let children: RouteRecordRaw[] = [];
childrenMap.forEach((el) => {
// 拼接完整路径
// 例如:父路由 '/platform' + 子路由 'carousel' = '/platform/carousel'
el.path = lastRouter ? lastRouter.path + '/' + el.path : el.path;
// 如果子路由还有子路由,且使用 ParentView,继续递归扁平化
if (el.children && el.children.length && el.component?.toString() === 'ParentView') {
children = children.concat(filterChildren(el.children, el));
} else {
children.push(el);
}
});
return children;
};
示例:
扁平化前:
{
path: '/platform',
children: [
{
path: 'decoration',
children: [
{ path: 'carousel' },
{ path: 'solution' }
]
}
]
}
扁平化后:
[
{ path: '/platform/decoration/carousel' },
{ path: '/platform/decoration/solution' }
]
作用:将转换后的路由添加到 Vue Router 实例中。
// src/permission.ts (在路由守卫中)
const accessRoutes = await usePermissionStore().generateRoutes();
// 遍历所有路由,添加到 Vue Router
accessRoutes.forEach((route) => {
if (!isHttp(route.path)) {
router.addRoute(route); // 动态注册路由
}
});
router.addRoute() 的作用:
作用:将生成的路由保存到 Store,供侧边栏组件使用。
// src/store/modules/permission.ts
setSidebarRouters(constantRoutes.concat(sidebarRoutes));
侧边栏组件使用:
<!-- src/layout/components/Sidebar/index.vue -->
<template>
<el-menu :default-active="activeMenu">
<sidebar-item
v-for="route in sidebarRouters"
:key="route.path"
:item="route"
/>
</el-menu>
</template>
<script setup>
import { usePermissionStore } from '@/store/modules/permission';
const permissionStore = usePermissionStore();
const sidebarRouters = computed(() => permissionStore.getSidebarRoutes());
</script>
{
"name": "Platform",
"path": "/platform",
"component": "Layout",
"redirect": "/platform/carousel",
"alwaysShow": true,
"meta": {
"title": "平台装修",
"icon": "system"
},
"children": [
{
"name": "Carousel",
"path": "carousel",
"component": "platform/decoration/carousel/index",
"meta": {
"title": "轮播广告",
"icon": "component"
}
},
{
"name": "Solution",
"path": "solution",
"component": "platform/decoration/solution/index",
"meta": {
"title": "方案管理",
"icon": "documentation"
}
}
]
}
{
name: "Platform",
path: "/platform",
component: Layout, // 已转换为组件对象
redirect: "/platform/carousel",
alwaysShow: true,
meta: {
title: "平台装修",
icon: "system"
},
children: [
{
name: "Carousel",
path: "carousel",
component: () => import('@/views/platform/decoration/carousel/index.vue'),
meta: {
title: "轮播广告",
icon: "component"
}
},
{
name: "Solution",
path: "solution",
component: () => import('@/views/platform/decoration/solution/index.vue'),
meta: {
title: "方案管理",
icon: "documentation"
}
}
]
}
[
{
name: "Carousel",
path: "/platform/carousel",
component: () => import('@/views/platform/decoration/carousel/index.vue'),
meta: { title: "轮播广告", icon: "component" }
},
{
name: "Solution",
path: "/platform/solution",
component: () => import('@/views/platform/decoration/solution/index.vue'),
meta: { title: "方案管理", icon: "documentation" }
}
]
router.addRoute() 添加到路由表duplicateRouteChecker 会检查并报错component 路径必须对应 views 目录下的文件roles.length === 0)才生成路由X-Platform-Code 头过滤菜单