# 菜单管理模块

> 本文档描述菜单管理模块的数据库设计、后端实现、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` 菜单合并去重 |

