自定义表单跨端共用机制-PC设计APP渲染

自定义表单跨端共用机制(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 组件

关键观察:

  1. PC 端既管"建表单"也管"用表单" ------同一份 SpecDynamicForm 既给设计器预览用,也给设备详情/工单新增/工单步骤操作等业务用。
  2. APP 端只"用表单"------只渲染、只收集、只提交,从不修改 schema。
  3. API 是两套独立路径 (非一份):
    • PC 用 /nbParmFormList/*(表单管理 + 业务消费)
    • APP 用 /nbFlow/form/* + /home/survey/*(流程消费)
    • 之所以不统一,是因为 PC 端"管理"和"业务"都走表单系统,APP 端只对接业务流程入口。
  4. 数据契约只有一个 :fieldList[i].ext_parm(TEXT 字段,存整段 FormControl JSON 字符串)。

二、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)
  • 状态:本地 formControls state,每次属性变化通过 onValuesChange 实时回写
  • 类型:TypeScript FormControl interface

三态机:

状态 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}(强约定)
  • 编辑/只读态切换 (renderItem vs renderStaticItem 同一份 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 渲染

八、已知差异 / 待对齐项

  1. 字段 key 不一致 :PC 端 f_{id},APP 端直接 id。未来若抽公共 schema 包需先统一。
  2. APP 端 FormRenderer 缺 rating :落到 TODO: 未实现控件类型 rating 兜底(FormRenderer.js:877)。
  3. APP 端 typeObj 缺 customers :renderControl 实现了,但后端 CUSTOMERS 字段无法反向映射到 customers(在 typeObj 找前端 type 时会失败)。
  4. APP 端没有统一 formatSubmitValues:每个业务页自己实现 checkbox join / array→string 逻辑。
  5. 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 逻辑。
相关推荐
zhikouai1 小时前
AI辅助逆向工程链路:从二进制接入到等价性校验
java·linux·前端
沙漏无语1 小时前
pnpm 安装@vue-office包的幽灵依赖问题解决
前端·javascript·vue.js
霍格沃兹测试学院-小舟畅学2 小时前
Playwright MCP:AI Agent 驱动 Web 自动化测试的架构与执行机制
前端·人工智能·架构
Jmyd01232 小时前
VR全景线上展会制作流程技术解析:全景采集、点云重建与Web端渲染优化实战
前端·javascript·vr·三维数字化
北城笑笑2 小时前
Server 18 ,Nginx 双服务器 HTTP转HTTPS 代理实战( HTTP 地址换 HTTPS 域名 )
运维·服务器·前端·后端·nginx·http·https
随性而行3602 小时前
企业微信API接口开发实战:如何实现消息回调接收与业务事件处理?
java·前端·python·微信·机器人·企业微信
美味蛋炒饭.2 小时前
java八股面试(3)
java·开发语言·面试
IT研究室2 小时前
最新计算机毕业设计选题推荐-基于spring boot的美食配送平台-网站-文档指导-Java-springboot
java·spring boot·课程设计
℘团子এ2 小时前
vue2中自定义指令的使用
前端·javascript·vue.js