#Vue3 动态路由与菜单配置
#动态路由实现思路
后端返回菜单树,前端根据菜单类型(M-目录、C-菜单、B-按钮)动态生成为 Layout 子路由。
import router from '@/routers';
import type { RouteRecordRaw } from 'vue-router';
interface MenuRoute {
id: string;
name: string;
path: string | null;
type: 'M' | 'C' | 'B';
permission: string | null;
icon: string | null;
sort: number;
children?: MenuRoute[];
}#路由生成(扁平化)
只处理菜单类型(C),目录和按钮不需要单独路由:
export function generateRoutes(menus: MenuRoute[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
const flatMenus = flattenMenus(menus);
for (const menu of flatMenus) {
if (menu.type !== 'C') continue;
const fullPath = menu.path || '';
const routePath = fullPath.startsWith('/') ? fullPath.slice(1) : fullPath;
routes.push({
path: routePath,
name: menu.name,
component: getComponent(menu),
meta: {
title: menu.name,
icon: menu.icon || undefined,
sort: menu.sort,
keepAlive: true,
permission: menu.permission || undefined,
},
});
}
return routes;
}
function flattenMenus(menus: MenuRoute[]): MenuRoute[] {
const result: MenuRoute[] = [];
for (const menu of menus) {
result.push(menu);
if (menu.children?.length) result.push(...flattenMenus(menu.children));
}
return result;
}#添加/重置路由
export function addDynamicRoutes(menus: MenuRoute[]) {
generateRoutes(menus).forEach((route) => {
router.addRoute('Layout', route);
});
}
export function resetDynamicRoutes() {
const staticRouteNames = ['Login', 'Layout', 'Home', 'NotFound'];
router.getRoutes().forEach((route) => {
if (route.name && !staticRouteNames.includes(route.name as string)) {
router.removeRoute(route.name as string);
}
});
}#路由守卫(刷新 404 处理)
核心:静态路由必须包含 /:pathMatch(.*)* 的 404 catch-all 路由,避免 Vue Router 报 "No match found" 警告。
export const staticRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', ... },
{
path: '/',
name: 'Layout',
component: () => import('@/layouts/index.vue'),
redirect: '/home',
children: [
{ path: 'home', name: 'Home', ... },
],
},
// 必须存在,否则刷新动态路由时控制台会报 No match found 警告
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error-page/index.vue') },
];守卫中加载动态路由后,必须按路径重新导航,不能使用 { ...to, replace: true },因为 catch-all 匹配时 to.name 为 'NotFound',按 name 导航会导致死循环:
router.beforeEach(async (to) => {
NProgress.start();
const userStore = useUserStore();
const token = userStore.token;
if (!token) return createLoginRedirect(to.fullPath);
if (token && isJwtExpired(token)) { userStore.logout(); return createLoginRedirect(to.fullPath); }
// 关键:路由未加载时获取动态路由
if (!userStore.routeLoaded) {
try {
await userStore.fetchUserMenus(); // 内部调用 addDynamicRoutes
// ❌ return { ...to, replace: true } — 会携带 NotFound 的 name 导致死循环
// ✅ 按路径导航
return { path: to.path, query: to.query, replace: true };
} catch {
userStore.logout();
return createLoginRedirect(to.fullPath);
}
}
});#菜单数据结构
#后端返回格式(JSON)
[
{
"_id": "1",
"name": "系统管理",
"parent_id": "0",
"type": "M",
"path": "/system",
"icon": "SettingsOutline",
"sort": 1,
"status": 1
},
{
"_id": "100",
"name": "用户管理",
"parent_id": "1",
"type": "C",
"path": "/system/user",
"permission": "system:user:list",
"icon": "PeopleOutline",
"sort": 1,
"status": 1
}
]#字段说明
| 字段 | 说明 |
|---|---|
_id | 菜单 ID |
name | 菜单名称 |
parent_id | 父菜单 ID("0" 表示顶级) |
type | M-目录 C-菜单 B-按钮 |
path | 路由路径(完整路径,如 /system/user) |
permission | 权限标识(如 system:user:add) |
icon | 图标名称(对应 @vicons/ionicons5 的组件名) |
sort | 排序号 |
status | 状态(1-启用 0-禁用) |

