动态路由配置
开启后端模式
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.globeager 加载的所有动态路由) - 递归 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签名以匹配原接口调用方)