Skip to content

在线表单在移动端 ​

适用端:Web + 移动端 | 类型:机制

定制功能

「在线表单发布到 App 端」是本项目自研能力,上游没有。它把三样东西缝在一起:低代码表单设计器(infra)、分端导航树(system)、以及移动端一个自研的 form-create 渲染器(8,652 行 TypeScript + Vue)。上游 yudao-ui-admin-uniapp 只有 BPM 流程表单,没有在线表单运行时。

同一张在线表单,在 Web 端配一次,移动端就能用——不需要为移动端单独开发页面。这条链路怎么成立的:

  • 在 Web 设计器里点「发布到 App 端」,到底往几个地方写了东西?
  • 移动端凭什么知道该渲染哪些字段、哪些按钮?配置从哪来?
  • Web 用 form-create + Element Plus 渲染,移动端没有 Element Plus,那 55 个控件是怎么画出来的?
  • 有控件移动端画不出来时会怎样——白屏、报错,还是别的?
  • 「下线」一张表单,App 端的入口会自动消失吗?

前置阅读:服务端驱动导航:system_nav_node 全链路(App 入口是怎么进工作台的)、表单运行时与扩展点(Web 侧的运行时机制,移动端复用同一套配置与端点)

它解决什么问题 ​

在线表单的价值主张是「配一次、到处用」。但「到处」原本只包含 Web。要在移动端也能用,最省事的做法是给每张表单手写一个移动端页面——那等于把低代码的收益全丢掉。

本项目的做法是一个通用运行时页 + 一个自研渲染器:移动端只有 pages-infra/form-design/run/ 这一个页面目录,靠 ?code=xxx 区分是哪张表单;渲染器读的是和 Web 完全相同的 formConf / formFields / listConf,打的也是完全相同的 /infra/form-design/runtime/{code}/* 端点。两端唯一的差别是「rule 画成 Element Plus 还是 wot-design-uni」。

编号 2-5 是前端编排,不是一个后端事务

infra 模块不能依赖 system 模块(依赖是单向的),所以「发布」没法在后端一次做完。后端只负责算好配置回传(FormDesignPublishRespVO.menuConfig / navNodeConfig / needCreateMenu / needCreateNavNode),真正的三次写入由 PublishDialog.vue 依次调接口完成。

后果:这几步之间没有事务。第 3 步成功、第 4 步失败,库里就会留下一个有 Web 菜单但没有 App 入口的表单。排查这类「半发布」状态时要把三处都查一遍,见坑与注意。

代码在哪 ​

端位置作用
后端enlev-module-infra/.../service/formdesign/FormDesignServiceImpl.java:100-105三个常量:APP_NAV_ROUTE = "/pages-infra/form-design/run/index"、NAV_NODE_TYPE_ITEM = 3、APP_NAV_ICON = "list"
后端同上 :560-587publish():组装 menuConfig / navNodeConfig 回传前端
后端同上 :711-728buildNavNodeConfig():算 App 节点的名称/路由/参数/权限串/排序/图标
后端同上 :815-872unpublish():清 menu_id 与 app_nav_node_id,把两个编号交还前端去删
后端.../controller/admin/formdesign/FormDesignRuntimeController.java(17 个 /{code}/* 端点)运行时数据 CRUD,编程式鉴权(权限串含动态 code,@PreAuthorize 表达不了)
后端.../resources/form-design/form-widget-contracts.json规范副本:63 个控件的事实来源
后端.../resources/form-design/SYNC.md两份契约的同步约定
Websrc/views/infra/formDesign/components/PublishDialog.vue(:186,297-341,350-404)发布编排:列节点 → 建菜单 → 建/更新 App 节点 → 回写绑定
Websrc/api/infra/formDesign/index.ts:229-243,294-306PublishNavNodeConfigVO 类型与 bindAppNav
Websrc/components/FormWidgets/contracts/契约的构建期副本 + 访问层 index.ts(13,957 字节)
移动端src/pages-infra/form-design/run/index.vue(359 行)列表页:搜索弹窗、标签筛选、z-paging、卡片、FAB 新增
移动端src/pages-infra/form-design/run/form.vue(181 行)新增/编辑页
移动端src/pages-infra/form-design/run/detail.vue(197 行)详情页
移动端src/pages-infra/form-design/run/components/(1,516 行)list-conf.ts(757,解析列表配置)、rule-adapter.ts(398,从表单规则兜底推列)、search-popup.vue(260)、data-card.vue(101)
移动端src/api/infra/form-design/index.ts(100 行)8 个运行时接口 + FormRuntimeConfig 类型
移动端src/pages-bpm/components/form-create/(8,652 行)自研渲染器,三个 package:core / utils / wot-ui
移动端src/hooks/useAccess.tshasAccessByCodes(),读 userStore.permissions

渲染器放在 pages-bpm/ 下,但在线表单也在用它

src/pages-bpm/components/form-create/ 这个路径是历史遗留——它最初只为 BPM 流程表单而写。实测 6 个消费方里有 3 个属于在线表单:

消费方属于
pages-bpm/processInstance/create/form.vueBPM 发起流程
pages-bpm/processInstance/detail/audit/index.vueBPM 审批
pages-bpm/processInstance/detail/components/form-detail.vueBPM 详情
pages-infra/form-design/run/form.vue在线表单新增/编辑
pages-infra/form-design/run/detail.vue在线表单详情
pages-bpm/utils/form-create.ts共用工具(decodeFields 等)

所以改这个渲染器,BPM 和在线表单会一起受影响。别因为路径里有 pages-bpm 就以为改动只波及流程表单。

核心机制 ​

1 一次发布,三处登记 ​

publish()(FormDesignServiceImpl:560-587)本身只改 infra_form_design 一行(状态 → PUBLISHED、写 published_version),然后把「该建什么」算好回传:

java
respVO.setNeedCreateMenu(firstPublish);
if (firstPublish)      respVO.setMenuConfig(buildMenuConfig(formDesign, reqVO));
// 已有节点时也给配置:表单改名后 App 入口的名字要跟着变,前端拿它走更新
respVO.setNeedCreateNavNode(publishToApp && firstAppPublish);
if (publishToApp)      respVO.setNavNodeConfig(buildNavNodeConfig(formDesign, reqVO));

注意 navNodeConfig 的条件比 needCreateNavNode 宽:只要 publishToApp 就给配置,但只有首次发布到 App 才让前端去 create。第二次发布时前端拿同一份配置走 update 分支——这样「表单改了名,App 入口的名字跟着变」才成立。

前端 syncAppNavNode(PublishDialog.vue:350-404)据此分三条路:

后端说前端做返回
needCreateNavNode=truecreateNavNode(...) → bindAppNav(designId, navNodeId)'created'
false,且表单上有 appNavNodeIdgetNavNode(existingId) → updateNavNode({...existing, 只覆盖跟着表单走的字段})'updated'
false,但表单上没有 appNavNodeId抛错:「后端未下发新建标记,但表单上也没有已登记的 App 导航节点编号」——

更新分支只动 6 个字段(name / route / query / permissions / icon / sort),刻意不覆盖 parentId——:346-348 的注释写明:parentId 是用户首次发布时选的,再次发布时后端不再下发,拿 config.parentId 覆盖会把节点挪到 null 上去。

2 权限串只给 :query,这是设计而不是偷懒 ​

buildNavNodeConfig(:711-728)里最值得注意的一行:

java
config.setPermissions(String.format("infra:form-design-run:%s:query", formDesign.getCode()));

:706-710 的 javadoc 解释了为什么不把八个按钮权限都塞进去:

nav_node.permissions 的语义是「逗号分隔、任一命中即可见」,塞八个会变成「有任一权限就显示」——而我们要的恰恰是「能查列表的人才看得到入口」。新增/修改/删除按钮在运行页里再按各自的权限串单独判显隐。

所以是两级判定:

判什么判据在哪判
工作台看不看得到这个入口infra:form-design-run:{code}:query后端建树时剪枝(见 nav-node)
进去之后能不能新增/编辑/删除...:{code}:create / :update / :delete移动端 run/index.vue:183-201

移动端那半段的写法(:182-201):

ts
/** 权限串由发布时登记的菜单按钮生成,服务端每个端点各查各的,这里只管显隐 */
function hasRunAccess(action: string): boolean {
  return hasAccessByCodes([`infra:form-design-run:${formCode.value}:${action}`])
}
const canCreate = computed(() => hasRunAccess('create') && !!toolbarButton(listConf.value.operation, 'create'))
const canUpdate = computed(() => hasRunAccess('update') && !!rowButton(listConf.value.operation, 'edit'))
const canDelete = computed(() => hasRunAccess('delete') && !!rowButton(listConf.value.operation, 'delete'))

权限码和 UI 配置是 && 关系:有权但设计器没配这个按钮,不显示;配了按钮但没权,也不显示。新增走工具栏口径、编辑/删除走行内口径,:187-192 的注释说明这与 Web 一致——「operationColumn.show 关的是表格行末那一列,而『新增』在工具栏上,关掉操作列不该把新增入口一起带走」。

按钮文案也取自设计器配置(:202-204):「删除」在有些业务里叫「作废」,没配才用出厂文案。

前端的 hasRunAccess 只管显隐,不是鉴权

真正的拦截在 FormDesignRuntimeController 里,用 SecurityFrameworkService 编程式校验(类注释 :46-52 写明原因:权限串含动态 code,@PreAuthorize 的静态字符串表达不了)。同一段注释还有一句红线:

运行时接口本身【必须】校验菜单权限 —— 否则任何登录用户都能读写任意绑定表。

给运行时加新端点时,别忘了这一步:漏掉的表现是「换个 code 就能读到别人的表」,界面上完全看不出来。

3 一个运行时页承载所有表单 ​

run/index.vue 靠 code 区分表单,而 code 来自导航节点的 query:

ts
/** code 来自导航节点的 query(`code={表单编码}`),uni-app 会把 query 注入同名 prop */
const props = defineProps<{ code?: string }>()
const formCode = computed(() => props.code || '')

后端 buildNavNodeConfig 写的就是 config.setQuery("code=" + formDesign.getCode()),route 恒为 APP_NAV_ROUTE。所以工作台里所有在线表单入口的 route 都相同,只有 query 不同——这和 nav-node 里那 4 个共用 /pages/bpm/index 的 tabbar 项是同一个套路。

页面结构(index.vue:1-70)从上到下:自定义 navbar(标题取表单名)→ 搜索弹窗(没配查询项就不渲染,注释:「空搜索框点开会是一张白弹窗」)→ 标签筛选(tabItems.length > 1 才渲染)→ z-paging 列表 → FAB 新增按钮。

三个实现细节值得单说:

  • :auto="false" 是必需的。列配置来自 getFormRuntimeConfig,自动查询会在配置到位前先打一次 /page,那一次既没有排序字段也没有查询条件,「翻出来的第一页和后面几页规则不一致」。
  • 左侧树被折进搜索弹窗。Web 端列表左边有一棵常驻的树,移动端没那个位置,于是折成弹窗里的一个下拉——「一样是按那个字段筛,配置一个字都不丢」。同一字段已配了查询项时不再追加,因为「两个长得一样的下拉摆在一起,用户只会以为其中一个坏了」。
  • 没配列表列时从表单规则兜底推一份(deriveColumns(decodeFields(config.formFields)),rule-adapter.ts)。注释:「一张没有标题的卡片和『加载失败』在用户眼里是同一件事」。纯表单类型(formType=1)没有 list_conf,全靠这条兜底。

4 自研渲染器:rule → wot-design-uni ​

src/pages-bpm/components/form-create/ 是 form-create 规则模型的独立 uni-app/TS 实现——不是上游 form-create 的源码副本(目录结构 packages/{core,utils,wot-ui} 与 API 概念沿用,但没有一行上游代码)。8,652 行,分三层:

package内容关键文件
core与 UI 库无关的规则模型:类型注册表、校验、子表、控件生命周期registry.ts(171 行,28 个类型组)、validate.ts、subForm.ts、control.ts、manager.ts
utils纯函数:rule / json / merge / typerule.ts、merge.ts
wot-ui把 rule 画成 wot-design-uni 组件index.vue(外壳)、components/fieldRenderer.vue(332 行,分发器)、parsers/(8 个布局解析器)、core/alias.ts(37 行,rule type → 组件名)

渲染路径是两段的,别搞混:

第一段 parseRules(parsers/index.ts:19-31)把布局容器展平:row / table / card / group / tabs / collapse / space 七个解析器各有自己的 match(),命中就调 render() 把容器拆成子规则,必要时插入内部规则 __fcLayoutTitle(容器标题)和 __fcLayoutGap(间距占位)。移动端没有栅格概念,所以 row/col/table/td 这些在 Web 端是二维布局的类型,在移动端一律被拍平成纵向序列。

第二段 fieldRenderer.vue 是一条 30 个分支的 v-else-if 链,每个分支对应一个 isXxxType(rule) 谓词。谓词最终都落到 registry.ts 的 FORM_CREATE_TYPE_GROUPS——28 个类型组、共 130 多个可接受的 type 字符串,因为同一控件在 form-create 生态里有多种写法(upload / uploader / uploadFile / FileUpload / ImageUpload…全指向 fc-uploader)。这张表就是移动端对「什么 type 我认得」的唯一权威。

为什么不用 v-model 直接绑

wot-design-uni 的组件与 form-create 的 rule 模型在值的形状上不一致(多选是数组、日历区间是数组、sliderRange 是二元组…)。fieldRenderer 的每个分支都显式接 :model-value + @update:model-value="emitUpdate",中间由 registry.ts:154-170 的 isMultipleValueType / isCalendarArrayValueType / isDatePickerArrayValueType 判定该不该按数组处理。改值形状时这三个谓词和对应分支要一起改。

5 控件覆盖契约:55 个 supported,移动端渲染 43,缺 12 ​

Web 侧的规范副本 form-widget-contracts.json(contractVersion: 2)登记了 63 个控件,其中 55 个 supported: true(8 个是「内核提供但我们不支持」,发布校验会拒绝)。

把移动端四个分发来源(registry.ts 的 28 个类型组、core/utils.ts 的 4 个内联谓词、fieldRenderer.vue 的 5 个内联 rule.type ===、8 个 parser 的 match() 集合)合起来与那 55 个逐条对账,实测:

数量
契约登记的控件63
其中 supported: true55
移动端能渲染43(basic 11 / select 8 / advanced 6 / display 9 / layout 9)
移动端渲染不出来12

缺的 12 个,按影响面排:

type组名称缺了会怎样
Formulaadvanced计算公式值算不出来(Web 端由 FormFormula 驱动)
RelationFormselect关联表单选不了关联记录
RemotePickerselect远程选择器选不了远程数据
BillNumberInputsystem单据编号取不到号
SystemValuesystem系统值当前用户/时间等填不进去
OrgPickersystem组织选择器选不了组织
timeRangebasic时间区间填不了时间段
elTreeSelectselect树形选择选不了树(注意 treeSelect / TreeSelect / tree 是认得的,缺的只是 elTreeSelect 这一种写法)
CodeImagedisplay条码/二维码显示不出来
Linkdisplay超链接显示不出来
FormStepslayout步骤条分步表单会退化
FormSteplayout步骤同上

前 6 个是功能性的(填不出正确的值),后 6 个是展示/布局性的(值本身不受影响)。补齐优先级应该按这个分界走,而不是按列表顺序。

🔴 反向也成立:移动端还留着 3 个平台已删除的控件渲染器

契约把 UserSelect、DeptSelect、ApiSelect 标为 supported: false,label 直接写着「已删除」。Web 侧 src/components/FormWidgets/ 里已经没有这三个控件的任何实现(grep 只在契约 JSON 里命中)。

但移动端完整保留着它们:

文件行数
custom/deptSelect.vue604
custom/apiSelect.vue156
custom/userSelect.vue133
合计893 行死代码

外加 custom/index.ts 的导出、core/utils.ts:154-172 的三个谓词、fieldRenderer.vue:100-108 的三个分支、core/alias.ts:18-21 的四条别名。

设计器里已经选不到这三个控件,所以不可能有新的 rule 用到它们;只有历史表单里残留的 rule 才会走到。删之前先确认库里没有存量:查 infra_form_design.form_fields 里是否还含这三个 type。

(另外 4 个 supported: false 的类型——signaturePad、upload、tableForm、subForm——不是死代码:它们在 registry.ts 里是 signature / uploadFile / group 的别名写法,属于防御性兼容,别一起删掉。)

6 缺控件时不崩,显式降级 ​

fieldRenderer.vue:177-181 的兜底分支:

vue
<wd-form-item v-else :title="rule.title || rule.type" :title-width="titleWidth" :prop="rule.field" layout="vertical">
  <view class="fc-field-renderer__unsupported">
    暂不支持「{{ rule.title || rule.type }}」{{ unsupportedSuffix }}
  </view>
</wd-form-item>

unsupportedSuffix 是个 prop,默认 '组件'(:257-260),调用方可以改成「字段」之类。

这个设计的价值:上面那 12 个缺失控件不会让页面白屏或抛异常,用户看到的是一行明确的「暂不支持「计算公式」组件」,其余字段照常可填可提交。所以「移动端某张表单有个字段显示暂不支持」不是 bug 报告,是契约覆盖缺口的正常表现——处理方式是把该 type 补进渲染器,或者在设计器里换成移动端支持的控件。

7 下线:两处 NULL,两个坑 ​

unpublish()(:815-872)要把 menu_id 和 app_nav_node_id 都清成 NULL。写法上有个必须知道的陷阱,:836-849 的注释把它讲得很透:

🔴 menu_id 只能用 wrapper 显式置 NULL,写成 setMenuId(null) 是【什么都不做】:MP 的字段更新策略是 NOT_NULL,实体上为 null 的列根本不进 SET 子句。

所以代码是:

java
formDesignMapper.update(updateObj, new LambdaUpdateWrapper<FormDesignDO>()
        .eq(FormDesignDO::getId, id)
        .set(FormDesignDO::getMenuId, null)
        .set(FormDesignDO::getAppNavNodeId, null))

漏掉 .set(getAppNavNodeId, null) 的后果(注释原文):下线之后 App 入口还挂在移动端导航树上,点进去是一个已下线的表单。:850-852 明确写着「app_nav_node_id 与 menu_id 是同一条红线、同一个写法」。

menu_id 那个坑的历史后果更严重,注释里记了实测复现的死循环:删除时判据含 menu_id != null → 抛 BIND_MENU → 提示「请先执行下线」;下线时 published_version 已归 0 → 抛 NOT_PUBLISHED。两条路互相指对方,表单永远删不掉,「库里就是这么留下 12 行悬挂的」。

删除动作本身也在前端:后端只把编号交还出去(FormDesignUnpublishRespVO.menuIds / appNavNodeId),因为 infra 不能依赖 system。两者口径不同:

前端该怎么删为什么
menuIds(1 父 + 7 按钮)只能整批交给 /system/menu/delete-list单删见「还有子菜单」就抛 MENU_EXISTS_CHILDREN;父菜单排第一必然失败,7 个按钮随后删光,侧边栏留下一棵孤儿父菜单且不报错
appNavNodeId(叶子)可以单删 /system/nav-node/delete导航节点是叶子、没有子节点

menuIds 那个「父菜单永远排第一」的顺序也不是巧合::815-821 的注释说明 selectListByDesignId 不带 ORDER BY,InnoDB 走 design_id 二级索引 ⟹ 按主键升序返回,而 bindMenu 是先插父菜单再插按钮。

8 契约的两份副本,与一个不存在的守卫 ​

form-widget-contracts.json 有两份:

角色路径字节
规范副本(改这份)ainb/enlev-module-infra/src/main/resources/form-design/73,540
构建期副本ainb-ui-admin-vue3/src/components/FormWidgets/contracts/75,995

为什么前端不直接运行时拉接口:contracts/index.ts:8-11 说明 subTableWidgetType 这类结构性事实在模块加载期就要用(多个工具模块在顶层依赖它),改成异步会把一批同步函数染成异步。折中方案是「本地副本 + 一致性测试」。

🔴 那个一致性测试不存在,而且两份副本已经漂移了

contracts/index.ts:5-7 声称:

两份是否一致由 tests/e2e/form-widget-contract-sync.spec.ts 守着:它拉后端 /infra/form-design/widget-contracts 接口,与本地副本逐字段比对,不一致即失败。

这个文件不存在。 Web 仓没有 tests/ 目录也没有 playwright.config.ts(pnpm test:e2e 是坏的,见 AGENTS 约定)。后端 SYNC.md 自己如实承认了这一点:

⚠️ 没有。本仓不存在 tests/ 与 playwright.config.ts,pnpm test:e2e 跑不起来,vitest 里也没有覆盖这两份契约的 spec —— 前端副本目前只靠下面流程里的手工复制来对齐。

拦不住:前端副本与后端规范副本对不上。本来该由前端那条 e2e 比对来拦,但它在本仓不存在,所以「后端改了、忘了复制」今天不会有任何测试变红。

而漂移是已经发生的事实。实测逐字段比对两份 JSON(2026-10-25):

部分结果
contractVersion都是 2 ✅
widgets(63 项)完全一致 ✅
capabilities一致 ✅
jdbcTypeFamilies一致 ✅
structural❌ 不一致

structural 的唯一差异在 _comment 数组里的一行:规范副本写 enlev-module-bpm,前端副本仍写 yudao-module-bpm——改名时漏复制了一次。今天它只是一句注释、不影响行为,但它证明了这条防线是空的:下一次漂到 subFieldSeparator 上,表现会是「子表字段配了权限却不生效」,而 SYNC.md 明说这种症状「从现象很难反推到分隔符不一致」。

cell-format-contracts.json(列格式化契约,同样两份)实测内容完全一致,尚未漂移。

改契约的正确流程(SYNC.md):永远先改后端规范副本,再 cp 到前端,不要在前端直接编辑 JSON,复制完自己 diff 一遍。还有一条容易漏的:structural.subFieldSeparator 是编译期常量,会被内联进 bpm 的 class(JLS 15.28),只重装 infra 的 jar 而不重装 bpm 会静默漂移且单测看不见——要全量 mvn install 后重启。

数据模型 ​

表 / 列作用
infra_form_design表单设计主表。code(运行时用它寻址)、form_conf / form_fields / list_conf(渲染配置)、status、published_version、version(乐观锁)
infra_form_design.menu_id已发布的 Web 主菜单编号;下线时必须置 NULL
infra_form_design.app_nav_node_id已发布的 App 导航节点编号;下线时必须置 NULL。由 2026-10-24-form-design-app-nav.sql 添加
infra_form_design_menu发布时建的菜单登记(1 父 + 7 按钮),供下线时精确回收
system_nav_nodeApp 导航节点,见 nav-node

FormRuntimeConfig(移动端 src/api/infra/form-design/index.ts:5-30)是 /config 端点的返回形状,也是渲染器需要的全部输入:

字段用途
code / name / formTypeformType:1=纯表单,2=表单+列表
formConfform-create 的表单配置 JSON
formFields字段规则,每项是一段 JSON 字符串(要 decodeFields 解)
listConf列表配置 JSON,仅 formType=2 有值
pkField主键字段名,编辑/删除要用
versionField乐观锁字段名,为空表示未启用
validateRules / logicRules / formScript / customButtons / detailTabs / printTemplate / changeLogEnabled / extensionMode / submitHookTimeoutAction扩展点,移动端目前只消费其中一部分(见未证实)

实测本地库(2026-10-25):41 行有 Web 菜单绑定,其中只有 3 行填了 app_nav_node_id(表单 2247→节点 361、2249→362、880501→360)。也就是说发布到 App 端目前只在 3 张表单上用过,其中 2247 那条已经是悬空绑定(节点 361 被手工软删了),详见 nav-node 的数据模型一节。

接口清单 ​

移动端 src/api/infra/form-design/index.ts 只用了 8 个,但后端 FormDesignRuntimeController 一共有 17 个 /{code}/* 端点:

移动端调用端点用途
✅ getFormRuntimeConfigGET /config渲染配置
✅ getFormRuntimeDataPagePOST /page列表分页
✅ getFormRuntimeDataGET /get?id=单条详情
✅ createFormRuntimeDataPOST /create新增
✅ updateFormRuntimeDataPUT /update?id=修改
✅ deleteFormRuntimeDataDELETE /delete?id=删除
✅ getFormRuntimeDistinctValuesGET /distinct-values?field=查询项候选值
✅ fetchDataInterfaceOptionsPOST /infra/data-interface/options?code=数据接口下拉选项(不在 runtime 前缀下)
——GET /check-unique字段唯一性校验
——GET /data-logs数据变更日志
——POST /delete-batch批量删除
——GET /plain-field明文取单字段(脱敏字段的「点击查看」)
——POST /export导出
——GET /reference-options引用选项
——POST /import/preview、POST /import/execute、GET /import/template导入三步
——POST /invoke-button自定义按钮

9 个端点移动端没接。其中影响用户可感知功能的是:check-unique(填了重复值要等提交才报错)、plain-field(脱敏字段在移动端看不了明文)、delete-batch(没有批量删)、export / import(移动端没有导入导出)、invoke-button(设计器配的自定义按钮在移动端点了没反应)、data-logs(看不到变更历史)。

listConf 里如果配了自定义按钮,移动端 customButtons 字段是读到了但没渲染还是根本没读,见未证实。

怎么扩展 ​

把一张已有的在线表单发布到 App 端:Web 设计器 → 发布 → 勾「发布到 App 端」→ 选上级分组(appParentNavNodeId)→ 确定。之后移动端工作台就会出现入口,不需要发版。

发布后移动端看不到入口,按这个顺序查(四步覆盖绝大多数):

  1. SELECT app_nav_node_id FROM infra_form_design WHERE code='xxx' —— 是 NULL 说明第 4/5 步没成,属于「半发布」;
  2. 拿到的 app_nav_node_id 去 system_nav_node 查 deleted / status / visible;
  3. 该节点的 permissions 是 infra:form-design-run:{code}:query——查当前用户有没有这个权限码(它由发布时建的按钮菜单产生,得授给角色);
  4. 移动端强刷工作台(loadNavTree(true)),别用持久化的旧树。

给移动端补一个缺失控件(以 Link 为例):

  1. packages/core/src/registry.ts 的 FORM_CREATE_TYPE_GROUPS 里加一组 link: ['Link', 'link'],并导出 isLinkTypeName;
  2. packages/wot-ui/src/core/utils.ts 加 isLinkType(rule) 包装;
  3. 写 packages/wot-ui/src/components/link.vue(或直接用 wd-*);
  4. fieldRenderer.vue 的 v-else-if 链里在兜底分支之前插一条;
  5. 如果是布局容器而非字段,改的是 parsers/ 而不是 fieldRenderer;
  6. 用一张真的含该控件的表单在 H5 上验一遍——没有单测能替你验。

补完记得回头更新本页那张「缺 12 个」的表。

改控件契约:见核心机制 8 的流程,先改后端、再 cp、再全量 mvn install。

测试 ​

层有什么怎么跑
后端 infraFormDesignUnpublishTest(4 个用例)、WidgetContractRegistryTest、CellFormatContractRegistryTest、SubFieldSeparatorSingleSourceTestmvn -pl enlev-module-infra test -Dtest=FormDesignUnpublishTest
Websrc/components/Form* 下 31 个 vitest spec(FormWidgets 21 / FormLogic 3 / FormCreate-subtable 3 / FormScript 2 / FormFormula 2)pnpm test:unit
移动端渲染器🔴 0 个——
契约两份副本一致性🔴 无(声称存在的 e2e 测试是空的)——

FormDesignUnpublishTest 的 4 个用例,@DisplayName 直接把坑写在标题里:

  1. 「🔴 下线之后 menu_id 必须为 NULL(MP 的 NOT_NULL 策略会静默吞掉 setMenuId(null)),且表单随后可删」
  2. 「🔴 下线之后 app_nav_node_id 必须为 NULL,且编号交还前端去删导航节点」
  3. 「菜单关联仍存活时,删除照样被拒(BIND_MENU 门禁没有因为本次修复而放宽)」
  4. 「对已下线的表单再下线,抛 NOT_PUBLISHED 且不留副作用」

移动端 8,652 行渲染器零测试是这一页最大的风险敞口:它同时服务 BPM 流程表单和在线表单,改一处两边都受影响,而唯一的验证手段是手工在 H5 上点。改它之前先想清楚要手工验哪几张表单。

坑与注意 ​

  • 🔴 发布是前端编排的 5 步、没有事务。第 3 步(建菜单)成了、第 4 步(建导航节点)失败,就留下「Web 有菜单、App 没入口」的半发布状态,不会报错也不会自动回滚。
  • 🔴 下线时两个 NULL 必须用 LambdaUpdateWrapper#set,写 setXxx(null) 会被 MyBatis-Plus 的 NOT_NULL 策略静默吞掉。历史上这个坑造成过 12 行悬挂记录和一条「删不掉也下不了线」的死循环。
  • 🔴 menuIds 只能整批交给 /system/menu/delete-list,逐个单删会先删光 7 个按钮、留下一棵孤儿父菜单且不报错。
  • 🔴 契约的前端副本没有任何自动化守卫,且已经漂移过一次(structural._comment 里的 yudao-module-bpm)。改契约必须走「先后端 → cp → diff → 全量 mvn install」。
  • 🔴 12 个 supported 控件移动端渲染不出来,其中 6 个是功能性的(Formula / RelationForm / RemotePicker / BillNumberInput / SystemValue / OrgPicker)。含这些控件的表单在移动端能打开、能提交,但那些字段的值不对。
  • 移动端还留着 893 行已删控件的死代码(deptSelect.vue 604 + apiSelect.vue 156 + userSelect.vue 133)。删之前先查 form_fields 里有没有存量 rule。
  • 「暂不支持「XX」组件」不是 bug,是契约覆盖缺口的正常降级表现。
  • permissions 只给 :query 是刻意设计。想「有编辑权也能看到入口」而往里加 :update,会变成 OR 语义下的「任一命中即可见」,和原意相反。
  • route 全部相同、只靠 query 区分表单。复制导航节点时忘了改 query,两个入口会打开同一张表单。
  • run/index.vue 的 z-paging 必须 :auto="false",否则配置到位前会先打一次无排序无条件的 /page,第一页和后面几页规则不一致。
  • formFields 是字符串数组,每项要 decodeFields 再解析。直接 JSON.parse(config.formFields) 会得到一个字符串数组而不是规则数组。
  • 渲染器在 pages-bpm/ 下但服务两端。改它同时影响 BPM 流程表单与在线表单。
  • 9 个运行时端点移动端没接,其中 invoke-button 意味着设计器配的自定义按钮在移动端点了没反应——这是功能缺口,不是配置错误。
  • 更新分支不覆盖 parentId。想让已发布的表单换到别的分组下,得去「App菜单」里手工改,重新发布不会挪它。

→ 本节是贴着上下文的提醒;按症状查的移动端全域清单见移动端坑位,跨端清单见坑位索引。

未证实 ​

  • FormRuntimeConfig 的扩展点字段移动端到底消费了几个:logicRules、formScript、validateRules、customButtons、detailTabs、printTemplate、changeLogEnabled、extensionMode、submitHookTimeoutAction 这 9 个字段都在类型里声明了,但本轮只确认了 formConf / formFields / listConf / pkField / name / formType 被消费。其余几个是「读了没用」还是「压根没读」,需要逐个 grep run/form.vue 与 detail.vue 才能定论——这直接决定「Web 端配的表单逻辑在移动端生不生效」。
  • 移动端渲染器对 logicRules(显隐/联动)的支持程度:packages/core/src/control.ts 存在,说明有控件生命周期机制,但它是否等价于 Web 的 FormLogic,未验证。
  • cell-format-contracts.json 的移动端消费方:Web 侧由 listCell.ts 消费、后端由 ExportCellRenderer 消费,移动端 rule-adapter.ts / data-card.vue 是否也按这份契约格式化单元格,未查证。若没有,同一份数据在 Web 列表和移动端卡片上的显示格式会不一致。
  • 12 个缺失控件在存量表单里的实际出现频率:本轮只对了契约,没有扫 infra_form_design.form_fields 统计哪些缺失控件真的被用过。优先级排序应该按这个频率,而不是按控件分组。
  • 3 张已发布到 App 的表单在移动端是否真的跑通:需要后端在跑(:48080)+ H5 登录态,本轮未验证。
  • WidgetContractController 暴露的 /infra/form-design/widget-contracts 端点有没有别的消费方:SYNC.md 说它是「运行时出口」用于诊断,但移动端没调它。

相关页面 ​