菜单管理
菜单管理
业务用途
菜单是权限的最小粒度载体,组织成树形结构。平台菜单分三种类型:
| 类型 | menuType | 作用 |
|---|---|---|
| 目录 | M | 容器节点,不对应页面,用于分组(如「系统管理」) |
| 菜单 | C | 对应一个前端路由页面(如「用户管理」),有 path 与 component |
| 按钮 | F | 挂在菜单下的操作按钮(如「新增」「删除」),用于按钮级权限控制 |
menuType 取值
代码中按 RuoYi 惯例使用 M/C/F(权限设计器矩阵查询兼容 0/1/page/button 等历史值)。新建菜单时建议统一用 M/C/F。
菜单的 perms(权限标识,如 system:user:list)是 @PreAuthorize("hasAuthority('xxx')") 校验的依据。用户登录后,后端按其角色聚合所有已分配菜单的 perms,前端据此控制按钮显隐与路由守卫。

涉及文件
| 层 | 文件路径 |
|---|---|
| 控制器 | backend/src/main/java/com/lowcode/controller/system/SysMenuController.java |
| 服务实现 | backend/src/main/java/com/lowcode/service/impl/SysMenuServiceImpl.java |
| 实体 | backend/src/main/java/com/lowcode/entity/SysMenu.java |
| Mapper | backend/src/main/java/com/lowcode/mapper/SysMenuMapper.java(含 selectMenusByRoleId、selectPermsByRoleIds 等 XML 方法) |
| 前端 API | frontend/src/api/menu.ts |
| 前端页面 | frontend/src/views/system/menu/index.vue |
数据库表
sys_menu(菜单表)
| 字段 | 类型 | 说明 |
|---|---|---|
parent_id | Long | 父菜单 ID(根节点为 0) |
menu_name | String | 菜单名称 |
menu_type | String | 类型(M目录 / C菜单 / F按钮) |
path | String | 路由路径(目录/菜单用,如 user) |
component | String | 前端组件路径(菜单用,如 system/user/index) |
icon | String | 图标 |
order_num | Integer | 显示顺序(Java 字段名 sortOrder,@TableField("order_num")) |
perms | String | 权限标识(Java 字段名 permission,@TableField("perms"),如 system:user:list) |
status | String | 状态(0正常 1禁用) |
visible | String | 是否可见(0显示 1隐藏) |
children | List | 子菜单(@TableField(exist = false),仅树形结构用,不入库) |
后端实现
端点一览
SysMenuController 基础路径:/api/system/menu
| HTTP 方法 | 路径 | 方法名 | 说明 |
|---|---|---|---|
| GET | /api/system/menu/tree | tree | 获取当前登录用户的菜单树(按角色+租户过滤) |
| GET | /api/system/menu/list | list | 获取全部菜单列表(管理用,按 sortOrder 排序) |
| GET | /api/system/menu/{id} | getById | 获取菜单详情 |
| POST | /api/system/menu | add | 新增菜单 |
| PUT | /api/system/menu | update | 修改菜单 |
| DELETE | /api/system/menu/{id} | delete | 删除菜单(存在子菜单时拒绝) |
关键代码
/tree 端点返回的是当前用户可见的菜单树,调用 getUserMenuTree(userId, tenantId),这是权限控制的核心:
@GetMapping("/tree")
public Result<List<SysMenu>> tree(HttpServletRequest request) {
String token = jwtUtil.getTokenFromRequest(request);
if (token == null || !jwtUtil.validateToken(token)) {
return Result.success(List.of());
}
Long userId = jwtUtil.getUserIdFromToken(token);
Long tenantId = jwtUtil.getTenantIdFromToken(token);
return Result.success(menuService.getUserMenuTree(userId, tenantId));
}getUserMenuTree 的过滤逻辑分三步,是理解平台菜单权限的关键:
@Override
public List<SysMenu> getUserMenuTree(Long userId, Long tenantId) {
// 1. 查用户的所有角色
List<Long> roleIds = userRoleMapper.selectList(...).stream()
.map(SysUserRole::getRoleId).filter(Objects::nonNull).toList();
if (roleIds.isEmpty()) return new ArrayList<>();
// 2. 查这些角色关联的菜单 ID(角色权限)
Set<Long> roleMenuIds = new LinkedHashSet<>(roleMenuMapper.selectList(...).stream()
.map(SysRoleMenu::getMenuId).filter(Objects::nonNull).toList());
// 3. 与租户已授权菜单 ID 取交集(租户权限)
Set<Long> tenantMenuIds = new LinkedHashSet<>(tenantMenuMapper.selectList(...).stream()
.map(SysTenantMenu::getMenuId).filter(Objects::nonNull).toList());
roleMenuIds.retainAll(tenantMenuIds); // 关键:双重过滤
// 4. 补全父级菜单(否则子菜单找不到父节点,树会断裂)
Set<Long> allowedIds = new LinkedHashSet<>(roleMenuIds);
for (Long menuId : roleMenuIds) {
addParentMenus(allowedIds, menuId);
}
// 5. 查菜单实体并构建树
List<SysMenu> allMenus = list(new LambdaQueryWrapper<SysMenu>()
.in(SysMenu::getId, allowedIds).orderByAsc(SysMenu::getSortOrder));
return buildTree(allMenus, 0L);
}双重过滤:角色权限 AND 租户权限
用户最终能看到的菜单 = 「角色分配的菜单」∩「租户授权的菜单」。即使角色配了某菜单,若租户(sys_tenant_menu)未授权该菜单,用户依然看不到。这是 SaaS 多租户场景下的重要约束,运维时容易忽略。
删除菜单前会校验子节点,防止删出孤儿节点:
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
long childCount = menuService.count(new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getParentId, id));
if (childCount > 0) {
return Result.error("存在子菜单,不允许删除");
}
menuService.removeById(id);
return Result.success("删除成功");
}buildTree 是经典的递归树构建:从 parentId=0 开始,把每条记录的 children 填上其直接子节点:
private List<SysMenu> buildTree(List<SysMenu> allMenus, Long parentId) {
List<SysMenu> menuTree = new ArrayList<>();
for (SysMenu menu : allMenus) {
if (parentId.equals(menu.getParentId())) {
menu.setChildren(buildTree(allMenus, menu.getId()));
menuTree.add(menu);
}
}
return menuTree;
}前端实现
- API 层
frontend/src/api/menu.ts:getMenuTree(当前用户树,用于侧边栏渲染)、getMenuList(全部菜单,管理用)、getMenu、addMenu、updateMenu、deleteMenu。 - 页面
frontend/src/views/system/menu/index.vue:el-tree展示菜单树,支持新增/编辑节点,表单中选择父级菜单、类型(目录/菜单/按钮)、填写路由 path、component、权限标识 perms。

操作步骤
- 进入「系统管理 -> 菜单管理」。
- 新增目录:类型选
M,填名称、图标、排序,parent 选根。 - 在目录下新增菜单:类型选
C,填路由 path、组件 component、权限标识(如system:user:list)。 - 在菜单下新增按钮:类型选
F,填权限标识(如system:user:add),供前端按钮鉴权。
5. 到[角色管理](./role.html)给角色勾选这些菜单节点。 常见问题
按钮类型(F)菜单在前端怎么用?
按钮菜单不渲染为导航项,它的 perms 标识用于前端 v-hasPermi 指令或 @PreAuthorize 校验。例如配了 system:user:add,前端「新增」按钮用 v-hasPermi="['system:user:add']" 控制显隐。
新建了菜单但用户看不到?
按顺序排查:① 用户是否关联了包含该菜单的角色;② 角色是否勾选了该菜单(sys_role_menu);③ 租户是否授权了该菜单(sys_tenant_menu);④ 菜单 status 是否为 0、visible 是否为 0。
删除菜单提示「存在子菜单」?
菜单树要求先删子节点再删父节点。若要批量删除整棵子树,需自行递归删除所有后代。
