编写列表组件
接下来看看列表组件的例子。还是沿用 oak-general-business 中的 System 和 Application:在 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(),
};
},
});
这里比单行组件多了几个典型特征:
isList: true,说明这是列表结点;formData中的data不再是一行,而是一个数组;- 组件接受一个额外的
systemId参数; - 组件同样在
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 同样由编译器注入类型:applications 和 oakExecutable 来自 formData,systemId 来自 properties,Oak 运行状态与方法由框架合同补齐。传统 TSX 不再手写 WebComponentProps。如果这里使用了未在 formData、properties 或框架内置合同中出现的字段,严格构建应当直接报错,正确修复位置通常是 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拿到的是一组行数据;- 通过
addItem、removeItem、updateItem、execute等方法管理这组数据; - 每一行仍然能通过子路径继续往下挂组件。
所以在 Oak 里,列表完全可以长成:
- 表格;
- Tabs;
- 卡片网格;
- 时间线;
- 左侧列表 + 右侧详情。
真正的关键不是 UI 形态,而是你有没有把它挂成一个正确的 list 结点。
另一类最常见的列表页:PageHeader + FilterPanel + ListPro
除了 Tabs 型列表,在真实项目里更常见的其实是标准后台列表页。这个模式在:
oak-pay-business/src/components/order/listtaicang/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>
这个结构里最重要的不是视觉层,而是运行时关系:
FilterPanel和ListPro共用同一条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 列表组件很适合配合:
- 行内编辑;
- 局部弹窗;
- 批量修改;
- 列表内创建和删除。
写列表页时的一个顺手检查清单
每次写完列表组件,建议顺手检查下面几项:
- 这是不是一个真正的 list 结点,也就是
isList: true? oakPath有没有表达清楚它和父组件的关系?- 如果页面用了
FilterPanel、Search、Pagination,它们是不是挂在同一条 list 路径上? - 行数据有没有必要先在
formData里整理成更易展示的结构? - 新增、删除、局部更新,是不是都走 runningTree 的方法,而不是在 TSX 里自己额外维护一套假状态?
这一节最重要的结论
- 列表组件的
formData中拿到的是行数组,而不是单条对象。 - 列表组件通常不只负责展示,还会负责新增、删除、分页、过滤、排序等交互入口。
addItem(...) + 子路径 Upsert + execute()是 Oak 列表内创建子项的典型模式。- 如果父子对象关系明确,优先用相对
oakPath表达关系,再考虑额外写filters。 - 列表页最常见的两种形态,是
Tabs型工作台和PageHeader + FilterPanel + ListPro型后台列表页。