|
|
@@ -0,0 +1,658 @@
|
|
|
+# 菜单管理模块
|
|
|
+
|
|
|
+> 本文档描述菜单管理模块的数据库设计、后端实现、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<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](file://haha-mapper/src/main/resources/mapper/MenuMapper.xml)
|
|
|
+
|
|
|
+```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](file://haha-mapper/src/main/java/com/haha/mapper/RoleMenuMapper.java)
|
|
|
+
|
|
|
+空接口,继承 `BaseMapper<RoleMenu>`,使用 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<Long> roleIds)` | 根据角色ID列表生成用户可见的菜单树 |
|
|
|
+| `getMenuIdsByRoleId(Long roleId)` | 获取角色已分配的菜单ID列表 |
|
|
|
+| `saveRoleMenus(Long roleId, List<Long> 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<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 格式的树形结构:
|
|
|
+
|
|
|
+```java
|
|
|
+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](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<List<Map<String, Object>>>
|
|
|
+```
|
|
|
+
|
|
|
+**返回数据格式示例**:
|
|
|
+```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<Long>` | 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<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](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<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 保留字
|
|
|
+
|
|
|
+- `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<Long>`。
|
|
|
+
|
|
|
+### 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` 菜单合并去重 |
|
|
|
+
|