自定义表单跨端共用机制(PC 设计 → APP 渲染)
目的:打通 PC 端(
newhome-web)和 APP 端(xinhewan-app)对"自定义表单"的理解边界。适用:
- APP 端(
xinhewan-app):src/components/FormRender/*、src/components/PropertyOrderDetail/CustomFormStaticDisplay.js、src/pages/Home/Survey.js、src/pages/Home/QuestionnaireForm.js、src/pages/RepairProcess/RepairForms.js、src/services/api.js- PC 端(
newhome-web,引用作参考):src/common/FormControlUtils.tsx、src/pages/parameterManagement/addForm/index.tsx、src/components/SpecDynamicForm/index.tsx、src/components/FormPreview/PhoneFormPreview.tsx状态:以下结论以两侧仓库的代码与 PC 端实现方案文档为准;之前的猜测已逐条修正。
一、整体形态
核心约定 :表单的形态由纯数据 schema (FormControl[])决定,不耦合任何 UI 框架。设计器、预览、运行态渲染、移动端,全是同一份 JSON 的不同呈现。
#mermaid-svg-kElx9IYe1njlxLPH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kElx9IYe1njlxLPH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kElx9IYe1njlxLPH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kElx9IYe1njlxLPH .error-icon{fill:#552222;}#mermaid-svg-kElx9IYe1njlxLPH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kElx9IYe1njlxLPH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kElx9IYe1njlxLPH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kElx9IYe1njlxLPH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kElx9IYe1njlxLPH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kElx9IYe1njlxLPH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kElx9IYe1njlxLPH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kElx9IYe1njlxLPH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kElx9IYe1njlxLPH .marker.cross{stroke:#333333;}#mermaid-svg-kElx9IYe1njlxLPH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kElx9IYe1njlxLPH p{margin:0;}#mermaid-svg-kElx9IYe1njlxLPH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kElx9IYe1njlxLPH .cluster-label text{fill:#333;}#mermaid-svg-kElx9IYe1njlxLPH .cluster-label span{color:#333;}#mermaid-svg-kElx9IYe1njlxLPH .cluster-label span p{background-color:transparent;}#mermaid-svg-kElx9IYe1njlxLPH .label text,#mermaid-svg-kElx9IYe1njlxLPH span{fill:#333;color:#333;}#mermaid-svg-kElx9IYe1njlxLPH .node rect,#mermaid-svg-kElx9IYe1njlxLPH .node circle,#mermaid-svg-kElx9IYe1njlxLPH .node ellipse,#mermaid-svg-kElx9IYe1njlxLPH .node polygon,#mermaid-svg-kElx9IYe1njlxLPH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kElx9IYe1njlxLPH .rough-node .label text,#mermaid-svg-kElx9IYe1njlxLPH .node .label text,#mermaid-svg-kElx9IYe1njlxLPH .image-shape .label,#mermaid-svg-kElx9IYe1njlxLPH .icon-shape .label{text-anchor:middle;}#mermaid-svg-kElx9IYe1njlxLPH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kElx9IYe1njlxLPH .rough-node .label,#mermaid-svg-kElx9IYe1njlxLPH .node .label,#mermaid-svg-kElx9IYe1njlxLPH .image-shape .label,#mermaid-svg-kElx9IYe1njlxLPH .icon-shape .label{text-align:center;}#mermaid-svg-kElx9IYe1njlxLPH .node.clickable{cursor:pointer;}#mermaid-svg-kElx9IYe1njlxLPH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kElx9IYe1njlxLPH .arrowheadPath{fill:#333333;}#mermaid-svg-kElx9IYe1njlxLPH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kElx9IYe1njlxLPH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kElx9IYe1njlxLPH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kElx9IYe1njlxLPH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kElx9IYe1njlxLPH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kElx9IYe1njlxLPH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kElx9IYe1njlxLPH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kElx9IYe1njlxLPH .cluster text{fill:#333;}#mermaid-svg-kElx9IYe1njlxLPH .cluster span{color:#333;}#mermaid-svg-kElx9IYe1njlxLPH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kElx9IYe1njlxLPH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kElx9IYe1njlxLPH rect.text{fill:none;stroke-width:0;}#mermaid-svg-kElx9IYe1njlxLPH .icon-shape,#mermaid-svg-kElx9IYe1njlxLPH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kElx9IYe1njlxLPH .icon-shape p,#mermaid-svg-kElx9IYe1njlxLPH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kElx9IYe1njlxLPH .icon-shape .label rect,#mermaid-svg-kElx9IYe1njlxLPH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kElx9IYe1njlxLPH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kElx9IYe1njlxLPH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kElx9IYe1njlxLPH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} RN App(xinhewan-app)
PC 后台 - 表单设计器(newhome-web)
🗄️ 后端服务
拖拽 @dnd-kit 配属性
预览
复用
① POST /nbParmFormList/save
GET /nbParmFormList/:id
② GET /nbFlow/form/:id
② POST /home/survey/selectByFormIdAndUserId
③ POST /nbFlow/do
③ POST /home/survey/saveResponse
④ GET /nbFlow/flowOrderDetail
form_field 表
ext_parm TEXT 存 JSON
form_response 表
存业主填写值
设计器
pages/parameterManagement/addForm/index.tsx
统一动态渲染器
SpecDynamicForm/index.tsx
PC 端 antd 组件
手机壳只读预览
FormPreview/PhoneFormPreview.tsx
旧问卷 Survey.js
新问卷 QuestionnaireForm.js
报修流程 RepairForms.js
工单详情 CustomFormStaticDisplay.js
FormRenderer.js 通用
RN 组件
QuestionnaireRenderer 问卷内置
RN 组件
关键观察:
- PC 端既管"建表单"也管"用表单" ------同一份
SpecDynamicForm既给设计器预览用,也给设备详情/工单新增/工单步骤操作等业务用。 - APP 端只"用表单"------只渲染、只收集、只提交,从不修改 schema。
- API 是两套独立路径 (非一份):
- PC 用
/nbParmFormList/*(表单管理 + 业务消费) - APP 用
/nbFlow/form/*+/home/survey/*(流程消费) - 之所以不统一,是因为 PC 端"管理"和"业务"都走表单系统,APP 端只对接业务流程入口。
- PC 用
- 数据契约只有一个 :
fieldList[i].ext_parm(TEXT 字段,存整段FormControlJSON 字符串)。
二、Schema 数据形状(两侧共识)
PC 端 src/common/FormControlUtils.tsx 给出了 TypeScript 定义;APP 端通过 JSON.parse(item.extParm) 拿到完全相同的对象:
ts
interface FormControl {
id: string
type: 'radio' | 'checkbox' | 'input' | 'textarea' | 'stepper'
| 'datetime' | 'image' | 'rating' // 基础 8 种
| 'project' | 'room' | 'property_room' // 内容 3 种
| 'cascader' | 'customers' // 内容 2 种
label: string
placeholder?: string
required: boolean
important: boolean // ★ PC 端有,APP 端目前未消费(仅展示用:工单列表+推送)
disabled?: number // 0 可改 / 1 禁用
options?: string[]
defaultValues?: string[]
normalValues?: string[] // 正常值(数据校验/异常标记,APP 端未消费)
cascaderCategory?: string
businessObject?: string // OWNER/INNER,APP 端未消费
validation?: { min?: number; max?: number }
stepperConfig?: { min; max; step; defaultValue; unit?; normalValue? }
datetimeConfig?: { format; showTime; limitType? } // 0不限制 1不可早于今 2不可晚于今
imageConfig?: { antiCheat; maxCount; maxSize? } // maxSize 是压缩后体积
}
2.1 控件清单(PC/APP 对照)
| 分组 | 控件 | type | PC 端支持 | APP FormRenderer | APP QuestionnaireRenderer |
|---|---|---|---|---|---|
| 基础 | 单选 | radio |
✅ antd Radio.Group | ✅ 自研圆点 | ✅ |
| 基础 | 多选 | checkbox |
✅ Checkbox.Group | ✅ antd-mobile Checkbox | ✅ |
| 基础 | 单行文本 | input |
✅ antd Input | ✅ TextInput | ✅ |
| 基础 | 多行文本 | textarea |
✅ Input.TextArea | ✅ TextInput multiline | ✅ |
| 基础 | 步进器 | stepper |
✅ antd InputNumber | ✅ 自研 Stepper.js | ✅ |
| 基础 | 日期时间 | datetime |
✅ antd DatePicker | ✅ 自研 DateTimePicker.js | ✅ |
| 基础 | 图片上传 | image |
✅ ImageUploader(带压缩) | ✅ DefaultImagePicker(自研) | ✅ |
| 基础 | 评分 | rating |
✅ 仅预览用 | ❌ 未实现 | ✅ 五星 |
| 内容 | 项目 | project |
✅ antd Select | ✅ Picker | ❌ |
| 内容 | 房源 | room |
✅ Cascader 三级懒加载 | ✅ Picker 三步异步 | ❌ |
| 内容 | 认证房产 | property_room |
✅ Cascader | ✅ Picker | ❌ |
| 内容 | 级联 | cascader |
✅ antd Cascader | ✅ Picker 二级联动 | ❌ |
| 内容 | 选择客户 | customers |
✅ antd Select | ✅ Picker(联动 room) | ❌ |
⚠️ APP FormRenderer 缺口:
rating完全未实现(只有 QuestionnaireRenderer 支持,FormRenderer 落到TODO兜底)customers在 renderControl 里有 case,但 typeObj 映射表里没登记 (来自后端 fieldType 转前端 type 时的盲区,需补'customers': 'CUSTOMERS')
2.2 字段命名约定(两侧不一致)
| 端 | 字段 key 格式 | 来源 |
|---|---|---|
| PC 端 | f_{control.id} 强制前缀 |
SpecDynamicForm 内部一致 |
| APP FormRenderer | 直接用 id |
FormValues 内部 |
| APP QuestionnaireRenderer | 直接用 id |
QuestionnaireForm 内部 |
| APP StaticDisplay | 按后端原始结构(无 key 概念) | 直接遍历 data[] |
这是已知的、事实的不一致 ------APP 端没遵循 PC 端
f_{id}约定。这是个潜在的维护风险:如果未来想抽公共 schema 包,必须先统一 key 格式。
三、后端存储(事实契约)
后端是"透明的搬运工"------只存 ext_parm 原文,不解析不处理。
3.1 核心表
#mermaid-svg-Sx0yjB8fMm6dPlMv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Sx0yjB8fMm6dPlMv .error-icon{fill:#552222;}#mermaid-svg-Sx0yjB8fMm6dPlMv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Sx0yjB8fMm6dPlMv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Sx0yjB8fMm6dPlMv .marker.cross{stroke:#333333;}#mermaid-svg-Sx0yjB8fMm6dPlMv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Sx0yjB8fMm6dPlMv p{margin:0;}#mermaid-svg-Sx0yjB8fMm6dPlMv .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Sx0yjB8fMm6dPlMv .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-Sx0yjB8fMm6dPlMv .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-Sx0yjB8fMm6dPlMv .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-Sx0yjB8fMm6dPlMv .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-Sx0yjB8fMm6dPlMv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-Sx0yjB8fMm6dPlMv .node rect,#mermaid-svg-Sx0yjB8fMm6dPlMv .node circle,#mermaid-svg-Sx0yjB8fMm6dPlMv .node ellipse,#mermaid-svg-Sx0yjB8fMm6dPlMv .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Sx0yjB8fMm6dPlMv .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Sx0yjB8fMm6dPlMv .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Sx0yjB8fMm6dPlMv .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Sx0yjB8fMm6dPlMv .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-Sx0yjB8fMm6dPlMv .edgeLabel .label text{fill:#333;}#mermaid-svg-Sx0yjB8fMm6dPlMv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1对多
1对多
form_template
int
id
PK
string
name
string
biz_type
string
form_type
FLOW=工单
int
status
1在用 0待发 -1历史
int
version
int
ref_version_id
form_field
int
id
PK
int
form_id
FK
string
field_type
TEXT/RADIO/IMAGE/CASCADER...
string
field_title
int
is_need
1必填 0非必填
int
important
text
ext_parm
★ 整段 FormControl JSON
json
option_list
radio/checkbox 后端冗余索引
string
normal_value
string
cascader_type_id
int
sort
form_response
int
id
PK
int
form_id
FK
int
field_id
FK
string
field_value
用户填的值
json
image_urls
IMAGE 专用
json
cascader_full_name
CASCADER 专用
int
user_id
datetime
create_time
关键事实:
ext_parm列是事实契约 ------所有端按JSON.parse(item.ext_parm)还原出完整的FormControl。option_list/normal_value/cascader_type_id是后端为索引/校验而做的冗余副本,前端不应从这里读数据。image_urls/cascader_full_name是 APP 端详情回显专用的"已加工好"字段。
3.2 API(两套独立路径)
| 端 | 接口 | 用途 |
|---|---|---|
| PC | GET /nbParmFormList |
分页查询表单列表(status 区分三态) |
| PC | GET /nbParmFormList/{id} |
获取表单定义 |
| PC | POST /nbParmFormList/save |
新增/编辑/升版(oprType 1/2/3) |
| PC | POST /nbParmFormList/publish |
发版 |
| PC | DELETE /nbParmFormList/{id} |
删除 |
| APP | GET /nbFlow/form/{id} |
拉表单结构(报修用) |
| APP | POST /nbFlow/do |
提交工单 |
| APP | POST /home/survey/selectByFormIdAndUserId |
拉问卷(带回显) |
| APP | POST /home/survey/saveResponse |
提交问卷 |
| APP | GET /home_new/{id}/blocks / /units / getHomesByUnitId |
room 三级级联 |
| APP | GET /user/listByHomeIdNew |
customers 选项 |
⚠️ 这是两套独立 API ,不是一份。PC 走"表单管理"接口,APP 走"业务流程"接口。两者共享后端
ext_parm存储,但 HTTP 端点分离。
四、PC 端:设计器 + 渲染器(事实细节)
以下是已确认 的 PC 端实现细节,纠正之前"猜测自研"的模糊描述。
4.1 设计器(pages/parameterManagement/addForm/index.tsx)
#mermaid-svg-NdQeeN3phHSAkitO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NdQeeN3phHSAkitO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NdQeeN3phHSAkitO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NdQeeN3phHSAkitO .error-icon{fill:#552222;}#mermaid-svg-NdQeeN3phHSAkitO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NdQeeN3phHSAkitO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NdQeeN3phHSAkitO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NdQeeN3phHSAkitO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NdQeeN3phHSAkitO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NdQeeN3phHSAkitO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NdQeeN3phHSAkitO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NdQeeN3phHSAkitO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NdQeeN3phHSAkitO .marker.cross{stroke:#333333;}#mermaid-svg-NdQeeN3phHSAkitO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NdQeeN3phHSAkitO p{margin:0;}#mermaid-svg-NdQeeN3phHSAkitO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NdQeeN3phHSAkitO .cluster-label text{fill:#333;}#mermaid-svg-NdQeeN3phHSAkitO .cluster-label span{color:#333;}#mermaid-svg-NdQeeN3phHSAkitO .cluster-label span p{background-color:transparent;}#mermaid-svg-NdQeeN3phHSAkitO .label text,#mermaid-svg-NdQeeN3phHSAkitO span{fill:#333;color:#333;}#mermaid-svg-NdQeeN3phHSAkitO .node rect,#mermaid-svg-NdQeeN3phHSAkitO .node circle,#mermaid-svg-NdQeeN3phHSAkitO .node ellipse,#mermaid-svg-NdQeeN3phHSAkitO .node polygon,#mermaid-svg-NdQeeN3phHSAkitO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NdQeeN3phHSAkitO .rough-node .label text,#mermaid-svg-NdQeeN3phHSAkitO .node .label text,#mermaid-svg-NdQeeN3phHSAkitO .image-shape .label,#mermaid-svg-NdQeeN3phHSAkitO .icon-shape .label{text-anchor:middle;}#mermaid-svg-NdQeeN3phHSAkitO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NdQeeN3phHSAkitO .rough-node .label,#mermaid-svg-NdQeeN3phHSAkitO .node .label,#mermaid-svg-NdQeeN3phHSAkitO .image-shape .label,#mermaid-svg-NdQeeN3phHSAkitO .icon-shape .label{text-align:center;}#mermaid-svg-NdQeeN3phHSAkitO .node.clickable{cursor:pointer;}#mermaid-svg-NdQeeN3phHSAkitO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NdQeeN3phHSAkitO .arrowheadPath{fill:#333333;}#mermaid-svg-NdQeeN3phHSAkitO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NdQeeN3phHSAkitO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NdQeeN3phHSAkitO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NdQeeN3phHSAkitO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NdQeeN3phHSAkitO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NdQeeN3phHSAkitO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NdQeeN3phHSAkitO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NdQeeN3phHSAkitO .cluster text{fill:#333;}#mermaid-svg-NdQeeN3phHSAkitO .cluster span{color:#333;}#mermaid-svg-NdQeeN3phHSAkitO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NdQeeN3phHSAkitO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NdQeeN3phHSAkitO rect.text{fill:none;stroke-width:0;}#mermaid-svg-NdQeeN3phHSAkitO .icon-shape,#mermaid-svg-NdQeeN3phHSAkitO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NdQeeN3phHSAkitO .icon-shape p,#mermaid-svg-NdQeeN3phHSAkitO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NdQeeN3phHSAkitO .icon-shape .label rect,#mermaid-svg-NdQeeN3phHSAkitO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NdQeeN3phHSAkitO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NdQeeN3phHSAkitO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NdQeeN3phHSAkitO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击添加
拖拽排序
点击选中
onValuesChange 实时回写
保存
左侧 控件库
基础8 + 内容5
中间 手机壳画布
右侧 属性面板
POST /nbParmFormList/save
技术栈(已确认):
- 拖拽:
@dnd-kit/core+@dnd-kit/sortable(不是react-dnd) - UI:
antd组件库 - 表单:
antd Form(属性面板的propertyForm) - 状态:本地
formControlsstate,每次属性变化通过onValuesChange实时回写 - 类型:TypeScript
FormControlinterface
三态机:
| 状态 | status | 可操作 |
|---|---|---|
| 在用版本 | 1 | 预览 / 升版 / 复制 |
| 待发版 | 0 | 预览 / 编辑 / 发版 / 删除 |
| 历史版本 | -1 | 预览 / 复制 |
升版(oprType=3) :复制当前在用版本作为新草稿,老版本号递增,新版本 refVersionId 指向老版本。
4.2 渲染器(components/SpecDynamicForm/index.tsx)
所有 PC 业务消费方的统一入口:
tsx
const formRef = useRef<SpecDynamicFormHandles>(null)
<SpecDynamicForm
fields={fields} // FormControl[]
initialValues={initial} // 回显,key 形如 f_{id}
onSubmit={handleSubmit}
hideToolbar={true} // 嵌入抽屉/页面用
columns={2} // 1 单列 / 2 双列
buildingId={buildingId} // room 控件兜底
/>
// 父组件通过 ref 拿:
formRef.current?.getValues() // { f_1: 'xxx', f_2: ['a','b'], ... }
formRef.current?.setEditing(false)
formRef.current?.submit()
关键设计:
- 字段名一律
f_{id}(强约定) - 编辑/只读态切换 (
renderItemvsrenderStaticItem同一份 schema) - 统一提交格式化 (
formatSubmitValues,覆盖所有 type) - builtin 工具栏 (编辑/取消/提交),可
hideToolbar隐藏 - 每 type 用 antd 组件 :
Input / Input.TextArea / Radio.Group / Checkbox.Group / InputNumber / DatePicker / Cascader / Select / ImageUploader rating在 PC 仅预览用(不存值,只展示)
4.3 手机壳预览(FormPreview/PhoneFormPreview.tsx)
- 268×560 圆角手机框 + 顶部刘海遮罩
- 中间滚动区按控件顺序渲染
- 复用
renderControlPreviewOnly,所以预览 = 真实渲染的只读态 - 用在:表单管理列表的"预览"弹窗、设计器顶部的"预览"弹窗
4.4 提交格式化(formatSubmitValues,PC 端有统一出口)
| type | 格式化逻辑 |
|---|---|
| datetime | dayjs(v).format(format) |
| checkbox | 数组 → 'a,b,c' |
| image | 数组 → 'url1,url2' |
| customers | "其他" → "name(phone)";其他 → label |
| cascader/room/property_room | 数组 → 取最深层级 id(字符串) |
| 其他 | 强制转字符串 |
⚠️ APP 端没有这个统一格式化函数 ------
Survey.js/RepairForms.js/QuestionnaireForm.js各自实现自己的格式化逻辑。这是 PC 端比 APP 端做得好的地方。
4.5 PC 端的级联控件数据源
| 控件 | 数据接口 |
|---|---|
| cascader | getCascaderList({ typeId: cascaderCategory }) |
| project | getOrgBuildingList |
| room 第 1 级 | queryBuildingsByCommunityId |
| room 第 2 级 | queryUnitsByHomeId |
| room 第 3 级 | queryHomesByUnitId |
| customers | getUserListByHomeIdNew(homeId) |
五、APP 端:渲染与收集(事实细节)
5.1 通用渲染器 FormRenderer.js
入口签名(FormRenderer.js:44):
jsx
<FormRenderer
fields={fields} // schema
echodata={echodata} // 已存在的填写值
onChange={handleChange} // (values, extra) => void
buildings={list} // project 控件的选项源
home={homeList} // property_room 控件的选项源
curId={curId} // room 三级级联入口
disabled={false} // 整体禁用
showFixedDescription={true} // 是否显示内置描述字段
/>
主循环:
jsx
{fields_form.map(f => (
<View key={f.id}>
<Text style={[styles.requiredMark, {opacity: f.required ? 1 : 0}]}>*</Text>
<Text>{f.label}</Text>
{renderControl(f)} {/* switch(f.type) → 12 个 case(缺 rating) */}
</View>
))}
renderControl 内部 switch(f.type) 写了 12 个 case(实测:stepper/input/textarea/radio/checkbox/datetime/image/property_room/project/room/cascader/customers + default 兜底)。没有 rating。
typeObj 映射(11 个):
js
{
radio: 'RADIO', checkbox: 'CHECKBOX', input: 'TEXT', textarea: 'TEXTAREA',
stepper: 'STEPPER', datetime: 'DATETIME', image: 'IMAGE', room: 'ROOM',
project: 'PROJECT', property_room: 'PROPERTY_ROOM', cascader: 'CASCADER',
// 缺:rating: 'RATING', customers: 'CUSTOMERS'
}
值收集 (单参 vs 双参):
js
const updateValue = (id, value) => {
setFormValues({...formValues, [id]: value});
onChange?.(newValues); // 单参
};
const updateFixedDescription = text => {
setFixedDescription(text);
onChange?.(formValues, {[fixedDescriptionFieldId]: text}); // 双参:description 走"第二参数"
};
5.2 问卷渲染器 QuestionnaireRenderer(在 QuestionnaireForm.js)
jsx
<QuestionnaireRenderer
fields={fields}
values={formValues} // 外部维护(受控)
onChange={setFormValues} // 整对象 onChange
readOnly={readOnly}
/>
8 个 case :input/textarea/radio/checkbox/rating/stepper/datetime/image。没有 room/property_room/project/cascader/customers 。多了 rating(五星评分)。
5.3 只读回显 CustomFormStaticDisplay.js
给工单详情用,完全没有表单控件 ,纯按 parmFormFieldVo.fieldType 渲染:
jsx
data.map(item => {
const type = item.parmFormFieldVo?.fieldType;
if (type === 'PROPERTY_ROOM' || type === 'ROOM') {
// 异步预拉 getHomeInfoById 把 id 拼成 "楼栋 / 房号"
} else if (type === 'CASCADER') {
return item.cascaderFullName;
} else if (type === 'IMAGE') {
return item.imageUrls;
} else {
return item.fieldValue || '--';
}
})
只读字段类型,不读 schema。
六、PC ↔ APP 两侧实现对比(修正后的总览)
| 维度 | PC 端(newhome-web) | APP 端(xinhewan-app) |
|---|---|---|
| 拖拽库 | @dnd-kit/core + @dnd-kit/sortable |
不需要 |
| UI 框架 | antd(Form/Input/DatePicker/Cascader/Checkbox.Group...) | antd-mobile-rn(少量) + 自研 + react-native-picker |
| 核心渲染器 | SpecDynamicForm(一个组件统治所有业务) |
3 套:FormRenderer + QuestionnaireRenderer + CustomFormStaticDisplay |
| Schema 类型 | TypeScript FormControl interface |
纯 JS 对象 |
| 字段 key 格式 | f_{id}(强约定) |
直接用 id(不一致) |
| 提交格式化 | formatSubmitValues(统一出口) |
散在业务页(Survey/RepairForms/QuestionnaireForm 各自写) |
| 手机壳预览 | PhoneFormPreview 268×560 + 刘海 |
无 |
| 状态机 | 在用/待发版/历史三态 | 不感知 |
| 升版机制 | oprType=3 升版 | 不涉及 |
| API | /nbParmFormList/* |
/nbFlow/form/* + /home/survey/* |
| 控件实现 | 用 antd 组件为主 | 自研 Stepper/DateTimePicker/ImagePicker + 第三方 picker |
rating 控件 |
✅ 仅预览用 | ✅ 仅 QuestionnaireRenderer 实现 |
customers 控件 |
✅ Select | ✅ Picker(联动 room) |
修正之前错误认知:
- ❌ "PC 端可能用
react-dnd" → ✅ 用@dnd-kit- ❌ "PC 端大概率是 formily/qform" → ✅ 自研 + dnd-kit + antd
- ❌ "APP 端没用到低代码引擎" → ✅ 确认(PC 端也没用)
- ❌ "PC/APP 用同一套 API" → ✅ 两套独立 API ,只共享后端
ext_parm存储
七、APP 端消费场景横评
| 场景 | 文件 | 渲染器 | 数据源 API | 提交 API | 备注 |
|---|---|---|---|---|---|
| 旧问卷 | pages/Home/Survey.js |
FormRenderer |
/home/survey/selectByFormIdAndUserId |
/home/survey/saveResponse |
已提交则全字段禁用 |
| 新问卷 | pages/Home/QuestionnaireForm.js |
QuestionnaireRenderer(内置) |
getUserSurveyDetail |
saveSurveyResponse |
支持 rating 五星;支持"问卷结果"只读模式 |
| 报修流程 | pages/RepairProcess/RepairForms.js |
FormRenderer |
/nbFlow/form/:id |
/nbFlow/do |
含流程字段 stepId/flowId/oprInfo/userInfo |
| 工单详情 | components/PropertyOrderDetail/CustomFormStaticDisplay.js |
只读自渲染 | getHomeInfoById / getUserListByHomeIdNew |
--- | 不解析 extParm,按 parmFormFieldVo.fieldType 渲染 |
八、已知差异 / 待对齐项
- 字段 key 不一致 :PC 端
f_{id},APP 端直接id。未来若抽公共 schema 包需先统一。 - APP 端 FormRenderer 缺
rating:落到TODO: 未实现控件类型 rating兜底(FormRenderer.js:877)。 - APP 端 typeObj 缺
customers:renderControl 实现了,但后端CUSTOMERS字段无法反向映射到customers(在typeObj找前端 type 时会失败)。 - APP 端没有统一
formatSubmitValues:每个业务页自己实现 checkbox join / array→string 逻辑。 - PC 端的
important/normalValues/disabled字段 :APP 端没消费(暂时没业务影响,但若 PC 端配置了disabled=1需要禁用对应字段,APP 端会忽略)。
九、加新控件的改动清单
| 想加的东西 | PC 端 | APP 端 | 后端 |
|---|---|---|---|
通用控件 xxx |
FormControlUtils.tsx 的 type 联合 + basicControlTypes + renderControlPreviewOnly case + addForm 的 typeObj + SpecDynamicForm 的 renderItem + renderStaticItem + formatSubmitValues |
FormRenderer.js 的 typeObj + renderControl case |
fieldType 字典加一项 |
问卷专用 yyy |
--- | QuestionnaireForm.js 的 QuestionnaireRenderer.renderControl |
--- |
详情回显 zzz |
--- | CustomFormStaticDisplay.js 的 data.map 分支 |
--- |
最小改动原则 :两侧都加 case + 后端
fieldType字典加项即可,无需改任何"模板"文件。
十、整套体系的心智模型
把它当成一个 JSON Schema 驱动的渲染协议:
PC 端 = "写协议的甲方 + 自己也是"乙方"(消费方)"
后端 = "存协议的乙方"
APP 端 = "按协议画 UI 的丙方"
- 协议字段 :每条字段的
ext_parm(JSON 字符串,整段FormControl)。 - 协议键 :
type(决定控件)、label/required/options/validation/xxxConfig。 - PC 端契约义务:拖出来什么就存什么;自有渲染器用 antd 严格按 schema 渲染。
- APP 端契约义务 :识别
type字符串、读懂对应 config、把用户输入按业务形态提交。 - PC/APP 自由度 :只要新增控件的
type字符串双方都"看得懂",否则落到TODO/暂不支持兜底。 - 共享约束 :未来想抽公共包(
shared/FormControl.ts)需要先统一字段 key 格式和formatSubmitValues逻辑。