字段
供 ModelForm、关联对话框、内联编辑器和展示场景使用的、基于元数据驱动的字段系统。
Field 是应用代码中面向业务的主要入口。
本文用于说明:
Fieldprops 与覆盖方式,三种渲染模式(表单 / 展示 / 声明)required/readonly/hiddendependsOn(...)filters(关联查询与Option/MultiOption客户端选项过滤)- 远程
Field.onChange - 运行时值契约
- 按字段类型划分的前端行为
相关文档:
- 关联字段:
RelationTable、SelectTree、OneToMany、ManyToMany - Widgets:
FieldType -> WidgetType矩阵与 widget 专属示例 - ModelForm:页面壳层
- ModelTable:只读单元格、内联编辑与侧栏
- ModelSideForm:侧栏 + 内嵌表单视图
- Tree:
SideTree与SelectTree使用的内部树形原语
导入
推荐的业务侧导入方式:
import { Field } from "@/components/fields";额外的公开导出:
import {
Field,
useDisplayRecord,
useDisplayRecordValue,
RelationTable,
type FieldCondition,
type FieldConditionContext,
type FieldOnChangeProp,
type RelationFormView,
type RelationTableProps,
} from "@/components/fields";内部说明:
ResolvedFields为内部能力,应只留在基础设施代码一侧,而不应成为面向业务的字段 API
字段渲染模式
Field 会根据 React 上下文自动判断渲染模式:
| 模式 | 条件 | 行为 |
|---|---|---|
| 表单模式 | 存在 FieldPropsContext | 可编辑字段,含校验与条件 |
| 展示模式 | 存在 RecordContext,且无 FieldPropsContext | 通过 FieldDisplayValue 只读展示行内值 |
| 声明模式 | 上述上下文均不存在 | 返回 null;由父级通过 children 收集声明 |
因此同一 <Field fieldName="name" /> 无需 mode prop,即可在表单、侧栏、卡片模板和表格列声明中复用。
展示模式
当 Field 位于 RecordContext 内(例如 SideCard、SideList 或 FormHeader 的子节点),会渲染为 FieldDisplayValue——只读行内值,与表格列使用相同的单元格渲染器。
import { RecordContextProvider } from "@/components/contexts/RecordContext";
<RecordContextProvider record={data} metaModel={metaModel}>
<Field fieldName="name" /> {/* 展示为只读值 */}
<Field fieldName="status" /> {/* 展示为只读值 */}
</RecordContextProvider>FieldDisplayScope
在 ModelForm 中已提供 FieldPropsContext。若需将 Field 强制为展示模式(例如在 FormHeader 子节点中),请用 FieldDisplayScope 包裹:
import { FieldDisplayScope } from "@/components/fields/display";
<FieldDisplayScope>
<Field fieldName="status" /> {/* 虽在 ModelForm 内,仍以展示模式渲染 */}
</FieldDisplayScope>FormHeader 会用 FieldDisplayScope 自动包裹其 children。
在 FieldDisplayScope 内若编写自定义仅展示组件,请使用 useDisplayRecordValue(path) 或 useDisplayRecord(paths) 仅订阅所需字段,而不要读取整份表单记录:
import { Field, useDisplayRecordValue } from "@/components/fields";
function HeaderStatusBadge() {
const status = useDisplayRecordValue<string>("status");
return <span>{status}</span>;
}
<FormHeader>
<Field fieldName="employeeCode" />
<HeaderStatusBadge />
</FormHeader>Group
用于在同一行内横向组合多个 Field 的行内 flex 容器。内部 Field 的标签一律隐藏——请改用 Group 的 labelName。
位置: @/components/fields/composition
Group 属性
| Prop | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
labelName | string | 否 | - | 渲染在行内组合上方的标签,替代各 Field 独立标签 |
separator | ReactNode | 否 | - | 子节点之间的分隔符(如 "-"、"·"、"/") |
className | string | 否 | - | flex 容器额外类名 |
children | ReactNode | 是 | - | 一般为 Field 元素 |
标签行为
- 展示模式(
FormHeader、SideCard等):Field 仅渲染为值的<span>,默认无标签。 - 表单模式(
FormBody内):Group会克隆子Field并设hideLabel={true}。若提供labelName,则在组合上方渲染单个FormLabel。
示例
展示模式——在 FormHeader 中(无标签):
<FormHeader>
<Group separator="·">
<Field fieldName="employeeCode" />
<Field fieldName="departmentName" />
</Group>
</FormHeader>表单模式——共享标签:
import { Group } from "@/components/fields/composition";
<FormSection labelName="名称">
<Group labelName="姓名" separator="-">
<Field fieldName="firstName" />
<Field fieldName="lastName" />
</Group>
</FormSection>表单模式——无标签(仅值):
<Group separator="/">
<Field fieldName="countryCode" />
<Field fieldName="areaCode" />
<Field fieldName="phoneNumber" />
</Group>推荐用法
在应用代码中,将 Field 作为唯一入口:
<Field fieldName="name" />
<Field fieldName="description" widgetType="Text" />
<Field fieldName="avatar" widgetType="Image" />
<Field fieldName="notes" widgetType="Markdown" />运行时会自动解析:
- 从元数据中获取
fieldType - 根据
fieldType选择默认字段适配器 - 根据
widgetType选择可选 widget 渲染器
直接使用适配器组件和底层 widget,只建议出现在字段基础设施内部。
快速关联示例:
<Field fieldName="departmentId" widgetType="SelectTree" />function UserTableView() {
return (
<RelationTable orders={["username", "ASC"]} pageSize={10}>
<Field fieldName="username" />
<Field fieldName="email" />
</RelationTable>
);
}
<Field fieldName="userIds" tableView={UserTableView} />;核心 Props
Field 基于元数据驱动,并支持字段级覆盖和运行时条件。
| Prop | 类型 | 必填 | 说明 |
|---|---|---|---|
fieldName | string | 是 | 当前模型中的元数据字段 key。 |
fieldType | FieldType | 否 | 可选的字段类型覆盖。若省略,则运行时使用元数据中的 fieldType。 |
widgetType | WidgetType | 否 | 可选的 widget 覆盖。必须与解析后的 fieldType 兼容。 |
widgetProps | Record<string, unknown> | 否 | 仅用于 widget 专属配置。表单 widget 和内联编辑器会使用;表格只读单元格不会使用。 |
placeholder | string | 否 | 字段级输入占位文案。优先于 widgetProps.placeholder。 |
hideLabel | boolean | 否 | 隐藏整个标签区域。 |
fullWidth | boolean | 否 | 文本类字段和关联字段的布局提示。 |
labelName | string | 否 | 元数据标签覆盖。 |
required | FieldCondition | 否 | 动态必填控制。支持 boolean、FilterCondition 或 dependsOn(...)。 |
readonly | FieldCondition | 否 | 动态只读控制。支持 boolean、FilterCondition 或 dependsOn(...)。 |
hidden | FieldCondition | 否 | 动态可见性控制。隐藏字段不会渲染,同时会抑制其校验。 |
defaultValue | unknown | 否 | 仅创建态使用的默认值覆盖。优先级高于 metaField.defaultValue 和对话框 / 页面级 defaultValues。 |
filters | string | FilterCondition | 否 | 过滤条件覆盖。用于关联查询,以及 Option / MultiOption 在客户端按 itemCode 过滤已加载的选项(再渲染)。Field.filters 会覆盖 metaField.filters。支持 JSON 字符串形式的元数据过滤条件以及 {{ expr }}(如 {{ fieldName }})引用。 |
filterBySource | boolean | 否 | 为 true 时,在 searchName 等服务端搜索调用中附带 SourceRecord 快照(所属模型 + recordId + 当前表单值),供后端按调用表单上下文应用业务规则。默认 false;按调用点开启。详见 关联字段 — filterBySource。 |
onChange | FieldOnChangeProp | 否 | 远程字段联动。支持简写 string[] 或 { update?, with? }。 |
tableView | RelationTableView | 否 | OneToMany / ManyToMany 的关联表格视图。须为零 props 的组件,且渲染 <RelationTable />。详见 关联字段。 |
formView | RelationFormView | 否 | 关联对话框 / 详情视图配置。详见 关联字段。 |
isPaged | boolean | 否 | 为 OneToMany / ManyToMany 启用分页关联表格模式。详见 关联字段。 |
FieldCondition:
type FieldCondition =
| boolean
| FilterCondition
| ((ctx: FieldConditionContext) => boolean);FieldConditionContext:
interface FieldConditionContext {
fieldName: string;
metaField: MetaField;
values: Record<string, unknown>;
value: unknown;
scope: "form" | "model-table" | "relation-table";
rowIndex?: number;
rowId?: string;
isEditing: boolean;
recordId?: string;
}行为说明:
boolean:最简单直接的形式FilterCondition:推荐用于常见业务规则的声明式写法dependsOn([...], evaluator):当需要显式字段订阅的函数式条件时推荐使用- 无效的
FilterCondition配置会触发开发态警告,并解析为false - 不支持裸函数条件;请使用
dependsOn([...], evaluator)包裹 - 相同的条件模型也用于表单和表格工具栏中的
Action.disabled与Action.hidden hidden会同时抑制渲染和校验- 在
ModelTable/RelationTable的内联编辑中,条件里的values是当前行对象,而不是整个表单对象 - 在表格声明中,
hidden只支持boolean,且会隐藏整列 widgetProps不会透传给ModelTable/RelationTable的只读单元格渲染器defaultValue适用于静态的字段级创建默认值。对于路由参数、父行值或未渲染字段等运行时 / 上下文预填,请使用对话框 / 页面级defaultValuesrequired={false}可以放宽元数据里的required;readonly={false}可以覆盖元数据里的只读设置
示例:
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="itemName"
required={dependsOn(["active", "itemCode"], ({ values, isEditing }) =>
!isEditing && values.active === true && values.itemCode !== "Temp"
)}
/>dependsOn(...)
当某个字段规则依赖其他值,且你希望显式声明订阅字段时,请使用 dependsOn([...], evaluator)。
import { dependsOn, Field } from "@/components/fields";
<Field
fieldName="itemName"
required={dependsOn(["active", "itemCode"], ({ values, isEditing }) =>
!isEditing && values.active === true && values.itemCode !== "Temp"
)}
/>为什么优先用 dependsOn(...),而不是裸函数:
- 依赖列表是显式的
- 运行时订阅更精确
evaluator依然保留完整的编程能力
优先级建议:先用 boolean,再用 FilterCondition 表达声明式业务规则,只有真正需要计算逻辑时再使用 dependsOn(...)。
filters
filters 用于:
- 关联字段 —— 约束后端查询
ManyToOne/OneToOne可搜索引用查询SelectTree关联选择查询OneToMany/ManyToMany远程关联表格查询ManyToMany选择器对话框查询
Option/MultiOption字段 —— 在客户端按itemCode过滤已加载的OptionReference[]后再渲染(适用于OptionSelect、Radio、StatusBar、CheckBoxwidget;Badge为纯展示,不受影响)。未传filters时展示全部选项。
可接受输入:
- 应用代码中的
FilterCondition - 来自元数据 / 后端 payload 的 JSON 字符串形式
过滤值中推荐使用的声明式值语法:
{{ fieldName }}:在发请求前从当前前端作用域解析(统一模板语法{{ expr }})TODAY、NOW、USER_ID、USER_EMP_ID、USER_POSITION_ID、USER_DEPT_ID、USER_COMP_ID:原样透传,由后端替换环境变量- 字面量:使用
{{ 'value' }}或后端 token 如{{ NOW }};保留字段引用在支持的场景下使用{{ @fieldName }}
示例 —— 关联:
<Field
fieldName="departmentId"
filters={[
["companyId", "=", "{{ companyId }}"],
"AND",
["active", "=", true],
"AND",
["effectiveDate", "<=", "TODAY"],
"AND",
["type", "=", "{{ TODAY }}"],
]}
/>示例 —— Option / MultiOption(过滤条件中的字段名须为 itemCode):
<Field
fieldName="country"
filters={[["itemCode", "IN", ["US", "CA", "UK"]]]}
/>
<Field
fieldName="tags"
filters={[["itemCode", "!=", "archived"]]}
/>行为:
Field.filters会覆盖metaField.filters- 若省略
Field.filters,关联 widget 会回退到metaField.filters;Option/MultiOptionwidget 会原样展示全部选项 {{ fieldName }}会基于当前作用域值解析:ModelForm:当前表单值ModelTable内联编辑:当前编辑行RelationTable内联编辑:当前关联行
- 解析后的字段值会在请求前做归一化:
ManyToOne/OneToOne->idOption->itemCodeMultiOption->itemCode[]
- 如果任意
{{ expr }}依赖缺失,则关联查询会视为未就绪,不会发送请求 - 前端不会求值后端环境 token,例如
TODAY;它们会原样透传 - 对未分页的
OneToMany字段,不含{{ fieldName }}引用的静态过滤条件也会写入getById的 subQuery,首屏即可拿到已过滤的关联数据;含{{ fieldName }}引用的过滤仅在远程模式查询时生效
远程 Field.onChange
Field 通过顶层 onChange prop 支持远程联动:
type FieldOnChangeProp =
| string[]
| {
update?: string[];
with?: string[] | "all";
};常见示例:
<Field fieldName="itemCode" onChange={["itemName", "itemTone"]} />
<Field
fieldName="itemCode"
onChange={{ update: ["itemName"], with: ["active"] }}
/>
<Field
fieldName="itemCode"
onChange={{ with: "all" }}
/>行为:
onChange={["a", "b"]}是onChange={{ update: ["a", "b"] }}的简写- 存在
update:只从响应values中提取这些字段 - 省略
update:会应用当前作用域内响应values的全部 key - 省略
with:请求只在编辑态发送id,并附带当前字段value with: ["a", "b"]:请求会附带这些字段对应的values,并使用提交 / API 形态with: "all":请求会附带当前作用域的所有值,并使用提交 / API 形态
当前支持的作用域:
ModelFormModelTable的当前内联编辑行RelationTable的当前内联编辑行
当前非目标:
- 顶层独立
OneToMany/ManyToMany容器交互不能作为触发源 - 独立对话框表单目前不会自动提供这套运行时能力
自动触发规则:
blur:文本类和编辑器类字段,如String、MultiString、数值输入、JSON、Filters、Orders、Code、Markdown、RichText、TemplateEditorchange:提交型字段,如Boolean、Date、DateTime、Time、Option、MultiOption、ManyToOne、OneToOne、File、MultiFile
前端使用的后端契约:
POST /<modelName>/onChange/<fieldName>请求体:
{
"id": "123",
"value": "ITEM-001",
"values": {
"active": true
}
}响应体:
{
"values": {
"itemName": "Open",
"itemTone": "Success"
},
"readonly": {
"itemName": true
},
"required": {
"itemTone": true
}
}响应规则:
values只 patch 返回的 key;缺失的 key 保持不变- 返回
null表示明确清空 readonly/required会独立于update应用- 远程返回的
readonly/required会覆盖元数据和本地条件,直到后续响应或作用域重置
作用域说明:
- 在
ModelForm中,with: "all"使用当前表单的提交形态;已注册的顶层关联字段会序列化为关联 patch payload,而不是原始 UI 行 - 在
ModelTable/RelationTable内联编辑中,values和with: "all"都只针对当前行,而不是整个表格或父表单
级联字段路径(展示) {#cascaded-field-path-display}
在 fieldName 中使用点号记法,可从关联记录(经 ManyToOne / OneToOne)读取字段并以只读展示 —— 无需手写 SubQuery、无需额外展示 widget、无需自研 hook。
{/* 在每条 AppEnv 卡片上渲染 DesignDeployment.deployStatus */}
<Field fieldName="lastDeploymentId.deployStatus" widgetType="StatusIcon" />
{/* 深度 3:AppEnv → Owner → Department.name */}
<Field fieldName="ownerId.departmentId.name" />行为:
- 始终只读 —— 不向 react-hook-form 注册,不会出现在
formState.dirtyFields,不参与校验 - 生效的
metaField是最深层模型上的叶子字段(例如DesignDeployment.deployStatus);fieldType/widgetType/optionSetCode/labelName均来自叶子,<Field>上的 props(label/widgetType/hideLabel/className)照常覆盖 - 支持于
ModelForm、ModelSideForm、ModelTable、ModelBoard、ModelCard—— 宿主遍历器自动将匹配的 SubQuery 折叠进数据请求,CascadedResolutionsProvider为每条路径注入叶子元数据 - 中间为
null时按叶子类型的空占位渲染(与普通 null 字段一致),不为「已删除引用」单独渲染墓碑 - 路径仅穿越
*-to-One关联;路径中出现 OneToMany / ManyToMany 会被后端拒绝(TRAVERSE_TO_MANY),字段回退为"-"占位 + 开发态console.error - 最大深度由后端约束;前端不做预校验
需要自定义渲染 / 回退时可命令式读取路径:
import { getValueAtPath } from "@/utils/object-path";
function HealthDot() {
const { record } = useRecordContext();
const deployStatus = getValueAtPath(record, "lastDeploymentId.deployStatus");
// ... 根据 deployStatus 派生 UI
}在宿主视图 JSX 某处声明 <Field fieldName="lastDeploymentId.deployStatus"> 才会向遍历器注册路径以便折叠 SubQuery —— getValueAtPath 只是对嵌套对象结果的类型化访问。
限制:
- 嵌套在
formView回调内部的级联路径(深度 > 0)尚未解析 —— 遍历器会打开发态console.warn,字段渲染空占位。请将级联路径放在顶层表单 / 列表作用域。
级联字段自动回填(写入)
MetaField.cascadedField 可以在编辑作用域中启用隐式自动回填,而无需源字段显式声明 Field.onChange。这与上文「级联字段路径(展示)」不同 —— 前者是读侧 / 展示,本条是写侧 / 选型后的值传播。
deptId.cascadedField = "employeeId.departmentId";
companyId.cascadedField = "employeeId.department.companyId";行为:
- 支持于
ModelForm、ModelTable内联编辑和RelationTable内联编辑 - 源字段必须是
ManyToOne或OneToOne,并且定义了relatedModel - 当源字段变化时,前端会请求一次
/<relatedModel>/getById,并从响应中读取所有依赖的级联路径 - 多个目标依赖同一个源字段时,会在一次查询中统一解析
- 如果源字段同时声明了
Field.onChange,两种效果会并行执行 - 如果两者都写入同一个目标字段,则
cascadedField优先 - 清空源字段时,会直接清空所有依赖的级联目标,不会调用
getById - 无效的级联元数据会被忽略,并给出开发态警告
语法说明:
- 格式为
<sourceField>.<path> <sourceField>必须是当前作用域中的同级字段<path>从源模型的getById响应中读取,可以是嵌套路径
字段类型概览
本节说明不同 fieldType 的默认前端行为。widget 专属变体和 props 表请见 Widgets。
字符串与文本
String:默认单行文本输入MultiString:标签式输入;值会在Enter、,或 blur 时提交,并以逗号分隔字符串存入表单状态- 常见的
Stringwidget 变体:URLEmailColorTextRichTextTemplateEditorMarkdownCode
示例:
<Field fieldName="name" />
<Field fieldName="homepage" widgetType="URL" />
<Field fieldName="description" widgetType="Text" />
<Field fieldName="notes" widgetType="Markdown" />
<Field fieldName="content" widgetType="RichText" />数值类型
Integer、Long、Double:数字类输入BigDecimal:保留小数字符串语义- 常见的数值类 widget 变体:
MonetaryPercentageSlider
<Field fieldName="amount" widgetType="Monetary" />
<Field fieldName="ratio" widgetType="Percentage" />
<Field fieldName="score" widgetType="Slider" />布尔与选项类型
Boolean:默认是SwitchOption:默认单选下拉MultiOption:默认是复选组风格的多选
常见 widget 变体:
CheckBoxRadioStatusBarStatusIcon— 只读、仅图标的指示器,由选项元数据中的itemTone与itemIcon驱动
只读展示是 自动的——Option / MultiOption 在任何只读上下文(表格单元格、卡片 / 看板单元格、只读表单)中渲染时,若选项带有 itemTone 会自动采用彩色 StatusBadge,否则为纯文本。无需声明 widgetType。
<Field fieldName="active" />
<Field fieldName="active" widgetType="CheckBox" />
<Field fieldName="status" widgetType="Radio" />
<Field fieldName="status" /> // 只读上下文 → 自动 Badge / 文本
<Field fieldName="status" widgetType="StatusIcon" />日期与时间类型
Date:标准日期选择器DateTime:日期时间输入Time:时间输入
特殊的格式导向 widget:
Date:yyyy-MM、MM-ddTime:HH:mm、HH:mm:ss
<Field fieldName="birthday" />
<Field fieldName="period" widgetType="yyyy-MM" />
<Field fieldName="startTime" widgetType="HH:mm" />引用类型
ManyToOne/OneToOne:默认是可搜索的关联选择器- 使用
filters为查询添加依赖约束 - 使用
widgetType="SelectTree"处理层级选择
<Field fieldName="departmentId" />
<Field fieldName="departmentId" widgetType="SelectTree" />SelectTree、RelationTable、OneToMany、ManyToMany 请见 关联字段。
文件类型
File:默认通用上传;Image为图片模式MultiFile:默认多文件上传;MultiImage为图片画廊模式
<Field fieldName="attachment" />
<Field fieldName="avatar" widgetType="Image" />
<Field fieldName="photos" widgetType="MultiImage" />表格 / 只读行为:
ModelTable和RelationTable会直接使用FileInfo.url做预览和下载渲染- 表格只读单元格会有意忽略面向表单的
widgetProps
结构化值类型
JSON/DTO:结构化对象或数组值,默认编辑器偏向 JSONFilters:过滤构建器值Orders:排序构建器值
<Field fieldName="config" />
<Field fieldName="filters" />
<Field fieldName="orders" />运行时值契约
Field.defaultValue、容器级 defaultValues、form.getValues() 和 useWatch() 使用的都是字段 UI 值,而不是原始 API payload 值。
| 字段类型 | UI / 表单值 | 提交 / API 形态 |
|---|---|---|
File | FileInfo | null | fileId | null |
MultiFile | FileInfo[] | fileId[] | null |
JSON / DTO | 结构化对象 / 数组或 null | 结构化对象 / 数组或 null |
Filters | FilterCondition | null | FilterCondition | null |
Orders | 结构化排序元组或 null | 结构化排序元组或 null |
OneToMany | 关联行 / 行草稿 | 增量 patch map |
ManyToMany | ModelReference[] 或关联行 | 增量 patch map |
说明:
- 后端 payload 和元数据默认值可能仍以字符串形式到达;字段运行时会在加载时把它们归一化为 UI 形态
- 传入页面 / 对话框
defaultValues时,请直接使用上表中的 UI 形态,而不是预先字符串化 ManyToMany即使使用widgetType="TagList",提交时仍是普通增量 patch map- 顶层关联字段的细节请见 关联字段。
FileInfo
File 和 MultiFile 字段在 UI 状态中使用 FileInfo 对象。
重要运行时行为:
- 预览和下载渲染使用
FileInfo.url File只读单元格会回退为文件名链接MultiFile只读单元格显示第一个文件名,并附带+N
布局说明
fullWidth 对这些字段渲染器有意义:
TextRichTextTemplateEditorMarkdownCodeOneToManyManyToMany
这些布局的默认值都是 true。
只读说明
当用户仍然需要清晰读取或复制值时,优先使用 readonly,而不是禁用控件。
一般建议:
readonly:详情页、审计页、仅查看状态disabled:受工作流、权限、前置条件或提交状态阻塞
示例
<Field fieldName="email" widgetType="Email" />
<Field fieldName="bio" widgetType="Text" fullWidth />
<Field
fieldName="notes"
widgetType="Markdown"
widgetProps={{ mode: "preview", minHeight: 360 }}
/>
<Field
fieldName="script"
widgetType="Code"
widgetProps={{ language: "sql", lineWrapping: false, minHeight: 320 }}
/>
<Field
fieldName="avatar"
widgetType="Image"
widgetProps={{
aspectRatio: "1 / 1",
display: "avatar",
crop: { enabled: true, shape: "round" },
}}
/>