Chet.Admin 模块详解④:菜单树 + 动态路由生成 🌳
《Chet.Admin 全栈实战》系列第 10 篇
前言
权限系统里,菜单 是个被低估的模块。
很多人以为菜单就是一串 JSON 配置,写死在前端就行了。但企业级项目里,菜单其实承担了三重身份:
- 🗂️ 导航骨架:决定侧边栏长什么样
- 🛣️ 路由表:决定哪些 URL 可访问
- 🔐 权限载体:按钮级别的细粒度控制
Chet.Admin 把这三件事整合到了一套「菜单实体 + 动态路由」方案里。今天这篇就来拆开看。
一、菜单实体的三种类型
菜单实体定义在 Chet.Admin.Domain/Menu/MenuEntity.cs,关键字段如下:
public class MenuEntity : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string? Component { get; set; } // 组件路径
public string? Redirect { get; set; } // 重定向
public string? Icon { get; set; } // 图标
public int ParentId { get; set; } // 父菜单ID(0=顶级)
public string Type { get; set; } = "Menu"; // 类型
public int Sort { get; set; }
public bool IsEnabled { get; set; } = true;
public bool IsExternal { get; set; } // 是否外链
public bool IsCache { get; set; } // 是否缓存
public bool IsVisible { get; set; } = true; // 是否显示
public string? Permission { get; set; } // 权限标识
public string? Description { get; set; }
public List<MenuEntity> Children { get; set; } = [];
public List<RoleMenuEntity> RoleMenus { get; set; } = [];
}最核心的划分在 Type 字段,共三种类型:
| 类型 | Type 值 | 作用 | 典型场景 |
|---|---|---|---|
| 📁 目录 | Directory | 一级分组,不渲染页面 | 「系统管理」「用户中心」 |
| 📄 菜单 | Menu | 实际页面,对应路由 | 「用户列表」「角色列表」 |
| 🔘 按钮 | Button | 不进路由,只做权限校验 | 「删除用户」「导出 Excel」 |
为什么要分目录/菜单/按钮?
因为三种东西在 UI 上的呈现完全不同:
- 目录 = 折叠面板,只渲染标题和图标
- 菜单 = 渲染
<router-view> - 按钮 = 不进侧边栏,但会出现在
v-access指令的校验码里
如果用一张表混存,再写一堆 if-else 判断渲染方式,代码会很乱。用 Type 字段切分后,每条数据职责清晰。
二、后端:递归构建菜单树
2.1 控制器的 7 个接口
MenusController.cs 暴露的接口一览:
[HttpGet] // 所有菜单(扁平)
[HttpGet("tree")] // 菜单树(全量)
[HttpGet("my-tree")] // 当前用户菜单树(按角色过滤)
[HttpGet("paged")] // 分页查询
[HttpGet("{id}")] // 详情
[HttpPost] // 创建
[HttpPut("{id}")] // 更新
[HttpDelete("{id}")] // 删除注意 my-tree 这个接口,它是动态路由的入口。普通用户登录后不会拿到全部菜单,而是按角色过滤后的子集。
2.2 递归建树:BuildMenuTree
MenuService.GetMenuTreeAsync() 返回全量菜单树,核心是 BuildMenuTree 方法:
private static IEnumerable<MenuTreeDto> BuildMenuTree(List<MenuTreeDto> allMenus, int parentId)
{
return allMenus
.Where(m => m.ParentId == parentId)
.OrderBy(m => m.Sort)
.Select(m =>
{
m.Children = BuildMenuTree(allMenus, m.Id).ToList();
return m;
})
.ToList();
}思路:
- 从扁平列表里挑出
ParentId == parentId的节点 - 对每个节点递归调用自己,把结果挂到
Children - 按
Sort排序,保证展示顺序可控
优点:简单、不需要建索引;数据库一次性查回扁平列表,无 N+1 问题。
2.3 用户菜单:祖先补全算法
最有意思的是 GetMyMenuTreeAsync,它要根据用户角色返回"我的菜单树"。
如果直接拿 userMenus 建树,会出问题:叶子节点的父级不在集合里,树就断了。所以必须把祖先菜单补全进来:
public async Task<IEnumerable<MenuTreeDto>> GetMyMenuTreeAsync(int userId)
{
var allMenus = (await _menuRepository.GetAllAsync()).ToList();
var userMenus = (await _menuRepository.GetMenusByUserIdAsync(userId)).ToList();
if (userMenus.Count == 0) return [];
// 收集需要包含的菜单ID(被分配的菜单 + 其所有祖先菜单)
var includedIds = new HashSet<int>();
var menuById = allMenus.ToDictionary(m => m.Id);
foreach (var menu in userMenus)
{
var current = menu;
while (current != null && includedIds.Add(current.Id))
{
if (current.ParentId > 0
&& menuById.TryGetValue(current.ParentId, out var parent))
{
current = parent;
}
else
{
break;
}
}
}
var filteredMenus = allMenus.Where(m => includedIds.Contains(m.Id)).ToList();
var menuDtos = _mapper.Map<List<MenuTreeDto>>(filteredMenus);
return BuildMenuTree(menuDtos, 0);
}关键点:
- 用
HashSet<int>记录要包含的 ID,自动去重 includedIds.Add(...)返回false说明遇到环,跳出避免死循环- 从叶子节点向上回溯,直到
ParentId == 0 - 最后用同一套
BuildMenuTree重新建树
这样返回给前端的菜单树层级完整,目录结构不会断。
三、前端:动态路由注册
3.1 路由加载流程
前端的路由分三类,定义在 router/routes/index.ts:
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', { eager: true });
/** 动态路由 */
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
/** 路由列表:基本路由 + 外部路由 + 404 兜底 */
const routes: RouteRecordRaw[] = [
...coreRoutes,
...externalRoutes,
fallbackNotFoundRoute,
];- coreRoutes:登录页、404 页等基础路由,不走权限校验
- dynamicRoutes:通过
import.meta.glob收集modules/下的静态路由模块 - 菜单路由:登录后从后端
my-tree接口拉取,运行时通过addRoute动态注册
3.2 generateAccess:菜单转路由
核心逻辑在 router/access.ts:
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
const layoutMap: ComponentRecordType = {
BasicLayout,
IFrameView,
};
return await generateAccessible(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
message.loading({ content: `${$t('common.loadingMenu')}...`, duration: 1.5 });
return await getMyMenusApi();
},
forbiddenComponent,
layoutMap,
pageMap,
});
}三个关键点:
pageMap:通过import.meta.glob把views/**/*.vue全部预扫描,建立「组件路径 → 异步组件」映射fetchMenuListAsync:调用getMyMenusApi()拿到后端菜单树generateAccessible:Vben Admin 的工具函数,把菜单树自动转成路由配置,内部调用router.addRoute注入
这样后端只要返回标准的菜单结构,前端不需要任何改动就能注册新页面。新增功能模块时只动后端菜单表就行。
3.3 菜单字段到路由字段的映射
后端菜单字段 → 前端路由字段的对应关系:
| 菜单字段 | 路由字段 | 说明 |
|---|---|---|
path | route.path | 路由路径 |
component | route.component | 组件路径,到 pageMap 里查 |
redirect | route.redirect | 进入目录时的默认跳转 |
name | route.meta.title | 菜单显示名 |
icon | route.meta.icon | 图标 |
sort | route.meta.order | 排序 |
isVisible | route.meta.hideInMenu | 反向控制 |
isCache | route.meta.keepAlive | 页面缓存 |
isExternal | route.meta.link | 外链 |
字段统一 = 后端配置即所见,不需要写路由文件。
四、前端:菜单管理页面
4.1 VxeTable 树形渲染
views/system/menu/index.vue 用 VxeTable 展示菜单树,关键配置:
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns,
proxyConfig: {
ajax: {
query: async () => {
// 使用扁平数据,让 vxe-table 通过 treeConfig.transform 自动构建树
const list = await getAllMenusApi();
return { items: list || [], total: list?.length || 0 };
},
},
},
rowConfig: { keyField: 'id' },
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true, // 自动扁平 → 树
expandAll: true,
indent: 20,
},
pagerConfig: { enabled: false }, // 树形结构不分页
},
});亮点:
- 后端返回扁平数组,不需要预先建树
- VxeTable 通过
treeConfig.transform=true+parentField自动构建树 - 关闭分页器:菜单数据量小,一次加载全部
这样后端可以同时给扁平接口(管理列表用)和树形接口(前端路由用),各取所需。
4.2 三种类型的 Tag 渲染
类型字段用颜色 Tag 区分:
const typeMap: Record<string, { color: string; label: string }> = {
Directory: { color: 'processing', label: '目录' }, // 蓝色
Menu: { color: 'success', label: '菜单' }, // 绿色
Button: { color: 'warning', label: '按钮' }, // 橙色
};
// 列定义里用 slots 自定义渲染
{
field: 'type', title: '类型', width: 80,
slots: {
default: ({ row }) => {
const t = typeMap[row.type];
return h(Tag, { color: t?.color || 'default' }, () => t?.label || row.type);
},
},
},一眼就能看出哪行是目录、哪行是菜单、哪行是按钮。
4.3 表单字段的联动显隐
最有意思的是表单 Schema。不同类型需要填的字段不一样:
- 目录:要
redirect,不要component - 菜单:要
component、isCache、isExternal - 按钮:只要
permission和description
Vben Form 的 dependencies.if 字段完美解决:
const formSchema: VbenFormSchema[] = [
{
component: 'Select', fieldName: 'type', label: '类型',
defaultValue: 'Menu', rules: 'required',
componentProps: {
options: [
{ label: '目录', value: 'Directory' },
{ label: '菜单', value: 'Menu' },
{ label: '按钮', value: 'Button' },
],
},
help: '目录=一级分组, 菜单=页面, 按钮=操作权限',
},
{
component: 'Input', fieldName: 'permission', label: '权限标识',
help: '如 system:user:list',
dependencies: {
triggerFields: ['type'],
if(values) { return values.type === 'Menu' || values.type === 'Button'; },
},
},
{
component: 'Input', fieldName: 'component', label: '组件路径',
dependencies: {
triggerFields: ['type'],
if(values) { return values.type === 'Menu'; },
},
},
{
component: 'Input', fieldName: 'redirect', label: '重定向',
dependencies: {
triggerFields: ['type'],
if(values) { return values.type === 'Directory'; },
},
},
// ... 其他字段
];机制:
triggerFields声明依赖的表单字段- 当
type变化时,框架会重新计算if(values) - 返回
false的字段会从表单里卸载
切到「按钮」类型时,路由路径、组件路径、图标全部消失,表单瞬间清爽。无需手写 v-if。
4.4 editingId 模式:追踪编辑状态
Chet.Admin 全项目统一用 editingId 区分新增和编辑:
const editingId = ref(0);
const [Modal, modalApi] = useVbenModal({
onConfirm: async () => {
const values = await formApi.getValues();
if (editingId.value) {
await updateMenuApi(editingId.value, values);
message.success('更新成功');
} else {
await createMenuApi(values);
message.success('创建成功');
}
modalApi.close();
gridApi.query();
},
async onOpenChange(isOpen) {
if (isOpen) {
formApi.resetForm();
const data = modalApi.getData<Record<string, any>>();
editingId.value = data?.id || 0;
// ... 加载父级菜单树
if (data) formApi.setValues(data);
}
},
});
function onCreate(parentId = 0) { modalApi.setData({ parentId }).open(); }
function onEdit(row: any) { modalApi.setData(row).open(); }好处:
- 同一个 Modal 同时承担新增和编辑,减少代码重复
editingId === 0一眼看出是新增模式onCreate(parentId)支持从某行点击「新增子菜单」,自动填入父级
五、IconPicker:图标选择器组件
菜单管理里有个看起来不起眼但很关键的组件:IconPicker。
Chet.Admin 用的是 Iconify 体系,集成了 15 万 + 个图标。但怎么让用户选?写个 Input 让用户手动敲图标名?显然不行。
5.1 全局注册
在 adapter/component/index.ts 里全局注册:
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// ... 其他组件
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter', // 图标显示在输入框右侧
inputComponent: Input,
modelValueProp: 'value',
}),
};
globalShareState.setComponents(components);
}5.2 在 Schema 里使用
菜单表单里直接当字段类型用:
{
component: 'IconPicker', fieldName: 'icon', label: '图标',
componentProps: {
prefix: 'lucide', // 默认 lucide 图标集
autoFetchApi: false, // 不自动拉取,避免首屏卡顿
},
dependencies: {
triggerFields: ['type'],
if(values) { return values.type !== 'Button'; }, // 按钮不选图标
},
},设计点:
prefix: 'lucide'限定图标集,避免 15 万图标全展开autoFetchApi: false按需加载,打开弹窗时才请求- 通过
withDefaultPlaceholder包装后,统一了 placeholder 体验

六、API 层的极简封装
api/system/menu.ts 只有 5 个函数,零冗余:
import { requestClient } from '#/api/request';
export async function getAllMenusApi() {
return requestClient.get('/menus');
}
export async function getMenuTreeApi() {
return requestClient.get('/menus/tree');
}
export async function createMenuApi(data: any) {
return requestClient.post('/menus', data);
}
export async function updateMenuApi(id: number, data: any) {
return requestClient.put(`/menus/${id}`, data);
}
export async function deleteMenuApi(id: number) {
return requestClient.delete(`/menus/${id}`);
}没有手写 try-catch、没有手写 loading,这些通用逻辑都被 requestClient 封装掉了。业务代码只关心"调哪个接口"。
七、权限标识的妙用
菜单的 Permission 字段(如 system:user:list)有两个用途:
7.1 控制按钮显隐
<Button v-if="hasAccessByCodes(['system:menu:create'])" type="primary" @click="onCreate(0)">
<Plus class="mr-2 size-4" />新增
</Button>7.2 控制表格行操作
<VbenTableAction
:actions="[
{ text: '新增', auth: 'system:menu:create', onClick: () => onCreate(row.id) },
{ text: '编辑', auth: 'system:menu:update', onClick: () => onEdit(row) },
]"
:dropdown-actions="[
{ text: '删除', auth: 'system:menu:delete', danger: true,
popConfirm: { title: '确认删除?', confirm: () => onDelete(row) } }
]"
/>auth 字段就是权限码。用户没权限 → 按钮/操作自动隐藏,无需手写 v-if。
八、设计回顾
总结一下这套方案的几个亮点:
- ✅ 菜单即路由:后端配置菜单即配置路由,无需改前端代码
- ✅ 祖先补全算法:保证用户菜单树层级完整
- ✅ 类型字段切分:目录/菜单/按钮职责清晰,表单动态显隐
- ✅ VxeTable transform:扁平数据自动建树,前后端都简单
- ✅ IconPicker 全局注册:15 万图标即选即用
- ✅ editingId 模式:新增编辑共用 Modal,减少代码重复
- ✅ 权限码统一:菜单表里的
permission字段直接驱动前端按钮显隐
下篇预告
下篇讲 部门管理:树形 CRUD + 父子关系 + 部门编码唯一校验 + 与用户数据权限联动。
「Chet.Admin 模块详解⑤:部门树形管理与组织架构 🏢」 敬请期待 👀
🔗 GitHub:https://github.com/qiect/Chet.Admin 🔗 Gitee:https://gitee.com/qiect/Chet.Admin ⭐ 觉得不错的话,点个 Star 支持一下吧!
#ChetAdmin #全栈开发 #.NET10 #Vue3 #动态路由 #RBAC