# 6.3 菜单管理 :::tip 概述 菜单管理负责维护系统的菜单树和权限节点,是 RBAC 权限体系的基础数据——**菜单即权限**:菜单表的 `permission` 字段即接口权限编码。`MenuService` 继承 `BaseService`(系统共享,不隔离)。 ::: ## 模块特点 | 特性 | 说明 | |------|------| | 基类 | `BaseService`(系统共享,不做租户隔离) | | 树形结构 | `treeParentField='parent_id'`、`treeLikeField='name'`,支持树形列表与子树搜索 | | 菜单类型 | type: **0=菜单/目录(可导航) 1=权限节点**(`permission` 非空,参与接口鉴权) | | 删除保护 | `beforeDelete` 检查子菜单与角色引用,任一存在则阻止删除 | | 缓存联动 | 菜单变更时 `MenuObserver` 自动清除菜单/权限缓存 | ## API 接口 | 方法 | 路径 | 权限码 | 说明 | |------|------|--------|------| | GET | `/api/menu/list` | sys:menu:list | 树形列表 | | GET | `/api/menu/detail/{id}` | sys:menu:detail | 详情 | | POST | `/api/menu/add` | sys:menu:add | 新增 | | PUT | `/api/menu/update` | sys:menu:update | 修改 | | DELETE | `/api/menu/delete/{id}` | sys:menu:delete | 删除 | :::tip 为何没有分页接口? 菜单是全局树形数据,体量小且前端需要完整树渲染,因此只提供 `list`(全量树形)而非 `page`(分页)。 ::: ## Service 属性配置 ```php // app/Services/MenuService.php(来自项目实际代码) class MenuService extends BaseService { use HasCrudOperations, HasQueryScopes; protected string $modelClass = Menu::class; protected array $pageLikeFields = ['name']; protected array $pageEqFields = ['status', 'type']; protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc']; // 树形结构 protected string $treeParentField = 'parent_id'; protected string $treeLikeField = 'name'; // 树形搜索字段(命中节点保留父链) } ``` ## 菜单数据结构 ```json { "id": 100, "parentId": 0, "name": "系统管理", "type": 0, "path": "/system", "icon": "SettingOutlined", "sort": 10, "permission": "", "component": "", "target": 0, "hide": false, "children": [ { "id": 101, "parentId": 100, "name": "用户管理", "type": 0, "path": "/user", "component": "/system/user/index", "children": [ { "id": 102, "parentId": 101, "name": "查看用户", "type": 1, "permission": "sys:user:page" }, { "id": 103, "parentId": 101, "name": "添加用户", "type": 1, "permission": "sys:user:add" } ] } ] } ``` (键名经驼峰自动转换;`children` 由树形组装生成。`GET /index/getMenus` 只返回 `type=0` 的可导航节点——`type=1` 权限节点不进菜单树,仅供鉴权查询。) ## 字段说明 | 字段 | 说明 | |------|------| | `type` | **0=菜单/目录(可导航) 1=权限节点**(鉴权专用,`permission` 必填) | | `status` | 0=正常 1=停用(`getMenus` 只取 0) | | `hide` | 0=可见 1=不可见(`getMenus` 只取 0) | | `path` | 路由路径(type=0) | | `component` | 前端组件路径(以 `/` 开头,type=0 页面必填,映射到 `views/**/index.vue`) | | `permission` | 权限标识(type=1 必填且非空,格式 `sys:{模块}:{操作}`) | | `target` | 打开方式:0=组件 1=内链 2=外链 | ## 数据流转 ```text 菜单管理(本模块) │ 写入 laravel_menu ▼ GET /index/getMenus(IndexController) │ 按当前用户权限过滤(user_role → role_menu → menu) ▼ 前端 asyncRouteStore │ 转换为 Vue Router 动态路由 + 侧边栏菜单 ▼ v-perm 指令按 permission 控制按钮显隐 ``` ## 删除保护(beforeDelete 钩子) ```php // app/Services/MenuService.php(摘自实际代码) protected function beforeDelete(int $id): void { // 存在未删除的子菜单时不允许删除(软删除由全局 Scope 过滤) $hasChildren = Menu::where('parent_id', $id)->exists(); if ($hasChildren) { throw new BizException('存在子菜单,不可删除'); } // 已被角色引用时不允许删除,避免 role_menu 产生孤儿数据 $hasRoles = RoleMenu::where('menu_id', $id)->exists(); if ($hasRoles) { throw new BizException('该菜单已被角色引用,不可删除'); } } ``` :::warning 删除前校验 删除菜单时先检查子菜单(`存在子菜单,不可删除`),再检查角色引用(`该菜单已被角色引用,不可删除`),任一命中即抛出 `BizException` 阻止删除——需先解除引用/删除子菜单。菜单变更后相关角色的权限缓存由 `MenuObserver` 自动清除。 ::: ## 最佳实践 | 实践 | 说明 | |------|------| | type=0 管导航 | 目录/页面用 type=0;页面必填 `component`(前端组件路径),目录留空 | | type=1 管权限 | 权限节点用 type=1,`permission` 必填且非空(鉴权查询条件之一) | | 权限节点挂页面下 | type=1 节点作为 type=0 页面的子级,便于角色树勾选 | | 状态与隐藏 | `status=0` 启用、`hide=0` 可见才会出现在 `getMenus` 结果中 | | 权限码规范 | `sys:{模块}:{操作}`,与控制器 `#[Permission]` 完全一致 |