• 菜单 API

    用途与前置条件

    scoped.menus 管理后台菜单配置。后台页面优先使用 configs/items/views/loadApis/actions/responses 逐项创建菜单、页面、接口、按钮和响应字段;配置即代码或插件安装可以继续使用 menus.config.* 批量保存完整 MenuConfigInputsubject.menus 是用户运行时入口,用同一套配置投影当前用户可见视图、按钮状态、页面状态和接口响应字段。

    新项目不要直接维护底层 nodesapiBindings 或 owner 关系;这些是编译后的兼容模型,主要服务历史 v2 清单、迁移工具和批量导入。普通后台管理页应把 menus.configs/items/views/loadApis/actions/responses 当作主 API,把 menus.config.* 当作高级整包入口。

    使用前需要完成:

    • pc.init() 已成功。
    • 已通过 pc.scope(scope, defaults?) 获取可信 scope 下的管理上下文;后台请求可在 defaults 里绑定 actorId/requestId
    • 需要运行时投影时,已通过 pc.forSubject({ userId, scope }) 获取当前用户上下文。

    我想做什么

    目标首选 API说明
    创建空配置menus.configs.previewCreate(input) / menus.configs.create(input)后台先创建一套菜单配置,再逐项添加菜单和页面。
    读取完整菜单树menus.configs.get(configId) / menus.config.get(configId)返回 MenuConfigSnapshot,完整树在 data.menus
    创建菜单、页面、接口、按钮、字段menus.items.*menus.views.*menus.loadApis.*menus.actions.*menus.responses.*面向后台管理页面的低心智对象方法。
    一次提交多个菜单变更menus.management.applyChanges(configId, changes);需要展示影响时先 previewChanges()一个表单保存多个变更时使用。普通安全变更可自动预览并提交。
    预览菜单配置menus.config.preview(config, options?)保存前校验菜单、页面、接口、响应字段、容量和冲突。
    保存菜单配置menus.config.save(config, options)用预览返回的 expectedpreviewToken 提交配置。
    读取配置列表menus.configs.list(query?) / menus.config.list(query?)列出多套菜单配置摘要,不返回菜单树节点。
    删除配置menus.config.previewRemove(configId) / menus.config.remove(configId, options)先预览删除影响,再安全撤销已删除来源。
    批量变更menus.config.previewChanges(changes) / menus.config.applyChanges(changes, options)多个配置共享同一 endpoint 且必须原子调整时使用。
    投影用户菜单subject.menus.getViewTree()getActionMap()getViewState()filterResponse()请求期读取有效授权,返回 UI 状态或响应字段投影。

    签名

    menus.management.previewChanges(configId: string, changes: NonEmptyMenuManagementChangeArray, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.management.applyChanges(configId: string, changes: NonEmptyMenuManagementChangeArray, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    
    menus.configs.previewCreate(input: MenuConfigCreateInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.configs.create(input: MenuConfigCreateInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    menus.configs.previewUpdate(configId: string, patch: MenuConfigUpdateInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.configs.update(configId: string, patch: MenuConfigUpdateInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    menus.configs.get(configId: string): Promise<VersionedResult<MenuConfigSnapshot>>
    menus.configs.list(query?: MenuConfigListQuery): Promise<PageResult<MenuConfigSummary>>
    menus.configs.previewRemove(configId: string, input?: MenuManagementRemoveInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.configs.remove(configId: string, input?: MenuManagementRemoveInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    
    menus.items.previewCreate(configId: string, input: MenuItemCreateInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.items.create(configId: string, input: MenuItemCreateInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    menus.views.previewCreate(configId: string, menuId: string, input: MenuViewCreateInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.views.create(configId: string, menuId: string, input: MenuViewCreateInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    menus.loadApis.previewAdd(configId: string, viewId: string, input: MenuLoadApiAddInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.loadApis.add(configId: string, viewId: string, input: MenuLoadApiAddInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    menus.actions.previewCreate(configId: string, viewId: string, input: MenuActionCreateInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.actions.create(configId: string, viewId: string, input: MenuActionCreateInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    menus.responses.previewSet(configId: string, input: MenuResponseSetInput, options?: MenuManagementPreviewOptions): Promise<ImpactPreview<MenuManagementPlan>>
    menus.responses.set(configId: string, input: MenuResponseSetInput, options?: MenuManagementExecuteOptions): Promise<MutationResult<MenuManagementResult>>
    
    menus.config.preview(config: MenuConfigInput, options?: MenuConfigPreviewOptions): Promise<ImpactPreview<MenuConfigPlan>>
    menus.config.save(config: MenuConfigInput, options: MenuConfigSaveOptions): Promise<MutationResult<MenuConfigSaveResult>>
    menus.config.get(configId: string): Promise<VersionedResult<MenuConfigSnapshot>>
    menus.config.list(query?: MenuConfigListQuery): Promise<PageResult<MenuConfigSummary>>
    menus.config.previewRemove(configId: string, options?: MenuConfigPreviewOptions): Promise<ImpactPreview<MenuConfigRemovePlan>>
    menus.config.remove(configId: string, options: MenuConfigRemoveOptions): Promise<MutationResult<MenuConfigRemoveResult>>
    menus.config.previewChanges(changes: NonEmptyMenuConfigChangeArray, options?: MenuConfigPreviewOptions): Promise<ImpactPreview<MenuConfigChangeSetPlan>>
    menus.config.applyChanges(changes: NonEmptyMenuConfigChangeArray, options: MenuConfigChangeSetOptions): Promise<MutationResult<MenuConfigChangeSetResult>>
    
    subject.menus.getViewTree(options: { configId: string }): Promise<SubjectRuntimeResult<readonly ViewTreeNode[]>>
    subject.menus.getActionMap(input: { configId: string; viewId: string }): Promise<SubjectRuntimeResult<Readonly<Record<string, ActionPermissionState>>>>
    subject.menus.getViewState(input: { configId: string; viewId: string } | { path: string }): Promise<SubjectRuntimeResult<ViewPermissionState>>
    subject.menus.filterResponse(apiResource: ApiResource, payload: unknown): Promise<SubjectRuntimeResult<unknown>>

    关键参数标记:configId 定位一套菜单配置;changes: NonEmptyMenuManagementChangeArray 是增量变更;config: MenuConfigInput 是批量完整配置。

    MenuManagementExecuteOptions 有两种形态:

    // 普通后台保存:自动内部预览并提交;传 requestId 时会自动派生幂等键。
    { actorId?: string; reason?: string; requestId?: string; idempotencyKey?: string }
    
    // 显式预览确认:用于级联删除、撤权删除、容量风险或管理端先展示影响。
    { ...preview.expected, previewToken: preview.previewToken, actorId?: string, requestId?: string, idempotencyKey?: string }

    不要只传半套显式参数:只有 previewToken 没有 expectedRevisions,或只有 expectedRevisions 没有 previewToken,都会返回 INVALID_ARGUMENTmenus.config.save/remove/applyChanges 是旧的完整配置批量入口,仍然必须使用 preview 返回的 expected/previewToken

    对象方法还有对应的 previewUpdate/update/previewRemove/remove,签名与 previewCreate/create 一致,只是多了要更新或删除的 menuId/viewId/resource/actionId

    参数对象

    增量管理参数

    类型必填字段说明
    MenuConfigCreateInputconfigId创建一套空菜单配置;可选 title/meta
    MenuItemCreateInputid/title创建菜单;可选 parentId/icon/navigation/enabled/meta。不传 parentId 表示顶层菜单。
    MenuViewCreateInputid/type/title,页面还需要 path/component创建页面、抽屉、弹窗、tab、iframe 或外链视图。
    MenuLoadApiAddInputresource给页面添加默认加载接口;格式是 api:METHOD:/path,系统自动使用 invoke
    MenuActionCreateInputtitle/resource给页面添加按钮或操作;resource 可为 api:*ui:button:*
    MenuResponseSetInputowner/response给 load 或 api action 配置响应字段;owner 指向字段属于哪个接口来源。
    MenuManagementExecuteOptions自动模式或显式确认模式普通增量写推荐使用 pc.scope(scope, defaults) 绑定 actorId/requestId,调用对象方法时可省略 optionsidempotencyKey 仅用于高级覆盖;级联删除、撤权删除或容量风险用 preview 返回的 expectedRevisions/previewToken

    MenuResponseSetInput.owner 有三种写法:

    { ownerType: 'load', viewId: 'orders-list', resource: 'api:GET:/api/orders' }
    { ownerType: 'action', viewId: 'orders-list', actionId: 'export' }
    { ownerType: 'api', apiResource: 'api:GET:/api/orders' }

    ownerType: 'api' 会匹配当前配置中声明了这个 API 的 load 或 api action。后台页面能定位到具体页面时,更推荐使用 loadaction,错误更容易排查。

    字段类型必填/默认说明
    configIdstring必填一套菜单配置的稳定 ID,后续授权和运行时读取都用它。
    titlestring可选管理端展示名。
    menusMenuConfigMenuInput[]必填,至少 1 项顶层菜单分组或菜单项。
    metaRecord<string, PolicyValue>可选透传给管理端或前端的可序列化元数据。
    字段类型必填/默认说明
    idstring必填当前配置内稳定唯一的菜单 ID。
    titlestring必填菜单标题。
    childrenMenuConfigMenuInput[]可选子菜单。
    viewsMenuViewInput[]可选该菜单下可进入的页面、抽屉、弹窗或 tab。
    navigationboolean默认 true是否出现在导航树。
    enabledboolean默认 true是否启用。
    icon/i18nKey/meta展示元数据可选供前端消费,不参与授权判断。
    字段类型必填/默认说明
    idstring必填当前配置内稳定唯一的视图 ID。
    typepage/dialog/drawer/tab/...必填视图类型。
    titlestring必填视图标题。
    pathstring页面常用前端路由路径;也可用 { path } 调用 getViewState()
    component/urlstring按类型前端组件或外部地址。
    loadMenuLoadInput[]默认 []页面进入时需要调用的接口。
    actionsMenuActionInput[]默认 []页面按钮或操作。
    navigation/enabled/i18nKey/meta视图元数据可选控制导航展示和前端附加信息。

    load.resource: ApiResource 必须形如 api:GET:/api/ordersactions[].resource: ApiResource | UiResource 可指向后端接口或前端 UI 能力。response?: ResponseProjectionConfigInput 可写在 loadactions 上,详见配置接口与响应字段 API

    方法详解:配置管理

    • 用途:在保存前校验菜单配置,并预览内部菜单、接口、响应字段和已有角色授权的影响。
    • 参数config 是完整 MenuConfigInputoptions 可带 actorId/reason/detailBudget
    • 状态影响:只读,不写入配置。
    • 原始返回ImpactPreview<MenuConfigPlan>;重点读取 executableconflictsplan.afterexpectedpreviewToken

    • 用途:提交已预览的菜单配置。
    • 参数config 必须与预览一致;options 必须包含 expectedpreviewToken,可选 actorId/requestIdidempotencyKey 只用于高级覆盖默认幂等策略。
    • 状态影响:写入 _menu_configs,同步内部菜单节点、接口契约和响应字段索引,并处理受影响角色来源。
    • 原始返回MutationResult<MenuConfigSaveResult>;配置快照在 data.config,内部写入摘要在 data.manifestOperations

    • 用途:读取指定配置的最新快照;管理端要展示完整菜单树时,用这个方法。
    • 参数configId 为配置 ID。
    • 状态影响:只读。
    • 原始返回VersionedResult<MenuConfigSnapshot>;完整菜单树在 data.menus,包含菜单、子菜单、页面、加载接口、按钮和响应字段配置。data.revision 可作为管理端展示的版本信息。

    • 用途:分页读取当前 scope 下的菜单配置摘要。
    • 参数query 可带 configId/first/after
    • 状态影响:只读。
    • 原始返回PageResult<MenuConfigSummary>;摘要包含 menuCount/viewCount/actionCount/responseFieldCount。它用于列出多套配置,不用于列出某套配置下的菜单树节点;完整树请用 menus.configs.get(configId)menus.config.get(configId)

    • 用途:预览删除一套菜单配置会移除哪些配置资产。
    • 参数configId 和可选预览上下文。
    • 状态影响:只读,不删除。
    • 原始返回ImpactPreview<MenuConfigRemovePlan>;重点检查 removedAssets。当前配置快照删除不会自动改写角色菜单授权,历史授权可通过角色菜单读取和 stale 修复链路处理。

    • 用途:执行已预览的配置删除。
    • 参数configId 必须与预览一致;optionsexpected/previewToken
    • 状态影响:删除配置快照并同步移除内部菜单/API 资产;不会自动撤销角色菜单授权。
    • 原始返回MutationResult<MenuConfigRemoveResult>

    • 用途:一次预览多套配置的保存或删除。
    • 参数changes: NonEmptyMenuConfigChangeArray,每项是 { operation: 'save', config }{ operation: 'remove', configId }
    • 状态影响:只读。
    • 原始返回ImpactPreview<MenuConfigChangeSetPlan>;用于插件安装、模块升级或批量导入前审查。

    • 用途:原子提交已预览的批量配置变更。
    • 参数:原始 changes 加预览返回的 expected/previewToken
    • 状态影响:批量保存/删除配置,并同步所有内部菜单与接口资产。
    • 原始返回MutationResult<MenuConfigChangeSetResult>

    方法详解:用户运行时

    subject.menus.getViewTree(options)

    • 用途:返回当前用户在指定配置下可见的导航树。
    • 参数options.configId 指定菜单配置。
    • 状态影响:只读;按当前用户有效角色和菜单授权投影。
    • 原始返回SubjectRuntimeResult<readonly ViewTreeNode[]>;按钮不会作为树节点返回。

    subject.menus.getActionMap(input)

    • 用途:返回某个视图下每个按钮或操作是否可见、是否可用以及原因。
    • 参数input.configIdinput.viewId
    • 状态影响:只读。
    • 原始返回SubjectRuntimeResult<Record<string, ActionPermissionState>>;对象键是 action ID。

    subject.menus.getViewState(input)

    • 用途:判断当前用户是否允许进入某个视图。
    • 参数:可传 { configId, viewId },也可传 { path }
    • 状态影响:只读。
    • 原始返回SubjectRuntimeResult<ViewPermissionState>allowed 表示权限允许,navigationReachable 表示导航链路可达。

    subject.menus.filterResponse(apiResource, payload)

    • 用途:按当前用户的响应字段授权裁剪接口响应。
    • 参数apiResourceapi:METHOD:/pathpayload 是准备返回给前端的数据。
    • 状态影响:只读,但会先检查当前用户是否能 invokeapiResource
    • 原始返回SubjectRuntimeResult<unknown>;裁剪后的响应在 data

    响应与副作用

    保存配置会产生 mutation envelope、审计 ID、revision、缓存失效结果和内部同步摘要。运行时方法不写入数据库,返回 SubjectRuntimeResult<T>,其中 data 是前端真正使用的数据,detailBudget 是诊断信息。

    {
      "changed": true,
      "data": {
        "config": { "configId": "admin", "revision": 1 },
        "manifestOperations": { "total": 3 },
        "retainedGrantCount": 0,
        "revokedGrantCount": 0
      },
      "auditId": "audit_..."
    }

    失败与限制

    常见失败包括配置 ID 重复或缺失、资源格式无效、响应字段路径非法、自动提交需要显式预览确认、预览 token 过期、revision 冲突和容量超限。load.resource 必须是 api: 资源;响应字段只能引用配置里声明过的字段。保存配置不会自动给任何角色或用户授权。

    增量管理自动模式遇到级联删除、撤权删除或不可自动确认的影响时,会抛出 MENU_MANAGEMENT_PREVIEW_CONFLICT。读取 details.operations/conflicts/warnings 给管理员展示,然后调用对应的 preview*(),确认后带 expected/previewToken 执行。

    示例

    const menuConfig = {
      configId: 'admin',
      menus: [{
        id: 'orders',
        title: 'Orders',
        views: [{
          id: 'orders-list',
          type: 'page',
          title: 'Orders',
          path: '/orders',
          component: 'OrdersPage',
          load: [{
            resource: 'api:GET:/api/orders',
            response: {
              target: 'items',
              preserve: ['total'],
              fields: [{ field: 'orderNo', title: '订单号' }],
            },
          }],
        }],
      }],
    };
    
    const preview = await scoped.menus.config.preview(menuConfig, { actorId: 'admin' });
    if (!preview.executable) throw new Error('resolve menu config conflicts first');
    
    const saved = await scoped.menus.config.save(menuConfig, {
      ...preview.expected,
      previewToken: preview.previewToken,
      actorId: 'admin',
    });
    
    const menus = pc.forSubject({ userId: 'u-menu', scope }).menus;
    const tree = await menus.getViewTree({ configId: 'admin' });
    const view = await menus.getViewState({ configId: 'admin', viewId: 'orders-list' });
    const projected = await menus.filterResponse('api:GET:/api/orders', payload);
    const projectedData = projected.data;
    {
      "tree": [{ "id": "orders", "enabled": true }],
      "view": { "allowed": true, "view": { "id": "orders-list" } },
      "projectedData": {
        "items": [{ "orderNo": "O-1001", "status": "paid" }],
        "total": 1
      }
    }

    相关内容

    参见管理菜单配置接口与响应字段角色菜单权限 API