编写组件/页面
定义完 Entity 后,就可以开始编写组件和页面了。Oak 的前端开发和常见 React 项目最大的不同在于:组件往往不是独立地“自己发请求”,而是挂在一棵数据树上,围绕对象路径来组织。
因此,写 Oak 组件时,建议先分清三层角色:
- 页面 / panel 容器:负责组织路径、tab、弹窗、子组件编排;
- 业务组件:负责某个对象的详情、编辑、列表、动作;
- 抽象通用组件:只负责展示或编辑,不负责具体业务语义。
这一章后面的几个小节,会分别讲单个组件怎么写、组件之间怎么组织、OakComponent 有哪些参数和运行时能力。这里先给你一个整体视角。
先记住三类最常见的业务组件
对于关联到 Entity 的组件,最常见的还是这三类:
List:列表组件,显示当前对象的多条数据;Detail:详情组件,显示当前对象的一条数据;Upsert:编辑组件,编辑当前对象的一条数据,或为一条新数据准备 create。
如果一个组件不关联任何 Entity,那么它就是 Virtual 组件。Virtual 组件通常用来做:
- 首页、看板、工作台;
- 多个 Entity 混排的页面壳;
- 纯布局、纯交互容器;
- 只负责承载子组件的 panel / wrapper。
组件不是只分“页面”和“子组件”
在真实 Oak 项目里,组件通常还会再按职责分成下面几类:
| 类型 | 常见命名 | 作用 |
|---|---|---|
| 页面入口 | pages/... | 路由入口、挂页面根路径 |
| panel 容器 | panel | 组织 tab、弹窗、多个子组件 |
| 业务详情组件 | detail | 展示一条对象数据 |
| 业务编辑组件 | upsert | 编辑一条对象数据 |
| 业务列表组件 | list / 具体名字 | 管理一组对象 |
| 通用抽象组件 | oak-frontend-base/components/... | 纯展示/编辑壳,不带具体业务语义 |
例如在 oak-general-business 中:
system/panel是System的容器组件;system/detail展示并承接提交入口;system/upsert负责编辑System;system/application展示并管理属于某个System的Application列表。
application/panel 也采用了同样的模式:自己先取当前 Application 的核心数据,再把配置、样式、公众号能力、模板能力等拆成多个 tab 子组件。
这类 panel 组件,是 Oak 项目里非常值得掌握的一种组织方式。
优先复用的顺序
在 Oak 项目里,建议按下面这个顺序考虑复用,而不是一上来就自己写一套新组件。
1. 先看 oak-frontend-base 有没有抽象组件
oak-frontend-base/src/components 里有一批通用抽象组件,比如:
detailupsertlistfilterfilterPanelpaginationactionBtnrefAttr
这类组件的特点是:
- 它们通常不代表某个具体业务;
- 更像“对象展示器”“对象编辑器”“列表渲染器”;
- 更适合拿来快速搭一个管理页、详情块、编辑块。
2. 再看 oak-general-business / oak-pay-business 有没有完整业务组件
如果你的需求已经落在公共业务包的职责范围里,优先复用它们现成的业务组件。例如:
SystemPanelApplicationPanel- 用户、登录、授权、消息、文件类组件;
- 支付、账户、提现、物流类组件。
这些组件不仅有 UI,还通常已经把对象结构、路径组织、交互流程、动作提交一并处理好了。
3. 只有当公共组件不匹配时,再写项目私有组件
通常在下面几种情况下,才值得自己新写:
- 现有公共组件的对象结构与你项目不一致;
- 页面交互或展示方式有明显差异;
- 你需要新增项目私有字段、动作或子组件关系;
- 你需要一个专门的 panel 来组织项目内多个公共能力。
oak-frontend-base 抽象组件怎么用
这部分文档以前讲得还不够。这里直接按源码整理一份速查表。
抽象 detail 组件
源码入口:
oak-frontend-base/src/components/detail/index.ts
这类组件更像“详情展示壳”。它本身不负责对象取数,而是渲染父组件已经拿到的数据。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前展示的是哪个对象 |
attributes | 要展示哪些字段 |
data | 当前行数据 |
title | 标题 |
bordered | 是否带边框 |
layout | horizontal 或 vertical |
column | 列数,可按断点配置 |
适合场景:
- 某个 panel 已经取到了当前对象数据;
- 你只想快速把一组字段渲染成标准详情块;
- 不想每次都手写
Descriptions/ label 映射 / 枚举颜色逻辑。
抽象 upsert 组件
源码入口:
oak-frontend-base/src/components/upsert/index.ts
这类组件更像“表单编辑壳”,主要负责把字段定义转成输入控件,并通过 update(...) 写回当前结点。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前编辑的是哪个对象 |
attributes | 要编辑哪些字段 |
data | 当前行数据 |
layout | 表单布局 |
mode | default 或 card |
helps | 字段帮助文案 |
适合场景:
- 你已经有当前对象上下文;
- 只想快速把若干字段做成编辑表单;
- 想复用字段类型推导、标签文案、枚举处理。
抽象 list 组件
源码入口:
oak-frontend-base/src/components/list/index.ts
它本质上是一个列表渲染器,而不是“自动帮你查询对象”的业务列表页。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前列表对应哪个对象 |
attributes | 列定义 |
data | 行数据数组 |
loading | 是否加载中 |
extraActions | 额外操作按钮 |
onAction | 行动作回调 |
rowSelection | 多选配置 |
hideHeader | 是否隐藏表头 |
disableSerialNumber | 是否禁用序号列 |
size | 表格尺寸 |
scroll | 滚动设置 |
empty | 空状态内容 |
opWidth | 操作列宽度 |
ListPro 不接受 tablePagination。它使用 Oak 自有的 Pagination 组件并从当前 list 结点读取分页状态;需要调整共享分页布局时,应修改或覆盖 Oak components/pagination 的 .pagination 根样式,而不是配置 Ant Design Table 的分页参数。
适合场景:
- 父组件已经通过 Oak 数据树拿到了行数组;
- 你只想把这组行数据渲染成一个统一样式的列表;
- 行上还需要配合
#oakLegalActions、#oakLegalCascadeActions展示动作按钮。
抽象 actionBtn 组件
源码入口:
oak-frontend-base/src/components/actionBtn/index.ts
这个组件不是“提交按钮”,而是动作按钮渲染器。它的职责是把当前行可执行动作、级联动作和额外动作,整理成一组按钮或下拉项。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前动作所属对象 |
actions | 当前行允许展示的动作 |
cascadeActions | 当前行允许展示的级联动作 |
extraActions | 额外自定义动作 |
onAction | 点击动作后的回调 |
适合场景:
- 列表行操作区;
- 详情页右上角动作区;
- 需要同时展示普通动作和子对象动作的地方。
源码里还有两个值得注意的行为:
- 普通动作和额外动作会一起参与排序和裁剪,超出的会进“更多”;
- 级联动作的文案会沿路径解析目标 entity,再做 i18n 翻译。
如果你的页面已经通过 actions / cascadeActions 拿到了合法动作集合,actionBtn 往往是最省事的渲染方式。
抽象 filterPanel 组件
源码入口:
oak-frontend-base/src/components/filterPanel/index.ts
这个组件本质上是列表筛选条件面板,它本身不定义对象取数,而是帮你把筛选列转换成一组 named filters,并挂到当前 list 结点上。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前筛选面板对应的对象 |
columns | 要渲染哪些筛选项 |
适合场景:
- 后台列表页;
- 管理台侧边筛选区;
- 需要把多个筛选条件统一折叠、展开、重置的列表页面。
这里有两个源码层面的真实约束:
- 当前只支持一项全文检索列,也就是
columns里最多一个$text; - 小程序端默认只展示前三个筛选项,其余会折叠进“更多筛选”。
另外,filterPanel 的重置和确认,最终调用的是当前结点上的:
removeNamedFilterByName(...)refresh()
所以它最适合挂在已经建立好的 list 路径上,而不是脱离列表独立使用。
抽象 refAttr 组件
源码入口:
oak-frontend-base/src/components/refAttr/index.ts
这个组件主要用于外键字段展示和选择。如果某个字段本质上是“从别的对象里挑一条或多条”,而你不想每次都手写一套选择逻辑,它非常有用。
常用参数如下:
| 参数 | 作用 |
|---|---|
placeholder | 占位文案 |
multiple | 是否多选 |
entityId | 单选当前值 |
entityIds | 多选当前值 |
pickerRender | 如何查询候选项、如何展示标题 |
onChange | 选择变化回调 |
pickerRender / pickerDef 实际会决定:
- 候选对象是哪个 entity;
- 用什么 projection 去取候选行;
- 用什么 filter 限定候选范围;
- 标题如何渲染;
- 选择模式是
radio、select还是别的形态。
源码里还有两个很实用的约束:
radio模式要求候选总数count <= 5;select模式要求候选总数count <= 20。
这意味着它更适合:
- 候选范围不大的外键选择;
- 单选/多选对象引用;
- 表单里的“选用户”“选系统”“选上级对象”。
如果候选集很大,通常应该改成专门的列表挑选页,而不是继续塞进 refAttr。
抽象 pagination 组件
源码入口:
oak-frontend-base/src/components/pagination/index.ts
这个组件是列表分页状态的展示层,它依赖当前结点里的 oakPagination,并不会自己定义分页逻辑。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前分页对应的对象 |
showQuickJumper | 是否允许快速跳页 |
size | 分页器尺寸 |
showSizeChanger | 是否允许切换每页条数 |
showTotal | 自定义总数展示 |
适合场景:
- 已经用 Oak list 组件建立了分页结点;
- 你只需要一个分页器 UI 与当前结点联动。
需要注意的是:
- 它依赖当前 list 结点上的
oakPagination; - 因此应放在同一条 list 路径上下文中使用;
- 如果列表根本没有配置分页或总数,这个组件也不会凭空帮你算出来。
抽象 search 组件
源码入口:
oak-frontend-base/src/components/search/index.ts
这个组件是列表关键字搜索辅助组件。它的核心能力不是渲染一个输入框本身,而是把关键字转换成名为 search 的 named filter,挂到当前 list 结点上。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前搜索针对哪个对象 |
attributes | 指定在哪些字段上搜索 |
placeholder | 搜索框占位文案 |
它的实际行为有两种:
- 如果不传搜索字段,则默认生成全文检索 filter,即
$text.$search; - 如果传了字段数组,则会生成一组
$or条件,按字段路径分别构造搜索 filter。
适合场景:
- 后台列表页顶部快速搜索;
- 配合
filterPanel形成“关键字 + 条件筛选”的组合检索; - 某些对象只希望暴露少数字段给用户搜索。
一个实践建议是:
- 如果你的对象已经建立了全文索引,优先让
search走全文检索; - 如果你只想允许用户搜少数字段,再显式传
attributes。
抽象 filter 组件
源码入口:
oak-frontend-base/src/components/filter/index.ts
和 filterPanel 不同,filter 是单个筛选项组件。它负责把某一列的筛选定义,转换成具体控件和具体 filter。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前筛选项对应哪个对象 |
column | 这一项的筛选定义 |
从源码看,它会根据字段类型和操作符自动决定渲染方式,例如:
- 数值、文本字段:输入框;
datetime:日期选择器;boolean、enum:选择器;ref:引用对象选择器。
同时它会根据配置自动生成 named filter 名称,并在用户确认后调用:
addNamedFilter(...)removeNamedFilterByName(...)
适合场景:
- 你不想用整块
filterPanel,而是只想在页面某处插入一个单独筛选器; - 你想自定义筛选布局,但仍希望复用 Oak 的字段类型推导和 filter 构造逻辑。
抽象 picker 组件
源码入口:
oak-frontend-base/src/components/picker/index.ts
picker 是基于 list 结点的对象选择器。和 refAttr 相比,它更适合“打开一个候选列表让用户选”的场景,而不是直接把小范围候选塞进单个表单控件。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 候选对象属于哪个 entity |
multiple | 是否多选 |
onSelect | 用户确认选择后的回调 |
title | 每行如何显示标题 |
titleLabel | 选择框标题 |
filter | 候选数据过滤条件 |
sorter | 候选数据排序 |
projection | 候选行需要取哪些字段 |
这个组件本身会建立一个 list 结点:
entity()直接来自外部 props;projection()直接复用外部传入的 projection;filters直接把外部传入的 filter 接入当前结点。
适合场景:
- 候选对象很多,
refAttr不够用; - 需要带筛选条件地选一条或多条对象;
- 需要在弹窗、抽屉、独立 tab 中做“从对象列表里挑选”。
可以把它理解成:比 refAttr 更偏列表式选择,比自己手写一个 list + 选择逻辑更省事。
抽象 actionBtnPanel / actionTabPanel 组件
源码入口:
oak-frontend-base/src/components/actionBtnPanel/index.tsoak-frontend-base/src/components/actionTabPanel/index.ts
这两个组件都属于动作集合展示壳,区别主要在布局:
actionBtnPanel更像按钮面板 / 操作工具条;actionTabPanel更像分页签 / 卡片式动作网格。
actionBtnPanel 的常用参数:
| 参数 | 作用 |
|---|---|
entity | 动作所属对象 |
items | 要展示的动作项 |
mode | 展示模式,如 default、cell、table-cell |
column | 一行显示多少项 |
fixed | 是否固定布局 |
actionTabPanel 的常用参数:
| 参数 | 作用 |
|---|---|
entity | 动作所属对象 |
items | 要展示的动作项 |
rows | 每页几行 |
column | 每行几列 |
mode | 文本或其它展示模式 |
适合场景:
- 首页或详情页上的统一动作区;
- 小程序中的多动作入口面板;
- 一组动作很多,需要分页或折叠展示。
这两个组件内部都会对动作项做一层统一处理:
- 如果动作项有
label,优先使用自定义文案; - 否则优先翻译 entity 自身动作文案;
- 再不行就退回
common::action.xxx。
因此,当你已经整理好动作项数组时,这两个组件可以帮你省掉一层动作按钮布局代码。
抽象 listPro 组件
源码入口:
oak-frontend-base/src/components/listPro/index.tsx
如果说 list 更像“纯列表渲染器”,那么 listPro 更像 Oak 后台里最常见的标准列表页壳。它内部实际上是:
- 上方
ToolBar; - 中间
list; - 配合
TableContext管理列显示状态; - 默认把刷新动作绑定到当前
oakPath对应结点上。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前列表对应哪个对象 |
attributes | 列定义 |
data | 行数据数组 |
title | 列表标题 |
extraContent | 工具条右侧附加内容 |
buttonGroup | 工具条按钮组 |
extraActions | 行额外动作 |
onAction | 行动作回调 |
oakPath | 当前列表路径,默认刷新会用到 |
rowSelection | 多选配置 |
disableSerialNumber | 是否关闭序号列 |
size / scroll / empty / opWidth | 表格显示控制 |
hideDefaultButtons | 是否隐藏默认工具条 |
onReload | 自定义刷新逻辑 |
这里有一个很实用的源码细节:
- 如果你传了
oakPath,但没有自定义onReload,listPro工具条上的刷新会直接调用features.runningTree.refresh(oakPath); - 因此它最适合和 Oak list 结点放在同一路径上下文里使用。
适合场景:
- 后台标准列表页;
- 列表上方带标题、按钮、刷新入口;
- 想复用
List + ToolBar的统一外观,而不是每次手拼。
在真实项目中,这种用法非常常见。例如:
oak-pay-business/src/components/order/list/web.pc.tsxtaicang/src/pages/console/order/list/web.pc.tsx
都采用了 FilterPanel + ListPro 的组合。
抽象 pageHeader 组件
源码入口:
oak-frontend-base/src/components/pageHeader/index.ts
这个组件属于页面壳组件,主要负责:
- 页头标题;
- 返回按钮;
- 页头右侧操作区;
- 页面内容容器。
pageHeader 的常用参数:
| 参数 | 作用 |
|---|---|
title | 页标题 |
subTitle | 副标题 |
extra | 页头右侧内容 |
tags | 标题旁标签 |
showBack / onBack / delta | 返回按钮控制 |
contentMargin | 内容区是否保留默认边距 |
contentStyle / contentClassName | 内容区样式 |
children | 页面主体内容 |
适合场景:
- 后台管理页的统一页头;
- 列表页、详情页、配置页的内容容器;
- 你希望把“页面标题 + 筛选区 + 列表区”包在一个稳定的视觉外壳里。
在 taicang 和 haina-busi 里,这类页面壳的使用都非常多,尤其是:
- 列表页最外层包
PageHeader; - 页头内部先放筛选区;
- 下方再放
ListPro或详情内容。
关系权限管理相关组件
源码入口:
oak-frontend-base/src/components/relation/path/listoak-frontend-base/src/components/relation/path/detailoak-frontend-base/src/components/relation/path/upsertoak-frontend-base/src/components/relation/actionAuthoak-frontend-base/src/components/relation/relationAuth
这组组件不是通用页面壳,而是围绕系统实体 path、actionAuth、relationAuth 的专用管理组件。它们主要用于:
- 配置对象路径;
- 配置动作授权矩阵;
- 配置关系授权矩阵。
对新手来说,先把它们理解成“系统管理后台专用组件”就够了。平时业务开发里,不建议把它们当成通用抽象组件到处复用;真正需要对象关系授权配置时,再顺着这几个目录去读源码会更合适。
再补一个实用建议:通用组件放哪一层
这些抽象通用组件,最常见的放置位置通常是:
| 组件 | 推荐放置层 |
|---|---|
detail | detail / panel 子块 |
upsert | upsert / 弹窗 / 步骤块 |
list | 业务 list 外壳内部 |
actionBtn | 列表操作列、详情页动作区 |
actionBtnPanel / actionTabPanel | 首页动作区、页头动作区、操作面板 |
search | list 页顶部快速搜索区 |
filter | 自定义单筛选项区域 |
filterPanel | list 页顶部或侧边筛选区 |
refAttr | 表单字段内部 |
picker | 弹窗/抽屉中的对象选择区 |
pagination | list 页底部 |
listPro | 后台标准列表页主体 |
pageHeader | 页面最外层壳 |
在项目里通常会再包一层 AbstractComponents
这也是实际项目里非常常见的一步。
像 taicang、oak-pay-business、haina-busi 这类项目,都会在自己的:
src/components/AbstractComponents.ts
里,把 oak-frontend-base 的抽象组件按本项目的 EntityDict 重新声明一遍,例如:
import AbsListPro from '@oak-frontend-base/components/listPro';
import { EntityDict } from '@project/oak-app-domain';
const ListPro = AbsListPro as <T extends keyof EntityDict>(
...props: Parameters<typeof AbsListPro<EntityDict, T>>
) => React.ReactElement;
这样做有三个直接好处:
- 组件使用时会自动带上项目自己的 Entity 类型;
- 页面里不用每次都手写一长串泛型;
- 后续如果要统一替换或二次封装抽象组件,也有一个稳定入口。
如果你的项目已经进入“组件越来越多”的阶段,很建议尽早建立这层封装。
一个后台列表页的推荐拼法
如果你现在正在写一个后台列表页,最稳妥、也最接近真实项目的组合通常是:
- 最外层用
pageHeader做页面壳; - 页头或顶部区域放
filterPanel; - 主体区域放
listPro; - 行内新增、编辑再用弹窗挂
upsert; - 详情跳转或局部编辑再决定用共享路径、行路径或绝对路径。
一个非常典型的渲染结构大概像这样:
<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最好放在同一条 list 路径上;- 这样筛选条件、刷新动作、分页状态才会自然落在同一个 runningTree 结点里。
什么时候用抽象组件,什么时候自己写 OakComponent
可以用下面这个标准判断:
优先用抽象组件
当你只是需要:
- 展示一条当前对象数据;
- 编辑一条当前对象数据;
- 渲染一组当前对象数据;
- 做标准字段展示、标准字段编辑、标准表格列表。
优先自己写 OakComponent
当你需要:
- 自己定义
projection、filters、sorters、pagination; - 自己组织
oakPath和父子结点; - 在
formData中组合多段数据; - 自己决定
execute、clean、弹窗、tab、步骤条等交互结构; - 把多个业务组件组装成一个 panel。
实际上,Oak 项目里最常见的写法不是“二选一”,而是:
- 外层自己写一个业务
OakComponent; - 内层在合适的位置复用
oak-frontend-base抽象组件。
一个很典型的 panel 模式
如果你打开下面两个组件,会看到非常相似的结构:
oak-general-business/src/components/system/paneloak-general-business/src/components/application/panel
它们的共同特点是:
- panel 自己先拿到当前对象的核心数据;
- 再通过 tab 或子区域,挂多个子组件;
- 子组件有的共享路径,有的走关联路径,有的走绝对路径;
- panel 自己不一定负责每个 tab 的细节,但负责整体编排。
这种模式非常适合:
- 管理后台详情页;
- 配置中心;
- 一个对象下挂很多子能力的业务场景;
- 支付、系统配置、公众号能力这种“一个对象,多块配置”的页面。
阅读顺序建议
为了避免一开始被路径和组件树绕晕,建议按下面顺序阅读后面的章节:
这样先理解“单个组件怎么工作”,再理解“多个组件怎么组成页面”,会更顺。