本文档描述菜单管理模块的数据库设计、后端实现、API 接口、前端管理页面及动态路由加载机制。 菜单管理是权限控制体系的基础,通过动态菜单实现角色级的页面可见性控制。
菜单管理模块实现运营平台侧边栏菜单的动态配置与角色权限绑定。核心能力:
| 能力 | 说明 |
|---|---|
| 增删改查 | 对 t_menu 表进行 CRUD 操作,支持按菜单名称模糊搜索 |
| 级联删除 | 删除父菜单时,递归逻辑删除所有子孙菜单 |
| 菜单树构建 | 将扁平菜单列表按 parent_id 关系构建为树形结构(前端 asyncRoutes 格式) |
| 角色菜单绑定 | 支持为每个角色分配可见菜单,保存到 t_role_menu 表 |
| 动态路由下发 | 前端请求 /menu/routes 获取当前用户可见的菜单树,动态注册路由 |
模块在系统中的位置:
系统设置
└── 菜单管理 (/system/menu) ← 本模块前端管理页
└── 角色管理 (/system/role) ← 角色-菜单权限分配
建表 SQL:create_menu.sql
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| id | BIGINT | — | 主键(雪花算法) |
| parent_id | BIGINT | 0 | 父菜单ID,0表示顶级菜单 |
| menu_type | INT | 0 | 菜单类型: 0=菜单 1=iframe 2=外链 3=按钮 |
| title | VARCHAR(100) | — | 菜单名称 |
| name | VARCHAR(100) | '' | 路由名称(须与前端 .ts 路由文件中的 name 一致) |
| path | VARCHAR(200) | '' | 路由路径 |
| component | VARCHAR(200) | '' | 组件路径(后端仅存储,由前端 addAsyncRoutes 解析匹配) |
rank |
INT | 99 | 菜单排序(越小越靠前) |
redirect |
VARCHAR(200) | '' | 路由重定向 |
| icon | VARCHAR(100) | '' | 菜单图标(ri: / ep: 前缀的 iconify 图标名) |
| extra_icon | VARCHAR(100) | '' | 右侧图标 |
| enter_transition | VARCHAR(50) | '' | 进场动画 |
| leave_transition | VARCHAR(50) | '' | 离场动画 |
| active_path | VARCHAR(200) | '' | 菜单激活路径 |
| auths | VARCHAR(200) | '' | 权限标识(按钮级别,逗号分隔) |
| frame_src | VARCHAR(500) | '' | iframe 链接地址 |
| frame_loading | TINYINT | 1 | iframe 是否开启加载动画 |
| keep_alive | TINYINT | 0 | 是否缓存页面 |
| hidden_tag | TINYINT | 0 | 是否禁止添加到标签页 |
| fixed_tag | TINYINT | 0 | 是否固定显示在标签页 |
| show_link | TINYINT | 1 | 是否显示该菜单 |
| show_parent | TINYINT | 0 | 是否显示父级菜单 |
| create_time | DATETIME | CURRENT_TIMESTAMP | 创建时间 |
| update_time | DATETIME | CURRENT_TIMESTAMP | 更新时间(自动更新) |
| deleted | TINYINT | 0 | 逻辑删除: 0=正常 1=已删除 |
索引:
KEY idx_parent_id (parent_id),
KEY idx_menu_type (menu_type),
KEY idx_rank (`rank`)
MySQL 保留字处理:rank 和 redirect 是 MySQL 保留字,实体类中使用 @TableField("rank") 和 @TableField("redirect") 进行转义。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键(雪花算法) |
| role_id | BIGINT | 角色ID |
| menu_id | BIGINT | 菜单ID |
| create_time | DATETIME | 创建时间 |
唯一约束:UNIQUE KEY uk_role_menu (role_id, menu_id)
文件:Menu.java
@Data
@TableName("t_menu")
public class Menu implements Serializable {
@TableId(type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@JsonSerialize(using = ToStringSerializer.class)
private Long parentId; // 父菜单ID,0表示顶级
private Integer menuType; // 0=菜单 1=iframe 2=外链 3=按钮
private String title; // 菜单名称
private String name; // 路由名称
private String path; // 路由路径
private String component; // 组件路径
@TableField("`rank`")
private Integer rank; // 排序(MySQL保留字转义)
@TableField("`redirect`")
private String redirect; // 重定向(MySQL保留字转义)
private String icon; // 菜单图标
private String extraIcon; // 右侧图标
private String enterTransition; // 进场动画
private String leaveTransition; // 离场动画
private String activePath; // 激活路径
private String auths; // 权限标识
private String frameSrc; // iframe链接
private Boolean frameLoading; // iframe加载动画
private Boolean keepAlive; // 是否缓存
private Boolean hiddenTag; // 禁止标签页
private Boolean fixedTag; // 固定标签页
private Boolean showLink; // 是否显示
private Boolean showParent; // 是否显示父级
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updateTime;
private Integer deleted; // 逻辑删除标记
}
文件:RoleMenu.java
@Data
@TableName("t_role_menu")
public class RoleMenu implements Serializable {
@TableId(type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@JsonSerialize(using = ToStringSerializer.class)
private Long roleId;
@JsonSerialize(using = ToStringSerializer.class)
private Long menuId;
private LocalDateTime createTime;
}
文件:MenuMapper.java
public interface MenuMapper extends BaseMapper<Menu> {
// 查询角色的所有菜单ID(注解式SQL)
@Select("SELECT menu_id FROM t_role_menu WHERE role_id = #{roleId}")
List<Long> selectMenuIdsByRoleId(@Param("roleId") Long roleId);
// 根据角色ID列表查询菜单(XML映射文件)
List<Menu> selectMenusByRoleIds(@Param("roleIds") List<Long> roleIds);
// 删除角色的所有菜单关联(注解式SQL)
@Delete("DELETE FROM t_role_menu WHERE role_id = #{roleId}")
int deleteByRoleId(@Param("roleId") Long roleId);
}
文件:MenuMapper.xml
<select id="selectMenusByRoleIds" resultMap="MenuResultMap">
SELECT DISTINCT m.* FROM t_menu m
INNER JOIN t_role_menu rm ON m.id = rm.menu_id
WHERE rm.role_id IN
<foreach collection="roleIds" item="roleId" open="(" separator="," close=")">
#{roleId}
</foreach>
AND m.deleted = 0
ORDER BY m.`rank` ASC, m.id ASC
</select>
使用 DISTINCT 去重,避免用户拥有多个角色时出现重复菜单。
文件:RoleMenuMapper.java
空接口,继承 BaseMapper<RoleMenu>,使用 MyBatis-Plus 内置 CRUD。
文件:MenuService.java
| 方法 | 说明 |
|---|---|
getAllMenus() |
获取所有未删除菜单(按 rank + id 排序) |
searchByTitle(String title) |
按菜单名称模糊搜索 |
createMenu(Menu menu) |
创建菜单(自动设置创建时间、默认 rank=99、默认 menuType=0) |
updateMenu(Menu menu) |
更新菜单(自动更新 updateTime) |
deleteMenu(Long id) |
逻辑删除菜单及其所有子孙菜单 |
getUserMenuTree(List<Long> roleIds) |
根据角色ID列表生成用户可见的菜单树 |
getMenuIdsByRoleId(Long roleId) |
获取角色已分配的菜单ID列表 |
saveRoleMenus(Long roleId, List<Long> menuIds) |
保存角色菜单关联(先删后插) |
文件:MenuServiceImpl.java
public void deleteMenu(Long id) {
List<Long> idsToDelete = new ArrayList<>();
collectChildIds(id, idsToDelete); // 递归收集所有子孙菜单
idsToDelete.add(id);
for (Long menuId : idsToDelete) {
// 逐个标记 deleted=1
updateById(menu);
}
}
private void collectChildIds(Long parentId, List<Long> result) {
List<Menu> children = lambdaQuery()
.eq(Menu::getParentId, parentId)
.eq(Menu::getDeleted, 0)
.list();
for (Menu child : children) {
result.add(child.getId());
collectChildIds(child.getId(), result); // 递归子节点
}
}
buildMenuTree 方法将扁平菜单列表转换为前端 asyncRoutes 格式的树形结构:
private List<Map<String, Object>> buildMenuTree(Long parentId, List<Menu> allMenus) {
List<Map<String, Object>> tree = new ArrayList<>();
// 1. 筛选当前层级子节点,按 rank 排序
List<Menu> children = allMenus.stream()
.filter(m -> m.getParentId().equals(parentId))
.sorted(Comparator.comparing(Menu::getRank, ...))
.toList();
for (Menu menu : children) {
// 2. 构建节点:path、name、meta
Map<String, Object> node = new LinkedHashMap<>();
node.put("path", menu.getPath());
node.put("name", menu.getName());
// meta: { icon, title, rank, showParent }
// ...
// 3. 递归构建子节点
List<Map<String, Object>> childNodes = buildMenuTree(menu.getId(), allMenus);
if (!childNodes.isEmpty()) {
node.put("children", childNodes);
if (menu.getRedirect() != null) {
node.put("redirect", menu.getRedirect()); // 父级路由重定向
}
} else {
// 叶子节点需要 component
node.put("component", menu.getComponent());
}
tree.add(node);
}
return tree;
}
关键逻辑:
children + 可选 redirectcomponent,由前端 addAsyncRoutes 解析匹配menuType=3(按钮类型)和 showLink=false 的菜单文件:MenuController.java
注意:菜单管理属于运营平台独有功能,Controller 位于
haha-admin模块。
| 方法 | 路径 | 权限 | 日志 | 说明 |
|---|---|---|---|---|
| POST | /menu |
menu:read |
— | 获取菜单列表(支持按 title 搜索) |
| POST | /menu/create |
menu:create |
@Log |
创建菜单 |
| PUT | /menu/{id} |
menu:update |
@Log |
更新菜单 |
| DELETE | /menu/{id} |
menu:delete |
@Log |
删除菜单(级联) |
| GET | /menu/routes |
无需注解 | — | 获取当前用户可见的菜单树(侧边栏渲染用) |
| POST | /menu/role-menu |
role:read |
— | 获取全部菜单(角色权限分配用) |
| POST | /menu/role-menu-ids |
role:read |
— | 获取指定角色已分配的菜单ID列表 |
| POST | /menu/save-role-menus |
role:update |
@Log |
保存角色菜单关联 |
/menu/routes这是侧边栏动态渲染的核心接口,调用链如下:
前端发起 GET /menu/routes (携带 adminAccessToken)
→ MenuController.getUserRoutes()
→ 从 Sa-Token Session 获取 roleIds(登录时存入)
→ menuService.getUserMenuTree(roleIds)
→ MenuMapper.selectMenusByRoleIds(roleIds) // XML 多表关联查询
→ 过滤 menuType != 3 且 showLink == true
→ buildMenuTree() 递归构建树形结构
→ 返回 Result<List<Map<String, Object>>>
返回数据格式示例:
{
"code": 200,
"message": "操作成功",
"data": [
{
"path": "/dashboard",
"name": "Dashboard",
"redirect": "/dashboard/index",
"meta": { "icon": "ri:dashboard-line", "title": "数据概览", "rank": 1 },
"children": [
{
"path": "/dashboard/index",
"name": "Dashboard",
"component": "",
"meta": { "title": "数据概览", "rank": 1 }
}
]
}
]
}
Controller 中定义了三个内部 DTO:
| DTO | 字段 | 对应接口 |
|---|---|---|
MenuQueryDTO |
title: String |
POST /menu |
RoleMenuQueryDTO |
id: Long |
POST /menu/role-menu-ids |
SaveRoleMenuDTO |
roleId: Long, menuIds: List<Long> |
POST /menu/save-role-menus |
目录:src/views/system/menu/
| 文件 | 说明 |
|---|---|
index.vue |
菜单列表页(搜索、表格、增删改按钮) |
form.vue |
菜单编辑弹窗(表单) |
utils/hook.tsx |
核心逻辑 Hook(搜索、表格列定义、CRUD 操作) |
utils/enums.ts |
枚举常量(菜单类型、显示/隐藏等选项) |
utils/types.ts |
TypeScript 类型定义 |
utils/rule.ts |
表单验证规则 |
| 值 | 标签 | 说明 |
|---|---|---|
| 0 | 菜单 | 普通路由菜单 |
| 1 | iframe | 内嵌 iframe 页面 |
| 2 | 外链 | 外部链接,新窗口打开 |
| 3 | 按钮 | 按钮级权限,不显示在菜单中 |
创建/编辑菜单时的字段与 Menu 实体完全对应:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| menuType | number | 是 | 菜单类型 |
| parentId | number | — | 父菜单(选择上级菜单) |
| title | string | 是 | 菜单名称 |
| name | string | 是 | 路由名称 |
| path | string | 是 | 路由路径 |
| component | string | — | 组件路径 |
| rank | number | — | 排序(数字越小越靠前) |
| redirect | string | — | 路由重定向 |
| icon | string | — | 菜单图标 |
| showLink | boolean | — | 是否显示 |
| showParent | boolean | — | 是否显示父级 |
| keepAlive | boolean | — | 页面缓存 |
| fixedTag | boolean | — | 固定标签页 |
| hiddenTag | boolean | — | 隐藏标签页 |
| frameSrc | string | — | iframe 链接 |
| frameLoading | boolean | — | iframe 加载动画 |
| auths | string | — | 权限标识 |
文件:src/api/system.ts
/** 获取菜单列表 */
export const getMenuList = (data?: object) =>
http.request<Result>("post", "/menu", { data });
/** 创建菜单 */
export const createMenu = (data: object) =>
http.request<Result>("post", "/menu/create", { data });
/** 更新菜单 */
export const updateMenu = (id: number, data: object) =>
http.request<Result>("put", `/menu/${id}`, { data });
/** 删除菜单 */
export const deleteMenuApi = (id: number) =>
http.request<Result>("delete", `/menu/${id}`);
/** 获取角色菜单权限(全部菜单,权限分配页用) */
export const getRoleMenu = (data?: object) =>
http.request<Result>("post", "/menu/role-menu", { data });
/** 获取角色已分配的菜单ID */
export const getRoleMenuIds = (data?: object) =>
http.request<Result>("post", "/menu/role-menu-ids", { data });
菜单从后端到侧边栏渲染的完整数据流:
1. 用户登录
→ 后端验证账号密码
→ 将 roleIds 存入 Sa-Token Session
→ 返回 accessToken + 用户信息
2. 前端路由初始化 (permission.ts, router/utils.ts)
→ 调用 handleAsyncRoutes() 获取动态路由
→ 请求 GET /menu/routes (携带 token)
→ 后端返回用户可见的菜单树
3. 前端处理 (router/utils.ts → addAsyncRoutes)
→ 对每条动态路由:
a. 设置 meta.backstage = true(标记为后端路由)
b. 通过 path 匹配 modulesRoutesKeys(前端组件映射表)
c. 解析 component 为实际 Vue 组件
d. 按 path 去重注册到 router.options.routes[0].children
4. 侧边栏渲染 (permission.ts → handleWholeMenus)
→ 合并 constantMenus(静态路由) + routes(动态路由)
→ 按 path 去重(动态路由 backstage:true 优先覆盖静态路由)
→ ascending() 排序 → filterTree() 过滤 → filterNoPermissionTree() 角色过滤
→ 写入 wholeMenus → NavVertical.vue 渲染侧边栏
关键设计点:
meta.backstage = true),因为后台配置可能有更精确的权限信息。component 字段存储的是路径(如 "/dashboard/index"),前端 addAsyncRoutes 通过与 modulesRoutesKeys 键名匹配来解析为实际组件。name 字段必须与前端 .ts 路由文件中定义的 name 完全一致,否则 Vue Router 会报 No match for 警告。文件:seed_menu.sql
种子数据包含 52 条菜单记录,覆盖 11 个一级菜单组,与前端静态路由结构 1:1 对应:
| 一级菜单 | ID段 | rank | 子菜单数 | 说明 |
|---|---|---|---|---|
| 数据概览 | 100-101 | 1 | 1 | Dashboard,重定向 /dashboard/index |
| 订单管理 | 200-202 | 2 | 2 | 订单列表 + 退款管理 |
| 运营管理 | 300-304 | 3 | 4 | 门店、设备、地图、补货员 |
| 商品管理 | 400-402 | 4 | 2 | 商品列表 + 新品申请 |
| 库存管理 | 500-503 | 5 | 3 | 库存列表 + 库存变动 + 上货记录 |
| 客户管理 | 600-601 | 6 | 1 | 客户列表 |
| 分销管理 | 700-707 | 6 | 7 | 概览、分销员、推荐、佣金、提现、报表、配置 |
| 营销中心 | 800-814 | 7 | 9 | 活动、优惠券、用户优惠券、定时折扣 + 数据统计(4子项) |
| 统计报表 | 900-904 | 8 | 4 | 概览、品类、利润、复购 |
| 系统设置 | 1000-1008 | 8 | 8 | 管理员、角色、权限、公告、日志、同步、字典、菜单管理 |
| 设备层模版管理 | 1100 | 8 | 0 | 独立顶级菜单,无子菜单 |
admin 角色(role_id=1)拥有全部 52 条菜单的访问权限:
INSERT INTO t_role_menu (id, role_id, menu_id) VALUES
(90001, 1, 100), (90002, 1, 101),
-- ... 共 52 条
(90052, 1, 1100);
id >= 100 AND id < 1200 范围的菜单,避免误删其他业务数据。ascending() 函数对同 rank 的菜单会自动分配次级排序。| 权限编码 | 说明 | 覆盖接口 |
|---|---|---|
menu:read |
菜单查询 | POST /menu |
menu:create |
菜单创建 | POST /menu/create |
menu:update |
菜单更新 | PUT /menu/{id} |
menu:delete |
菜单删除 | DELETE /menu/{id} |
注意:GET /menu/routes 不加 @RequirePermission,因为所有登录用户都需要获取自己可见的菜单树。
角色管理页面调用菜单分配接口,使用的权限编码属于 role 模块:
| 权限编码 | 覆盖接口 |
|---|---|
role:read |
POST /menu/role-menu, POST /menu/role-menu-ids |
role:update |
POST /menu/save-role-menus |
菜单管理页面在侧边栏的入口路径为 /system/menu,其可见性由:
router/modules/system.ts):定义了 /system/menu 路由,meta.roles: ["admin"]两层控制结合,确保只有拥有 menu:read 权限且被分配了该菜单的角色才能看到此页面。
以下时序图展示了从登录到侧边栏菜单渲染的完整数据流:
sequenceDiagram
participant U as 用户
participant FE as 前端 (Vue Router)
participant Ctrl as MenuController
participant Svc as MenuServiceImpl
participant DB as t_menu / t_role_menu
U->>FE: 1. 登录 (username + password)
FE->>Ctrl: 2. POST /login
Ctrl->>Ctrl: 3. 验证凭证,将 roleIds 存入 Sa-Token Session
Ctrl-->>FE: 4. 返回 accessToken + 用户信息
FE->>FE: 5. 路由初始化 → handleAsyncRoutes()
FE->>Ctrl: 6. GET /menu/routes (携带 token)
Ctrl->>Ctrl: 7. StpUtil.getSession().get("roleIds")
Ctrl->>Svc: 8. getUserMenuTree(roleIds)
Svc->>DB: 9. SELECT m.* FROM t_menu m JOIN t_role_menu rm ...
DB-->>Svc: 10. 返回扁平菜单列表
Svc->>Svc: 11. 过滤(排除按钮类型、隐藏菜单)
Svc->>Svc: 12. buildMenuTree() 递归构建树形结构
Svc-->>Ctrl: 13. 返回菜单树
Ctrl-->>FE: 14. Result<List<Map>> JSON
FE->>FE: 15. addAsyncRoutes() 注册动态路由
FE->>FE: 16. handleWholeMenus() 合并 + 去重
FE->>FE: 17. ascending() 排序 + filterTree() 过滤
FE-->>U: 18. 渲染侧边栏菜单
name 完全一致,否则 Vue Router 动态注册时会报 No match for 警告。dashboard.ts 中定义 name: "Dashboard",数据库 name 字段也必须是 "Dashboard"。modulesRoutesKeys 自动匹配)。buildMenuTree 中会输出 component 字段,但值可以为空字符串 ""。rank 字段用于控制菜单在侧边栏的显示顺序,值越小越靠前。ascending() 函数会按字典序自动分配次级排序。rank=6(客户管理、分销管理)和 rank=8(统计报表、系统设置、设备层模版)存在重复,属于正常设计。rank 和 redirect 是 MySQL 保留字,在 SQL 中需要用反引号 ` 包裹。@TableField("rank") 处理,MyBatis XML 中使用 `rank` 处理。deleteMenu 方法会递归查找并标记所有子孙菜单的 deleted=1。当新增前端静态路由模块或修改现有路由结构时,需要同步更新:
RoleServiceImpl 将用户的角色 ID 列表以逗号分隔字符串存入 Sa-Token Session(key: "roleIds")。MenuController.getUserRoutes() 从 Session 读取并解析为 List<Long>。permission.ts 中的 handleWholeMenus 对静态路由和动态路由按 path 去重,动态路由优先(backstage: true),因为数据库中可能包含更精确的权限和排序配置。
| 层次 | 文件 | 说明 |
|---|---|---|
| 数据库 | create_menu.sql | 建表 DDL |
| 数据库 | seed_menu.sql | 种子数据 |
| 实体 | Menu.java | 菜单实体 |
| 实体 | RoleMenu.java | 角色菜单关联实体 |
| Mapper | MenuMapper.java | 菜单 Mapper |
| Mapper | MenuMapper.xml | 菜单 XML 映射 |
| Mapper | RoleMenuMapper.java | 角色菜单 Mapper |
| Service | MenuService.java | 菜单服务接口 |
| Service | MenuServiceImpl.java | 菜单服务实现 |
| Controller | MenuController.java | 菜单管理控制器 |
| 前端页面 | views/system/menu/ | 菜单管理页面 |
| 前端 API | src/api/system.ts | 前端 API 封装 |
| 路由处理 | src/router/utils.ts | addAsyncRoutes 动态路由注册 |
| 权限处理 | src/store/modules/permission.ts | handleWholeMenus 菜单合并去重 |