菜单管理模块.md 25 KB

菜单管理模块

本文档描述菜单管理模块的数据库设计、后端实现、API 接口、前端管理页面及动态路由加载机制。 菜单管理是权限控制体系的基础,通过动态菜单实现角色级的页面可见性控制。


1. 模块概述

菜单管理模块实现运营平台侧边栏菜单的动态配置与角色权限绑定。核心能力:

能力 说明
增删改查 对 t_menu 表进行 CRUD 操作,支持按菜单名称模糊搜索
级联删除 删除父菜单时,递归逻辑删除所有子孙菜单
菜单树构建 将扁平菜单列表按 parent_id 关系构建为树形结构(前端 asyncRoutes 格式)
角色菜单绑定 支持为每个角色分配可见菜单,保存到 t_role_menu 表
动态路由下发 前端请求 /menu/routes 获取当前用户可见的菜单树,动态注册路由

模块在系统中的位置:

系统设置
  └── 菜单管理 (/system/menu)  ← 本模块前端管理页
  └── 角色管理 (/system/role)   ← 角色-菜单权限分配

2. 数据库设计

2.1 t_menu(菜单表)

建表 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 保留字处理rankredirect 是 MySQL 保留字,实体类中使用 @TableField("rank")@TableField("redirect") 进行转义。

2.2 t_role_menu(角色菜单关联表)

字段 类型 说明
id BIGINT 主键(雪花算法)
role_id BIGINT 角色ID
menu_id BIGINT 菜单ID
create_time DATETIME 创建时间

唯一约束UNIQUE KEY uk_role_menu (role_id, menu_id)


3. 实体类

3.1 Menu

文件: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;         // 逻辑删除标记
}

3.2 RoleMenu

文件: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;
}

4. Mapper 层

4.1 MenuMapper

文件: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);
}

4.2 MenuMapper.xml

文件: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 去重,避免用户拥有多个角色时出现重复菜单。

4.3 RoleMenuMapper

文件:RoleMenuMapper.java

空接口,继承 BaseMapper<RoleMenu>,使用 MyBatis-Plus 内置 CRUD。


5. Service 层

5.1 MenuService 接口

文件: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) 保存角色菜单关联(先删后插)

5.2 MenuServiceImpl 实现

文件:MenuServiceImpl.java

5.2.1 级联删除实现

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);  // 递归子节点
    }
}

5.2.2 菜单树构建算法

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 + 可选 redirect
  • 叶子节点(无子节点):包含 component,由前端 addAsyncRoutes 解析匹配
  • 过滤掉 menuType=3(按钮类型)和 showLink=false 的菜单

6. Controller 层

文件:MenuController.java

注意:菜单管理属于运营平台独有功能,Controller 位于 haha-admin 模块。

6.1 接口清单

方法 路径 权限 日志 说明
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 保存角色菜单关联

6.2 路由下发接口详解:GET /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 }
        }
      ]
    }
  ]
}

6.3 内部 DTO

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

7. 前端实现

7.1 菜单管理页面

目录:src/views/system/menu/

文件 说明
index.vue 菜单列表页(搜索、表格、增删改按钮)
form.vue 菜单编辑弹窗(表单)
utils/hook.tsx 核心逻辑 Hook(搜索、表格列定义、CRUD 操作)
utils/enums.ts 枚举常量(菜单类型、显示/隐藏等选项)
utils/types.ts TypeScript 类型定义
utils/rule.ts 表单验证规则

7.1.1 菜单类型枚举

标签 说明
0 菜单 普通路由菜单
1 iframe 内嵌 iframe 页面
2 外链 外部链接,新窗口打开
3 按钮 按钮级权限,不显示在菜单中

7.1.2 表单字段

创建/编辑菜单时的字段与 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 权限标识

7.2 前端 API 封装

文件: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 });

7.3 动态路由加载流程

菜单从后端到侧边栏渲染的完整数据流:

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 渲染侧边栏

关键设计点

  • 去重策略:当静态路由(前端 .ts 文件)与动态路由(数据库)存在相同 path 时,优先使用动态路由(meta.backstage = true),因为后台配置可能有更精确的权限信息。
  • 组件匹配component 字段存储的是路径(如 "/dashboard/index"),前端 addAsyncRoutes 通过与 modulesRoutesKeys 键名匹配来解析为实际组件。
  • name 一致性:数据库 name 字段必须与前端 .ts 路由文件中定义的 name 完全一致,否则 Vue Router 会报 No match for 警告。

8. 种子数据

文件: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 独立顶级菜单,无子菜单

8.1 角色-菜单关联

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);

8.2 注意事项

  • id 范围:种子数据使用 100-1199 区间,清理脚本只删除 id >= 100 AND id < 1200 范围的菜单,避免误删其他业务数据。
  • rank 重复:分销管理(6) 与客户管理(6) 同排序、统计报表(8) 与系统设置(8) 同排序,前端 ascending() 函数对同 rank 的菜单会自动分配次级排序。
  • 旧数据清理:执行 seed_menu.sql 前会先清空 admin 角色的旧关联、以及 ID 2001-5002 区间的遗留菜单。

9. 权限控制

9.1 菜单管理 API 权限

权限编码 说明 覆盖接口
menu:read 菜单查询 POST /menu
menu:create 菜单创建 POST /menu/create
menu:update 菜单更新 PUT /menu/{id}
menu:delete 菜单删除 DELETE /menu/{id}

注意:GET /menu/routes 不加 @RequirePermission,因为所有登录用户都需要获取自己可见的菜单树。

9.2 角色菜单分配权限

角色管理页面调用菜单分配接口,使用的权限编码属于 role 模块:

权限编码 覆盖接口
role:read POST /menu/role-menu, POST /menu/role-menu-ids
role:update POST /menu/save-role-menus

9.3 前端菜单入口权限

菜单管理页面在侧边栏的入口路径为 /system/menu,其可见性由:

  • 静态路由router/modules/system.ts):定义了 /system/menu 路由,meta.roles: ["admin"]
  • 动态菜单(数据库 ID=1008):通过角色菜单关联控制

两层控制结合,确保只有拥有 menu:read 权限且被分配了该菜单的角色才能看到此页面。


10. 菜单树构建数据流

以下时序图展示了从登录到侧边栏菜单渲染的完整数据流:

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. 渲染侧边栏菜单

11. 注意事项与最佳实践

11.1 菜单 name 字段

  • 必须与前端路由文件中的 name 完全一致,否则 Vue Router 动态注册时会报 No match for 警告。
  • 示例:前端 dashboard.ts 中定义 name: "Dashboard",数据库 name 字段也必须是 "Dashboard"

11.2 component 字段

  • 后端不存储组件路径的实际值(前端通过 modulesRoutesKeys 自动匹配)。
  • 叶子节点在 buildMenuTree 中会输出 component 字段,但值可以为空字符串 ""

11.3 rank 排序

  • rank 字段用于控制菜单在侧边栏的显示顺序,值越小越靠前。
  • 同 rank 的菜单,ascending() 函数会按字典序自动分配次级排序。
  • 种子数据中 rank=6(客户管理、分销管理)和 rank=8(统计报表、系统设置、设备层模版)存在重复,属于正常设计。

11.4 MySQL 保留字

  • rankredirect 是 MySQL 保留字,在 SQL 中需要用反引号 ` 包裹。
  • 实体类中使用 @TableField("rank") 处理,MyBatis XML 中使用 `rank` 处理。

11.5 级联删除

  • 删除父菜单时,deleteMenu 方法会递归查找并标记所有子孙菜单的 deleted=1
  • 这是逻辑删除,数据不会从数据库物理删除。

11.6 种子数据更新

当新增前端静态路由模块或修改现有路由结构时,需要同步更新:

  1. seed_menu.sql — 种子数据
  2. create_menu.sql — 建表 SQL(如新增字段)
  3. Menu.java — 实体类(如新增字段)
  4. MenuMapper.xml — resultMap 映射(如新增字段)

11.7 roleIds 存储

  • 登录时,RoleServiceImpl 将用户的角色 ID 列表以逗号分隔字符串存入 Sa-Token Session(key: "roleIds")。
  • MenuController.getUserRoutes() 从 Session 读取并解析为 List<Long>

11.8 前端去重机制

permission.ts 中的 handleWholeMenus 对静态路由和动态路由按 path 去重,动态路由优先backstage: true),因为数据库中可能包含更精确的权限和排序配置。


12. 相关文件清单

层次 文件 说明
数据库 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 菜单合并去重