Files
2026-10-08 17:50:29 +08:00

5.5 KiB
Raw Permalink Blame History

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 属性配置

// 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';   // 树形搜索字段(命中节点保留父链)
}

菜单数据结构

{
    "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=外链

数据流转

菜单管理(本模块)
    │ 写入 laravel_menu
    ▼
GET /index/getMenus(IndexController)
    │ 按当前用户权限过滤(user_role → role_menu → menu)
    ▼
前端 asyncRouteStore
    │ 转换为 Vue Router 动态路由 + 侧边栏菜单
    ▼
v-perm 指令按 permission 控制按钮显隐

删除保护(beforeDelete 钩子)

// 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] 完全一致