自定义表单设计器 - 教学讲解目标读者第一次接触本项目自定义表单设计器的同学阅读时长约 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]useStateFormControl[]([])// ① 整个表单的骨架const[selectedControl,setSelectedControl]useStateFormControl|null(null)// ② 当前选中的那个控件const[propertyForm]Form.useForm()// ③ 右侧属性面板自己的 antd Form记住这 3 个变量整个设计器就是一个循环工具栏点按钮 → ① 加一条 → 中央渲染出第 i 条 → 用户点中第 i 条 → ② 更新 → 右侧属性面板填入第 i 条的值 → 用户改右侧 → ③ 的 onValuesChange 触发 → 把新值写回 ① 的第 i 条 → 一轮循环结束下面我们就来一次走完这个循环。二、中央预览区到底怎么画的核心问题 12.1 答案先说它没有用Form也没有用SpecDynamicForm。它就是一个最普通的div套一个纯函数// addForm/index.tsx 第 835-838 行中间预览区的某个控件项 div>exportconstrenderControlPreviewOnly(control:FormControl,needTitle?:any){switch(control.type){caseinput:returndivdiv{control.label}/divInput disabled placeholder{...}//divcasetextarea:returndivdiv{control.label}/divTextArea disabled...//divcaseradio:returndivdiv{control.label}/divdiv{options.map(...)}/div/div// ... 13 个 case}}两个关键点所有 input/select 都是disabled的FormControlUtils.tsx 第 92 行起都加了disabled—— 你点不动它它只是个长这样的占位符没有Form包裹——这个区域没有提交的概念它的作用就是让你看控件长啥样、让你能拖拽排序、让你点击切换选中态2.2 为什么不复用SpecDynamicFormSpecDynamicForm是给真正填表的场景用的设备详情、工单新增、抽屉它要处理受控表单值必填校验提交格式化联动逻辑room → customers、project → room设计器的中央预览区不需要这些——它只是画个样子。所以代码里另写了一个轻量级的renderControlPreviewOnly只做按 type 渲染这一件事。2.3 三个看似一样但完全不同的渲染入口入口位置用在哪儿是不是Form是不是 SpecDynamicForm是不是 disabledrenderControlPreviewOnly(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 >三、右侧属性面板改完值去哪了核心问题 23.1 先认识一个 antd 表单propertyForm是专门给右侧属性面板用的一个 antdForm实例第 244 行Form.useForm()拿到const[propertyForm]Form.useForm()它和上面那个form表单元数据那个表单246 行是两个独立的 Form 实例——这是初学者最容易绕晕的地方。3 个 Form 实例的关系名字作用在哪form顶部表单名称/业务对象/工单类型/备注那块的 antd Form顶部 CardpropertyForm右侧控件属性面板的 antd Form右侧 CardSpecDynamicForm内部真正填表场景用的 Form设备详情/工单新增等3.2 选中控件时右侧面板怎么填上值看handleEditControladdForm/index.tsx:553-595consthandleEditControl(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.setFieldsValue3.3 改了值怎么写回formControls右侧面板的 Form 上挂了一个onValuesChange回调addForm/index.tsx:914Form form{propertyForm} onValuesChange{handleSaveProperty} ...handleSavePropertyaddForm/index.tsx:597-643的逻辑就是把改完的值原样塞回 formControls 里对应的那条consthandleSaveProperty(changedValues,allValues){if(!selectedControl)return// 防御没选中就别动// ① 把 propertyForm 的字段值转成 FormControl 形状// 比如options 是 \n 拼接的字符串要 split 回数组// 比如stepper 的 normalRange {min, max} 要拼成 min-max 字符串letoptionsArrallValues.options?.split(\n)letcomputedNormalValues...// ② 用最新的值构造一个新的 FormControl 对象constupdatedControl:FormControl{...selectedControl,// 保留原 id、typelabel:allValues.label,required:allValues.required,options:optionsArr,// ... 其他所有字段}// ③ 写回 formControls 数组替换掉对应 id 的那条setFormControls(formControls.map(cc.idselectedControl.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-620if(selectedControl.typestepper){constrangeallValues.normalRangeif(range?.min!undefinedrange?.max!undefined){computedNormalValues${range.min}-${range.max}// 10,100 → 10-100}else{computedNormalValuesundefined}}例子 Bradio/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数组 → 中央预览区立刻重渲染。四、提交给后端的数据长什么样核心问题 34.1 入口handleSaveFormaddForm/index.tsx:701-731consthandleSaveForm(){form.validateFields()// 校验顶部表单名称/业务对象/工单类型/备注.then(asyncvalues{if(formControls.length0){message.warning(请至少添加一个表单控件)return}constdata{oprType:EditMode,// 1 新增 2 编辑 3 升版parmFormListVo:processFormData(values),// 拼成最终结构}awaitsaveForm(data)// POST /nbParmFormList/savemessage.success(保存成功)history.back()})}4.2 最外层结构processFormDataaddForm/index.tsx:734-781拼出来的东西长这样{oprType:1,parmFormListVo:{id:,// 新增时空字符串编辑/升版时为数字name:物业报修登记,remark:业主端报修,bizType:OWNER,formType:FLOW,fieldList:[{/* 第 1 个控件 */},{/* 第 2 个控件 */}]// 升版oprType3时还会多 3 个字段refVersionId, status, version}}4.3 每条 field 的组合体结构每条 field 都会同时提交 4 类信息addForm/index.tsx:738-770letfield{// 【第一类基础元数据】4 个fieldType:typeObj[control.type],// ① 控件类型后端大写RADIO / CUSTOMERSfieldTitle:control.label,// ② 控件标题isNeed:control.required?1:0,// ③ 是否必填数字不是布尔important:control.important?1:0,// ④ 是否关键信息数字不是布尔// 【第二类事实契约】1 个extParm:JSON.stringify(control),// ⑤ 整个控件对象序列化成 JSON 字符串}// 【第三类按 type 走的便捷字段】if(control.typeradio||control.typecheckbox){field.optionListcontrol.options?.map(option({value:option}))// ⑥ 选项列表}if(control.typeradio){field.normalValue...// ⑦ 正常值单值}if(control.typecheckbox){field.normalValue...// ⑦ 正常值逗号拼成的字符串}if(control.typestepper){field.normalValue...// ⑦ min-max 字符串比如 10-100}if(control.typecascader){field.cascaderTypeIdcontrol.cascaderCategory// ⑧ 级联分类 ID}4.4 一个具体例子假设你拖出来了这样 2 个控件单选label紧急程度,options[一般, 紧急],requiredtrue选择客户label联系人,requiredfalse,importanttrue提交给后端的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-298res.fieldList.map(item{letextJSON.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的typeObjconsttypeObj{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 升版oprType3时多 3 个字段if(EditMode3){formData_api.refVersionIdid// 指向老版本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 行的这几个关键函数handleAddControl411-456—— 加控件的初始化逻辑handleDeleteControl461-505—— 删除控件含 customers 联动handleEditControl553-595—— 选中控件 → propertyForm 填值handleSaveProperty597-643—— 右侧面板改值 → 写回 formControlsprocessFormData734-781—— 拼提交 JSON再读SpecDynamicForm/index.tsx约 700 行的 3 个 switchrenderItem—— 编辑态13 种 caserenderStaticItem—— 只读态13 种 caseformatSubmitValues—— 提交时13 种 case5.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 个地方加renderControlPreviewOnlySpecDynamicForm的 3 个 switch typeObj——少一个就出 bug提交字段新增在processFormData里加 if 分支参考 radio/checkbox/stepper/cascader 的写法拖拽行为变更看 dnd-kit 的DndContextSortableContext部分addForm/index.tsx:170-251联动逻辑新增比如 project 选完联动 room看SpecDynamicForm里handleProjectChange249-262 行和handleRoomChange195-213 行的模式六、附录3 个核心流程图6.1 加一个新控件用户点击工具栏按钮 (e.g. 图片上传) ↓ handleAddControl(image) ↓ ├─ 校验 customers 强依赖411 行 ├─ 生成 newControl415-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 行 ├─ 构造 updatedControl624-639 行 └─ setFormControls(数组 map 替换对应 id) ↓ 中央预览区 useState 触发重渲染 ↓ renderControlPreviewOnly(updatedControl) 输出新的样子6.3 保存表单用户点击 [保存] ↓ handleSaveForm (701 行) ↓ ├─ form.validateFields() // 校验顶部表单 ├─ 校验 formControls 非空 └─ processFormData(values) // 734 行 ↓ ├─ 拼 parmFormListVoid, name, remark, bizType, formType ├─ 遍历 formControls 拼 fieldList │ ├─ fieldType typeObj[type] // 小写→大写 │ ├─ fieldTitle label │ ├─ isNeed / important 数字 │ ├─ extParm JSON.stringify(control) │ └─ 按 type 拼 optionList / normalValue / cascaderTypeId ├─ 升版时拼 refVersionId / status / version772-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这俩什么关系AextParm是事实契约完整 JSON 字符串fieldType是从 extParm 解析出来的便捷字段大写枚举方便后端建索引。Q后端拿到fieldList[0]时怎么知道这个控件的完整配置AJSON.parse(item.extParm)得到完整FormControl对象。fieldType/optionList这些只是辅助。Qcustomers控件和room控件的依赖关系在设计器和运行时分别怎么保证A设计器handleAddControl添加 customers 前校验411-414 行handleDeleteControl删除 room 时联动删除 customers461-505 行运行时SpecDynamicFormroom 变更时清空 customers 字段195-213 行并重新拉客户列表八、相关文件清单模块路径行数作用核心类型 预览渲染src/common/FormControlUtils.tsx~400FormControl接口、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列表 预览 发版后端 APIsrc/services/api.ts(行 2344-2372)30 行getParmFormList*/saveForm/publishForm/delParmForm