瀏覽代碼

菜单管理模块

skyline 3 周之前
父節點
當前提交
e506c39faa
共有 33 個文件被更改,包括 1478 次插入45 次删除
  1. 658 0
      docs/菜单管理模块.md
  2. 2 0
      haha-admin-web/src/api/auth.ts
  3. 1 1
      haha-admin-web/src/api/routes.ts
  4. 17 2
      haha-admin-web/src/api/system.ts
  5. 1 1
      haha-admin-web/src/layout/components/lay-sidebar/components/SidebarItem.vue
  6. 2 1
      haha-admin-web/src/router/modules/customer.ts
  7. 2 1
      haha-admin-web/src/router/modules/dashboard.ts
  8. 2 1
      haha-admin-web/src/router/modules/distribution.ts
  9. 2 1
      haha-admin-web/src/router/modules/inventory.ts
  10. 2 1
      haha-admin-web/src/router/modules/layer-template.ts
  11. 2 1
      haha-admin-web/src/router/modules/marketing.ts
  12. 2 1
      haha-admin-web/src/router/modules/operation.ts
  13. 2 1
      haha-admin-web/src/router/modules/order.ts
  14. 2 1
      haha-admin-web/src/router/modules/product.ts
  15. 2 1
      haha-admin-web/src/router/modules/statistics.ts
  16. 11 1
      haha-admin-web/src/router/modules/system.ts
  17. 10 1
      haha-admin-web/src/store/modules/permission.ts
  18. 2 2
      haha-admin-web/src/store/modules/user.ts
  19. 22 25
      haha-admin-web/src/views/system/menu/utils/hook.tsx
  20. 2 0
      haha-admin-web/types/router.d.ts
  21. 2 1
      haha-admin-web/vite.config.ts
  22. 169 0
      haha-admin/src/main/java/com/haha/admin/controller/MenuController.java
  23. 44 0
      haha-admin/src/main/java/com/haha/admin/service/impl/AdminLoginServiceImpl.java
  24. 11 1
      haha-common/src/main/java/com/haha/common/vo/UserVO.java
  25. 105 0
      haha-entity/src/main/java/com/haha/entity/Menu.java
  26. 1 0
      haha-entity/src/main/java/com/haha/entity/Order.java
  27. 36 0
      haha-entity/src/main/java/com/haha/entity/RoleMenu.java
  28. 32 0
      haha-mapper/src/main/java/com/haha/mapper/MenuMapper.java
  29. 11 0
      haha-mapper/src/main/java/com/haha/mapper/RoleMenuMapper.java
  30. 45 0
      haha-mapper/src/main/resources/mapper/MenuMapper.xml
  31. 75 0
      haha-service/src/main/java/com/haha/service/MenuService.java
  32. 200 0
      haha-service/src/main/java/com/haha/service/impl/MenuServiceImpl.java
  33. 1 0
      haha-service/src/main/java/com/haha/service/payment/impl/PaymentServiceImpl.java

+ 658 - 0
docs/菜单管理模块.md

@@ -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` 菜单合并去重 |
+

+ 2 - 0
haha-admin-web/src/api/auth.ts

@@ -10,6 +10,8 @@ export type LoginResult = {
       nickname: string;
       avatar: string | null;
       phone: string;
+      roles: string[];
+      permissions: string[];
     };
   };
 };

+ 1 - 1
haha-admin-web/src/api/routes.ts

@@ -6,5 +6,5 @@ type Result = {
 };
 
 export const getAsyncRoutes = () => {
-  return http.request<Result>("get", "/get-async-routes");
+  return http.request<Result>("get", "/menu/routes");
 };

+ 17 - 2
haha-admin-web/src/api/system.ts

@@ -44,6 +44,21 @@ export const getMenuList = (data?: object) => {
   return http.request<Result>("post", "/menu", { data });
 };
 
+/** 创建菜单 */
+export const createMenu = (data: object) => {
+  return http.request<Result>("post", "/menu/create", { data });
+};
+
+/** 更新菜单 */
+export const updateMenu = (id: number, data: object) => {
+  return http.request<Result>("put", `/menu/${id}`, { data });
+};
+
+/** 删除菜单 */
+export const deleteMenuApi = (id: number) => {
+  return http.request<Result>("delete", `/menu/${id}`);
+};
+
 /** 获取系统管理-部门管理列表 */
 export const getDeptList = (data?: object) => {
   return http.request<Result>("post", "/dept", { data });
@@ -76,10 +91,10 @@ export const getSystemLogsDetail = (data?: object) => {
 
 /** 获取角色管理-权限-菜单权限 */
 export const getRoleMenu = (data?: object) => {
-  return http.request<Result>("post", "/role-menu", { data });
+  return http.request<Result>("post", "/menu/role-menu", { data });
 };
 
 /** 获取角色管理-权限-菜单权限-根据角色 id 查对应菜单 */
 export const getRoleMenuIds = (data?: object) => {
-  return http.request<Result>("post", "/role-menu-ids", { data });
+  return http.request<Result>("post", "/menu/role-menu-ids", { data });
 };

+ 1 - 1
haha-admin-web/src/layout/components/lay-sidebar/components/SidebarItem.vue

@@ -94,7 +94,7 @@ function hasOneShowingChild(children: menuType[] = [], parent: menuType) {
     return true;
   });
 
-  if (showingChildren[0]?.meta?.showParent) {
+  if (parent.meta?.showParent) {
     return false;
   }
 

+ 2 - 1
haha-admin-web/src/router/modules/customer.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:user-heart-line",
     title: "客户管理",
-    rank: 6
+    rank: 6,
+    roles: ["admin", "operator"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/dashboard.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:dashboard-line",
     title: "数据概览",
-    rank: 1
+    rank: 1,
+    roles: ["admin", "operator", "viewer"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/distribution.ts

@@ -20,7 +20,8 @@ export default {
   meta: {
     icon: "ep:share",
     title: "分销管理",
-    rank: 6
+    rank: 6,
+    roles: ["admin"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/inventory.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:stack-line",
     title: "库存管理",
-    rank: 5
+    rank: 5,
+    roles: ["admin", "operator"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/layer-template.ts

@@ -5,6 +5,7 @@ export default {
   meta: {
     title: "设备层模版管理",
     icon: "ri:stack-line",
-    rank: 8
+    rank: 8,
+    roles: ["admin"]
   }
 } satisfies RouteConfigsTable;

+ 2 - 1
haha-admin-web/src/router/modules/marketing.ts

@@ -16,7 +16,8 @@ export default {
   meta: {
     icon: "ri:megaphone-line",
     title: "营销中心",
-    rank: 7
+    rank: 7,
+    roles: ["admin", "operator"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/operation.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:briefcase-4-line",
     title: "运营管理",
-    rank: 3
+    rank: 3,
+    roles: ["admin", "operator"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/order.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:file-list-2-line",
     title: "订单管理",
-    rank: 2
+    rank: 2,
+    roles: ["admin", "operator", "viewer"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/product.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:inbox-line",
     title: "商品管理",
-    rank: 4
+    rank: 4,
+    roles: ["admin", "operator"]
   },
   children: [
     {

+ 2 - 1
haha-admin-web/src/router/modules/statistics.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:bar-chart-grouped-line",
     title: "统计报表",
-    rank: 8
+    rank: 8,
+    roles: ["admin", "operator", "viewer"]
   },
   children: [
     {

+ 11 - 1
haha-admin-web/src/router/modules/system.ts

@@ -4,7 +4,8 @@ export default {
   meta: {
     icon: "ri:settings-3-line",
     title: "系统设置",
-    rank: 8
+    rank: 8,
+    roles: ["admin"]
   },
   children: [
     {
@@ -69,6 +70,15 @@ export default {
         icon: "ri:book-2-line",
         title: "字典管理"
       }
+    },
+    {
+      path: "/system/menu",
+      name: "SystemMenu",
+      component: () => import("@/views/system/menu/index.vue"),
+      meta: {
+        icon: "ri:menu-line",
+        title: "菜单管理"
+      }
     }
   ]
 } satisfies RouteConfigsTable;

+ 10 - 1
haha-admin-web/src/store/modules/permission.ts

@@ -25,8 +25,17 @@ export const usePermissionStore = defineStore("pure-permission", {
   actions: {
     /** 组装整体路由生成的菜单 */
     handleWholeMenus(routes: any[]) {
+      // 合并静态和动态路由,按 path 去重(动态路由 backstage 优先)
+      const merged = this.constantMenus.concat(routes);
+      const pathMap = new Map<string, any>();
+      for (const r of merged as any[]) {
+        if (!pathMap.has(r.path) || r.meta?.backstage) {
+          pathMap.set(r.path, r);
+        }
+      }
+      const deduplicated = Array.from(pathMap.values());
       this.wholeMenus = filterNoPermissionTree(
-        filterTree(ascending(this.constantMenus.concat(routes)))
+        filterTree(ascending(deduplicated))
       );
       this.flatteningRoutes = formatFlatteningRoutes(
         this.constantMenus.concat(routes) as any

+ 2 - 2
haha-admin-web/src/store/modules/user.ts

@@ -88,8 +88,8 @@ export const useUserStore = defineStore("pure-user", {
                 expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
                 username: data.username,
                 nickname: res.data.userInfo?.nickname || "",
-                roles: ["admin"],
-                permissions: ["*:*:*"]
+                roles: res.data.userInfo?.roles || [],
+                permissions: res.data.userInfo?.permissions || []
               };
               setToken(tokenData);
             }

+ 22 - 25
haha-admin-web/src/views/system/menu/utils/hook.tsx

@@ -1,7 +1,7 @@
 import editForm from "../form.vue";
 import { handleTree } from "@/utils/tree";
 import { message } from "@/utils/message";
-import { getMenuList } from "@/api/system";
+import { getMenuList, createMenu, updateMenu, deleteMenuApi } from "@/api/system";
 import { transformI18n } from "@/plugins/i18n";
 import { addDialog } from "@/components/ReDialog";
 import { reactive, ref, onMounted, h } from "vue";
@@ -169,26 +169,20 @@ export function useMenu() {
       beforeSure: (done, { options }) => {
         const FormRef = formRef.value.getRef();
         const curData = options.props.formInline as FormItemProps;
-        function chores() {
-          message(
-            `您${title}了菜单名称为${transformI18n(curData.title)}的这条数据`,
-            {
-              type: "success"
-            }
-          );
-          done(); // 关闭弹框
-          onSearch(); // 刷新表格数据
-        }
-        FormRef.validate(valid => {
+        FormRef.validate(async valid => {
           if (valid) {
-            console.log("curData", curData);
-            // 表单规则校验通过
-            if (title === "新增") {
-              // 实际开发先调用新增接口,再进行下面操作
-              chores();
-            } else {
-              // 实际开发先调用修改接口,再进行下面操作
-              chores();
+            try {
+              if (title === "新增") {
+                await createMenu(curData);
+                message(`新增菜单「${transformI18n(curData.title)}」成功`, { type: "success" });
+              } else {
+                await updateMenu((curData as any).id, curData);
+                message(`修改菜单「${transformI18n(curData.title)}」成功`, { type: "success" });
+              }
+              done();
+              onSearch();
+            } catch (error) {
+              message("操作失败,请重试", { type: "error" });
             }
           }
         });
@@ -196,11 +190,14 @@ export function useMenu() {
     });
   }
 
-  function handleDelete(row) {
-    message(`您删除了菜单名称为${transformI18n(row.title)}的这条数据`, {
-      type: "success"
-    });
-    onSearch();
+  async function handleDelete(row) {
+    try {
+      await deleteMenuApi(row.id);
+      message(`删除菜单「${transformI18n(row.title)}」成功`, { type: "success" });
+      onSearch();
+    } catch (error) {
+      message("删除失败,请重试", { type: "error" });
+    }
   }
 
   onMounted(() => {

+ 2 - 0
haha-admin-web/types/router.d.ts

@@ -98,6 +98,8 @@ declare global {
       showLink?: boolean;
       /** 菜单升序排序,值越高排的越后(只针对顶级路由)`可选` */
       rank?: number;
+      /** 页面级别权限设置 `可选` */
+      roles?: Array<string>;
     };
     /** 子路由配置项 */
     children?: Array<RouteChildrenConfigsTable>;

+ 2 - 1
haha-admin-web/vite.config.ts

@@ -40,7 +40,8 @@ export default ({ mode }: ConfigEnv): UserConfigExport => {
     "/layer-templates",
     "/distribution",
     "/refund",
-    "/replenishers"
+    "/replenishers",
+    "/menu"
   ];
   
   // 动态生成代理配置

+ 169 - 0
haha-admin/src/main/java/com/haha/admin/controller/MenuController.java

@@ -0,0 +1,169 @@
+package com.haha.admin.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.haha.admin.annotation.RequirePermission;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
+import com.haha.common.vo.Result;
+import com.haha.entity.Menu;
+import com.haha.service.MenuService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 菜单管理控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/menu")
+@RequiredArgsConstructor
+public class MenuController {
+
+    private final MenuService menuService;
+
+    /**
+     * 获取菜单列表(兼容前端 getMenuList 的 POST 请求)
+     */
+    @RequirePermission("menu:read")
+    @PostMapping
+    public Result<List<Menu>> list(@RequestBody(required = false) MenuQueryDTO queryDTO) {
+        List<Menu> menus;
+        if (queryDTO != null && queryDTO.getTitle() != null && !queryDTO.getTitle().isEmpty()) {
+            menus = menuService.searchByTitle(queryDTO.getTitle());
+        } else {
+            menus = menuService.getAllMenus();
+        }
+        return Result.success(menus);
+    }
+
+    /**
+     * 创建菜单
+     */
+    @RequirePermission("menu:create")
+    @Log(module = "菜单管理", operation = OperationType.INSERT, summary = "创建菜单")
+    @PostMapping("/create")
+    public Result<Menu> create(@RequestBody Menu menu) {
+        Menu created = menuService.createMenu(menu);
+        return Result.success("创建成功", created);
+    }
+
+    /**
+     * 更新菜单
+     */
+    @RequirePermission("menu:update")
+    @Log(module = "菜单管理", operation = OperationType.UPDATE, summary = "更新菜单")
+    @PutMapping("/{id}")
+    public Result<Menu> update(@PathVariable Long id, @RequestBody Menu menu) {
+        menu.setId(id);
+        Menu updated = menuService.updateMenu(menu);
+        return Result.success("更新成功", updated);
+    }
+
+    /**
+     * 删除菜单
+     */
+    @RequirePermission("menu:delete")
+    @Log(module = "菜单管理", operation = OperationType.DELETE, summary = "删除菜单")
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(@PathVariable Long id) {
+        menuService.deleteMenu(id);
+        return Result.success("删除成功");
+    }
+
+    /**
+     * 获取当前用户的菜单树(侧边栏渲染用)
+     * 不需要 @RequirePermission,登录用户均可访问
+     */
+    @GetMapping("/routes")
+    public Result<List<Map<String, Object>>> getUserRoutes() {
+        // 从 Sa-Token Session 获取用户的角色ID列表
+        String roleIdsStr = (String) StpUtil.getSession().get("roleIds");
+        List<Long> roleIds = parseRoleIds(roleIdsStr);
+
+        if (roleIds.isEmpty()) {
+            return Result.success(List.of());
+        }
+
+        List<Map<String, Object>> menuTree = menuService.getUserMenuTree(roleIds);
+        return Result.success(menuTree);
+    }
+
+    /**
+     * 获取全部菜单(角色权限分配用)
+     */
+    @RequirePermission("role:read")
+    @PostMapping("/role-menu")
+    public Result<List<Menu>> getRoleMenu() {
+        List<Menu> allMenus = menuService.getAllMenus();
+        return Result.success(allMenus);
+    }
+
+    /**
+     * 获取指定角色的菜单ID列表
+     */
+    @RequirePermission("role:read")
+    @PostMapping("/role-menu-ids")
+    public Result<List<Long>> getRoleMenuIds(@RequestBody RoleMenuQueryDTO queryDTO) {
+        if (queryDTO == null || queryDTO.getId() == null) {
+            return Result.success(List.of());
+        }
+        List<Long> menuIds = menuService.getMenuIdsByRoleId(queryDTO.getId());
+        return Result.success(menuIds);
+    }
+
+    /**
+     * 保存角色菜单关联
+     */
+    @RequirePermission("role:update")
+    @Log(module = "菜单管理", operation = OperationType.UPDATE, summary = "保存角色菜单关联")
+    @PostMapping("/save-role-menus")
+    public Result<Void> saveRoleMenus(@RequestBody SaveRoleMenuDTO dto) {
+        if (dto.getRoleId() == null) {
+            return Result.error(400, "角色ID不能为空");
+        }
+        menuService.saveRoleMenus(dto.getRoleId(), dto.getMenuIds());
+        return Result.success("保存成功");
+    }
+
+    /**
+     * 解析角色ID字符串为Long列表
+     */
+    private List<Long> parseRoleIds(String roleIdsStr) {
+        if (roleIdsStr == null || roleIdsStr.trim().isEmpty()) {
+            return List.of();
+        }
+        try {
+            return java.util.Arrays.stream(roleIdsStr.split(","))
+                    .map(String::trim)
+                    .filter(s -> !s.isEmpty())
+                    .map(Long::parseLong)
+                    .collect(Collectors.toList());
+        } catch (NumberFormatException e) {
+            log.warn("解析角色ID失败: {}", roleIdsStr, e);
+            return List.of();
+        }
+    }
+
+    // ===== 内部DTO =====
+
+    @lombok.Data
+    public static class MenuQueryDTO {
+        private String title;
+    }
+
+    @lombok.Data
+    public static class RoleMenuQueryDTO {
+        private Long id;
+    }
+
+    @lombok.Data
+    public static class SaveRoleMenuDTO {
+        private Long roleId;
+        private List<Long> menuIds;
+    }
+}

+ 44 - 0
haha-admin/src/main/java/com/haha/admin/service/impl/AdminLoginServiceImpl.java

@@ -7,7 +7,9 @@ import com.haha.common.vo.LoginVO;
 import com.haha.common.vo.Result;
 import com.haha.common.vo.UserVO;
 import com.haha.entity.Admin;
+import com.haha.entity.Role;
 import com.haha.mapper.AdminMapper;
+import com.haha.mapper.RoleMapper;
 import com.haha.service.DataPermissionService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -31,6 +33,9 @@ public class AdminLoginServiceImpl implements AdminLoginService {
     @Autowired
     private AdminMapper adminMapper;
 
+    @Autowired
+    private RoleMapper roleMapper;
+
     @Autowired
     private DataPermissionService dataPermissionService;
     
@@ -86,6 +91,8 @@ public class AdminLoginServiceImpl implements AdminLoginService {
                         .nickname(admin.getRealName())
                         .avatar(admin.getAvatar())
                         .phone(admin.getPhone())
+                        .roles(List.of("admin"))
+                        .permissions(permissions)
                         .build();
                 
                 LoginVO loginVO = LoginVO.builder()
@@ -137,6 +144,9 @@ public class AdminLoginServiceImpl implements AdminLoginService {
             List<String> permissions = getUserPermissions(admin.getRoleIds());
             StpUtil.getSession().set("permissions", permissions);
             
+            // 获取用户的角色编码列表
+            List<String> roleCodes = getRoleCodes(admin.getRoleIds());
+            
             log.info("管理员登录成功: username={}, userId={}", username, admin.getId());
             
             // 构建返回数据
@@ -145,6 +155,8 @@ public class AdminLoginServiceImpl implements AdminLoginService {
                     .nickname(admin.getRealName() != null ? admin.getRealName() : admin.getUsername())
                     .avatar(admin.getAvatar())
                     .phone(admin.getPhone())
+                    .roles(roleCodes)
+                    .permissions(permissions)
                     .build();
             
             LoginVO loginVO = LoginVO.builder()
@@ -214,4 +226,36 @@ public class AdminLoginServiceImpl implements AdminLoginService {
             return List.of();
         }
     }
+
+    /**
+     * 根据角色ID列表获取角色编码
+     * @param roleIdsStr 角色ID字符串(逗号分隔)
+     * @return 角色编码列表
+     */
+    private List<String> getRoleCodes(String roleIdsStr) {
+        if (roleIdsStr == null || roleIdsStr.trim().isEmpty()) {
+            return List.of();
+        }
+
+        try {
+            List<Long> roleIds = Arrays.stream(roleIdsStr.split(","))
+                    .map(String::trim)
+                    .filter(s -> !s.isEmpty())
+                    .map(Long::parseLong)
+                    .collect(Collectors.toList());
+
+            if (roleIds.isEmpty()) {
+                return List.of();
+            }
+
+            return roleMapper.selectByIds(roleIds)
+                    .stream()
+                    .map(Role::getCode)
+                    .filter(code -> code != null && !code.isEmpty())
+                    .collect(Collectors.toList());
+        } catch (Exception e) {
+            log.error("获取角色编码失败: roleIds={}, error={}", roleIdsStr, e.getMessage());
+            return List.of();
+        }
+    }
 }

+ 11 - 1
haha-common/src/main/java/com/haha/common/vo/UserVO.java

@@ -3,8 +3,10 @@ package com.haha.common.vo;
 import lombok.Builder;
 import lombok.Data;
 
+import java.util.List;
+
 /**
- * 鐢ㄦ埛淇℃伅灞曠ず瀵硅薄
+ * 用户信息展示对象
  */
 @Data
 @Builder
@@ -13,4 +15,12 @@ public class UserVO {
     private String nickname;
     private String avatar;
     private String phone;
+    /**
+     * 角色编码列表
+     */
+    private List<String> roles;
+    /**
+     * 权限编码列表
+     */
+    private List<String> permissions;
 }

+ 105 - 0
haha-entity/src/main/java/com/haha/entity/Menu.java

@@ -0,0 +1,105 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 菜单实体类
+ */
+@Data
+@TableName("t_menu")
+public class Menu implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    /** 父菜单ID,0表示顶级菜单 */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long parentId;
+
+    /** 菜单类型: 0=菜单 1=iframe 2=外链 3=按钮 */
+    private Integer menuType;
+
+    /** 菜单名称 */
+    private String title;
+
+    /** 路由名称 */
+    private String name;
+
+    /** 路由路径 */
+    private String path;
+
+    /** 组件路径 */
+    private String component;
+
+    /** 菜单排序 */
+    @TableField("`rank`")
+    private Integer rank;
+
+    /** 路由重定向 */
+    @TableField("`redirect`")
+    private String redirect;
+
+    /** 菜单图标 */
+    private String icon;
+
+    /** 右侧图标 */
+    private String extraIcon;
+
+    /** 进场动画 */
+    private String enterTransition;
+
+    /** 离场动画 */
+    private String leaveTransition;
+
+    /** 菜单激活路径 */
+    private String activePath;
+
+    /** 权限标识 */
+    private String auths;
+
+    /** iframe链接地址 */
+    private String frameSrc;
+
+    /** iframe是否开启加载动画 */
+    private Boolean frameLoading;
+
+    /** 是否缓存页面 */
+    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;
+
+    /** 逻辑删除: 0=正常 1=已删除 */
+    private Integer deleted;
+}
+

+ 1 - 0
haha-entity/src/main/java/com/haha/entity/Order.java

@@ -18,6 +18,7 @@ public class Order implements Serializable {
 
     /** 乐观锁版本号 */
     @Version
+    @TableField(exist = false)
     private Integer version = 1;
 
     private String orderNo;

+ 36 - 0
haha-entity/src/main/java/com/haha/entity/RoleMenu.java

@@ -0,0 +1,36 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 角色菜单关联实体类
+ */
+@Data
+@TableName("t_role_menu")
+public class RoleMenu implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    /** 角色ID */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long roleId;
+
+    /** 菜单ID */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long menuId;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+}

+ 32 - 0
haha-mapper/src/main/java/com/haha/mapper/MenuMapper.java

@@ -0,0 +1,32 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.Menu;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 菜单Mapper接口
+ */
+@org.apache.ibatis.annotations.Mapper
+public interface MenuMapper extends BaseMapper<Menu> {
+
+    /**
+     * 查询角色的所有菜单ID
+     */
+    @Select("SELECT menu_id FROM t_role_menu WHERE role_id = #{roleId}")
+    List<Long> selectMenuIdsByRoleId(@Param("roleId") Long roleId);
+
+    /**
+     * 根据角色ID列表查询该角色拥有的菜单
+     */
+    List<Menu> selectMenusByRoleIds(@Param("roleIds") List<Long> roleIds);
+
+    /**
+     * 删除角色的所有菜单关联
+     */
+    @org.apache.ibatis.annotations.Delete("DELETE FROM t_role_menu WHERE role_id = #{roleId}")
+    int deleteByRoleId(@Param("roleId") Long roleId);
+}

+ 11 - 0
haha-mapper/src/main/java/com/haha/mapper/RoleMenuMapper.java

@@ -0,0 +1,11 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.RoleMenu;
+
+/**
+ * 角色菜单关联Mapper接口
+ */
+@org.apache.ibatis.annotations.Mapper
+public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
+}

+ 45 - 0
haha-mapper/src/main/resources/mapper/MenuMapper.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.haha.mapper.MenuMapper">
+
+    <resultMap id="MenuResultMap" type="com.haha.entity.Menu">
+        <id column="id" property="id"/>
+        <result column="parent_id" property="parentId"/>
+        <result column="menu_type" property="menuType"/>
+        <result column="title" property="title"/>
+        <result column="name" property="name"/>
+        <result column="path" property="path"/>
+        <result column="component" property="component"/>
+        <result column="`rank`" property="rank"/>
+        <result column="redirect" property="redirect"/>
+        <result column="icon" property="icon"/>
+        <result column="extra_icon" property="extraIcon"/>
+        <result column="enter_transition" property="enterTransition"/>
+        <result column="leave_transition" property="leaveTransition"/>
+        <result column="active_path" property="activePath"/>
+        <result column="auths" property="auths"/>
+        <result column="frame_src" property="frameSrc"/>
+        <result column="frame_loading" property="frameLoading"/>
+        <result column="keep_alive" property="keepAlive"/>
+        <result column="hidden_tag" property="hiddenTag"/>
+        <result column="fixed_tag" property="fixedTag"/>
+        <result column="show_link" property="showLink"/>
+        <result column="show_parent" property="showParent"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+        <result column="deleted" property="deleted"/>
+    </resultMap>
+
+    <!-- 根据角色ID列表查询菜单 -->
+    <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>
+
+</mapper>

+ 75 - 0
haha-service/src/main/java/com/haha/service/MenuService.java

@@ -0,0 +1,75 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.haha.entity.Menu;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 菜单服务接口
+ */
+public interface MenuService extends IService<Menu> {
+
+    /**
+     * 获取所有菜单(扁平列表)
+     *
+     * @return 菜单列表
+     */
+    List<Menu> getAllMenus();
+
+    /**
+     * 根据菜单名称搜索
+     *
+     * @param title 菜单名称
+     * @return 菜单列表
+     */
+    List<Menu> searchByTitle(String title);
+
+    /**
+     * 创建菜单
+     *
+     * @param menu 菜单信息
+     * @return 创建的菜单
+     */
+    Menu createMenu(Menu menu);
+
+    /**
+     * 更新菜单
+     *
+     * @param menu 菜单信息
+     * @return 更新后的菜单
+     */
+    Menu updateMenu(Menu menu);
+
+    /**
+     * 删除菜单(逻辑删除,同时删除子菜单)
+     *
+     * @param id 菜单ID
+     */
+    void deleteMenu(Long id);
+
+    /**
+     * 根据用户角色ID列表获取该用户可见的菜单树
+     *
+     * @param roleIds 角色ID列表
+     * @return 菜单树(前端 asyncRoutes 格式)
+     */
+    List<Map<String, Object>> getUserMenuTree(List<Long> roleIds);
+
+    /**
+     * 获取角色的菜单ID列表
+     *
+     * @param roleId 角色ID
+     * @return 菜单ID列表
+     */
+    List<Long> getMenuIdsByRoleId(Long roleId);
+
+    /**
+     * 保存角色的菜单关联
+     *
+     * @param roleId 角色ID
+     * @param menuIds 菜单ID列表
+     */
+    void saveRoleMenus(Long roleId, List<Long> menuIds);
+}

+ 200 - 0
haha-service/src/main/java/com/haha/service/impl/MenuServiceImpl.java

@@ -0,0 +1,200 @@
+package com.haha.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.haha.entity.Menu;
+import com.haha.entity.RoleMenu;
+import com.haha.mapper.MenuMapper;
+import com.haha.mapper.RoleMenuMapper;
+import com.haha.service.MenuService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 菜单服务实现类
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements MenuService {
+
+    private final RoleMenuMapper roleMenuMapper;
+
+    @Override
+    public List<Menu> getAllMenus() {
+        return lambdaQuery()
+                .eq(Menu::getDeleted, 0)
+                .orderByAsc(Menu::getRank, Menu::getId)
+                .list();
+    }
+
+    @Override
+    public List<Menu> searchByTitle(String title) {
+        return lambdaQuery()
+                .eq(Menu::getDeleted, 0)
+                .like(Menu::getTitle, title)
+                .orderByAsc(Menu::getRank, Menu::getId)
+                .list();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Menu createMenu(Menu menu) {
+        menu.setCreateTime(LocalDateTime.now());
+        menu.setUpdateTime(LocalDateTime.now());
+        menu.setDeleted(0);
+        if (menu.getRank() == null) {
+            menu.setRank(99);
+        }
+        if (menu.getMenuType() == null) {
+            menu.setMenuType(0);
+        }
+        save(menu);
+        log.info("创建菜单成功: id={}, title={}", menu.getId(), menu.getTitle());
+        return menu;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Menu updateMenu(Menu menu) {
+        menu.setUpdateTime(LocalDateTime.now());
+        updateById(menu);
+        log.info("更新菜单成功: id={}, title={}", menu.getId(), menu.getTitle());
+        return menu;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteMenu(Long id) {
+        // 递归查找所有子菜单
+        List<Long> idsToDelete = new ArrayList<>();
+        collectChildIds(id, idsToDelete);
+        idsToDelete.add(id);
+
+        // 逻辑删除
+        for (Long menuId : idsToDelete) {
+            Menu menu = new Menu();
+            menu.setId(menuId);
+            menu.setDeleted(1);
+            menu.setUpdateTime(LocalDateTime.now());
+            updateById(menu);
+        }
+        log.info("删除菜单成功: id={}, 共删除{}条", id, idsToDelete.size());
+    }
+
+    /**
+     * 递归收集所有子菜单ID
+     */
+    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);
+        }
+    }
+
+    @Override
+    public List<Map<String, Object>> getUserMenuTree(List<Long> roleIds) {
+        if (roleIds == null || roleIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        // 查询角色拥有的所有菜单
+        List<Menu> menus = baseMapper.selectMenusByRoleIds(roleIds);
+
+        // 过滤只保留菜单和目录类型(0=菜单, 1=iframe, 2=外链),排除按钮(3)
+        menus = menus.stream()
+                .filter(m -> m.getMenuType() != null && m.getMenuType() != 3)
+                .filter(m -> m.getShowLink() == null || m.getShowLink())
+                .collect(Collectors.toList());
+
+        // 构建菜单树
+        return buildMenuTree(0L, menus);
+    }
+
+    /**
+     * 递归构建菜单树(前端 asyncRoutes 格式)
+     */
+    private List<Map<String, Object>> buildMenuTree(Long parentId, List<Menu> allMenus) {
+        List<Map<String, Object>> tree = new ArrayList<>();
+
+        List<Menu> children = allMenus.stream()
+                .filter(m -> m.getParentId() != null && m.getParentId().equals(parentId))
+                .sorted(Comparator.comparing(Menu::getRank, Comparator.nullsLast(Comparator.naturalOrder())))
+                .collect(Collectors.toList());
+
+        for (Menu menu : children) {
+            Map<String, Object> node = new LinkedHashMap<>();
+            node.put("path", menu.getPath());
+            node.put("name", menu.getName());
+
+            Map<String, Object> meta = new LinkedHashMap<>();
+            if (menu.getIcon() != null && !menu.getIcon().isEmpty()) {
+                meta.put("icon", menu.getIcon());
+            }
+            meta.put("title", menu.getTitle());
+            if (menu.getRank() != null) {
+                meta.put("rank", menu.getRank());
+            }
+            if (menu.getShowParent() != null && menu.getShowParent()) {
+                meta.put("showParent", true);
+            }
+
+            node.put("meta", meta);
+
+            // 递归构建子节点
+            List<Map<String, Object>> childNodes = buildMenuTree(menu.getId(), allMenus);
+            if (!childNodes.isEmpty()) {
+                node.put("children", childNodes);
+                if (menu.getRedirect() != null && !menu.getRedirect().isEmpty()) {
+                    node.put("redirect", menu.getRedirect());
+                }
+            } else {
+                // 叶子节点需要 component
+                if (menu.getComponent() != null && !menu.getComponent().isEmpty()) {
+                    node.put("component", menu.getComponent());
+                }
+            }
+
+            tree.add(node);
+        }
+
+        return tree;
+    }
+
+    @Override
+    public List<Long> getMenuIdsByRoleId(Long roleId) {
+        return roleMenuMapper.selectList(
+                new LambdaQueryWrapper<RoleMenu>()
+                        .eq(RoleMenu::getRoleId, roleId)
+        ).stream().map(RoleMenu::getMenuId).collect(Collectors.toList());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void saveRoleMenus(Long roleId, List<Long> menuIds) {
+        // 先删除旧关联
+        baseMapper.deleteByRoleId(roleId);
+
+        // 再插入新关联
+        if (menuIds != null && !menuIds.isEmpty()) {
+            for (Long menuId : menuIds) {
+                RoleMenu rm = new RoleMenu();
+                rm.setRoleId(roleId);
+                rm.setMenuId(menuId);
+                rm.setCreateTime(LocalDateTime.now());
+                roleMenuMapper.insert(rm);
+            }
+        }
+        log.info("保存角色菜单关联成功: roleId={}, menuCount={}", roleId, menuIds != null ? menuIds.size() : 0);
+    }
+}

+ 1 - 0
haha-service/src/main/java/com/haha/service/payment/impl/PaymentServiceImpl.java

@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.HashMap;
 import java.util.List;