表单开发
import { RenderFormField, RenderFormPanel, RenderNumberInput, RenderTextBox,} from 'ds-ui'
表单是业务系统中最常见的页面形态。DirectSurface UI 推荐用 RenderFormPanel 管理字段排列,用 RenderFormField 管理标签、必填、单位、校验状态和提示信息。
组件参数和动态字段 API 见 FormPanel 与 FormField。本指南重点讲页面状态、校验和页面组合。
如果你正在搭完整业务页面,先看 常用业务场景组合。本页重点说明表单本身如何组织、手动接线和校验。框架不根据 model 或字段名自动绑定值。
表单结构
RenderFormPanel
RenderFormField
RenderTextBox / RenderComboBox / RenderDatePicker
RenderFormPanel 负责字段网格,RenderFormField 负责单个字段的标签和状态,具体输入组件只负责值编辑。FormSession 位于页面或 controller 层,用来保存草稿、校验结果和提交状态,不参与布局和绘制。
组件选择
| 字段类型 | 推荐组件 | 说明 |
|---|---|---|
| 单行文本 | TextBox | 公共类名是 RenderTextBox。适合姓名、编码、关键字。 |
| 多行文本 | TextArea | 适合备注、批注、说明。 |
| 数字 | NumberInput | 适合年龄、数量、金额、比例。 |
| 日期时间 | DatePicker | 适合日期、时间、日期时间字段。 |
| 单选枚举 | ComboBox | 公共类名是 RenderComboBox。值必须来自候选项。 |
| 多选枚举 | MultiSelectDropdown | 适合标签、诊断集合、权限集合。 |
| 树选择 | DropTreeEdit | 适合科室、分类、层级字典。 |
| 表格选择 | LookupEdit | 适合需要多列候选信息的选择。 |
最小示例
const draft = { name: '', age: 0,} const form = new RenderFormPanel({ columns: 'auto', maxColumns: 2, minColumnWidth: 240, labelWidth: 88, labelPlacement: 'adaptive',}) form.addField(new RenderFormField({ label: '姓名', required: true, child: new RenderTextBox({ value: draft.name, placeholder: '请输入姓名', onChange: value => { draft.name = value }, }),})) form.addField(new RenderFormField({ label: '年龄', unit: '岁', child: new RenderNumberInput({ value: draft.age, onChange: value => { draft.age = value }, }),}))
字段值管理
Render 组件只负责交互和值变化通知,不会偷偷创建业务表单模型。默认做法是由页面持有 draft,每个 editor 通过 value + onChange 显式接线:
const draft = { diagnosis: '' } const diagnosisInput = new RenderTextBox({ value: draft.diagnosis, onChange: value => { draft.diagnosis = value },})
这种写法让数据更新和联动在页面代码中可见。需要取消恢复、dirty/touched、异步校验或防重复提交时,可选用页面级 FormSession 和显式字段绑定:
import { FormBindingBag, FormSession, RenderFormField, RenderFormPanel, RenderTextBox,} from 'ds-ui' 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 RenderFormField({ 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 RenderFormField({ label: '姓名', required: true, status: 'error', message: '姓名不能为空', child: new RenderTextBox({ placeholder: '请输入姓名' }),})
这样标签、提示、输入控件可以形成一个完整的校验区域。
validationDisplay 可用值:
compact:输入状态 + badge tooltip,适合密集表单。inline:输入状态 + editor 下方文案。status-only:仅保留输入状态,不重复显示消息。none:字段展示层不投影校验视觉。
EntryGrid 使用 validationPresentation: 'row-feedback' 时会自动临时采用 status-only,使每条消息只有一个文案位置。
校验可以分两类:
| 类型 | 时机 | 展示 |
|---|---|---|
| 即时校验 | 输入、失焦、选择值变化 | 更新 RenderFormField 的 status 和 message。 |
| 提交校验 | 点击保存或提交命令 | 汇总所有字段错误,并把第一个错误滚动到可视区域。 |
FormSession 可以注册 change、blur、submit 三种触发时机的同步或异步规则,并把不同来源的错误分开保存。使用 bindField() 时,绑定会把当前字段的第一条错误投影给 RenderFormField;手动接线时仍可直接订阅 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 列。
- 新页面优先使用
columns: 'auto' + maxColumns + minColumnWidth,窄宽度下由 FormPanel 自动降列。 - label 在窄字段中需要放到 editor 上方时,使用
labelPlacement: 'adaptive';切换依据是字段实际宽度,不需要业务监听 resize。 - 需要自动降列时使用
columns: 'auto';需要稳定列数时直接传数字,不再组合额外布尔开关。 - 占据整行的备注、主诉或摘要字段使用
{ columnSpan: 'full' }。 - 字段阅读顺序必须稳定,不能因为响应式导致业务语义混乱。
- 超长标签优先省略号加 tooltip,超长值根据业务决定省略或换行。
动态字段
FormPanel 的字段集合对外只读。条件字段和权限字段通过明确方法修改:
const form = new RenderFormPanel({ columns: 'auto', maxColumns: 2 })const insuranceField = new RenderFormField({ label: '医保类型', child: new RenderTextBox({ value: '' }),}) form.insertField(1, insuranceField)form.moveField(insuranceField, 0)form.setFieldData(insuranceField, { columnSpan: 'full' })form.removeField(insuranceField)
这些方法会维护 parent/owner 并自动请求重新布局。不要直接 push children 或修改 childData。
页面自动布局骨架
长表单页使用 DockPanel 分配顶部、底部和剩余空间,中间只保留一个垂直滚动所有者:
import { RenderButton, RenderDockPanel, RenderFormPanel, RenderScrollViewer, RenderStackPanel, RenderText,} from 'ds-ui' const form = new RenderFormPanel({ columns: 'auto', maxColumns: 3, minColumnWidth: 240, labelPlacement: 'adaptive',})const sections = new RenderStackPanel({ orientation: 'vertical', spacing: 12, crossAxisAlignment: 'stretch',})sections.addChild(form) const actions = new RenderStackPanel({ orientation: 'horizontal', spacing: 8, crossAxisAlignment: 'center',})actions.addChild(new RenderButton({ label: '保存', onClick: () => {} }))actions.addChild(new RenderButton({ label: '取消', onClick: () => {} })) const page = new RenderDockPanel()page.addChild(new RenderText('资料编辑'), { dock: 'top' })page.addChild(actions, { dock: 'bottom' })page.setFill(new RenderScrollViewer({ direction: 'vertical', child: sections }))
这个结构中:
- DockPanel 自动把剩余高度交给 fill 区域。
- ScrollViewer 是页面纵向的唯一滚动所有者。
- FormPanel 自动处理列数、label 位置和字段重排。
- 页面不计算 content 剩余高度,不监听 resize,不手写字段坐标。
主从编辑页
主记录侧栏需要在窄宽度收起时,使用 RenderAdaptiveSidebarPanel:
const fullMasterList = new RenderText('完整主记录列表')const compactMasterList = new RenderText('紧凑主记录入口')const editorShell = new RenderText('编辑区') const page = new RenderAdaptiveSidebarPanel({ sidebar: fullMasterList, collapsedSidebar: compactMasterList, content: editorShell, breakpoint: 720,})
editorShell 可以继续使用 DockPanel + ScrollViewer + FormPanel。侧栏切换后 FormPanel 接收新的实际宽度并自动重排,业务不需要转发 resize。
弹窗表单
需要 WinForms 风格的真模态编辑窗口时,把同一套表单骨架放入 RenderWindow,再调用 showModalWindow()。窗口内仍只保留一个纵向滚动边界;保存和取消通过明确按钮回调处理,不由表单自动提交。
使用建议
- 简单字段用
RenderFormPanel。 - 大量动态字段可以先分组,再在组内使用 FormPanel。
- 表格内编辑不要嵌完整表单,应使用 DataGrid 的单元格编辑器。
FormSession和FormBindingBag应和当前页面、弹窗或文档页同生共死,不要放进应用级单例。- 表单页关闭时先 dispose binding bag,再 dispose 会话,清理字段接线、未完成的异步校验、提交和页面级上下文。
典型组合
查询表单
查询表单通常短而密集,适合放在页面顶部:
PageHeader
RenderFormPanel 查询条件
CommandToolbar 查询 / 重置 / 导出
DataGrid
查询条件只作为页面 draft。点击查询后再请求数据,不建议每个字段变化都立即刷新大表格。
编辑表单
编辑表单通常需要分组和滚动:
ScrollViewer
Section 基本信息
RenderFormPanel
Section 联系方式
RenderFormPanel
Section 备注
TextArea
Footer CommandToolbar
保存、取消、提交、审核等操作应放在固定操作区或页面命令栏,避免用户滚动到表单底部才看到关键动作。
弹窗表单
弹窗表单适合短字段和临时编辑:
Modal / Popover
RenderFormPanel
Confirm / Cancel
弹窗每次打开时根据当前值创建独立 FormSession。取消时 reset 或直接 dispose,确定后把 snapshot() 交给打开源页面合并;不要让弹窗直接编辑源页面对象。
常见错误
只改输入框边框表示错误
这样标签、必填标识、错误文案和输入框状态会割裂。应把错误状态放到 RenderFormField。
在表格单元格里嵌完整表单
表格编辑应使用 DataGrid 的编辑能力或单元格 popup 编辑器。完整表单适合页面、抽屉或弹窗。
把页面草稿放到应用级上下文
页面草稿应放在页面对象、页面 controller 或页面级 App Context 中。放到应用级上下文会导致 tab 间串数据和关闭后内存不释放。