自定义表单设计器 - 教学讲解
目标读者:第一次接触本项目自定义表单设计器的同学
阅读时长:约 20 分钟
学完之后,你能完整说清楚下面这 3 件事:
- 设计器中间"手机壳"里的控件预览,到底是怎么画出来的?
- 设计器右侧"属性面板"上的每个输入项,改完之后值去了哪里?
- 最后点"保存"时,提交给后端的那坨 JSON 长什么样、字段之间的关系是什么?
一、先看全局:设计器这页长什么样
打开src/pages/parameterManagement/addForm/index.tsx(路由/parameterManagement/formManagement/add),整页就是下面这张图:
┌──────────────────────────────────────────────────────────────────────┐ │ [返回] [预览] [保存] │ ← 顶部操作栏 ├──────────────────────────────────────────────────────────────────────┤ │ 表单名称: [____] 业务对象: [业主▼] 工单类型: [工单表单▼] 备注: │ ← 表单元数据 ├────────────┬──────────────────────────────┬─────────────────────────┤ │ │ │ │ │ 控件工具栏 │ 📱 手机壳预览区 │ 控件属性面板 │ │ (左侧) │ (中间,可拖拽) │ (右侧,编辑选中项) │ │ │ │ │ │ ·基础控件 │ [⋮⋮ 标题1] │ 标题: [____] │ │ -单选 │ [单选示例] │ 是否必填: [☐] │ │ -多选 │ │ 关键信息: [☐] │ │ -文本 │ [⋮⋮ 标题2] │ 占位文字: [____] │ │ -... │ [多行文本示例] │ 最小长度: [__] │ │ │ │ 最大长度: [__] │ │ ·特殊控件 │ [⋮⋮ 标题3] │ │ │ -认证房产│ [项目示例] │ │ │ -房源 │ │ │ │ -选择客户│ │ │ │ │ │ │ └────────────┴──────────────────────────────┴─────────────────────────┘ ↓ 拖 ↑ 拖 ↑ 改 (从工具栏拖入) (在预览区里上下调位置) (改属性实时写回)两个核心数据流:
左侧工具栏 ──[点击按钮]──> formControls 数组 + 1 ↓ (中央预览) ─[点击某行]──> selectedControl ↓ ↓ (右侧属性面板) ──[改值]──> onValuesChange ↓ 更新 formControls[i] ↓ [保存] → 拼成 JSON → 提交后端理解整个设计器,就只需要理解 3 个状态变量:
const[formControls,setFormControls]=useState<FormControl[]>([])// ① 整个表单的"骨架"const[selectedControl,setSelectedControl]=useState<FormControl|null>(null)// ② 当前选中的那个控件const[propertyForm]=Form.useForm()// ③ 右侧属性面板自己的 antd Form记住这 3 个变量,整个设计器就是一个循环:
工具栏点按钮 → ① 加一条 → 中央渲染出第 i 条 → 用户点中第 i 条 → ② 更新 → 右侧属性面板填入第 i 条的值 → 用户改右侧 → ③ 的 onValuesChange 触发 → 把新值写回 ① 的第 i 条 → 一轮循环结束
下面我们就来一次走完这个循环。
二、中央预览区到底怎么画的?(核心问题 1)
2.1 答案先说
它没有用<Form>,也没有用SpecDynamicForm。
它就是一个最普通的<div>套一个纯函数:
// addForm/index.tsx 第 835-838 行(中间预览区的某个控件项) <div>exportconstrenderControlPreviewOnly=(control:FormControl,needTitle?:any)=>{switch(control.type){case'input':return<div><div>{control.label}</div><Input disabled placeholder={...}/></div>case'textarea':return<div><div>{control.label}</div><TextArea disabled.../></div>case'radio':return<div><div>{control.label}</div><div>{options.map(...)}</div></div>// ... 13 个 case}}两个关键点:
- 所有 input/select 都是
disabled的(FormControlUtils.tsx 第 92 行起都加了disabled)—— 你点不动它,它只是个"长这样"的占位符 - 没有
<Form>包裹——这个区域没有"提交"的概念,它的作用就是"让你看控件长啥样、让你能拖拽排序、让你点击切换选中态"
2.2 为什么不复用SpecDynamicForm?
SpecDynamicForm是给"真正填表"的场景用的(设备详情、工单新增、抽屉),它要处理:
- 受控表单值
- 必填校验
- 提交格式化
- 联动逻辑(room → customers、project → room)
设计器的中央预览区不需要这些——它只是"画个样子"。所以代码里另写了一个轻量级的renderControlPreviewOnly,只做"按 type 渲染"这一件事。
2.3 三个"看似一样但完全不同的"渲染入口
| 入口 | 位置 | 用在哪儿 | 是不是<Form> | 是不是 SpecDynamicForm | 是不是 disabled |
|---|---|---|---|---|---|
renderControlPreviewOnly(control) | FormControlUtils.tsx:72 | 设计器中央预览、表单管理列表预览弹窗 | ❌ | ❌ | ✅ |
<SpecDynamicForm fields={...} /> | components/SpecDynamicForm/index.tsx | 设备详情、工单新增、抽屉 | ✅ | ✅ 它自己 | ❌(可填) |
<PhoneFormPreview formControls={...} /> | components/FormPreview/PhoneFormPreview.tsx | 表单管理列表预览弹窗 | ❌ | ❌(内部调renderControlPreviewOnly) | ✅ |
记住这 3 个名字,看到代码里调哪个就知道它在干嘛。
2.4 中央预览区的真实 DOM 结构
addForm/index.tsx:820-905(简化版):
<div >三、右侧属性面板:改完值去哪了?(核心问题 2)3.1 先认识一个 antd 表单
propertyForm是专门给"右侧属性面板"用的一个 antd<Form>实例(第 244 行Form.useForm()拿到):
const[propertyForm]=Form.useForm()
它和上面那个form(表单元数据那个表单,246 行)是两个独立的 Form 实例——这是初学者最容易绕晕的地方。
3 个 Form 实例的关系:
名字 作用 在哪 form顶部"表单名称/业务对象/工单类型/备注"那块的 antd Form 顶部 Card propertyForm右侧"控件属性"面板的 antd Form 右侧 Card SpecDynamicForm内部真正"填表"场景用的 Form 设备详情/工单新增等
3.2 选中控件时,右侧面板怎么填上值?
看handleEditControl(addForm/index.tsx:553-595):
consthandleEditControl=(control:FormControl)=>{setSelectedControl(control)// ① 标记"当前选中这个"// ② 关键:把控件的所有属性塞到 propertyForm 里propertyForm.setFieldsValue({...control,// id, type, label, required, options...options:control.options?.join('\n'),// options 数组 → 用换行拼成字符串defaultValues:...,// 归一化默认值validation:control.validation,// 嵌套对象保留stepperConfig:control.stepperConfig,// 同上datetimeConfig:control.datetimeConfig,// 同上imageConfig:control.imageConfig,// 同上})}
做的是两件事:
- 告诉父组件"我被选中了"(
setSelectedControl) - 告诉右侧 Form “你的字段值是这个”(
propertyForm.setFieldsValue)
3.3 改了值,怎么写回formControls?
右侧面板的 Form 上挂了一个onValuesChange回调(addForm/index.tsx:914):
<Form form={propertyForm} onValuesChange={handleSaveProperty} ...>
handleSaveProperty(addForm/index.tsx:597-643)的逻辑就是把改完的值原样塞回 formControls 里对应的那条:
consthandleSaveProperty=(changedValues,allValues)=>{if(!selectedControl)return// 防御:没选中就别动// ① 把 propertyForm 的字段值转成 FormControl 形状// 比如:options 是 '\n' 拼接的字符串,要 split 回数组// 比如:stepper 的 normalRange {min, max} 要拼成 "min-max" 字符串letoptionsArr=allValues.options?.split('\n')letcomputedNormalValues=...// ② 用最新的值构造一个新的 FormControl 对象constupdatedControl:FormControl={...selectedControl,// 保留原 id、typelabel:allValues.label,required:allValues.required,options:optionsArr,// ... 其他所有字段}// ③ 写回 formControls 数组(替换掉对应 id 的那条)setFormControls(formControls.map(c=>c.id===selectedControl.id?updatedControl:c))setSelectedControl(updatedControl)// 自己也更新一下,免得下次对不上}
重点:
右侧面板没有"保存"按钮——你改一个字段,就实时写回到formControls里。
中央预览区因为引用的是formControls数组,也会立刻跟着变。
这就是为什么"右侧改 label,中央立刻看到 label 变了"——因为它们读的是同一个数据源。
3.4 特殊字段的"实时写回"举例
例子 A:步进器的"正常值范围"
右侧 Form 上的字段是normalRange.min和normalRange.max(两个数字输入框)。但保存到formControls里要变成字符串"min-max"(比如"10-100"),因为后端的normalValue字段就是字符串。
所以handleSaveProperty里有一段特殊处理(addForm/index.tsx:612-620):
if(selectedControl.type==='stepper'){constrange=allValues.normalRangeif(range?.min!==undefined&&range?.max!==undefined){computedNormalValues=`${range.min}-${range.max}`// 10,100 → "10-100"}else{computedNormalValues=undefined}}
例子 B:radio/checkbox 的 options
右侧面板上展示的是OptionsList组件(addForm/index.tsx 第 167-251 行),它本身又用 dnd-kit 做了一次"选项排序"的拖拽。改完选项后走的是另一条路径:
// OptionsList 的 onOptionsChange 回调onOptionsChange={newOptions=>{// ① 同步给 propertyFormpropertyForm.setFieldsValue({options:newOptions.join('\n')})// ② 再触发 handleSavePropertyhandleSaveProperty({options:newOptions.join('\n')},propertyForm.getFieldsValue())}}
所以拖动选项排序 → 改propertyForm.options字符串 → 触发handleSaveProperty→ 写回formControls[i].options数组 → 中央预览区立刻重渲染。
四、提交给后端的数据长什么样?(核心问题 3)
4.1 入口
handleSaveForm(addForm/index.tsx:701-731):
consthandleSaveForm=()=>{form.validateFields()// 校验顶部"表单名称/业务对象/工单类型/备注".then(asyncvalues=>{if(formControls.length===0){message.warning('请至少添加一个表单控件')return}constdata={oprType:EditMode,// 1 新增 2 编辑 3 升版parmFormListVo:processFormData(values),// 🔑 拼成最终结构}awaitsaveForm(data)// POST /nbParmFormList/savemessage.success('保存成功')history.back()})}
4.2 最外层结构
processFormData(addForm/index.tsx:734-781)拼出来的东西长这样:
{"oprType":1,"parmFormListVo":{"id":"",// 新增时空字符串,编辑/升版时为数字"name":"物业报修登记","remark":"业主端报修","bizType":"OWNER","formType":"FLOW","fieldList":[{/* 第 1 个控件 */},{/* 第 2 个控件 */}]// 升版(oprType=3)时还会多 3 个字段:refVersionId, status, version}}
4.3 每条 field 的"组合体"结构
每条 field 都会同时提交 4 类信息(addForm/index.tsx:738-770):
letfield={// 【第一类:基础元数据】4 个fieldType:typeObj[control.type],// ① 控件类型(后端大写:'RADIO' / 'CUSTOMERS')fieldTitle:control.label,// ② 控件标题isNeed:control.required?1:0,// ③ 是否必填(数字,不是布尔)important:control.important?1:0,// ④ 是否关键信息(数字,不是布尔)// 【第二类:事实契约】1 个extParm:JSON.stringify(control),// ⑤ 🔑 整个控件对象序列化成 JSON 字符串}// 【第三类:按 type 走的便捷字段】if(control.type==='radio'||control.type==='checkbox'){field.optionList=control.options?.map(option=>({value:option}))// ⑥ 选项列表}if(control.type==='radio'){field.normalValue=...// ⑦ 正常值(单值)}if(control.type==='checkbox'){field.normalValue=...// ⑦ 正常值(逗号拼成的字符串)}if(control.type==='stepper'){field.normalValue=...// ⑦ "min-max" 字符串,比如 "10-100"}if(control.type==='cascader'){field.cascaderTypeId=control.cascaderCategory// ⑧ 级联分类 ID}
4.4 一个具体例子
假设你拖出来了这样 2 个控件:
- 单选:
label="紧急程度",options=["一般", "紧急"],required=true - 选择客户:
label="联系人",required=false,important=true
提交给后端的fieldList就是:
[{"fieldType":"RADIO","fieldTitle":"紧急程度","isNeed":1,"important":0,"extParm":"{\"id\":\"1700000000000\",\"type\":\"radio\",\"label\":\"紧急程度\",\"required\":true,\"important\":false,\"options\":[\"一般\",\"紧急\"],\"normalValues\":[\"一般\"],\"defaultValues\":[\"一般\"],\"businessObject\":\"\",\"validation\":{\"min\":1,\"max\":50},\"stepperConfig\":{\"min\":0,\"max\":100,\"step\":1,\"defaultValue\":0,\"unit\":\"\"},\"datetimeConfig\":{\"format\":\"YYYY-MM-DD HH:mm:ss\",\"showTime\":true,\"limitType\":0},\"imageConfig\":{\"antiCheat\":false,\"maxCount\":5,\"maxSize\":1}}","optionList":[{"value":"一般"},{"value":"紧急"}],"normalValue":"一般"},{"fieldType":"CUSTOMERS","fieldTitle":"联系人","isNeed":0,"important":1,"extParm":"{\"id\":\"1700000000001\",\"type\":\"customers\",\"label\":\"联系人\",\"required\":false,\"important\":true,\"placeholder\":\"请选择客户\",\"businessObject\":\"\",\"validation\":{},\"stepperConfig\":{...},\"datetimeConfig\":{...},\"imageConfig\":{...}}"}]
注意几个细节:
- extParm 是个 JSON 字符串(不是对象)——后端存的 raw 就是字符串,因为有些老数据库不喜欢嵌套对象
- extParm 里包含了"全部 13 个字段类型都有的默认配置"——比如客户控件没有
stepperConfig,但 extParm 里也会有(默认值),这是handleAddControl第 411-456 行的"创建新控件时初始化全部 config"那段代码的功劳 - optionList / normalValue / cascaderTypeId 是从 extParm 解析出来的"独立字段"——后端可以选择只存 extParm(自包含),也可以选择把高频查询字段抽出来(更快建索引)
4.5 后端extParm的"双重身份"
后端拿到这条数据时,既可以:
- 当字符串存(最省事,存啥取啥)
- 或者解析后存到多个字段(方便建索引/做统计)
消费方(设备详情、工单新增)取数据时都做同一件事(addForm/index.tsx:289-298):
res.fieldList.map(item=>{letext=JSON.parse(item.extParm)// 解析 extParm 拿到完整控件对象return{...ext,// 散开拿到所有配置id:item.id,// 用后端的字段 ID 覆盖本地 idfieldValue:item.fieldValue||'',// 回显值(如果有的话)}})
消费方只看 extParm——fieldType/optionList这些"便捷字段"是给后端做非功能性需求(索引、统计、跨表查询)用的。
4.6 前端 type ↔ 后端 type 的映射
addForm/index.tsx:670-686的typeObj:
consttypeObj={radio:'RADIO',checkbox:'CHECKBOX',input:'TEXT',// 注意:前端叫 input,后端叫 TEXTtextarea:'TEXTAREA',stepper:'STEPPER',datetime:'DATETIME',image:'IMAGE',room:'ROOM',project:'PROJECT',property_room:'PROPERTY_ROOM',cascader:'CASCADER',rating:'RATING',customers:'CUSTOMERS',}
前端小写,后端大写——别忘了这个转换。extParm里的type还是小写(这是前端自己的契约),后端只是另存一个fieldType大写方便数据库索引。
4.7 升版(oprType=3)时多 3 个字段
if(EditMode===3){formData_api.refVersionId=id// 指向老版本deleteformData_api.id// 升版时不传 id,让后端生成新 idconst{status,version}=originFormData// 从最初加载的数据里取formData_api={...formData_api,status,version}}
意思是:“我要基于老版本升一版,请帮我生成新 id,但 status 和 version 这两个字段请你保留老版本的值”——这是因为后端做版本号管理需要这两个字段。
五、读源码的几个建议
5.1 推荐的阅读顺序
- 先读
src/common/FormControlUtils.tsx(约 400 行)—— 弄懂FormControl是什么、13 种 type 各自长啥样、renderControlPreviewOnly怎么渲染 - 再读
addForm/index.tsx(约 1500 行)的这几个关键函数:handleAddControl(411-456)—— 加控件的初始化逻辑handleDeleteControl(461-505)—— 删除控件(含 customers 联动)handleEditControl(553-595)—— 选中控件 → propertyForm 填值handleSaveProperty(597-643)—— 右侧面板改值 → 写回 formControlsprocessFormData(734-781)—— 拼提交 JSON
- 再读
SpecDynamicForm/index.tsx(约 700 行)的 3 个 switch:renderItem—— 编辑态:13 种 caserenderStaticItem—— 只读态:13 种 caseformatSubmitValues—— 提交时:13 种 case
5.2 一些容易绕晕的点
绕晕点 解法 为什么有 3 个 Form 实例? 顶部"表单元数据"1 个、右侧"属性面板"1 个、SpecDynamicForm自己 1 个——它们各管各的 customers强依赖room看 addForm/index.tsx:411-414(添加时校验)、461-505(删除时联动) 设计器中央预览为什么不是SpecDynamicForm 预览不需要 form state、不需要校验、不需要联动——所以另写了一个轻量的 提交 JSON 里extParm是字符串还是对象 字符串——所有消费方拿到都要JSON.parse(item.extParm) 前端 type 是小写,后端为什么存大写 后端做大写映射(typeObj),方便 SQL 字段枚举
5.3 改代码前必看
- 控件新增:13 个 case 要在 3 个地方加(
renderControlPreviewOnly+SpecDynamicForm的 3 个 switch +typeObj)——少一个就出 bug - 提交字段新增:在
processFormData里加 if 分支(参考 radio/checkbox/stepper/cascader 的写法) - 拖拽行为变更:看 @dnd-kit 的
DndContext+SortableContext部分(addForm/index.tsx:170-251) - 联动逻辑新增(比如 project 选完联动 room):看
SpecDynamicForm里handleProjectChange(249-262 行)和handleRoomChange(195-213 行)的模式
六、附录:3 个核心流程图
6.1 加一个新控件
用户点击工具栏按钮 (e.g. "图片上传") ↓ handleAddControl('image') ↓ ├─ 校验 customers 强依赖(411 行) ├─ 生成 newControl(415-456 行) │ ├─ id: Date.now().toString() │ ├─ type: 'image' │ ├─ label: '图片上传N' │ ├─ imageConfig: { maxCount: 5, maxSize: 1, antiCheat: false } │ └─ 其他 12 个字段的默认值 ├─ setFormControls([...formControls, newControl]) ├─ setSelectedControl(newControl) // 自动选中新加的 ├─ propertyForm.setFieldsValue(newControl) // 右侧面板填上 └─ setTimeout 滚动到新加的控件(470-491 行)
6.2 改一个控件的属性
用户在右侧面板改 "最大长度" 输入框 ↓ propertyForm 的 onValuesChange 触发 ↓ handleSaveProperty(changedValues, allValues) ↓ ├─ options 字符串 → 数组(608-610 行) ├─ stepper normalRange → "min-max" 字符串(612-620 行) ├─ 构造 updatedControl(624-639 行) └─ setFormControls(数组 map 替换对应 id) ↓ 中央预览区 useState 触发重渲染 ↓ renderControlPreviewOnly(updatedControl) 输出新的样子
6.3 保存表单
用户点击 [保存] ↓ handleSaveForm (701 行) ↓ ├─ form.validateFields() // 校验顶部表单 ├─ 校验 formControls 非空 └─ processFormData(values) // 734 行 ↓ ├─ 拼 parmFormListVo(id, name, remark, bizType, formType) ├─ 遍历 formControls 拼 fieldList │ ├─ fieldType = typeObj[type] // 小写→大写 │ ├─ fieldTitle = label │ ├─ isNeed / important = 数字 │ ├─ extParm = JSON.stringify(control) │ └─ 按 type 拼 optionList / normalValue / cascaderTypeId ├─ 升版时拼 refVersionId / status / version(772-781 行) └─ 返回 parmFormListVo ↓ saveForm({ oprType, parmFormListVo }) ↓ POST /nbParmFormList/save
七、给初学者的小测验
看完文档,能回答下面这 5 个问题就说明真的懂了:
Q:设计器中央预览区用的是什么渲染函数?是<SpecDynamicForm>吗?
A:不是。是renderControlPreviewOnly纯函数,定义在FormControlUtils.tsx:72。
Q:用户在右侧改options数组(比如拖动排序、删除一项),值是怎么从右侧 Form 走到中央预览的?
A:右侧 Form 字段options是\n拼接的字符串 →onOptionsChange调handleSaveProperty→ 写回formControls[i].options数组 → 中央useState触发重渲染。
Q:提交给后端时,每个控件会同时提交extParm和fieldType,这俩什么关系?
A:extParm是"事实契约"(完整 JSON 字符串),fieldType是从 extParm 解析出来的"便捷字段"(大写枚举),方便后端建索引。
Q:后端拿到fieldList[0]时,怎么知道这个控件的完整配置?
A:JSON.parse(item.extParm),得到完整FormControl对象。fieldType/optionList这些只是辅助。
Q:customers控件和room控件的依赖关系在设计器和运行时分别怎么保证?
A:
- 设计器:
handleAddControl添加 customers 前校验(411-414 行),handleDeleteControl删除 room 时联动删除 customers(461-505 行) - 运行时(
SpecDynamicForm):room 变更时清空 customers 字段(195-213 行),并重新拉客户列表
八、相关文件清单
模块 路径 行数 作用 核心类型 + 预览渲染 src/common/FormControlUtils.tsx~400 FormControl接口、13 个 case 渲染、控件注册表设计器 src/pages/parameterManagement/addForm/index.tsx~1500 整个设计器页面 动态渲染器 src/components/SpecDynamicForm/index.tsx~700 真正"填表"用的渲染器(设备详情/工单新增/抽屉) 手机壳预览 src/components/FormPreview/PhoneFormPreview.tsx~80 表单管理列表的预览弹窗容器 表单管理列表 src/pages/parameterManagement/formManagement/index.tsx~500 列表 + 预览 + 发版 后端 API src/services/api.ts(行 2344-2372)30 行 getParmFormList*/saveForm/publishForm/delParmForm