表单开发
import { FormBindingBag, FormSession, RenderFieldPresenter, RenderFormPanel, RenderNumberInput, RenderTextBox,} from 'ds-ui'
表单是业务系统中最常见的页面形态。DirectSurface UI 推荐用 RenderFormPanel 管理字段排列,用 RenderFieldPresenter 管理标签、必填、单位、校验状态和提示信息。
如果你正在搭完整业务页面,先看 常用业务场景组合。本页重点说明表单本身如何组织、绑定和校验。
表单结构
RenderFormPanel
RenderFieldPresenter
RenderTextBox / RenderComboBox / RenderDatePicker
RenderFormPanel 负责字段网格,RenderFieldPresenter 负责单个字段的标签和状态,具体输入组件只负责值编辑。FormSession 位于页面或 controller 层,用来保存草稿、校验结果和提交状态,不参与布局和绘制。
组件选择
| 字段类型 | 推荐组件 | 说明 |
|---|---|---|
| 单行文本 | TextField | 公共类名是 RenderTextBox。适合姓名、编码、关键字。 |
| 多行文本 | TextArea | 适合备注、批注、说明。 |
| 数字 | NumberInput | 适合年龄、数量、金额、比例。 |
| 日期时间 | DatePicker | 适合日期、时间、日期时间字段。 |
| 单选枚举 | Dropdown | 公共类名是 RenderComboBox。值必须来自候选项。 |
| 多选枚举 | MultiSelectDropdown | 适合标签、诊断集合、权限集合。 |
| 树选择 | DropTreeEdit | 适合科室、分类、层级字典。 |
| 表格选择 | LookupEdit | 适合需要多列候选信息的选择。 |
最小示例
const form = new RenderFormPanel({ columns: 2, labelWidth: 88, adaptive: true, columnGap: 16, rowGap: 8,}) form.addField(new RenderFieldPresenter({ label: '姓名', required: true, child: new RenderTextBox({ placeholder: '请输入姓名' }),})) form.addField(new RenderFieldPresenter({ label: '年龄', unit: '岁', child: new RenderNumberInput({ value: 0 }),}))
字段值管理
Render 组件只负责交互和值变化通知,不会偷偷创建业务表单模型。简单页面可以直接维护一个 draft;需要取消、关联字段回填、异步校验或防重复提交时,使用页面级 FormSession 和显式字段绑定:
interface PatientDraft { name: string age: number} const session = new FormSession<PatientDraft>({ name: '', age: 0,})const bindings = new FormBindingBag(session)const nameInput = new RenderTextBox({})const formPanel = new RenderFormPanel({ columns: 2 })const nameField = new RenderFieldPresenter({ label: '姓名', required: true, child: nameInput,}) bindings.bindField('name', nameInput, { presenter: nameField, rules: [value => value.trim() ? undefined : '姓名不能为空'],})formPanel.addField(nameField)
推荐规则:
- 输入组件的
value是 UI 当前值。 FormSession的 draft 是提交前的业务草稿,bindField()显式声明输入组件对应哪个字段。- 服务层 DTO 在提交时由
session.snapshot()或runSubmit()传入的 draft 转换出来。 - 不要把后端原始对象直接作为多个输入组件共享的可变对象,避免取消编辑时难以回滚。
- 一次业务操作需要更新多个字段时使用
setValues(),外部只收到一次完整变化。
FormBindingBag 不会扫描 Render Tree,也不会根据组件嵌套猜字段。页面仍然平铺地决定哪个输入对应哪个字段;同一个组件原有的 onChange 也可以继续处理页面反馈等独立逻辑。值同步、失焦校验、错误投影和提交前编辑收尾见编辑器契约与表单字段绑定,编辑快照、dirty、touched、恢复和事务见编辑会话。
校验状态
字段校验应该落在字段展示层,而不是只改变输入组件边框。
const nameField = new RenderFieldPresenter({ label: '姓名', required: true, status: 'error', message: '姓名不能为空', child: new RenderTextBox({ placeholder: '请输入姓名' }),})
这样标签、提示、输入控件可以形成一个完整的校验区域。
校验可以分两类:
| 类型 | 时机 | 展示 |
|---|---|---|
| 即时校验 | 输入、失焦、选择值变化 | 更新 RenderFieldPresenter 的 status 和 message。 |
| 提交校验 | 点击保存或提交命令 | 汇总所有字段错误,并把第一个错误滚动到可视区域。 |
FormSession 可以注册 change、blur、submit 三种触发时机的同步或异步规则,并把不同来源的错误分开保存。使用 bindField() 时,绑定会把当前字段的第一条错误投影给 RenderFieldPresenter;手动接线时仍可直接订阅 form.errors。具体写法见表单校验与提交。
异步校验和提交都绑定在会话生命周期上。页面关闭时调用 session.dispose(),当前校验和提交信号会被取消,旧结果不会覆盖新值。
表单提交
表单提交建议分四层:
- UI 层负责具体输入和编辑器暂存状态。
- 显式字段绑定负责把正式值同步到
FormSession。 - 页面层通过
FormSession执行校验、管理提交状态并组装请求对象。 - 服务层提交数据并返回结果。
按钮、菜单、快捷键应尽量绑定页面命令,不要在每个按钮里重复写权限和保存逻辑。
interface RecordDraft { title: string} const session = new FormSession<RecordDraft>({ title: '' })const persist = async ( draft: Readonly<RecordDraft>, _signal: AbortSignal,): Promise<RecordDraft> => ({ ...draft }) const saveCommand = { id: 'form.save', title: '保存', canExecute: () => !session.isSubmitting, execute: async () => { const result = await session.runSubmit(persist) if (result.status === 'submitted') { session.acceptChanges(result.value) } },}
runSubmit() 会先执行 submit 校验,并阻止同一会话重复提交。它不会替业务决定何时建立新基线;保存成功后由页面显式调用 acceptChanges()。命令状态变化后调用 commandManager.invalidate()。详见 命令与权限。
响应式规则
- 桌面宽度下可以使用 2 到 4 列。
- 窄宽度下使用
adaptive: true降低列数。 - 字段阅读顺序必须稳定,不能因为响应式导致业务语义混乱。
- 超长标签优先省略号加 tooltip,超长值根据业务决定省略或换行。
使用建议
- 简单字段用
RenderFormPanel。 - 大量动态字段可以先分组,再在组内使用 FormPanel。
- 表格内编辑不要嵌完整表单,应使用 GridView 的单元格编辑器。
FormSession和FormBindingBag应和当前页面、弹窗或文档页同生共死,不要放进应用级单例。- 表单页关闭时先 dispose binding bag,再 dispose 会话,清理字段接线、未完成的异步校验、提交和页面级上下文。
典型组合
查询表单
查询表单通常短而密集,适合放在页面顶部:
PageHeader
RenderFormPanel 查询条件
CommandToolbar 查询 / 重置 / 导出
GridView
查询条件只作为页面 draft。点击查询后再请求数据,不建议每个字段变化都立即刷新大表格。
编辑表单
编辑表单通常需要分组和滚动:
ScrollViewer
Section 基本信息
RenderFormPanel
Section 联系方式
RenderFormPanel
Section 备注
TextArea
Footer CommandToolbar
保存、取消、提交、审核等操作应放在固定操作区或页面命令栏,避免用户滚动到表单底部才看到关键动作。
弹窗表单
弹窗表单适合短字段和临时编辑:
Modal / Popover
RenderFormPanel
Confirm / Cancel
弹窗每次打开时根据当前值创建独立 FormSession。取消时 reset 或直接 dispose,确定后把 snapshot() 交给打开源页面合并;不要让弹窗直接编辑源页面对象。
常见错误
只改输入框边框表示错误
这样标签、必填标识、错误文案和输入框状态会割裂。应把错误状态放到 RenderFieldPresenter。
在表格单元格里嵌完整表单
表格编辑应使用 GridView 的编辑能力或单元格 popup 编辑器。完整表单适合页面、抽屉或弹窗。
把页面草稿放到应用级上下文
页面草稿应放在页面对象、页面 controller 或页面级 App Context 中。放到应用级上下文会导致 tab 间串数据和关闭后内存不释放。