ModelForm
基于 react-hook-form 和 Zod 的元数据驱动创建 / 编辑表单容器。
相关文档
导入
import { ModelForm } from "@/components/views/form/ModelForm";快速开始
推荐在 src/app/**/[id]/page.tsx 中这样使用:
import { UserAccountUnlockActionDialog } from "@/app/user/user-account/components/user-account-unlock-action-dialog";
import { Action } from "@/components/actions/Action";
import { FormSection } from "@/components/views/form/components/FormSection";
import { Field } from "@/components/fields";
import { FormBody } from "@/components/views/form/components/FormBody";
import { FormHeader } from "@/components/views/form/components/FormHeader";
import { FormToolbar } from "@/components/views/form/components/FormToolbar";
import { ModelForm } from "@/components/views/form/ModelForm";
export default function EditUserAccountPage() {
return (
<ModelForm modelName="UserAccount">
<FormHeader />
<FormToolbar>
<Action
label="Lock Account"
operation="lockAccount"
placement="more"
confirmMessage="Lock this user account?"
successMessage="User account locked."
/>
<Action
type="dialog"
label="Unlock Account"
operation="unlockAccount"
placement="more"
successMessage="User account unlocked."
component={UserAccountUnlockActionDialog}
/>
</FormToolbar>
<FormBody>
<FormSection label="General" hideHeader>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</FormSection>
</FormBody>
</ModelForm>
);
}ModelForm 现在提供 runtime/provider 与页面壳层间距,并自动解析路由 id:
params.id === "new"=> 创建模式(id = null)params.id存在且不为"new"=> 编辑模式- 若路由没有
id参数 => 默认创建模式
校验行为:
- 默认为
onBlur reValidateMode为onChange
对话框模式(Action type=“form”)
通过 <Action type="form" /> 打开时,ModelForm 可在对话框内运行。此模式下会自动适配:
- ID 解析:忽略路由
params.id(仅使用idprop;默认创建模式) - 创建 / 更新成功:关闭对话框,而非
router.push - 取消:关闭对话框,而非返回上一页
- relatedField 注入:父记录
id会合并进defaultValues为{ [relatedField]: parentId },并包含在 API 载荷中 —— 即使该字段未在表单中展示
ModelForm 本身无需特殊 props —— 对话框模式通过 ActionFormRuntimeContext 自动检测。
示例:
// 父表单页面
<FormToolbar>
<Action
type="form"
label="Add Config Group"
placement="toolbar"
component={ConfigGroupForm}
relatedField="tenantConfigId"
/>
</FormToolbar>
// 子表单组件(作为 Action.component)
function ConfigGroupForm() {
return (
<ModelForm modelName="TenantConfigGroup">
<FormToolbar />
<FormBody enableAuditLog={false}>
<FormSection label="General" hideHeader>
<Field fieldName="groupName" />
<Field fieldName="description" />
{/* tenantConfigId 未展示,但会自动注入 API 载荷 */}
</FormSection>
</FormBody>
</ModelForm>
);
}需要自定义变体?在子组件中使用 useModelFormContext(),并直接重排 FormHeader/FormToolbar/FormBody。
字段的标准用法现位于 Fields。 Widget 兼容性与 widget 专属示例见 Widget 矩阵。 关联字段行为见 关联字段。 这些文档涵盖:
Fieldprops 与元数据覆盖FieldType -> WidgetType兼容性- widget 专属
widgetProps - 关联字段行为(
Reference、OneToMany、ManyToMany)
下方快速示例仅作本地捷径;fields README 为准。
默认推荐使用 Field(按 fieldType 元数据自动分发),配合元数据覆盖与基于条件的控制。
Field 上的元数据覆盖示例:
<Field
fieldName="name"
label="Custom Label"
readonly
required={false}
hideLabel={true}
fullWidth={false}
widgetType="URL"
filters={[["active", "=", true]]}
defaultValue="https://example.com"
/>Field.defaultValue 是创建时的字段覆盖。静态、页面级默认值优先用它;动态预填(如路由参数或父上下文值)仍放在对话框 / 页面级 defaultValues。
若传递容器级 defaultValues,请直接使用字段 UI 值:
File:FileInfo | nullMultiFile:FileInfo[]JSON/DTO:结构化 object/array 值Filters:FilterConditionOrders:结构化 order 元组 / 数组
详细字段值契约见 Field。
条件字段控制示例:
import { dependsOn, Field } from "@/components/fields";
<Field fieldName="status" readonly={true} />
<Field fieldName="itemTone" hidden={["active", "=", false]} />
<Field
fieldName="description"
readonly={[
["status", "IN", ["approved", "archived"]],
"OR",
[["type", "=", "SYSTEM"], "AND", ["editable", "!=", true]],
]}
/>
<Field
fieldName="label"
required={dependsOn(["active", "itemCode"], ({ values, isEditing }) =>
!isEditing && values.active === true && values.itemCode !== "Temp"
)}
/>远程字段联动示例:
<Field fieldName="itemCode" onChange={["label", "itemTone"]} />
<Field
fieldName="itemCode"
onChange={{ update: ["label"], with: ["active"] }}
/>关联筛选联动示例:
<Field fieldName="companyId" />
<Field
fieldName="departmentId"
filters={[
["companyId", "=", "{{ companyId }}"],
"AND",
["active", "=", true],
"AND",
["effectiveDate", "<=", "TODAY"],
]}
/>ModelForm 中的关联筛选说明:
{{ companyId }}在关联查询发出前,从当前表单值解析(统一模板语法{{ expr }})TODAY、NOW、USER_ID、USER_COMP_ID等后端环境 token 原样透传;字面量按需使用{{ 'value' }}或{{ NOW }}等后端 tokenField.filters覆盖metaField.filters;若省略,元数据筛选仍生效- 未解析的
{{ expr }}依赖会暂停关联查询,而非加载未筛选数据
使用 widgetType 驱动渲染行为的示例:
<Field
fieldName="startTime"
widgetType="HH:mm"
/>
<Field
fieldName="photo"
widgetType="Image"
/>
<Field
fieldName="gallery"
widgetType="MultiImage"
widgetProps={{ maxFiles: 6, columns: 3, aspectRatio: "4 / 3", helperText: "Recommended 1200x900" }}
/>
<Field
fieldName="score"
widgetType="Slider"
widgetProps={{ minValue: 0, maxValue: 100, step: 5 }}
/>
<Field
fieldName="content"
widgetType="RichText"
/>
<Field
fieldName="notes"
widgetType="Markdown"
widgetProps={{ mode: "split", minHeight: 360 }}
/>
<Field
fieldName="script"
widgetType="Code"
widgetProps={{ language: "python", minHeight: 320, lineNumbers: true }}
/>
<Field
fieldName="startTime"
placeholder="Select start time"
/>File / MultiFile 在编辑模式下会自动使用当前 ModelForm 记录 id。
Widget Props
字段级输入占位符使用 placeholder。
仅 widget 专属配置使用 widgetProps。
作用域说明:
widgetProps适用于ModelFormwidget 与表格行内编辑器,因为这些路径直接渲染FieldModelTable/RelationTable只读单元格刻意不消费widgetProps;表格 image/file 单元格使用 ModelTable 中描述的共享紧凑渲染器
当前支持的示例:
<Field
fieldName="progress"
widgetType="Slider"
widgetProps={{ minValue: 0, maxValue: 10, step: 0.5 }}
/>
<Field
fieldName="avatar"
widgetType="Image"
widgetProps={{
aspectRatio: "1 / 1",
objectFit: "cover",
helperText: "Square image recommended",
crop: { enabled: true, aspect: 1, shape: "round" },
}}
/>
<Field
fieldName="photos"
widgetType="MultiImage"
widgetProps={{
maxFiles: 8,
columns: 4,
aspectRatio: "16 / 9",
uploadText: "Upload gallery",
crop: { enabled: true, aspect: 16 / 9 },
}}
/>
<Field
fieldName="status"
widgetType="Radio"
required
/>
<Field
fieldName="script"
widgetType="Code"
widgetProps={{
language: "sql",
minHeight: 320,
maxHeight: 560,
lineNumbers: true,
lineWrapping: false,
tabSize: 2,
}}
/>
<Field
fieldName="config"
widgetProps={{
minHeight: 320,
maxHeight: 560,
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
formatOnBlur: true,
}}
/>JsonField 现在默认使用 react-codemirror。常见 JSON 编辑器 widgetProps:
height:固定编辑器高度minHeight:最小编辑器高度maxHeight:最大编辑器高度lineNumbers:显示或隐藏行号槽lineWrapping:长行换行tabSize:缩进大小formatOnBlur:失焦后格式化合法 JSONautoFocus:挂载时聚焦编辑器
CodeWidget 支持以下常见 widgetProps:
language:plain、java、html、json、markdown、python、sql、yaml、ymlheight:固定编辑器高度minHeight:最小编辑器高度maxHeight:最大编辑器高度lineNumbers:显示或隐藏行号槽lineWrapping:长行换行tabSize:缩进大小autoFocus:挂载时聚焦编辑器
MarkdownWidget 支持以下常见 widgetProps:
mode:split、edit、preview(默认:split)height:固定编辑器 / 预览高度minHeight:最小编辑器 / 预览高度maxHeight:最大编辑器 / 预览高度lineNumbers:显示或隐藏编辑器行号lineWrapping:编辑器模式下长行换行tabSize:缩进大小autoFocus:挂载时聚焦编辑器
MarkdownWidget 使用 react-markdown 预览,并默认启用 remark-gfm。
mode 行为:
split:桌面端并排显示编辑器与预览;较小屏幕垂直堆叠edit:仅显示编辑器preview:仅显示预览
字段全宽
以下字段渲染器支持 Field 的 fullWidth:
StringField + PlainTextWidget(fieldType="String"+widgetType="PlainText")StringField + RichTextWidget(fieldType="String"+widgetType="RichText")StringField + MarkdownWidget(fieldType="String"+widgetType="Markdown")StringField + CodeWidget(fieldType="String"+widgetType="Code")OneToManyFieldManyToManyField
以上字段默认 fullWidth={true}。
设置 fullWidth={false} 以普通网格宽度渲染。
<Field fieldName="description" widgetType="PlainText" />
<Field fieldName="notes" widgetType="RichText" fullWidth={false} />
<Field fieldName="optionItems" fullWidth={false} />
<Field fieldName="userIds" fullWidth={false} />字段标签可见性
Field 支持 hideLabel,控制是否渲染整个字段标签块(FormLabelWithTooltip)。
- 默认:
hideLabel={false}(显示标签) - 设置
hideLabel={true}隐藏整个标签块(标签文字 + tooltip 图标)
<Field fieldName="description" hideLabel={true} />ReadOnly 与 Disabled
readOnly 与 disabled 语义不同:
readOnly:用户可清晰查看值,字段仍是正常详情阅读体验的一部分。详情页、审计式查看、需便于扫描 / 复制的字段优先用它。disabled:控件暂时或结构上不可用。权限限制、前置条件未满足、异步提交 / 加载、工作流 / 状态锁定或功能门控时优先用它。
HR SaaS 表单中,详情页通常应优先 readOnly 而非 disabled。
对 widgetType="Code" 与 widgetType="Markdown",只读且值为空的字段会显示共享 CodeEditorEmptyState 提示,而非空 CodeMirror(见 src/components/fields/widgets/README.md)。
XToMany 字段(默认增量提交)
ReferenceField 现在仅处理:
ManyToOneOneToOne
OneToMany 与 ManyToMany 由专用字段组件内部处理,仍通过以下方式使用:
<Field fieldName="..." />OneToMany
- UI:表单体内的本地关联表
- 支持:新增、编辑、删除
- 无
formView:行编辑使用表格单元格行内编辑(点击行进入编辑) - 有
formView:行编辑 / 创建使用运行时ModelDialog - 提交默认:patch map(增量)
行内编辑行为(OneToMany,无 formView):
- 仅点击行后进入编辑模式(进入页面时不自动选中)
- 编辑值直接写入主表单关联数组,随父级
Save/Create保存 - 可编辑单元格限于
<RelationTable><Field /></RelationTable>声明的列与可编辑关联模型字段的交集 - 行内编辑仅在本地表模式可用(
!isPaged或远程条件未满足) - 行级
required/readonly条件针对当前关联行评估,scope="relation-table" - 行级
Field.onChange远程联动也在scope="relation-table"运行,且仅 patch 当前关联行 RelationTable.pageSize仅影响分页关联表(isPaged)
启用模式:
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="label" />
<Field fieldName="active" />
</RelationTable>
);
}
function MultiSortTableView() {
return (
<RelationTable
orders={[
["sequence", "ASC"],
["itemCode", "DESC"],
]}
pageSize={20}
>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="label" />
</RelationTable>
);
}
// 启用表格单元格行内编辑(本地关联编辑推荐)
<Field fieldName="optionItems" tableView={OptionItemsTableView} />
// 禁用行内编辑,使用对话框编辑
<Field
fieldName="optionItems"
tableView={OptionItemsTableView}
formView={OptionItemsFormView}
/>
// 分页关联表(启用分页;可能切换到远程 searchPage 模式)
<Field fieldName="optionItems" tableView={OptionItemsTableView} isPaged />提交载荷形状:
{
"Create": [{ "name": "new row" }],
"Update": [{ "id": "101", "name": "changed" }],
"Delete": ["102", "103"]
}创建模式约束:
- 仅允许
Create
更新模式:
- 允许
Create/Update/Delete
OneToMany 视图绑定示例:
import { Field, RelationTable } from "@/components/fields";
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="label" readonly={[["active", "=", false]]} />
<Field fieldName="active" />
</RelationTable>
);
}
function OptionItemsFormView() {
return (
<ModelDialog title="Option Item">
<FormBody enableAuditLog={false}>
<FormSection label="General" hideHeader>
<Field fieldName="itemCode" />
<Field fieldName="label" />
<Field fieldName="sequence" />
<Field fieldName="active" />
<Field fieldName="description" />
</FormSection>
</FormBody>
</ModelDialog>
);
}
export default function SysOptionSetFormPage() {
return (
<ModelForm modelName="SysOptionSet">
<FormHeader />
<FormToolbar />
<FormBody>
<FormSection>
<Field fieldName="optionSetCode" />
<Field fieldName="name" />
<Field fieldName="description" />
<Field fieldName="active" />
</FormSection>
<FormSection>
<Field
fieldName="optionItems"
tableView={OptionItemsTableView}
formView={OptionItemsFormView}
/>
</FormSection>
</FormBody>
</ModelForm>
);
}ManyToMany
- UI:表单体内的本地关联表
- 支持:新增、删除
- 新增打开关联模型选择表对话框(搜索 / 排序 / 列 / 分页)
- 可选
formView可挂载自定义只读ModelDialog查看行详情 - 提交默认:patch map(增量)
提交载荷形状:
{
"Add": ["1", "2", "3"],
"Remove": ["4", "5"]
}创建模式约束:
- 仅允许
Add
更新模式:
- 允许
Add/Remove
ManyToMany 视图绑定示例:
import { Field, RelationTable } from "@/components/fields";
function RoleUsersTableView() {
return (
<RelationTable orders={["username", "ASC"]} pageSize={10}>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</RelationTable>
);
}
function RoleUsersFormView() {
return (
<ModelDialog title="User Detail">
<FormSection label="General" hideHeader>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</FormSection>
</ModelDialog>
);
}
export default function RoleFormPage() {
return (
<ModelForm modelName="Role">
<FormHeader />
<FormToolbar />
<FormBody>
<FormSection label="General" hideHeader>
<Field fieldName="name" />
<Field fieldName="code" />
<Field fieldName="description" />
<Field fieldName="active" />
</FormSection>
<FormSection>
<Field
fieldName="users"
tableView={RoleUsersTableView}
formView={RoleUsersFormView}
/>
</FormSection>
</FormBody>
</ModelForm>
);
}说明:
tableView通过零 prop 视图组件控制关联表列,该组件返回带子<Field />声明的<RelationTable />,以及可选的RelationTable.orders/RelationTable.pageSize。RelationTable.orders支持单个元组(["username", "ASC"])或多个元组([["username", "ASC"], ["email", "DESC"]])。- 远程关联表与选择器查询使用生效字段筛选(
Field.filters ?? metaField.filters)、关联作用域筛选,以及运行时搜索 / 列筛选。 isPaged(仅 OneToMany/ManyToMany 字段):false(默认):在getById中包含关联subQuery;关联表 UI 不分页,渲染全部本地行。true:关联表启用分页 UI;当recordId + relatedModel + 作用域关联筛选就绪时,由relatedModel.searchPage加载(远程模式),否则本地分页。
- 关联表 pageSize 默认
50;仅启用分页时(isPaged=true)显示 page-size 选择器。 - ManyToMany 选择器对话框(
Add)由服务端驱动;搜索 / 排序 / 翻页变更会触发searchPage请求。 formView可选。在ManyToMany中,点击行以只读模式打开ModelDialog;新增 / 移除仍走选择器行为。- 未解析的
{{ expr }}依赖会暂停远程关联查询与选择器查询,直到依赖的父表单值存在
OneToOne(拥有的内联)
对于拥有的 OneToOne 关系(如 UserProfile → UserAccount),向 OneToOne 字段传递 formView 会在父表单内联渲染其关联模型字段,而非显示引用选择器。
- UI:父表单体内的内联
FormSection(可多个) - 支持:编辑所有声明的子字段
- 子字段在父 RHF 实例中注册为
{fieldName}.{subField}(如userId.username) getById自动从formViewJSX 静态推导并添加subQueries: { userId: { fields: [...] } }—— 无需额外配置- 提交默认:增量(更新发送
{ id, ...onlyChangedSubFields };创建发送无id的完整子对象) formView内的字段条件(dependsOn、showWhen)针对子对象作用域解析,监听父表单值时会正确加前缀ManyToOne字段不支持formView;误用会在 dev 模式显示console.error
用法:
function UserAccountOneToOneView() {
return (
<FormSection label="Account">
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
<Field fieldName="policyId" />
</FormSection>
);
}
export default function UserProfileFormPage() {
return (
<ModelForm modelName="UserProfile">
<FormHeader />
<FormToolbar />
<FormBody>
<FormSection label="General" hideHeader>
<Field fieldName="fullName" />
<Field fieldName="birthDate" />
<Field fieldName="gender" />
</FormSection>
{/* OneToOne 内联:在此表单内渲染 UserAccount 字段 */}
<Field fieldName="userId" formView={UserAccountOneToOneView} />
</FormBody>
</ModelForm>
);
}提交载荷形状(更新,仅 nickname 变更):
{
"id": "...",
"userId": {
"id": "...",
"nickname": "Alice"
}
}提交载荷形状(创建):
{
"userId": {
"username": "alice",
"nickname": "Alice",
"email": "[email protected]",
"mobile": null,
"status": "ACTIVE",
"policyId": "1"
}
}未提供 formView 时,OneToOne 与 ManyToOne 行为相同,渲染引用选择器 widget。
兼容性
后端仍支持 XToMany 字段的全量提交。
前端 ModelForm 默认增量提交(PatchType map),以避免分页关联编辑中的全列表覆盖风险。
校验顺序
ModelForm 交给 react-hook-form 的 resolver 由三层组成,从内到外依次为:
- 字段级 schema ——
buildZodObjectFromMetaModel:类型结构、列自身的required、length/scale,以及来自constraints的值域(min/max/pattern);.pick(displayedFieldNames)只保留屏幕上展示的字段;最后由页面的schemaBuilder包装。 withFieldConstraintsResolver—— 作用于整行的元数据条件:requiredWhen/invalidWhen,以及按关联模型的约束(relatedMetaModelsByPath)校验 OneToMany 字段中的各行。已被 schema 拒绝的字段保留 schema 的错误信息。withFieldConditionResolver—— 剔除被页面级hidden条件移除的字段上的错误。
浏览器自带的校验已关闭(<form> 以及 DialogForm / WizardDialog 上设置了 noValidate):数字字段会把 min / max 渲染到 input 上供步进按钮使用,若不关闭,原生校验会在 resolver 运行前用浏览器气泡拦下提交,拒绝提示 toast 和字段聚焦就都不会发生。
关联对话框(ModelDialog → DialogForm)对所编辑的行运行同样的三层,只校验对话框中展示的字段。
第 2 层从编译后的表单计划(form plan)获取 relatedMetaModelsByPath,表单计划通过静态遍历 children 获知表单的嵌套模型。页面若通过自己的组件渲染关联,这次遍历就看不进去,映射返回为空,其中的行也就不受任何校验——这样的页面要通过同名 prop 自行传入该映射。
requiredWhen 即使是无条件的 true 形式也归第 2 层,因为后端对它的规则是”可以省略,不能清空”:更新时只有写入点名了该字段才会触发,因此规则出现之前保存的行仍然可以编辑。只有第 2 层能区分这两种情况——它拿得到表单的初始值(initialValues)。第 1 层只负责列自身的 required,不涉及任何条件。
resolver 的上下文(@mode、USER_ID、声明的字段类型)在 resolver 运行时读取,而非构建时:react-hook-form 在首次渲染时就捕获了 resolver,而当前登录用户要到之后的 effect 中才填入。
服务端按字段拒绝的写入(ApiError.fieldErrors,来自字段约束或 ModelWriteValidator)通过 form.setError(field, { type: "server" }) 标到对应字段上——仅限本表单渲染的字段名,避免只存在于服务端的列留下一个看不见却阻止提交的错误——同时保留 API 客户端已弹出的 toast。
页面结构
推荐默认布局:
- Header:标题 + 描述
- 粘性工具栏:
- 左侧:内置
FormEditStatus + FormPrimaryActions(ModelForm/ModelSideForm enableWorkflow=true时还有FormWorkflowActions) - 右侧:业务动作区(自定义动作 + 内置 Duplicate/Delete + More Actions)
- 左侧:内置
- Body:
FormBody渲染堆叠 section 或真正的 tabs,以及内置审计面板布局 - Audit:
FormBody(enableAuditLog)控制审计面板;大屏右侧、小屏底部
记录级访问
编辑模式下,表单向服务端询问调用方能对该记录做什么(POST /{model}/getRecordAccess);新建模式下,询问新记录可以展示哪些敏感字段集(GET /{model}/getCreateAccess)。答复经 RecordAccessContext 提供,有三种用途:Edit / Delete 跟随该记录上可用的动作(覆盖用户经由其他角色持有的权限);sensitiveFieldSet 在该记录上被隐藏的 FormSection 不渲染;可见、但仅由不能编辑该记录的角色授予的 section,其字段以只读渲染(ReadonlyFieldScope)。答复也按字段给出(hiddenFields / readonlyFields,以字段自身所属的模型为键):记录隐藏的任何 Field 显示其标签和一把锁,而不是输入框或值(NoAccessLock,与列表单元格使用的标记相同);记录保持只读的字段以只读渲染——无论所在 section 是否声明了字段集,父表单上展示的一对一子记录字段也与表单自身字段同样处理。表单只提交变更过的字段,因此被隐藏字段的空值永远不会被写回。答复加载期间,Edit 与 Delete 保持隐藏,由 sensitiveFieldSet 门控的 section 也保持隐藏(表单本身不锁定),因此记录拒绝的内容不会在屏幕上闪现;请求结束却没有答复(无记录或请求失败)时,照旧采用用户级门控。每次重新加载记录时表单都会重新询问(一次保存可能改变它允许的操作);新答复加载期间,同一记录的上一个答复继续生效,按钮与 section 不会卸载再挂载——只有打开另一条记录时才会再次等待。query key 携带的是重载计数器,从不包含记录的值。同样会在 ModelForm 之外渲染的组件,可以用 useHasRecordAccessProvider() 区分两种 null:在表单内,它读取表单的答复(失败时回退到用户级门控),而不是自己再请求一次。
Props
ModelForm Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
modelName | string | Yes | - | 用于从 API 请求元数据的模型名(/metadata/getMetaModel)。 |
id | string | null | No | Route params.id ("new" => null) | 可选覆盖。 |
schemaBuilder | (context) => ZodTypeAny | No | - | 运行时 schema 扩展器。接收从已解析元数据构建的 { metaModel, baseSchema }。 |
readOnly | boolean | No | false | 强制只读模式。 |
defaultValues | Record<string, unknown> | No | - | 合并进元数据默认值的额外默认值。适用于注入父上下文,如 relatedField 值。 |
copyFromId | string | null | No | - | 仅新建模式:用此源记录的可复制字段值(getCopyableFields)预填表单(复制流程)。全页表单通常用 ?copyFrom=<id> 查询参数;嵌入式表单传此 prop。显式上下文(defaultValues、查询参数、关联字段)优先于复制值。 |
enableWorkflow | boolean | No | false | 在工具栏左侧显示工作流动作组(仅编辑模式)。 |
enableCreate | boolean | No | auto | 内置 Create New 动作开关。三态,见下文「enable* 的三态解析」:false 隐藏,true 即使在只读表单上也显示,省略时仅在表单非只读时显示。 |
enableDuplicate | boolean | No | auto | 内置复制动作开关。三态解析与 enableCreate 相同。另需 MetaModel.copyable === true。 |
enableDelete | boolean | No | auto | 内置删除动作开关。三态解析与 enableCreate 相同。 |
enableUpdate | boolean | No | auto | 路由只读模式下工具栏 Edit 按钮的开关。false 隐藏该按钮;不影响表单自身的只读状态。受 update(或 updatePermissionId)门控。 |
updatePermissionId | string | No | - | 授权在本页编辑的权限 id,覆盖 MODEL_PERMISSIONS 中按模型查找的 update。用于由另一模型上的 customPermission 管理的页面(工作流草稿页)。 |
confirmDeleteMessage | string | No | Delete this {modelLabel}? This action cannot be undone. | 内置删除动作的确认文案。 |
timeline | ModelFormTimelineConfig | No | - | 时间轴模型行为开关(enableAddVersion / enableVersionPanel / versionSummaryFields,均可选)。仅当 metaModel.timeline 为 true 时生效;见下文「时间轴模型」。 |
sliceId | string | null | No | ?sliceId= 搜索参数 | 仅时间轴模型的编辑模式:加载指定版本(切片)而非 as-of 行(按 sliceId 跨时间轴 searchList)。整页表单通常用 ?sliceId=<x> 搜索参数(对话框模式忽略);嵌入式表单直接传该 prop。 |
relatedMetaModelsByPath | Record<string, MetaModel> | No | - | 表单计划触达不到的关联模型元数据,以持有它们的字段路径为键(employeeProfileId,而非 EmployeeProfile)。表单计划通过静态遍历 children 获知表单的嵌套模型,因此经由页面自有组件渲染的关联对它不可见:嵌套行不会按其自身模型的字段规则校验,其中的 widget 也拿不到元数据来显示必填星号或取值边界。处于这种情况的页面(向导步骤、定制的行内视图)通常已为提交载荷持有这些元数据,可以在这里声明。合并时优先级低于表单计划的结果:遍历确实触达的路径以计划为准。 |
beforeSubmit | (values, savedRecord, recordAccess) => boolean | Promise<boolean> | No | - | 校验通过后、写入之前调用;resolve 为 false 即取消保存(如用户拒绝了确认)。savedRecord 在编辑模式下是已加载的行,新建模式下为 null。recordAccess 是表单自身的记录级访问答复(见「记录级访问」):没有可参考的答复时为 null,加载中为 undefined——在这里读取,不要在页面里再查一次。 |
children | ReactNode | Yes | - | 表单页面布局内容(FormHeader/FormToolbar/FormBody)。 |
运行时字段条件:
Field.required、Field.readonly、Field.hidden支持boolean | FilterCondition | dependsOn(...)。- 条件针对当前表单值评估。
FilterCondition自动跟踪操作数字段与本地{{ fieldName }}引用。- 函数条件须用
dependsOn([...], evaluator)包装;不支持裸函数条件。 hidden字段不渲染,其校验错误被抑制。required={false}可在运行时放宽元数据required;readonly={false}可覆盖元数据 readonly。ModelForm与基于DialogForm的对话框表单使用相同运行时行为。
ModelForm 中的远程 Field.onChange:
- 请求路径为
POST /<modelName>/onChange/<fieldName> - 请求始终发送当前字段
value;编辑模式还发送id - 省略
with:仅id + value with: ["a", "b"]:仅发送 submit/API 形状中声明的依赖字段with: "all":发送当前表单 submit 形状- 顶层注册的 XToMany 字段序列化为关联 patch 载荷,而非原始 UI 行
- 响应
values仅 patch 返回的键;null清空字段 - 响应
readonly/required是字段名列表,列出处于该状态的全部字段;在update中声明但未出现在列表中的字段,重置为其元数据与本地条件决定的状态 - 响应
readonly/required覆盖本地生效状态,直至 reset、cancel、reload 或后续响应 - 此远程联动运行时针对
ModelForm实现;独立DialogForm不自动可用
FormHeader Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
title | string | No | metaModel.label (fallback pageTitle) | 可选覆盖。 |
description | string | No | metaModel.description | 可选覆盖。 |
extras | ReactNode | No | - | 标题附近渲染的额外 header 内容。时间轴模型在编辑模式下,header 会在 extras 之前自动渲染 FormSliceBadge:当前加载版本的生效区间(Current · 2026-01-01 → ongoing,9999-12-31 显示为 ongoing),带 Current / Past / Future 色调,回答”我正在看哪个版本”;非时间轴模型或新建模式不渲染。 |
children | ReactNode | No | - | 描述下方的展示模式内容。Field 子节点通过 FieldDisplayScope 以只读值渲染。行内布局使用 Group。 |
带展示模式子节点的 FormHeader:
<FormHeader>
<Group separator="·">
<Field name="employeeCode" />
<Field name="departmentName" />
</Group>
</FormHeader>FormBody Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
sectionNav | boolean | No | false | 启用侧边 section 导航。为 true 时,section/tab 至少有 2 个 section 才渲染导航。 |
enableAuditLog | boolean | No | true | 切换审计面板(仅编辑模式渲染)。模型自身的 MetaModel.enableAuditLog 为 false 时,无论此 prop 取何值,面板都保持隐藏。 |
enableVersionPanel | boolean | No | true | 切换时间轴模型的 Versions 面板(同时仍受 timeline.enableVersionPanel 门控)。在编辑关联表格某一行的对话框中应关闭:表格已经列出各个版本,且在父记录保存前该行只是表单本地状态。 |
children | ReactNode | Yes | - | 内容节点。根级 FormSection/Field 作为 tabs 上方共享内容渲染;根级 FormTab 激活 tabs 模式。FormTab 不能嵌套在另一个 FormTab 内。 |
FormBody 从根子节点推断布局模式。任意根级 FormTab 激活 tabs 模式;放在 FormTab 外的 FormSection 与 Field 渲染在 tab 条上方,作为所有 tab 可见的共享内容。
FormBody 默认还包含内置内容 surface 样式:rounded-(--ui-card-radius) border border-border bg-card p-(--ui-card-padding)。需要时可用 className 追加或覆盖默认样式。
FormTab Props
FormTab 是 tab 式 FormBody 布局的根内容块。可包含多个 FormSection 块或直接内容节点。
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
label | string | Yes | - | 可见 tab 标签。 |
value | string | No | auto | 可选稳定 tab id;从 label 自动推导。 |
sectionNav | boolean | No | - | 仅覆盖此 tab 的 FormBody sectionNav。定义时优先。 |
children | ReactNode | No | - | Tab 面板内容。仍推荐使用 FormSection。 |
FormSection Props
FormSection 是 FormBody 内的默认内容块。提供 section 标题 / 描述渲染、响应式字段网格、局部 section 动作与 section-nav 注册。
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
label | string | No | - | 可见 section 标签;也用作 section-nav 锚点文本。 |
description | string | No | - | 可选,渲染在 section header 下方的说明文字。 |
className | string | No | - | section 容器的额外 wrapper class。 |
columns | 1 | 2 | 3 | 4 | No | 2 | md+ 布局下 section 内容的响应式网格列数。 |
hideHeader | boolean | No | false | 隐藏视觉 section header,section 仍可参与导航。 |
divided | boolean | No | false | section 之间添加上边框。第一个 section 上 suppressed(:first-child)。 |
children | ReactNode | No | - | 通常为 Field 节点,以及可选的 section 作用域 Action 节点。 |
permission | string | No | - | section 级权限门控,以所在表单的模型为键;用户缺少 (modelName, permission) 时整个 section 被省略。未纳管 / 有歧义的组合(状态为 undefined)直接放行。 |
permissionId | string | No | - | 显式权限 id 门控;与 permission 同时设置时以此为准。用于跨模型门控(如 Employee 上的 Banking section)。 |
sensitiveFieldSet | string | string[] | No | - | 敏感字段集门控:调用方持有其中任一 id 即显示。在带有记录级答复的表单内跟随该答复(加载中隐藏;仅由不能编辑的角色授予该字段集时只读;记录隐藏了该字段集、而调用方在其他记录上持有它时,section 仍会绘制,其字段显示锁,与列表单元格一致);否则由用户级映射决定,管理员始终放行。与 permission* 是”且”的关系。 |
说明:
FormSection自动向最近的FormBodysection 注册表注册自身——被permission/permissionId/sensitiveFieldSet门控掉时除外:不渲染的 section 也不会出现在 section 导航中,门控放开后(如记录级答复到达时)才注册。- 省略
label时,导航标签回退为"Section"。 - 通用标签(
"Section")在导航中自动重命名为Section 1、Section 2等。 hideHeader仅影响渲染的 header;不禁用 section-nav 注册。divided在 section 无label(即 header 本身被隐藏)但仍需视觉分隔时最有用。有label时标题已提供分隔,通常无需divided。FormSection仅支持局部 UI 动作:type="link"与type="custom",placement="header"或placement="inline"。
Section Nav
FormSectionNav 内置于 FormBody;页面通常不直接渲染。
行为:
FormBody收集后代FormSection锚点,按注册顺序渲染导航。sectionNav默认false;设为true启用侧边导航。- 仅当前视图至少有 2 个已注册 section 时渲染导航。
- tabs 模式下,
FormTab自身的sectionNav优先于该 tab 的FormBody设置。FormTab上省略则继承FormBody的值。 - 点击导航项平滑滚动表单自身滚动容器,而非浏览器窗口。
- 导航高度不会超过表单滚动容器的可见部分:高度以此为上限(窗口尺寸变化时重新测量),列表自行滚动,滚到尽头也不会带动表单。内容滚动时,高亮的 section 始终保持在列表可见范围内。若没有这个上限,比矮窗口还高的导航会随内容一起滚走,后面的 section 要等内容滚到底才会出现。
- 堆叠模式下,侧边导航面向桌面:无右侧审计列时从
xl布局显示;审计日志在右侧时从2xl显示。
堆叠示例:
<FormBody sectionNav>
<FormSection label="General" hideHeader>
<Field fieldName="name" />
<Field fieldName="code" />
</FormSection>
<FormSection label="Security">
<Field fieldName="passwordMinLength" />
<Field fieldName="passwordComplexityEnabled" />
</FormSection>
<FormSection label="Audit">
<Field fieldName="createdBy" readOnly />
<Field fieldName="createdDate" readOnly />
</FormSection>
<FormSection label="Advanced">
<Field fieldName="description" />
</FormSection>
</FormBody>Tab 示例:
import { FormBody, FormTab } from "@/components/views/form/components/FormBody";
<FormBody>
<FormTab label="Profile" sectionNav>
<FormSection label="General">
<Field fieldName="name" />
<Field fieldName="code" />
</FormSection>
<FormSection label="Advanced">
<Field fieldName="description" />
</FormSection>
</FormTab>
<FormTab label="Members">
<Field fieldName="userIds" />
</FormTab>
</FormBody>FormToolbar Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
children | ReactNode | No | - | 自定义动作。推荐:<Action type="..." />。 |
className | string | No | - | 工具栏容器的额外 wrapper class。 |
leading | ReactNode | No | - | 填充左侧区域,如紧凑的记录摘要。此时放在工具栏的动作移到右侧区域,位于 Edit / Save 之后、Create New / More 之前。 |
空工具栏不绘制。工具栏内什么都没渲染时——没有按钮、没有新增版本的日期输入框、没有编辑状态标签——外框(边框、内边距、粘性栏)也一并隐藏,这样页面上没有任何可用动作的读者看到的是标题直接衔接内容,而不是一个空框。每个控件仍各自决定可见性(Edit 取决于 update 权限,Create New / More Actions 取决于各自的权限,Save / Cancel 取决于模式);工具栏通过 src/styles/globals.css 中的 [data-form-toolbar] 规则检查实际渲染出了什么,而不是重新计算这些条件。新放进工具栏的内容只要渲染出 button 或 input 就自动适用;纯文本项需要加上同样的 data-form-edit-status 标记才能让工具栏保持可见。以 leading 传入的内容(如记录摘要)同样会让工具栏保持可见,因此没有可用动作的读者仍能看到它。
ModelForm 中的 Actions
通用 Action / BulkAction API 现位于 Actions。
本节仅保留 ModelForm 容器规则与完整页面级示例。
容器支持:
| Container | Supported Action Types | Supported Placements |
|---|---|---|
FormToolbar | default, dialog, link, custom | toolbar, more |
FormSection | link, custom | header, inline |
规则:
FormToolbar是页面级业务动作区FormSection是局部 UI 动作区,不直接执行模型 API 动作- API 动作(
default/dialog)放在FormToolbar - 内置工作流 / 创建 / 复制 / 删除工具栏行为在
ModelForm/ModelSideFormprops 上配置 - 编辑模式有未保存变更:点击业务动作前会询问是否丢弃变更
- 创建模式:内置
Duplicate/Delete仍可见但禁用 - 内置
Duplicate仅在模型元数据copyable(MetaModel.copyable === true)时出现;不可复制模型无论enableDuplicate如何都不暴露该动作 - 内置
Duplicate从不直接插入:通过getCopyableFields加载源记录可复制值并打开预填的新建模式表单(全页表单导航到new?copyFrom=<id>;ModelSideForm 进入带预填的内联创建模式),用户可在正常创建流程保存前调整唯一 / 业务字段;哪些字段可复制(由各字段MetaField.copyable决定)由后端解析
完整示例:
import { Action } from "@/components/actions/Action";
import { FormSection } from "@/components/views/form/components/FormSection";
import { Field } from "@/components/fields";
import { ActionDialog } from "@/components/views/dialogs";
import { FormBody } from "@/components/views/form/components/FormBody";
import { FormToolbar } from "@/components/views/form/components/FormToolbar";
import { ModelForm } from "@/components/views/form/ModelForm";
import { ExternalLink, Lock, RefreshCw, ShieldCheck } from "lucide-react";
function UnlockDialog() {
return (
<ActionDialog title="Unlock Account">
<Field fieldName="reason" label="Reason" widgetType="PlainText" />
</ActionDialog>
);
}
<ModelForm modelName="UserAccount">
<FormToolbar>
<Action
label="Lock"
operation="lockAccount"
placement="toolbar"
icon={Lock}
confirmMessage="Lock this account?"
/>
<Action
type="dialog"
label="Unlock"
operation="unlockAccount"
placement="more"
icon={ShieldCheck}
component={UnlockDialog}
/>
</FormToolbar>
<FormBody>
<FormSection label="Credentials">
<Action
type="link"
label="Open Docs"
placement="header"
icon={ExternalLink}
href="https://docs.example.com/credentials"
/>
<Action
type="custom"
label="Regenerate Preview"
placement="inline"
icon={RefreshCw}
onClick={() => console.log("regenerate")}
/>
<Field fieldName="username" />
<Field fieldName="status" />
</FormSection>
</FormBody>
</ModelForm>;Context API
在 ModelForm 子组件内,使用 useModelFormContext() 访问:
pageTitle、pageDescriptionisEditing、isSubmitting、effectiveReadOnlyform(react-hook-form实例)onCancel()metaModel、id
级联字段路径
<Field fieldName="lastActivityId.status" />(点记法)读取关联记录的字段并以只读渲染。表单计划 walker 收集 body 中声明的每个级联路径,一次性调用 POST /metadata/resolveCascadedPaths 解析所有叶子 metaField,将匹配的 SubQueries 折叠进 getById,并通过 CascadedResolutionsProvider 向 <Field> 暴露解析结果。
<ModelForm modelName="AppEnv" recordId={envId}>
<Field fieldName="name" />
<Field fieldName="lastActivityId" /> {/* 普通 ManyToOne */}
<Field fieldName="lastActivityId.status" /> {/* 级联 — 只读 */}
<Field fieldName="lastActivityId.finishedTime" /> {/* 共享 base,自动合并 */}
<Field fieldName="ownerId.departmentId.name" /> {/* 深度 3 */}
</ModelForm>说明:
- 始终只读 —— 不向 RHF 注册,不出现在
formState.dirtyFields - 生效元数据(fieldType / widgetType / label / optionSetCode)来自叶子字段;
props.label/props.widgetType仍可覆盖 - 在
ModelSideForm内自动可用(其组合了ModelForm) formView回调内嵌套级联路径(深度 > 0)尚未解析 —— devconsole.warn与 ”-” 占位
完整参考与语义:Fields README 中的级联字段路径。
权限集成
ModelForm 根据生成的 MODEL_PERMISSIONS 查找表,按 modelName 自动门控 —— 业务页面无需为标准工具栏控件或任何自定义 <Action permission="…"> 子节点调用 usePermission。
仅传递 modelName 即可免费门控:
| Built-in control | Action segment checked | Effect when denied |
|---|---|---|
| Toolbar “Save” / write | update | 表单强制只读(无 Save 按钮,字段冻结) |
| Toolbar “Edit” button | update | 隐藏 —— enableUpdate 解析为 false |
| Toolbar “Create New” | create | 隐藏 —— enableCreate 解析为 false |
| Toolbar “Duplicate” | create | 隐藏 —— enableDuplicate 解析为 false |
| Toolbar “Delete” | delete | 隐藏 —— enableDelete 解析为 false |
<FormToolbar> 与 <FormSection> 内的自定义 <Action permission="…" /> 子节点在渲染前过滤 —— 与 ModelTable 相同语义。传递 manifest 动作段(如 permission="transfer")。
状态语义与 ModelTable 一致:
- Granted → 控件 / 动作保持可见。
- Denied → 控件 / 动作隐藏;拒绝
update时表单强制只读(即使 URL 为?mode=edit,用户也会进入只读 —— 服务端独立强制,此为 UX)。 - Unmanaged →
(modelName, action)不在MODEL_PERMISSIONS(跨页面歧义)。自动门控无意见,因此控件跟随表单模式:可编辑时显示,表单硬只读后隐藏。页面仍可传显式readOnly/enable*props。
业务 props 设为 false 时仍优先(页面显式隐藏 Edit,即使 SUPER_ADMIN)。SUPER_ADMIN 在内部短路权限查找。
enable* 的三态解析
enableCreate / enableDuplicate / enableDelete / enableUpdate 与 timeline.enableAddVersion 由页面 prop 加上权限状态解析得出,解析结果一路保持三态直到工具栏。这三个值不能简化为两个——各自含义不同:
| 解析结果 | 含义 | 非只读 | 硬只读(readOnly prop 强制) |
|---|---|---|---|
false | 关闭——页面主动关闭,或权限被拒绝,或模型是投影模型 | 隐藏 | 隐藏 |
undefined | 无人反对——页面未传 prop,且该 (model, action) 的权限已授予或未声明 | 显示 | 隐藏 |
true | 页面显式要求显示该控件 | 显示 | 显示 |
只有页面真正传入 true 才会得到 true。已授予的权限解析为 undefined 而不是 true——持有 create 并不意味着要推翻页面做出的只读决定。拒绝的判定顺序是先看页面是否关闭、再看权限,因此 enableCreate={true} 永远不会把控件交给授权映射拒绝的用户。
“硬只读”指 readOnly prop 强制表单只读。路由只读模式(可编辑记录上的 ?mode=read,用户可点击 Edit 继续编辑)不是硬只读,因此 undefined 在那里仍然显示——这就是常规的详情页工具栏。
因此只读详情页完全不需要 enable* props。 <ModelForm modelName="X" readOnly={true}> 会自行关闭 Create New / Duplicate / Delete / Add Version。只有在要关闭一个本来可用的控件时才传 enable*={false},只有在只读表单确实必须保留写入入口时才传 enable*={true}。
投影模型(MetaModel.projection === true,来自 @Model(projection = true)——只读模型,映射到另一模型所有的表):表单在所有模式下强制只读(包括 create),Edit / Create New / Duplicate / Delete / Add Version 以及时间线版本面板的 Correct / Delete 全部关闭,叠加在权限之上——后端会拒绝此类模型的一切写入。这是结构属性而非权限:SUPER_ADMIN 得到同样的只读表单。
示例:
<ModelForm modelName="Employee">
<FormToolbar>
<Action
type="custom"
label="Transfer"
permission="transfer" {/* 由 Employee.transfer 门控 */}
onClick={openTransferDialog}
/>
</FormToolbar>
<FormSection label="Profile">
<Field fieldName="fullName" />
...
</FormSection>
</ModelForm>Save / Cancel / Edit / Delete 完全根据 modelName="Employee" 自动显示 / 隐藏;“Transfer” 在用户缺少 Employee.transfer 时自动隐藏。
内置行为
- 创建 / 编辑模式默认与 reset 处理。
- Reset 行为有快照保护:
- 记录 / 模型身份变更 => reset
- pristine 表单 + 远程快照变更 => reset
- dirty 表单 + 后台 refetch => 不覆盖当前编辑
- 元数据解析策略:始终从
/metadata/getMetaModel获取;首次响应由 React Query 缓存并复用。 - 元数据驱动字段 props 由内部字段 runtime 解析;业务代码应停留在
Field。 - Cancel / Back 行为(按模式与位置拆分):
- 编辑 / 创建模式:
Cancel按钮在工具栏Save旁(FormPrimaryActions);dirty 时确认,reset 到最新加载快照,返回只读模式 - 只读模式:
Back按钮在页面 header 右侧(FormBackButton,ModelSideForm内隐藏);导航到 breadcrumb 推导的路由(resolveBackRoute= navigation manifest + pathname)。MultiViewlinkTo详情路由(/list/<tab>/<id>)解析为 tab crumb/list?tab=<tab>而非无路由的/list/<tab>,Back 不会 404 且回到来源 tab —— 无携带查询状态,刷新 / 深链解析一致
- 编辑 / 创建模式:
- Save/create mutation 处理与 toast。
- 被拒绝的 Save 同样会给出提示,绝不静默:
describeSubmitBlock把 resolver 的错误转换为一条错误 toast,点出第一个无效字段及其信息(Cannot save — ID Type: This field is required),并附上其余无效字段的数量,然后表单把该控件滚动到可视区域。控件由fieldControlSelector定位——[name]、[id],或每个FormItem与关联 widget 都会发布的data-field-path——因为大多数 widget(combobox、日期选择器、树选择器)不会在 DOM 上放置name,而过去对无效提交的唯一响应,就是一次什么也没找到的聚焦尝试。 - 审计查询内建
useGetChangeLogQuery(modelName, id):pageNumber=1pageSize=50order=DESCincludeCreation=truedataMask=true
- 时间轴模型取该版本的审计日志——
useGetSliceChangeLogQuery(modelName, sliceId),切片取自已加载的记录,分页参数相同(这里includeCreation写作getCreate)。进入表单的两条路径都适用:Versions 面板的?sliceId=链接,以及列表的 as-of 读取。实体级getChangeLog对时间轴模型会被拒绝,因此从不为其发起。 - 全局审计 API 开关:
configs.env.enableChangeLog(NEXT_PUBLIC_ENABLE_CHANGE_LOG,默认true)——经/runtime-env.js(src/lib/runtime-env.ts)在运行时读取,不打进 bundle;部署时与后端的system.enable-change-log(SYSTEM_ENABLECHANGELOG)取同一个值,面板跟随后端自己的开关- 禁用时
FormAuditPanel不发起 change-log API 请求,显示禁用提示
- 模型级审计开关:声明为
@Model(enableAuditLog = false)的模型(报表、批量重写的派生数据)不生成变更日志,其元数据为enableAuditLog: false。此时FormBody完全不渲染审计日志面板——不占侧栏,也不发请求——而不是显示一个永远为空的列表。只有显式的false才算关闭:早于该开关的后端不返回此字段,面板保持开启。 FormWorkflowActions+WorkflowActionGroup支持工作流状态:draft:submitpending:withdraw/approve/rejectapproved:withdraw approvalrejected:resubmit
- 表单 dirty 或 submitting 时工作流动作禁用。
- 审计事件渲染规则:
update:<=5展开,>5显示前 5 个 +Show all fields (N)create:默认折叠delete:仅操作信息
update条目列出的是该次写入触及的列,而不是该行的全部列。 变更日志上的两份载荷并不对称:dataBeforeChange是完整的原始行,dataAfterChange只包含变更过的列(后端发布方会从中剥离主键与审计列)。因此差异由dataAfterChange的键驱动,dataBeforeChange只用来查这些列原来的值。把两组键取并集——此面板过去的做法——会把每个未触及的列报告为value → —,把唯一真正变化的字段淹没其中。- 独立使用,以及多条记录合并为一个列表。 给定
modelName+recordId时,FormAuditPanel可在表单上下文之外渲染,标题与在表单内相同,都是Audit Log——展示框架审计日志的宿主,沿用产品其他地方对它的叫法。sources(AuditSource[]={ modelName, recordId?, label?, kind?, referencing?, rowLabel? })取代这一对参数,把多条记录合并成一个列表,所有记录统一按最新在前排序,每个条目标注其label(列表只有一个来源时不显示标注,否则就成了一整列同样的词)。没有recordId的来源会被跳过——未保存的行没有历史,为它发起请求会被后端拒绝。来源也可以改用referencing: { model, id, field }指定一组行——其模型中多对一字段field指向model的记录id的每一行,包括之后已删除的行——通过getRecordChangeLog的referencing参数查询,该参数匹配每条日志携带的引用;记录自身的条目会一并返回,列在referencing.label之下,没有该 label 时则略去。适用于记录自身模型没有关联字段可以触达的行。显示名称无法为其行命名的模型(只有一个普通 id 列)用rowLabel(row)命名行,该函数接收该行各条日志记录下的全部内容;指向字段以及仍以 id 形式保留的引用,会从行的详情中去掉。不适用于时间轴模型:切片的历史通过另一个端点按sliceId查询,两者无法交错合并。需要先确定查询哪些记录的宿主,在确定期间传入sourcesLoading/sourcesError:来源确定之前,空状态会是一个自信的错误答案,因此面板改为显示加载中或读取失败。 - 分布在多个模型中的记录。 在表单内,面板除列出记录自身的历史外,还列出其关联所持有的行的历史——默认开启(
relatedFields="auto")。变更日志按模型、按行保存,因此对附属记录的编辑——如员工在个人档案上的 Preferred Name、某个家庭成员行——记在该附属记录名下,否则就不会出现在该记录的审计列表中。"auto"取表单渲染的每个一对一与一对多字段(来自表单的读取计划):页面就地编辑的关联属于该记录,而页面不展示的关联——如员工所管理的部门——是另一条记录的历史,仅凭元数据无法区分。多对一字段从不计入。excludeRelatedFields把指定关联排除在"auto"之外;显式数组则手动指定关联,relatedFields={[]}只保留记录自身的历史。给定sources时忽略。全部历史只发一次请求GET /ChangeLog/getRecordChangeLog,由后端收集各行并作为一个列表分页:一对一按其当前指向的行,一对多按每条日志携带的引用——因此已删除子行的历史也会列出——对日志开始携带引用之前写入的日志,则按当前各行的 id 查找。只有在那之前就被删除的子行,其历史无法取回。读者的敏感字段集所隐藏的字段由后端移除,只触及这些字段的更新既不列出也不计数。 - 条目如何呈现。 共享同一 trace id 的日志条目属于同一次保存,显示为一个条目,其中每一行标注来源记录(条目背后只有一条记录时,标注移到条目上)。新增或移除的子行(
AuditSource.kind: "child")显示为一行,以其模型的显示名称指代该行,而不是列出它的每一列。与记录在同一次保存中创建的附属记录并入记录的初始快照;之后才创建的,其值显示为从空值设置。计算字段会被略去——它们变化只是因为其来源字段变了——没有剩余行的更新不显示。
时间轴模型
仅当 metaModel.timeline 为 true 时生效;非时间轴模型忽略本节全部内容。
- 切片徽章:编辑模式下,
FormHeader自动渲染当前加载版本的生效区间,带 Current / Past / Future 色调(Current · 2026-01-01 → ongoing;9999-12-31渲染为ongoing)。新建模式不渲染。 - 版本列表数据:
useVersionListQuery(modelName, id)拉取一个实体的全部切片(acrossTimeline: true,生效区间新者在前)。表单侧全部调用方都经buildVersionListFields把查询窄化为结构键 + 摘要字段,因此面板、撞日检查与删除确认计数共享同一个缓存条目。其 query key 遵循[modelName, ...]约定,因此 addVersion / update / deleteBySliceId 变更后invalidateModelQueries会自动刷新它。 timelineprop(ModelFormTimelineConfig):enableAddVersion/enableVersionPanel/versionSummaryFields——内置 Add Version 动作与 Versions 面板的开关。- Add Version 动作:已持久化时间轴记录上的内置工具栏动作;新建模式下渲染为禁用并附提示(与 Duplicate / Delete 同款约定)。门控:
metaModel.timeline且具备 create 权限(服务端addVersion走 create 管线,因此没有 update 权限的用户也可以新增版本)且timeline.enableAddVersion !== false。进入模式前先丢弃未保存修改(有脏值时弹确认框),让表单从干净基线开始。 - 新增版本模式:工具栏显示必填的 “Effective from” 日期(默认今天)与实时提示——常态为 “The new version is created exactly from the values shown on this form”,与既有版本同一生效日时切换为 “A version already starts on this date — submitting corrects that version instead”(日期数据来自
useVersionListQuery,仅模式激活时加载)。即使从路由只读模式进入,字段也会解锁。模式激活期间隐藏记录级动作(Create New / Duplicate / Delete);提交按钮显示Add Version,未设置生效日期前保持禁用。 - 新增版本提交:提交整行——
id+effectiveStartDate+ 全部展示字段——走addVersionAndFetch。所见即所得:新版本就是用户眼前看到的内容,以当前加载版本为底稿(零修改提交 = 复制该版本)。API 本身也接受部分行(缺失字段由服务端从相邻切片复制前滚)——这保留为集成调用方的能力;表单刻意不用它,确保”屏幕所见”与”实际落库”永不分叉。该模式下排除 XToMany 关系补丁——它们键在源切片的行上。成功后表单回到 as-of 今天的只读视图;直接打开新版本由 Versions 面板承接。 - 新增版本模式中的 Cancel 丢弃草稿并退出模式,不发生导航。
- Versions 面板:时间轴模型的已持久化记录上,
FormBody在审计日志上方渲染Versions侧板(同一右栏 / 底部堆叠)。每行显示生效区间、Current / Past / Future 徽标与摘要(timeline.versionSummaryFields,缺省用模型displayName);当前加载版本高亮。行动作:Open/Correct经?sliceId=搜索参数导航(只读 / 编辑模式;侧滑表单壳内隐藏——无切片路由),Delete在破坏性确认后删除该版本。Correct与Delete遵循工具栏权限约定(update / delete 状态;无权限时隐藏,未纳管模型显示)。删除唯一版本即删除实体本身(时间轴实体就是它的全部切片),确认文案升级为记录级措辞并改走实体删除——onDelete外键删除策略随之生效(侧滑表单壳内唯一版本的删除保持禁用:无列表导航目标)。展开行加载该版本的切片级变更日志(getSliceChangeLog)。行按最新在前排序;不连续的版本之间渲染一条虚线分隔No coverage: <start> → <end>——空档在后端是合法状态(落在空档内的 as-of 读取返回空),分隔条让它显式可见而非默默隐含。用timeline.enableVersionPanel: false关闭面板。 - 终结 / 重开(Terminate / Reopen):尾部(最新)版本行带
Terminate…动作——对话框询问结束日期(默认今天,不得早于尾版本的起始日期;要更早终结,先删除尾部版本,结束日期会按后端愈合规则向前转移)。确认后调用setEndDate(id, endDate)。已终结的尾版本显示破坏性色调的Terminated徽标,动作翻转为Reopen——确认后调用setEndDate(id, "9999-12-31"),记录恢复开放式有效期。两者都由 update 权限状态门控(与Correct同一约定),且在嵌入式宿主中保持可用(仅变更数据、不导航)。注意对话框的过去日期警告:用早于今天的日期终结会让记录立即从所有 as-of 列表与详情页消失——只能通过 Versions 面板的Open链接(?sliceId=)访问,重开也在那里完成。 - 加载指定版本:携带
sliceId(prop 或搜索参数)时,表单跨时间轴加载该切片而非 as-of 行——徽章显示其真实区间,编辑模式恰好修正该版本(update以sliceId为键),Add Version 也从它分叉。 - 实体删除:时间轴模型的内置 Delete 确认会明示影响范围——“All N versions will be removed”(N 来自 Versions 面板已缓存的查询)。
页面导航(Header 中的 Back + Prev/Next)
页面 header 右侧托管两个页面 / 记录级导航原语(非表单数据级),使 FormToolbar 聚焦表单生命周期动作:
FormBackButton(仅只读模式)—— 返回 breadcrumb 推导路由(resolveBackRoute):普通详情的注册列表页,或 MultiViewlinkTo详情的 tab crumb/list?tab=<tab>。纯 path/manifest 推导(无携带查询状态)。在ModelSideForm与编辑 / 创建模式隐藏(工具栏Cancel处理等价意图)。FormSiblingNav——‹ index/total ›按钮,跳转到上一条 / 下一条兄弟记录。
兄弟导航要求用户通过同模型兄弟列表视图(ModelTable / ModelBoard / ModelCard)的行 / 卡片点击进入:
- 真相源为
src/components/views/form/hooks/siblingNavStore.ts的模块级快照。列表视图在点击时将当前可见(已排序、服务端分页)id 写入快照;FormSiblingNav渲染时读取。 - 各视图作用域:
ModelTable:当前页 id(已应用服务端筛选 + 排序)。ModelBoard:点击卡片所属列内的 id。ModelCard:当前页 id。
- 无快照时隐藏按钮(如直接 URL、页面 reload 或不同模型)。在首 / 末条记录、submitting 中、表单 dirty 时禁用(避免未保存编辑在点击时丢失)。
- 快照为临时性:仅内存,不持久化到 URL、sessionStorage 或 React state。客户端路由变更(
router.push)仍可保留,全量 reload 重置。
对话框架构
详细对话框 API、props 与完整示例维护于: Dialogs。
快速选择:
ActionDialog:调用模型操作/{modelName}/{operation}(单条 / 批量)。ModelDialog:关联字段运行时对话框,无需显式modelName。
为避免文档漂移,本文件仅保留表单页指导;对话框细节集中在 dialogs README。