自定义表单:通俗理解指南
用生活化的比喻 + 流程图,把"PC 拖拽搭表单 → App 端渲染使用"讲明白。
本文档基于
newhome-web(PC)+xinhewan-app(APP)双端代码事实梳理。
〇、先打个生活比方
想象你开了一家物业公司,每天有业主来报修。
没有自定义表单的世界
每来一种新报修(空调、水管、电梯、门禁......),程序员就要改一次代码:
- 报修空调 → 写一个
RepairAirConditionPage.js - 报修水管 → 写一个
RepairWaterPage.js - 报修电梯 → 写一个
RepairElevatorPage.js
每加一种新业务,就找程序员 + 走 PR + 走发版。一周才能上线一个新表单。
有了自定义表单的世界
物业管理员自己 在 PC 后台搭积木:
- 拖一个"问题类型"过来
- 拖一个"上门时间"过来
- 拖一个"上传图片"过来
- 拖一个"联系人"过来
保存 ------业主打开 App 就看到这张新表了。5 分钟搞定,不用发版。
一句话总结:自定义表单 = 把"开发表单"变成"配置表单",让业务人员自己搞定业务变更。
一、整套系统的角色分工

| 端 | 角色 | 一句话任务 |
|---|---|---|
| 🖥️ PC 后台 | 甲方 + 业务消费方 | 拖控件配属性,也消费自己配的表单(设备详情/工单/巡检等) |
| 🗄️ 后端 | 仓库 | 存模板、存数据、转发请求 |
| 📱 RN App | 乙方 | 拿模板画 UI、收业主输入 |
重要:PC 端既是"建表单的"也是"用表单的"------设备详情页、新增工单页、工单操作抽屉都用自己配的表单。
二、端到端全流程(一张图走完)

关键事实 :PC/APP 不是同一套 API ------PC 用
/nbParmFormList/*,APP 用/nbFlow/form/*和/home/survey/*。它们共享同一个ext_parm数据库字段,但 HTTP 端点分开。
三、PC 端:拖拽搭表单(真实技术栈)
⚠️ 之前的猜测说"PC 端可能用 react-dnd/formily"------已确认是错的。
3.1 PC 端真实技术栈
| 技术 | 实际用法 |
|---|---|
| 拖拽库 | @dnd-kit/core + @dnd-kit/sortable |
| UI 框架 | antd(不是 antd-mobile) |
| 表单 | antd Form(属性面板用) |
| 类型 | TypeScript FormControl interface |
| 核心渲染器 | 自研 SpecDynamicForm(一个组件统治所有 PC 业务) |
| 预览 | 自研 PhoneFormPreview(手机壳) |
| 低代码引擎 | ❌ 没用 formily/qform |
3.2 PC 端界面布局

3.3 PC 端核心就两件事
1) 拖动(@dnd-kit):
tsx
const handleDragEnd = (event) => {
const {active, over} = event;
if (over && active.id !== over.id) {
setFormControls(arrayMove(
formControls,
formControls.findIndex(c => c.id === active.id),
formControls.findIndex(c => c.id === over.id),
));
}
};
2) 保存:
ts
POST /nbParmFormList/save
{
oprType: 1, // 1新增 2编辑 3升版
parmFormListVo: {
name: '空调报修',
bizType: 'OWNER',
formType: 'FLOW',
fieldList: [
{
fieldType: 'RADIO',
fieldTitle: '报修类型',
isNeed: 1,
extParm: JSON.stringify({ // ★ 整段控件配置
type: 'radio', label: '报修类型',
options: ['空调', '水管', '电梯'],
required: true,
}),
},
// ...
],
},
}
就这么简单。"拖拽"是 UI 表现,底层就是一份 JSON。
3.4 PC 端的三态机
| 状态 | status | 可操作 |
|---|---|---|
| 在用版本 | 1 | 预览 / 升版 / 复制 |
| 待发版 | 0 | 预览 / 编辑 / 发版 / 删除 |
| 历史版本 | -1 | 预览 / 复制 |
为什么需要"发版"机制?因为表单改了不能立刻生效(会影响线上工单),要先存成"待发版"→ 测试通过 → 手动发版才生效。
3.5 PC 端统一渲染器 SpecDynamicForm
这是整个体系的精华------同一个组件,PC 上所有业务(设备详情、新增工单、工单操作、巡检、保养、问卷)全用它:
tsx
// 用法
<SpecDynamicForm
fields={fields} // FormControl[]
initialValues={initial} // 回显(key 形如 f_{id})
onSubmit={handleSubmit}
hideToolbar={true} // 嵌入抽屉
columns={2} // 1单列 2双列
/>
// 父组件通过 ref 拿
const values = formRef.current.getValues()
// values 形如 { f_1: 'xxx', f_2: ['a','b'], ... }
PC 端特性:
- 字段名一律
f_{id}(强约定) - 编辑/只读态切换(同一份 schema 两套渲染)
- 统一提交格式化
formatSubmitValues(checkbox 数组→a,b,c、image 数组→url1,url2、datetime→dayjs 格式化、customers "其他"→"name(phone)"......) - 内置编辑/取消/提交工具栏
四、后端:仓库 + 翻译官
后端是个很单纯的角色 ------只做三件事:存模板、存数据、转发请求 。不解析 ext_parm 里的 JSON,原样存原样给。
4.1 后端存什么?

最核心的一列就是 ext_parm------存的正是 PC 端拖出来的所有配置:
json
{
"type": "radio",
"label": "问题类型",
"options": ["空调", "水管", "电梯"],
"required": true,
"important": true,
"defaultValues": "空调"
}
4.2 后端提供两套 API

之前猜测"PC/APP 用同一套 API"------已确认错误 。这是两套独立 API,只是共享同一个数据库字段
ext_parm。
五、App 端:拿 JSON 画 UI
这是本项目(
xinhewan-app)要做的事。核心思路只有一句:JSON 驱动渲染。
5.1 拿到后端返回的数据
json
// GET /nbFlow/form/123 响应
{
"fieldList": [
{
"id": 101,
"fieldType": "RADIO",
"fieldTitle": "问题类型",
"extParm": "{\"type\":\"radio\",\"label\":\"问题类型\",\"options\":[\"空调\",\"水管\"],\"required\":true}"
},
{
"id": 102,
"fieldType": "TEXT",
"fieldTitle": "联系电话",
"extParm": "{\"type\":\"input\",\"label\":\"联系电话\",\"validation\":{\"max\":11}}"
}
]
}
5.2 App 端做的事(一图流)

5.3 App 端三套渲染器(事实)
| 渲染器 | 文件 | 控件数 | 用途 |
|---|---|---|---|
FormRenderer |
src/components/FormRender/FormRenderer.js |
12(缺 rating) |
报修流程 + 旧问卷 |
QuestionnaireRenderer |
src/pages/Home/QuestionnaireForm.js(内置) |
8(无业务控件 + rating) |
新问卷(多五星评分) |
CustomFormStaticDisplay |
src/components/PropertyOrderDetail/CustomFormStaticDisplay.js |
0(只读) | 工单详情只读回显 |
App FormRenderer 的 switch 本质:
js
const renderControl = (field) => {
switch (field.type) {
case 'radio': return <Radio ... />;
case 'input': return <TextInput ... />;
case 'image': return <ImagePicker ... />;
case 'stepper': return <Stepper ... />;
// ... 12 个 case
default:
return <Text>TODO: 未实现控件类型 {field.type}</Text>;
}
};
5.4 App 端 4 个使用方

5.5 App 端的"坑"(之前没意识到的)
- 字段 key 不统一 :PC 端用
f_{id},APP 端直接用id。这是已存在的不一致。 typeObj缺customers映射 :renderControl 实现了 customers,但反向映射表里没登记,后端返回CUSTOMERS字段时会找不到前端 type。rating在 FormRenderer 里完全缺失 :会落到TODO兜底报错。- 没有统一的提交格式化 :每个业务页(Survey/RepairForms/QuestionnaireForm)自己写 checkbox 数组→字符串逻辑,不像 PC 有
formatSubmitValues统一出口。 - PC 端的
disabled/important/normalValues字段 APP 完全不消费。
六、用了什么第三方依赖?
| 依赖 | 用途 | 用了没 |
|---|---|---|
| PC 端 | ||
@dnd-kit/core + @dnd-kit/sortable |
拖拽 | ✅ |
antd |
UI 框架 | ✅ |
antd Form |
属性面板表单 | ✅ |
dayjs |
日期格式化 | ✅ |
formily / qform |
整套低代码 | ❌ 没用 |
| APP 端 | ||
react-native-picker |
弹下拉框 | ✅ |
@ant-design/react-native |
多选 / 弹窗 / Toast | ✅ |
Stepper.js(自研) |
数字加减 | 自研 |
DateTimePicker.js(自研) |
日期 | 自研 |
DefaultImagePicker(自研) |
图片 | 自研 |
formily / qform |
--- | ❌ 没用 |
react-dnd / dnd-kit |
拖拽 | ❌ App 端不需要 |
结论 :PC 端和 APP 端都没用任何低代码/表单引擎框架 。都是自研 + 通用 UI 库。两端的实现思路非常相似,只是 PC 用 antd-web + dnd-kit,APP 用 antd-mobile + 自研控件。
七、整套体系的心智模型

所有角色都只跟 JSON 打交道------这就是"自定义"的全部秘密。
再浓缩一次:
- PC = 搭积木的工人(@dnd-kit + antd)
- 后端 = 货架 (只存
ext_parm原文)- App = 按图纸(switch)把积木拼成实物的消费者(自研 + antd-mobile-rn + picker)
- 整个过程不写一行新代码就支持新表单------这就是"自定义表单"的全部秘密。
- 唯一耦合点 :
ext_parmJSON 的字段约定 +fieldType大写枚举的对照表。
八、PC 和 APP 的关键差异总结
| 维度 | PC | APP |
|---|---|---|
| 角色 | 建表 + 用表 | 只用表 |
| API | /nbParmFormList/* |
/nbFlow/form/* + /home/survey/* |
| 拖拽 | @dnd-kit | 不需要 |
| 渲染 | SpecDynamicForm(统一) | 3 套(FormRenderer / QuestionnaireRenderer / StaticDisplay) |
| 字段 key | f_{id} |
id 直接用 |
| 提交格式化 | formatSubmitValues 统一 |
各业务页自己写 |
| 状态机 | 在用/待发/历史 | 不感知 |
| 业务控件支持 | 全 | FormRenderer 缺 rating |
九、加新控件的改动清单
唯一耦合点就是 ext_parm JSON + fieldType 枚举。所以加新控件,两端各加 case + 后端加 fieldType 字典项 即可:
| 想加什么 | PC 端要改 | APP 端要改 | 后端要改 |
|---|---|---|---|
| 通用控件 X | FormControlUtils.tsx + basicControlTypes + addForm/typeObj + SpecDynamicForm/renderItem + renderStaticItem + formatSubmitValues |
FormRenderer.js 的 typeObj + renderControl case |
fieldType 字典加一项 |
| 问卷专用 Y | --- | QuestionnaireForm.js 的 QuestionnaireRenderer.renderControl |
--- |
| 详情回显 Z | --- | CustomFormStaticDisplay.js 的 data.map 分支 |
--- |
不需要改 schema 静态文件,不需要发版,不需要碰数据库------这就是 JSON 驱动的力量。