命令与权限
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
常见绑定方式:
- CommandButton:单个命令按钮。
- CommandToolbar:命令工具栏。
- MenuBar:主菜单。
- ContextMenu:右键菜单。
CommandShortcutController:快捷键。
按钮、菜单、快捷键都应该绑定 command id,而不是各自写 onClick。
生命周期和自动释放
| 注册方式 | 是否随组件释放 | 说明 |
|---|---|---|
manager.register() |
否,随 manager/root scope | 应用级命令,返回 disposer,可手动释放。 |
new RenderCommandScope({ commands }) |
是 | scope 由组件创建,组件 dispose 时释放。 |
scope.register(command) |
是,如果 scope 随页面释放 | 页面或组件命令。 |
外部传入 scope 且 disposeScopeOnDispose: 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。