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" 表示顶级)
typeM-目录 C-菜单 B-按钮
path路由路径(完整路径,如 /system/user
permission权限标识(如 system:user:add
icon图标名称(对应 @vicons/ionicons5 的组件名)
sort排序号
status状态(1-启用 0-禁用)