Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

编写列表组件

接下来看看列表组件的例子。还是沿用 oak-general-business 中的 SystemApplication:在 system/panel 里,我们不仅想展示当前 System 的详情,还想管理属于这个 System 的所有 Application

对应的公共组件在:

oak-general-business/src/components/system/application

逻辑层(index.ts)

这个组件的 index.ts 大致如下:

export default OakComponent({
    entity: 'application',
    isList: true,
    projection: {
        id: 1,
        name: 1,
        config: 1,
        description: 1,
        type: 1,
        systemId: 1,
        domainId: 1,
        style: 1,
    },
    properties: {
        systemId: '',
    },
    formData({ data }) {
        return {
            applications: data || [],
            oakExecutable: this.tryExecute(),
        };
    },
});

这里比单行组件多了几个典型特征:

  1. isList: true,说明这是列表结点;
  2. formData 中的 data 不再是一行,而是一个数组;
  3. 组件接受一个额外的 systemId 参数;
  4. 组件同样在 formData 中返回了 oakExecutable,用于控制列表内新建弹窗的确认按钮状态。

渲染层不只是“展示列表”

Oak 的列表组件,往往同时还承担列表内的增删改入口。system/application/web.pc.tsx 就是一个非常典型的例子:

export default function render(props) {
    const {
        oakFullpath,
        applications,
        oakExecutable,
        oakExecuting,
        systemId,
    } = props.data;
    const { addItem, removeItem, clean, execute, t } = props.methods;

    ...
}

列表 render 同样由编译器注入类型:applicationsoakExecutable 来自 formDatasystemId 来自 properties,Oak 运行状态与方法由框架合同补齐。传统 TSX 不再手写 WebComponentProps。如果这里使用了未在 formDataproperties 或框架内置合同中出现的字段,严格构建应当直接报错,正确修复位置通常是 index.ts,而不是扩大 render 参数类型。

这个组件除了渲染 applications 列表,还会:

  • 调用 addItem(...) 先在列表结点上添加一条待创建数据;
  • 调用 removeItem(...) 标记删除某一项;
  • 调用 clean() 取消弹窗中的未提交修改;
  • 调用 execute() 统一提交列表上的修改。

所以 Oak 里的 list 组件,常常不是“纯展示表格”,而是一个完整的列表工作台。

列表内新建的一个真实模式

在这个例子里,当用户点击“新增应用”时,组件会先这样做:

const id = addItem({
    systemId,
    config: {} as EntityDict['application']['Schema']['config'],
    warningVersions: [],
    dangerousVersions: [],
});
setCreateId(id);

然后把一个 ApplicationUpsert 挂到:

<ApplicationUpsert
    oakId={createId}
    oakPath={`${oakFullpath}.${createId}`}
/>

这段代码很值得仔细理解:

  • addItem(...) 只是先在当前列表结点下准备了一条待创建数据,并返回一个临时 id;
  • ApplicationUpsert 再通过这个 id,挂到当前列表项的子路径上;
  • 用户在弹窗里编辑这条待创建数据;
  • 最后统一 execute() 提交。

这就是 Oak 列表组件里最常见的“列表内创建一条子项”的模式。

列表内删除也是延迟提交

删除流程也不是立刻发请求,而是先在列表结点上记录操作,再统一提交。例如:

removeItem(removeId);
await execute();

这说明列表组件和单行 Upsert 组件在数据流上是一致的:

  • 先把修改写入 runningTree;
  • 再统一执行;
  • 中间始终可以 clean() 回滚未提交内容。

列表组件的过滤条件来自哪里

列表组件本身可以通过 filters 定义过滤条件,也可以像这个例子一样,更多依赖相对路径来表达对象关系。

system/panel/web.pc.tsx 中,这个列表组件是这样被挂载的:

<ApplicationList
    oakPath={`${oakFullpath}.application$system`}
    systemId={id}
/>

这个 oakPath 很关键,它表达的是:

  • 当前列表组件展示的不是任意 Application
  • 而是“属于当前 System 的那些 Application”。

也就是说,在 Oak 里,组件之间的相对路径通常就对应着对象之间的真实关系。

什么时候还要额外声明 filters

如果组件不依赖父结点路径,或者你希望它在多种场景下独立复用,那么就可以在组件里显式写 filters

filters: [
    {
        filter() {
            return {
                systemId: this.props.systemId,
            };
        },
    },
]

但如果父子组件的对象关系本来就很明确,那么优先使用符合对象关系的 oakPath,通常会让整个页面结构更自然。

列表组件不一定非得渲染成表格

system/application 这个例子其实已经说明了一点:Oak 的 list 组件本质上是“一个列表结点”,而不是“一个 table 组件”。

在这个组件里,列表最终渲染成的是:

  • Tabs
  • editable-card 的增删入口;
  • 每个 tab 里再挂一个 ApplicationPanel

也就是说,只要你的组件满足下面这些条件,它就是一个标准的 Oak 列表组件:

  • isList: true
  • formData 拿到的是一组行数据;
  • 通过 addItemremoveItemupdateItemexecute 等方法管理这组数据;
  • 每一行仍然能通过子路径继续往下挂组件。

所以在 Oak 里,列表完全可以长成:

  • 表格;
  • Tabs;
  • 卡片网格;
  • 时间线;
  • 左侧列表 + 右侧详情。

真正的关键不是 UI 形态,而是你有没有把它挂成一个正确的 list 结点。

另一类最常见的列表页:PageHeader + FilterPanel + ListPro

除了 Tabs 型列表,在真实项目里更常见的其实是标准后台列表页。这个模式在:

  • oak-pay-business/src/components/order/list
  • taicang/src/pages/console/order/list/web.pc.tsx

里都很典型。

这类页面通常长成这样:

<PageHeader title={t('pageTitle')}>
    <FilterPanel
        entity="order"
        oakPath={oakFullpath}
        columns={filterColumns}
    />
    <ListPro
        entity="order"
        oakPath={oakFullpath}
        data={orders}
        attributes={attributes}
        extraActions={extraActions}
        onAction={handleAction}
    />
</PageHeader>

这个结构里最重要的不是视觉层,而是运行时关系:

  • FilterPanelListPro 共用同一条 oakFullpath
  • 筛选条件、刷新、分页、行动作,都落在同一个 list 结点上;
  • 页面壳只负责标题和布局,不再自己重复管理另一套列表状态。

如果你在项目里写后台列表页,优先采用这个模式,通常会比“自己拼一堆状态”稳定得多。

formData 往往要先把行数据整理一遍

很多新手第一次写列表时,会直接把原始对象字段扔给渲染层。但真实项目里,更常见的写法是:formData 里先把行数据整理成更适合展示的结构。

例如 oak-pay-business/src/components/order/list/index.ts 中,就会先做一轮加工:

formData({ data }) {
    return {
        orders: data?.map((order) => {
            const { creator, price, paid, ...rest } = order;
            return {
                ...rest,
                price: ThousandCont(ToYuan(price!), 2),
                paid: ThousandCont(ToYuan(paid!), 2),
                creatorName: creator?.name || creator?.nickname || '-',
                creatorMobile: creator?.mobile$user?.[0]?.mobile || '-',
            };
        }),
    };
}

这里做的事情包括:

  • 金额字段格式化;
  • 关联对象字段摊平成列表列更容易消费的结构;
  • 缺省值兜底;
  • 把复杂 projection 转换成更扁平的展示数据。

这是一种非常推荐的习惯。因为这样做之后:

  • TSX 渲染层会明显更干净;
  • 列定义里的 render 逻辑会更短;
  • 你更容易在多个列表页之间复用同一种展示形态。

行动作通常分成两类

在真实项目里,列表页上的动作大致会分成两类:

1. 跳转类动作

最典型的是:

  • 查看详情;
  • 跳转到某个子页面;
  • 打开另一个 panel。

taicang/src/pages/console/order/list/web.pc.tsx 就会通过 extraActions + onAction 来做:

<ListPro
    ...
    extraActions={[
        {
            action: 'detail',
            label: t('common::action.detail'),
            show: true,
        },
    ]}
    onAction={async (row, action) => {
        if (action === 'detail') {
            navigateTo({
                url: '/order/detail',
                oakId: row.id,
            });
        }
    }}
/>

这类动作的特点是:

  • 不直接修改当前 list 结点的数据;
  • 更像“从当前行跳到另一个上下文”。

2. 局部更新类动作

另一类动作是在当前列表页里直接改某一行。例如同一个页面里,还会用:

updateItem({
    receivingMethod: value,
}, rmId);
await execute();

这种模式表示:

  • 先用 updateItem(...) 把某一行的修改写进当前 list 结点;
  • 再统一 execute() 提交;
  • 中间如果用户取消,可以直接 clean() 回滚。

这也是为什么 Oak 列表组件很适合配合:

  • 行内编辑;
  • 局部弹窗;
  • 批量修改;
  • 列表内创建和删除。

写列表页时的一个顺手检查清单

每次写完列表组件,建议顺手检查下面几项:

  1. 这是不是一个真正的 list 结点,也就是 isList: true
  2. oakPath 有没有表达清楚它和父组件的关系?
  3. 如果页面用了 FilterPanelSearchPagination,它们是不是挂在同一条 list 路径上?
  4. 行数据有没有必要先在 formData 里整理成更易展示的结构?
  5. 新增、删除、局部更新,是不是都走 runningTree 的方法,而不是在 TSX 里自己额外维护一套假状态?

这一节最重要的结论

  1. 列表组件的 formData 中拿到的是行数组,而不是单条对象。
  2. 列表组件通常不只负责展示,还会负责新增、删除、分页、过滤、排序等交互入口。
  3. addItem(...) + 子路径 Upsert + execute() 是 Oak 列表内创建子项的典型模式。
  4. 如果父子对象关系明确,优先用相对 oakPath 表达关系,再考虑额外写 filters
  5. 列表页最常见的两种形态,是 Tabs 型工作台和 PageHeader + FilterPanel + ListPro 型后台列表页。