操作
读者对记录做事:发货、取消订单、把它移到另一个仓库。在视图引擎里,记录上的命令是声明出来的,而不是画出来的。宿主说明做什么:有哪些命令、每条什么时候可用、不可用时为什么、要不要先确认、要填什么。引擎负责在哪里做、怎样做:按钮、菜单、对话框、键盘与读屏行为、批量执行及其结果。
| 宿主写 | 引擎做 |
|---|---|
操作,以及每个操作发送的命令(run) | 放置它们:行上的按钮和菜单、选择栏、记录详情、仪表盘的记录面板 |
什么时候可用、不可用时的原因(available),以及仅随时间变化的时刻(changesAt) | 带着原因禁用它;对只有部分能执行的选择,列出被拒绝的,并提供只保留能执行的 |
它的措辞、危险程度、是否先确认(confirm)、要填什么(form) | 问题和表单、焦点和播报 |
命令什么时候算完成:run 在读模型反映出它之后才 resolve | 同时执行几条,带进度和停止;每条记录的结果;重新读取视图 |
“这个订单现在能发货吗”是只有宿主知道的业务规则。“键盘用户怎样得知按钮为什么被禁用”“选中的两条发不了货时会怎样”“超时的请求算不算成功”是机制,由引擎写一次。
声明一个操作
先是命令。@ahoo-wang/wow-client 的命令网关按路由发送;wow-generator 生成的客户端为每个路由和命令体提供类型:
// src/views/orderCommands.ts
import type { Fetcher } from '@ahoo-wang/fetcher';
import { CommandGateway, wait } from '@ahoo-wang/wow-client';
/** The commands a person sends on one order. Each resolves once the snapshot reflects it. */
export interface OrderCommands {
ship(id: string, version: number): Promise<void>;
cancel(id: string, version: number, reason: string): Promise<void>;
moveTo(id: string, warehouse: string): Promise<void>;
}
export function orderCommands(fetcher: Fetcher): OrderCommands {
// A command client generated by wow-generator types each route and body; this one sends by path.
const commands = new CommandGateway({ fetcher });
// `Wow-Wait: snapshot`: answer once the snapshot holds the commit, for the engine reads the view again as `run` resolves.
const plan = [wait.snapshot()];
const send = async (
command: string,
id: string,
body: object,
version?: number,
) => {
// A refused command rejects: the engine reports the service's reason on that order.
await commands.send(`/example/order/{id}/${command}`, body, {
// The id fills `{id}`, encoded by the gateway, never pasted into the path.
id,
// The version the row showed: a command that already took, sent again, is refused as a conflict rather than applied twice.
expectedVersion: version,
wait: { plan },
});
};
return {
ship: (id, version) => send('ship-order', id, {}, version),
cancel: (id, version, reason) =>
send('cancel-order', id, { reason }, version),
moveTo: (id, warehouse) => send('change-warehouse', id, { warehouse }),
};
}每条命令等待 snapshot,所以它在快照包含这次提交后才 resolve;它把行上显示的版本作为 If-Match 发送,所以已经生效的命令再发一次,会以冲突被拒绝,而不是执行两次。
然后是操作。actions([...]) 检查 id 唯一、每个操作都有 run;出错时立即抛出:
// src/views/orderActions.ts
import { actions, text, type RecordRow } from '@ahoo-wang/wow-view-engine';
import type { OrderCommands } from './orderCommands';
interface OrderState {
status?: string;
warehouse?: string;
}
// Every field read below is in the definition's `record.rowFields` (next section).
const stateOf = (row: RecordRow) => (row.data.state ?? {}) as OrderState;
const versionOf = (row: RecordRow) => Number(row.data.version);
// The row key is the aggregate id (the definition sets no other `rowKey`).
const idOf = (row: RecordRow) => String(row.key);
export const orderActions = (commands: OrderCommands) =>
actions([
{
id: 'ship',
label: text('orders.ship'),
// The button in the row; the rest go behind the row's ⋯ menu.
primary: true,
// `true`, or why not: a key, said in the reader's language.
available: row =>
stateOf(row).status === 'PAID' ? true : text('orders.shipNotPaid'),
// Routine, but not taken back once sent: asked for one order too, with no danger tone.
confirm: { title: text('orders.shipTitle') },
run: row => commands.ship(idOf(row), versionOf(row)),
},
{
id: 'cancel',
label: text('orders.cancel'),
// Something is lost: the danger tone, asked for one order too, the consequence said.
tone: 'danger',
available: row =>
['CREATED', 'PAID'].includes(stateOf(row).status ?? '')
? true
: text('orders.cancelShipped'),
confirm: {
title: text('orders.cancelTitle'),
body: text('orders.cancelBody'),
},
form: {
reason: { label: text('orders.cancelReason'), input: 'text' },
},
// Off the selection bar: every cancellation has its own reason.
on: ['row', 'detail'],
// No answer in 30 seconds and the engine stops waiting for this order: its outcome is unknown.
timeout: 30_000,
run: (row, { reason }) =>
commands.cancel(idOf(row), versionOf(row), String(reason)),
},
{
id: 'warehouse',
label: text('orders.moveTo'),
// One field of options: its options are listed in the menu, with no form.
form: {
warehouse: {
label: text('orders.warehouse'),
options: [
{ value: 'SH', label: text('orders.shanghai') },
{ value: 'GZ', label: text('orders.guangzhou') },
],
},
},
// Asked with the option in hand: the warehouse the order is in is not offered again.
available: (row, { input }) =>
input?.warehouse !== undefined &&
input.warehouse === stateOf(row).warehouse
? text('orders.sameWarehouse')
: true,
// Undone as easily as done: one order moves at its pick; a selection is counted first.
confirm: { title: text('orders.moveTitle'), ask: 'bulk' },
run: (row, { warehouse }) =>
commands.moveTo(idOf(row), String(warehouse)),
},
]);把它们绑定在资源上:bind('orders', { route, actions: orderActions(orderCommands(fetcher)) })(接入宿主),资源出现在哪里,它的操作就跟到哪里。
| 成员 | 是什么 |
|---|---|
id、label | 在资源的操作中唯一的 id;它的名字 |
primary | 读者最常按的操作:行上的那个按钮。至多一个 |
tone | default 或 danger |
on | 在哪里提供:row、bulk、detail;默认每个位置都提供 |
hidden(row, ctx) | 在这条记录上不显示:这个人不能做这件事 |
available(row, ctx) | true,或者现在为什么不行:显示但禁用,并带原因 |
changesAt(row, ctx) | available 下一次仅随时间变化的时刻 |
confirm | 先问什么 |
form | 除了记录之外,命令还需要什么 |
moves | 看板卡片拖到另一栏时它设置的字段 |
run(row, input) | 为一条记录发送命令 |
timeout | 一条记录的 run 等多久 |
每条规则都拿到 { now, input? }:时间来自引擎,所以规则从不读时钟;input 是选中的选项或填好的表单。抛出异常的规则会拒绝这条记录。
可用性仅随时间变化时,写 changesAt,界面会在那时再问一次,所有可见记录共用一个定时器:
import { actions, text } from '@ahoo-wang/wow-view-engine';
export const holdActions = actions([
{
id: 'release',
label: text('orders.release'),
// Not while the hold lasts…
available: (row, { now }) =>
holdUntil(row) > now ? text('orders.onHold') : true,
// …and once it is over: the engine asks again at that moment.
changesAt: (row, { now }) =>
holdUntil(row) > now ? holdUntil(row) + 1 : null,
run: row => release(String(row.key)),
},
]);权限也放在这里。 这个人不能做的,hidden 不显示。他能做但这条记录现在不能接受的,available 禁用并用读者的话说明原因,以及该怎么做。最终决定权在服务:与它的命令处理函数检查的规则保持一致。
规则读取的字段
引擎只查询视图显示的东西,所以交给操作的行只带可见字段、身份和排序读取的字段。在隐藏了状态列的视图里,读取状态的规则什么都看不到,会禁用每一行。把规则读取的字段列进定义的 record.rowFields,每一行就都会带上它们:
import { defineView, text } from '@ahoo-wang/wow-view-engine';
export const ordersDefinition = defineView(descriptor, {
id: 'orders',
source: 'order',
title: text('orders.title'),
fields: {
aggregateId: text('orders.id'),
'state.status': text('orders.status'),
'state.warehouse': text('orders.warehouse'),
'state.amount': text('orders.amount'),
version: text('orders.version'),
},
// The actions read these three, whether or not the view shows them.
record: { rowFields: ['state.status', 'state.warehouse', 'version'] },
});在开发构建中,规则读取了行没有取回的字段时,引擎会发出警告 record.action.unfetched。开发时把每个带操作的视图都打开一次。
操作放在哪里
| 位置 | 绘制什么 |
|---|---|
| 一行或一张卡片 | 主操作是一个按钮;其余在它的“…的操作”菜单里 |
| 选择栏 | 主操作带上数量,然后是其余操作;不是全部都能执行时显示“{able}/{count}” |
| 记录详情 | on 包含 detail 的操作 |
| 仪表盘的记录面板 | 与工作台上一样;命令之后,整个仪表盘重新读取 |
禁用的按钮仍可获得焦点(aria-disabled,而不是 disabled),获得焦点时显示原因。EmbeddedView 不绘制声明的操作。
移动看板卡片
在看板布局中,把卡片移到另一栏是一个用 moves 指明它设置哪个字段的操作:
// src/views/dealActions.ts
import { actions, type RecordRow } from '@ahoo-wang/wow-view-engine';
const STAGES = [
{ value: 'LEAD', label: '线索' },
{ value: 'PROPOSAL', label: '方案报价' },
{ value: 'WON', label: '赢单' },
];
export const dealActions = (
changeStage: (id: string, stage: string) => Promise<void>,
) =>
actions([
{
id: 'change-stage',
label: 'Change stage',
// A card dropped on another lane runs this with `{ stage: <the lane> }`.
moves: 'stage',
// And the card's menu offers the same move, a stage at a time.
form: { stage: { label: 'Stage', options: STAGES } },
available: (row: RecordRow, { input }) => {
if (row.data.stage === 'WON') return 'A won deal stays won';
if (input && input.stage === row.data.stage) return 'Already there';
return true;
},
run: (row, input) => changeStage(String(row.key), String(input.stage)),
},
]);拿起卡片时,规则接受的每一栏都显示为可放下,规则拒绝的栏显示原因。卡片立即落下,之后重新读取视图;被拒绝或失败时,它回到原处。键盘和读屏用户通过卡片的菜单移动它,菜单由 form 提供。
确认与表单
什么时候问由引擎决定;宿主声明意图:
| 情况 | 是否先问? |
|---|---|
| 一组选中的记录 | 总是:多少条、哪些不会发送、为什么 |
一条记录,没有 confirm | 否 |
一条记录,有 confirm | 是 |
一条记录,confirm 带 ask: 'bulk' | 否;只有选中多条时才问 |
| 表单 | 总是,在对话框里 |
| 只有一个选项字段 | 选项就是菜单项;选中的就是输入 |
按出错的代价来选。会失去东西的,例如取消或退款:危险语气,加上一个说明后果的 confirm。例行但不可撤销的,例如发货:不带危险语气的 confirm。撤销和执行一样容易的,例如换仓库:ask: 'bulk'。
confirm 的措辞可以包含 {count}、{value} 和 {record}。表单字段是 text、number 或 boolean,或者选项;除非写 required: false,否则都是必填。
批量执行
run 写一条记录;引擎调度一组选中的记录。不能执行该操作的记录不会发送,记为未执行,并保持选中。最多同时执行四条,带进度和停止。执行完后重新读取视图,失败和未执行的记录仍保持选中。
结果
| 结果 | 何时 | 然后 |
|---|---|---|
| 完成 | run resolve 了 | 重新读取视图 |
| 失败 | run reject 了:服务拒绝了命令 | 显示服务给出的原因;记录保持选中 |
| 未执行 | 轮到它时,记录已不再符合条件 | 显示原因;记录保持选中 |
| 未知 | 没有回答:超时、中止、连接断开、操作的 timeout,或停止等待 | “结果未知,刷新查看”;取消选中这条记录 |
run 在读模型反映出这条命令之后才 resolve,否则随后的刷新读到的是旧状态,读者会以为什么都没发生。Wow 命令等待 snapshot,或者等待视图所读的那个订阅。
结果未知的记录会被取消选中,免得诱人盲目地再执行一次。读者仍可能再按一次,所以要让命令幂等:发送行上显示的版本(version 列在 record.rowFields 里),网关的请求 ID 让一次发送的重试只算一次。
失败或结果未知时的原始错误还会传给环境的 onError,用于监控。
插槽
声明表达不了的东西,例如一个外链或你自己的控件,放进插槽:bind 上的 slots: { row, bulk, global },绘制在声明的操作之后。发送命令的插槽调用它上下文的 run,结果报告方式与声明的操作相同。能声明的就声明。
测试操作
/testing 中的 actionHarness 按引擎自己的规则读取声明,不需要界面:
import { text, type RecordRow } from '@ahoo-wang/wow-view-engine';
import { actionHarness } from '@ahoo-wang/wow-view-engine/testing';
const row = (id: string, status: string): RecordRow => ({
key: id,
data: { version: 3, state: { status, warehouse: 'SH' } },
});
const commands: OrderCommands = {
ship: vi.fn(() => Promise.resolve()),
cancel: vi.fn(() => Promise.resolve()),
moveTo: vi.fn(() => Promise.resolve()),
};
const orders = actionHarness(
orderActions(commands),
[row('O-1', 'PAID'), row('O-2', 'SHIPPED')],
{ now: Date.parse('2026-09-30T00:00:00Z') },
);
// What each place offers; why a record does not take one.
expect(orders.at('bulk')).toEqual(['ship', 'warehouse']);
expect(orders.state('ship', 'O-2')).toEqual({
hidden: false,
available: false,
reason: text('orders.shipNotPaid'),
});
// A selection splits into the able and the refused, grouped by reason.
expect(orders.bulk('ship').able).toEqual(['O-1']);
// What a press asks: `{ asks, confirm }`.
expect(orders.asks('ship', 'row').asks).toBe(true);
expect(orders.asks('warehouse', 'row', { warehouse: 'GZ' }).asks).toBe(false);
// A choice's options, a form's blank required fields.
expect(orders.state('warehouse', 'O-1', { warehouse: 'SH' }).reason).toBe(
text('orders.sameWarehouse'),
);
expect(orders.missing('cancel', {})).toEqual(['reason']);
// `run` sends to the aggregate id, with the row's version.
await orders.run('cancel', 'O-1', { reason: 'Buyer asked' });
expect(commands.cancel).toHaveBeenCalledWith('O-1', 3, 'Buyer asked');对操作不接受的记录调用 run,会以 ActionRefusedError reject,就像引擎不会发送它一样。
一个完整的示例
ops 控制台的 parkedActions.ts 为停放条目声明了恢复、跳过、标记为可恢复和应用重试规格,带表单和确认;parkedActions.test.ts 用 actionHarness 测试它们。