Skip to content

动态路由配置 ​

开启后端模式 ​

1. apps/web-ele/src/preferences.ts ​

修改位置: app 配置对象

改动内容:

  • 新增 accessMode: 'backend' — 开启后端权限模式,菜单由后端接口返回
  • 新增 layout: 'sidebar-nav' — 强制使用侧边导航布局,避免混合导航模式下 sidebarVisible 计算错误导致侧边栏不显示
ts
app: {
  name: import.meta.env.VITE_APP_TITLE,
  accessMode: 'backend',
  layout: 'sidebar-nav',
},

2. apps/web-ele/src/mockdata/data.ts(新增) ​

作用: 模拟后端返回的菜单 path 列表

ts
const menuList = ["blog", "blog/article", "blog/tags", "user"];
export default menuList;

只返回 path 字符串,元信息由前端从路由模块自动补充。

3. apps/web-ele/src/router/routes/index.ts ​

ts
import type { RouteRecordRaw } from "vue-router";

import { mergeRouteModules, traverseTreeValues } from "@vben/utils";

import { coreRoutes, fallbackNotFoundRoute } from "./core";

const dynamicRouteFiles = import.meta.glob("./modules/**/*.ts", {
  eager: true,
});

// 有需要可以自行打开注释,并创建文件夹
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });

/** 动态路由 */
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);

/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
const staticRoutes: RouteRecordRaw[] = [];
const externalRoutes: RouteRecordRaw[] = [];

/** 路由列表,由基本路由、外部路由和404兜底路由组成
 *  无需走权限验证(会一直显示在菜单中) */
const routes: RouteRecordRaw[] = [
  ...coreRoutes,
  ...externalRoutes,
  fallbackNotFoundRoute,
];

/** 基本路由列表,这些路由不需要进入权限拦截 */
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);

/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];

/**
 * 从全部路由配置中提取菜单元信息
 * 按 path 建立映射
 */
function extractMetaFromRoutes(): Record<
  string,
  { hideChildrenInMenu?: boolean; icon?: string; order?: number; title: string }
> {
  const map: Record<string, any> = {};

  function walk(routeList: RouteRecordRaw[], parentPath = "") {
    for (const route of routeList) {
      // 子路由的 path 已经是完整路径(如 /blog/article),不需要再拼接 parentPath
      const fullPath = route.path.startsWith("/")
        ? route.path
        : `${parentPath}/${route.path}`;
      const key = fullPath.replace(/^\//, "");

      if (route.meta) {
        map[key] = {
          icon: route.meta.icon,
          order: route.meta.order,
          title: route.meta.title,
          hideChildrenInMenu: route.meta.hideChildrenInMenu,
        };
      }

      if (route.children) {
        walk(route.children, fullPath);
      }
    }
  }

  walk(accessRoutes);
  return map;
}

const allRoutesMetaMap = extractMetaFromRoutes();

export { accessRoutes, allRoutesMetaMap, coreRouteNames, routes };

重点说明 ​

改动内容:

3.1 新增 extractMetaFromRoutes() 函数 ​

  • 遍历 accessRoutes(即 router/routes/modules/*.ts 通过 import.meta.glob eager 加载的所有动态路由)
  • 递归 walk 每个路由的 meta,按完整 path(去掉开头 /)作为 key 建立映射
  • 提取 icon / order / title / hideChildrenInMenu 四个字段

3.2 新增导出 allRoutesMetaMap ​

ts
const allRoutesMetaMap = extractMetaFromRoutes();
export { accessRoutes, allRoutesMetaMap, coreRouteNames, routes };

关键点: import.meta.glob(..., { eager: true }) 是 Vite 编译期特性,build 时会被静态展开成真实 import,因此 allRoutesMetaMap 在生产打包后依然可用,不会出现 dev 有数据、build 后为空的情况。

4. apps/web-ele/src/api/core/menu.ts(核心改动,整个文件重写) ​

ts
import type { RouteRecordStringComponent } from "@vben/types";

import menuList from "#/mockdata/data";
import { allRoutesMetaMap } from "#/router/routes";

/**
 * 菜单元信息映射表
 * 从全部路由文件提取,按 data.ts 中的 path 筛选
 */
function getMenuMetaMap(): Record<
  string,
  {
    hideChildrenInMenu?: boolean;
    icon?: string;
    order?: number;
    title: string;
  }
> {
  const map: Record<string, any> = {};
  for (const path of menuList) {
    if (allRoutesMetaMap[path]) {
      map[path] = allRoutesMetaMap[path];
    }
  }
  return map;
}

/**
 * 将路径字符串转为大驼峰路由名
 * blog/article -> BlogArticle
 */
function toRouteName(path: string) {
  return path
    .split("/")
    .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
    .join("");
}

/**
 * 获取页面组件路径
 * blog/article -> blog/article/index
 * user -> user/index
 */
function getComponentPath(path: string) {
  if (path === "user") {
    return "user/index";
  }
  return `${path}/index`;
}

interface MenuNode {
  children: Map<string, MenuNode>;
  component?: string;
  meta?: {
    hideChildrenInMenu?: boolean;
    icon?: string;
    order?: number;
    title: string;
  };
  name: string;
  path: string;
}

function buildMenuTree(paths: string[]) {
  const root: MenuNode = {
    children: new Map(),
    name: "",
    path: "",
  };
  const menuMetaMap = getMenuMetaMap();

  // 先收集所有顶层路径
  const topLevelPaths = new Set(
    paths.map((p) => p.split("/")[0]).filter((p): p is string => !!p),
  );

  for (const path of paths) {
    const segments = path.split("/");
    let current = root;

    for (let i = 0; i < segments.length; i++) {
      const segment: any = segments[i];
      const fullPath = segments.slice(0, i + 1).join("/");
      const isLeaf = i === segments.length - 1;
      const isTopLevel = i === 0;

      if (!current.children.has(segment)) {
        const meta = menuMetaMap[fullPath];
        // 顶层节点且只有一个段(如 'user'),说明它本身是一个页面
        // 但 VBen 需要 BasicLayout 包裹,所以 component 先留空,由 nodeToRoute 处理
        const shouldBePage = isLeaf && !isTopLevel;
        current.children.set(segment, {
          children: new Map(),
          component: shouldBePage ? getComponentPath(fullPath) : undefined,
          meta,
          name: toRouteName(fullPath),
          path: `/${fullPath}`,
        });
      }

      const node = current.children.get(segment);
      if (!node) {
        continue;
      }
      // 只有非顶层的叶子节点才设置页面 component
      // 顶层叶子节点(如 'user')会在后面统一处理为 BasicLayout + index 子路由
      if (isLeaf && !isTopLevel && !node.component) {
        node.component = getComponentPath(fullPath);
        node.meta = menuMetaMap[fullPath];
      }

      current = node;
    }
  }

  // 处理顶层叶子节点(如 'user'):给它们包一层 BasicLayout,并添加 index 子路由
  for (const topPath of topLevelPaths) {
    const node = root.children.get(topPath);
    if (node && node.children.size === 0 && !node.component) {
      // 这是一个顶层页面节点,需要包 BasicLayout
      node.children.set("index", {
        children: new Map(),
        component: getComponentPath(topPath),
        meta: node.meta,
        name: `${node.name}Index`,
        path: `${node.path}/index`,
      });
    }
  }

  return root;
}

function nodeToRoute(node: MenuNode): RouteRecordStringComponent {
  const hasChildren = node.children.size > 0;
  const route: RouteRecordStringComponent = {
    name: node.name,
    path: node.path,
    // 有子路由的节点必须用 BasicLayout 布局,叶子节点用页面组件
    component: hasChildren ? "BasicLayout" : node.component || "BasicLayout",
    meta: node.meta,
  };

  if (hasChildren) {
    route.children = [...node.children.values()].map((child) =>
      nodeToRoute(child),
    );
  }

  return route;
}

/**
 * 获取用户所有菜单
 * 后端模式:由 data.ts 模拟后端返回的菜单数据
 */
export async function getAllMenusApi() {
  const root = buildMenuTree(menuList);
  return [...root.children.values()].map((node) => nodeToRoute(node));
}

重点说明 ​

4.1 移除原有实现 ​

  • 删除 requestClient.get<RouteRecordStringComponent[]>('/menu/all') 的真实 API 请求
  • 改为本地基于 menuList + allRoutesMetaMap 构建菜单树

4.2 新增 getMenuMetaMap() 函数 ​

  • 从 allRoutesMetaMap 中按 menuList 里的 path 筛选出当前用户可见的菜单元信息
  • 由于路由模块中的 title 已经在源文件里通过 $t() 解析(在路由模块加载时 i18n 已初始化),这里直接复用其结果,无需再次调用 $t

4.3 新增 toRouteName() 函数 ​

  • 将路径转为大驼峰路由名:blog/article → BlogArticle

4.4 新增 getComponentPath() 函数 ​

  • 映射页面组件路径:blog/article → blog/article/index
  • user 特殊处理为 user/index

4.5 新增 buildMenuTree() 函数 ​

  • 将扁平的 path 数组按 / 分段构建成树形 MenuNode 结构
  • 关键修复: 区分顶层节点和非顶层节点
    • 顶层页面节点(如 user)不直接设置 component,由后续统一处理
    • 非顶层叶子节点(如 blog/article)设置页面 component

4.6 新增顶层节点后处理逻辑 ​

  • 对顶层叶子节点(如 user)自动包一层 BasicLayout,并添加 index 子路由
  • 确保所有一级菜单都有布局包裹,避免点击后页面全屏无布局

4.7 新增 nodeToRoute() 函数 ​

  • 将 MenuNode 转为 RouteRecordStringComponent
  • 关键修复: 有 children 的节点强制使用 BasicLayout,不再使用可能不存在的 blog/index 等组件路径

4.8 getAllMenusApi() 返回值 ​

  • 同步返回本地构建好的路由树(保留 async 签名以匹配原接口调用方)