# 菜单管理模块 > 本文档描述菜单管理模块的数据库设计、后端实现、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](file://docs/database/create_menu.sql#L1-L50) | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | 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`")` 进行转义。 ### 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](file://haha-entity/src/main/java/com/haha/entity/Menu.java) ```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](file://haha-entity/src/main/java/com/haha/entity/RoleMenu.java) ```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](file://haha-mapper/src/main/java/com/haha/mapper/MenuMapper.java) ```java public interface MenuMapper extends BaseMapper { // 查询角色的所有菜单ID(注解式SQL) @Select("SELECT menu_id FROM t_role_menu WHERE role_id = #{roleId}") List selectMenuIdsByRoleId(@Param("roleId") Long roleId); // 根据角色ID列表查询菜单(XML映射文件) List selectMenusByRoleIds(@Param("roleIds") List roleIds); // 删除角色的所有菜单关联(注解式SQL) @Delete("DELETE FROM t_role_menu WHERE role_id = #{roleId}") int deleteByRoleId(@Param("roleId") Long roleId); } ``` ### 4.2 MenuMapper.xml **文件**:[MenuMapper.xml](file://haha-mapper/src/main/resources/mapper/MenuMapper.xml) ```xml ``` 使用 `DISTINCT` 去重,避免用户拥有多个角色时出现重复菜单。 ### 4.3 RoleMenuMapper **文件**:[RoleMenuMapper.java](file://haha-mapper/src/main/java/com/haha/mapper/RoleMenuMapper.java) 空接口,继承 `BaseMapper`,使用 MyBatis-Plus 内置 CRUD。 --- ## 5. Service 层 ### 5.1 MenuService 接口 **文件**:[MenuService.java](file://haha-service/src/main/java/com/haha/service/MenuService.java) | 方法 | 说明 | |------|------| | `getAllMenus()` | 获取所有未删除菜单(按 rank + id 排序) | | `searchByTitle(String title)` | 按菜单名称模糊搜索 | | `createMenu(Menu menu)` | 创建菜单(自动设置创建时间、默认 rank=99、默认 menuType=0) | | `updateMenu(Menu menu)` | 更新菜单(自动更新 updateTime) | | `deleteMenu(Long id)` | 逻辑删除菜单及其所有子孙菜单 | | `getUserMenuTree(List roleIds)` | 根据角色ID列表生成用户可见的菜单树 | | `getMenuIdsByRoleId(Long roleId)` | 获取角色已分配的菜单ID列表 | | `saveRoleMenus(Long roleId, List menuIds)` | 保存角色菜单关联(先删后插) | ### 5.2 MenuServiceImpl 实现 **文件**:[MenuServiceImpl.java](file://haha-service/src/main/java/com/haha/service/impl/MenuServiceImpl.java) #### 5.2.1 级联删除实现 ```java public void deleteMenu(Long id) { List idsToDelete = new ArrayList<>(); collectChildIds(id, idsToDelete); // 递归收集所有子孙菜单 idsToDelete.add(id); for (Long menuId : idsToDelete) { // 逐个标记 deleted=1 updateById(menu); } } private void collectChildIds(Long parentId, List result) { List 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 格式的树形结构: ```java private List> buildMenuTree(Long parentId, List allMenus) { List> tree = new ArrayList<>(); // 1. 筛选当前层级子节点,按 rank 排序 List children = allMenus.stream() .filter(m -> m.getParentId().equals(parentId)) .sorted(Comparator.comparing(Menu::getRank, ...)) .toList(); for (Menu menu : children) { // 2. 构建节点:path、name、meta Map node = new LinkedHashMap<>(); node.put("path", menu.getPath()); node.put("name", menu.getName()); // meta: { icon, title, rank, showParent } // ... // 3. 递归构建子节点 List> 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](file://haha-admin/src/main/java/com/haha/admin/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>> ``` **返回数据格式示例**: ```json { "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` | POST /menu/save-role-menus | --- ## 7. 前端实现 ### 7.1 菜单管理页面 **目录**:[src/views/system/menu/](file://haha-admin-web/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](file://haha-admin-web/src/api/system.ts) ```ts /** 获取菜单列表 */ export const getMenuList = (data?: object) => http.request("post", "/menu", { data }); /** 创建菜单 */ export const createMenu = (data: object) => http.request("post", "/menu/create", { data }); /** 更新菜单 */ export const updateMenu = (id: number, data: object) => http.request("put", `/menu/${id}`, { data }); /** 删除菜单 */ export const deleteMenuApi = (id: number) => http.request("delete", `/menu/${id}`); /** 获取角色菜单权限(全部菜单,权限分配页用) */ export const getRoleMenu = (data?: object) => http.request("post", "/menu/role-menu", { data }); /** 获取角色已分配的菜单ID */ export const getRoleMenuIds = (data?: object) => http.request("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](file://docs/database/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 条菜单的访问权限: ```sql 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. 菜单树构建数据流 以下时序图展示了从登录到侧边栏菜单渲染的完整数据流: ```mermaid 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> 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 保留字 - `rank` 和 `redirect` 是 MySQL 保留字,在 SQL 中需要用反引号 `` ` `` 包裹。 - 实体类中使用 `@TableField("`rank`")` 处理,MyBatis XML 中使用 `` `rank` `` 处理。 ### 11.5 级联删除 - 删除父菜单时,`deleteMenu` 方法会递归查找并标记所有子孙菜单的 `deleted=1`。 - 这是**逻辑删除**,数据不会从数据库物理删除。 ### 11.6 种子数据更新 当新增前端静态路由模块或修改现有路由结构时,需要同步更新: 1. [seed_menu.sql](file://docs/database/seed_menu.sql) — 种子数据 2. [create_menu.sql](file://docs/database/create_menu.sql) — 建表 SQL(如新增字段) 3. [Menu.java](file://haha-entity/src/main/java/com/haha/entity/Menu.java) — 实体类(如新增字段) 4. [MenuMapper.xml](file://haha-mapper/src/main/resources/mapper/MenuMapper.xml) — resultMap 映射(如新增字段) ### 11.7 roleIds 存储 - 登录时,`RoleServiceImpl` 将用户的角色 ID 列表以逗号分隔字符串存入 Sa-Token Session(key: `"roleIds"`)。 - `MenuController.getUserRoutes()` 从 Session 读取并解析为 `List`。 ### 11.8 前端去重机制 `permission.ts` 中的 `handleWholeMenus` 对静态路由和动态路由按 `path` 去重,**动态路由优先**(`backstage: true`),因为数据库中可能包含更精确的权限和排序配置。 --- ## 12. 相关文件清单 | 层次 | 文件 | 说明 | |------|------|------| | 数据库 | [create_menu.sql](file://docs/database/create_menu.sql) | 建表 DDL | | 数据库 | [seed_menu.sql](file://docs/database/seed_menu.sql) | 种子数据 | | 实体 | [Menu.java](file://haha-entity/src/main/java/com/haha/entity/Menu.java) | 菜单实体 | | 实体 | [RoleMenu.java](file://haha-entity/src/main/java/com/haha/entity/RoleMenu.java) | 角色菜单关联实体 | | Mapper | [MenuMapper.java](file://haha-mapper/src/main/java/com/haha/mapper/MenuMapper.java) | 菜单 Mapper | | Mapper | [MenuMapper.xml](file://haha-mapper/src/main/resources/mapper/MenuMapper.xml) | 菜单 XML 映射 | | Mapper | [RoleMenuMapper.java](file://haha-mapper/src/main/java/com/haha/mapper/RoleMenuMapper.java) | 角色菜单 Mapper | | Service | [MenuService.java](file://haha-service/src/main/java/com/haha/service/MenuService.java) | 菜单服务接口 | | Service | [MenuServiceImpl.java](file://haha-service/src/main/java/com/haha/service/impl/MenuServiceImpl.java) | 菜单服务实现 | | Controller | [MenuController.java](file://haha-admin/src/main/java/com/haha/admin/controller/MenuController.java) | 菜单管理控制器 | | 前端页面 | [views/system/menu/](file://haha-admin-web/src/views/system/menu/) | 菜单管理页面 | | 前端 API | [src/api/system.ts](file://haha-admin-web/src/api/system.ts) | 前端 API 封装 | | 路由处理 | [src/router/utils.ts](file://haha-admin-web/src/router/utils.ts) | `addAsyncRoutes` 动态路由注册 | | 权限处理 | [src/store/modules/permission.ts](file://haha-admin-web/src/store/modules/permission.ts) | `handleWholeMenus` 菜单合并去重 |