DirectSurface UIDirectSurface UI
开始使用
文档/业务指南

命令与权限

import {  CommandManager,  RenderCommandScope,  RenderStackPanel,  createAppContextKey,} from 'ds-ui'

命令系统用于统一按钮、菜单、快捷键、权限判断、启用状态和执行入口。复杂业务系统中,同一个动作不应在工具栏、右键菜单、主菜单和快捷键里写多套逻辑。

命令不是按钮。按钮只是命令的一种入口。

命令解决什么问题

问题 命令体系的处理方式
一个动作有多个入口 按钮、菜单、快捷键都绑定同一个 command id。
按钮权限散落 permission 统一接入权限服务。
启用状态散落 enabled / canExecute 统一计算当前业务状态。
页面关闭后旧按钮还能执行 页面命令注册在页面 scope,scope 随页面释放。
审计无法知道来源 CommandExecutionContext 包含 command id、trigger、source 和 appContext。

注册层级

命令一般跟着页面走,但应用也可以有全局命令。推荐分三层:

层级 注册位置 生命周期 示例
应用级命令 CommandManager.rootScope 当前应用实例 打开设置、退出登录、打开全局帮助。
页面级命令 页面 RenderCommandScope 当前 tab / 页面 保存当前病历、刷新当前表格、提交审核。
组件级命令 组件局部 RenderCommandScope 当前组件实例 删除当前行、复制当前节点、展开全部。

命令查找会从触发源所在 render 节点向上找最近的 CommandScope,再沿父 scope 向上查找。同 id 的局部命令会覆盖父级命令。

实际业务里,大多数命令都应注册在页面级或组件级。只有真正跨整个应用稳定存在的动作才放到应用级,例如全局帮助、退出登录、打开系统设置。保存当前文书、删除当前行、刷新当前列表这类动作都不应注册到 root scope。

最小示例

let dirty = true const manager = new CommandManager() manager.register({  id: 'record.save',  title: '保存',  icon: 'save',  canExecute: () => dirty,  execute: () => {    dirty = false    manager.invalidate()  },})

canExecute 会在 UI 刷新命令状态或执行前重新求值。执行前还会再检查一次,避免按钮状态过期导致非法执行。

页面级命令

页面命令应注册到页面自己的 RenderCommandScope,页面关闭时自动释放。

const pageContent = new RenderStackPanel({  orientation: 'vertical',  spacing: 8,}) const commandScope = new RenderCommandScope({  commandManager,  debugLabel: 'record-page',  commands: [    {      id: 'record.refresh',      title: '刷新',      icon: 'refresh',      execute: () => reload(),    },  ],  child: pageContent,})

如果页面状态变化后影响命令启用状态,应调用 commandManager.invalidate(),让绑定按钮、菜单或工具栏重新求值。

组件级命令

组件级命令适合和具体组件状态绑定,例如当前表格选中行、当前树节点、当前编辑器选区。

const gridCommandScope = new RenderCommandScope({  commandManager,  debugLabel: 'grid-actions',  commands: [    {      id: 'row.delete',      title: '删除行',      canExecute: () => canDelete(),      execute: () => remove(),    },  ],})

组件销毁时,RenderCommandScope 默认会 dispose 自己创建的 CommandScope,组件级命令也随之失效。

权限接入

权限服务由业务系统提供。框架只定义 PermissionService 协议。

const manager = new CommandManager({  permissionService: {    has(permission, context) {      context.trigger      return permission === 'record.save'    },  },}) manager.register({  id: 'record.save',  title: '保存',  permission: 'record.save',  execute: () => save(),})

权限服务通常读取应用上下文中的登录用户、角色、岗位、院区或业务状态,但业务对象结构由业务系统自己定义。框架不内置 CIS 权限模型。

权限数据通常来自业务系统登录后的菜单权限、按钮权限或后端鉴权结果。推荐在应用启动或用户切换时把权限服务放入应用级上下文,再由 CommandManager 调用它。框架不会规定权限码命名方式,也不会主动向后端查询权限。

permission、enabled、canExecute 的区别

字段 职责 典型来源
permission 功能权限,判断用户是否拥有该功能。 权限服务、角色权限、菜单权限。
visible 是否显示该命令入口。 模块配置、页面模式、业务流程。
enabled 当前是否可用。 页面状态、流程状态、只读模式。
canExecute 执行前业务条件。 选中行、文档脏状态、表单校验。
disabledReason 禁用原因文案。 给 tooltip、菜单或诊断面板显示。
checked 切换类命令是否选中。 视图模式、筛选开关、显示隐藏状态。

推荐规则:

  • 用户有没有这个功能:放 permission
  • 当前页面模式能不能操作:放 enabled
  • 当前临时状态能不能执行:放 canExecute
  • 入口要不要完全隐藏:放 visible

命令状态刷新

canExecute 不需要长期订阅每个字段。业务状态变化后调用 commandManager.invalidate() 即可。绑定了命令的按钮、工具栏和菜单会在刷新时重新求值。

这相当于“界面状态刷新时重新求值”。canExecute 应该是轻量同步函数,通常只读取页面内存状态,例如当前选中行、dirty 标记、只读模式、保存中状态。不要在 canExecute 中请求接口、遍历大数据、触发布局或修改组件。

常见刷新时机:

  • 当前选中行变化。
  • 表单脏状态变化。
  • 当前文档只读/可编辑状态变化。
  • 流程状态变化。
  • 权限服务或登录用户变化。
  • 异步操作开始或结束。
let selectedRowId: string | null = nullconst manager = new CommandManager() manager.register({  id: 'row.copy',  title: '复制行',  canExecute: () => selectedRowId !== null,  execute: () => copySelection(),}) selectedRowId = 'row-1'manager.invalidate()

不要在每一次 hover 或 paint 中调用 invalidate()。命令状态只需要在业务状态变化时刷新。

执行上下文

命令执行时会收到 CommandExecutionContext

字段 说明
appContext 从 source 所在 render tree 和应用根收集出的上下文快照。
commandManager 当前命令管理器。
source 触发命令的 render 对象。
trigger 'api''click''keyboard''menu'
event 原始事件或调用方传入的数据。
const currentRecordKey = createAppContextKey<{ id: string }>('record.current')const manager = new CommandManager() manager.register({  id: 'record.open',  title: '打开',  execute: context => {    const record = context.appContext.require(currentRecordKey)    record.id  },})

执行命令时尽量传入 source,否则命令只能使用 root scope 和应用级上下文。

绑定 UI

常见绑定方式:

按钮、菜单、快捷键都应该绑定 command id,而不是各自写 onClick

生命周期和自动释放

注册方式 是否随组件释放 说明
manager.register() 否,随 manager/root scope 应用级命令,返回 disposer,可手动释放。
new RenderCommandScope({ commands }) scope 由组件创建,组件 dispose 时释放。
scope.register(command) 是,如果 scope 随页面释放 页面或组件命令。
外部传入 scopedisposeScopeOnDispose: false 调用方自己负责释放。

页面级命令必须避免注册到 root scope,否则页面关闭后旧命令仍可能被菜单或快捷键触发。

拦截器和审计

命令拦截器适合做统一确认、审计埋点和错误处理。

const manager = new CommandManager() manager.addInterceptor({  afterExecute(command, context) {    command.id    context.trigger  },})

命令不是审计存储系统,但它是业务操作审计的入口。真正的审计记录、保存时机和服务端字段由业务系统决定。

常见错误

把按钮权限写在每个按钮里

这会导致工具栏、菜单、快捷键状态不一致。应该把权限写到命令上,UI 只绑定命令。

页面命令注册到 root scope

页面关闭后命令仍然存在,可能引用已经释放的页面对象。页面命令应放到页面 RenderCommandScope

canExecute 做重计算

canExecute 可能在界面刷新和执行前多次调用。它应该只读当前状态,不做网络请求、全量扫描或修改 UI。

状态变化后没有 invalidate

命令按钮不会自动知道业务状态变化。选中行、脏状态、权限变化后,应调用 commandManager.invalidate()

没传 source

不传 source 时,命令无法解析当前页面的局部上下文和局部命令 scope。按钮、菜单和快捷键应尽量使用触发源作为 source。

相关文档