---
url: /zh/typescript/view-engine/actions.md
description: 声明读者能对记录执行的命令：可用性及其原因、放置位置、确认与表单、批量执行、结果与幂等，并用 actionHarness 测试它们。
---

# 操作

读者对记录做事：发货、取消订单、把它移到另一个仓库。在视图引擎里，记录上的命令是**声明**出来的，而不是画出来的。宿主说明做什么：有哪些命令、每条什么时候可用、不可用时为什么、要不要先确认、要填什么。引擎负责在哪里做、怎样做：按钮、菜单、对话框、键盘与读屏行为、批量执行及其结果。

| 宿主写 | 引擎做 |
| --- | --- |
| 操作，以及每个操作发送的命令（`run`） | 放置它们：行上的按钮和菜单、选择栏、记录详情、仪表盘的记录面板 |
| 什么时候可用、不可用时的原因（`available`），以及仅随时间变化的时刻（`changesAt`） | 带着原因禁用它；对只有部分能执行的选择，列出被拒绝的，并提供只保留能执行的 |
| 它的措辞、危险程度、是否先确认（`confirm`）、要填什么（`form`） | 问题和表单、焦点和播报 |
| 命令什么时候算完成：`run` 在读模型反映出它之后才 resolve | 同时执行几条，带进度和停止；每条记录的结果；重新读取视图 |

“这个订单现在能发货吗”是只有宿主知道的业务规则。“键盘用户怎样得知按钮为什么被禁用”“选中的两条发不了货时会怎样”“超时的请求算不算成功”是机制，由引擎写一次。

## 声明一个操作 {#declare}

先是命令。`@ahoo-wang/wow-client` 的命令网关按路由发送；`wow-generator` 生成的客户端为每个路由和命令体提供类型：

```ts
// 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`；出错时立即抛出：

```ts
// 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)) })`（[接入宿主](./host#bind)），资源出现在哪里，它的操作就跟到哪里。

| 成员 | 是什么 |
| --- | --- |
| `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`，界面会在那时再问一次，所有可见记录共用一个定时器：

```ts
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` 禁用并用读者的话说明原因，以及该怎么做。最终决定权在服务：与它的命令处理函数检查的规则保持一致。

## 规则读取的字段 {#the-fields-a-rule-reads}

**引擎只查询视图显示的东西**，所以交给操作的行只带可见字段、身份和排序读取的字段。在隐藏了状态列的视图里，读取状态的规则什么都看不到，会禁用每一行。把规则读取的字段列进定义的 `record.rowFields`，每一行就都会带上它们：

```ts
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`。开发时把每个带操作的视图都打开一次。

## 操作放在哪里 {#placement}

| 位置 | 绘制什么 |
| --- | --- |
| 一行或一张卡片 | 主操作是一个按钮；其余在它的“…的操作”菜单里 |
| 选择栏 | 主操作带上数量，然后是其余操作；不是全部都能执行时显示“{able}/{count}” |
| 记录详情 | `on` 包含 `detail` 的操作 |
| 仪表盘的记录面板 | 与工作台上一样；命令之后，整个仪表盘重新读取 |

禁用的按钮仍可获得焦点（`aria-disabled`，而不是 `disabled`），获得焦点时显示原因。[`EmbeddedView`](../../reference/typescript/wow-view-engine/components.md#api-EmbeddedView) 不绘制声明的操作。

## 移动看板卡片 {#kanban}

在[看板布局](./definitions#a-kanban-layout)中，把卡片移到另一栏是一个用 `moves` 指明它设置哪个字段的操作：

```ts
// 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` | 是 |
| 一条记录，`confirm` 带 `ask: 'bulk'` | 否；只有选中多条时才问 |
| 表单 | 总是，在对话框里 |
| 只有一个选项字段 | 选项就是菜单项；选中的就是输入 |

按出错的代价来选。会失去东西的，例如取消或退款：危险语气，加上一个说明后果的 `confirm`。例行但不可撤销的，例如发货：不带危险语气的 `confirm`。撤销和执行一样容易的，例如换仓库：`ask: 'bulk'`。

`confirm` 的措辞可以包含 `{count}`、`{value}` 和 `{record}`。表单字段是 `text`、`number` 或 `boolean`，或者选项；除非写 `required: false`，否则都是必填。

## 批量执行 {#bulk}

`run` 写一条记录；引擎调度一组选中的记录。不能执行该操作的记录不会发送，记为**未执行**，并保持选中。最多同时执行四条，带进度和**停止**。执行完后重新读取视图，失败和未执行的记录仍保持选中。

## 结果 {#outcomes}

| 结果 | 何时 | 然后 |
| --- | --- | --- |
| 完成 | `run` resolve 了 | 重新读取视图 |
| 失败 | `run` reject 了：服务拒绝了命令 | 显示服务给出的原因；记录保持选中 |
| 未执行 | 轮到它时，记录已不再符合条件 | 显示原因；记录保持选中 |
| 未知 | 没有回答：超时、中止、连接断开、操作的 `timeout`，或**停止等待** | “结果未知，刷新查看”；取消选中这条记录 |

**`run` 在读模型反映出这条命令之后才 resolve**，否则随后的刷新读到的是旧状态，读者会以为什么都没发生。Wow 命令等待 `snapshot`，或者等待视图所读的那个订阅。

**结果未知的记录会被取消选中**，免得诱人盲目地再执行一次。读者仍可能再按一次，所以要让命令幂等：发送行上显示的版本（`version` 列在 `record.rowFields` 里），网关的请求 ID 让一次发送的重试只算一次。

失败或结果未知时的原始错误还会传给环境的 `onError`，用于监控。

## 插槽 {#slots}

声明表达不了的东西，例如一个外链或你自己的控件，放进插槽：`bind` 上的 `slots: { row, bulk, global }`，绘制在声明的操作之后。发送命令的插槽调用它上下文的 `run`，结果报告方式与声明的操作相同。能声明的就声明。

## 测试操作 {#harness}

`/testing` 中的 [`actionHarness`](../../reference/typescript/wow-view-engine/testing.md#api-actionHarness) 按引擎自己的规则读取声明，不需要界面：

```ts
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，就像引擎不会发送它一样。

## 一个完整的示例 {#a-complete-example}

ops 控制台的 [`parkedActions.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/platform/ops/console/src/views/parkedActions.ts) 为停放条目声明了恢复、跳过、标记为可恢复和应用重试规格，带表单和确认；[`parkedActions.test.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/platform/ops/console/src/views/parkedActions.test.ts) 用 `actionHarness` 测试它们。
