DirectSurface UIDirectSurface UI
开始使用
文档/业务指南

表单开发

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    },  }),}))
LIVE CANVAS可运行组件示例
ON-DEMAND RUNTIME交互式示例将在进入视区时启动避免文档首屏同时初始化多个 Canvas Runtime

字段值管理

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 间串数据和关闭后内存不释放。

相关文档