在线表单在移动端
适用端: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-587 | publish():组装 menuConfig / navNodeConfig 回传前端 |
| 后端 | 同上 :711-728 | buildNavNodeConfig():算 App 节点的名称/路由/参数/权限串/排序/图标 |
| 后端 | 同上 :815-872 | unpublish():清 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 | 两份契约的同步约定 |
| Web | src/views/infra/formDesign/components/PublishDialog.vue(:186,297-341,350-404) | 发布编排:列节点 → 建菜单 → 建/更新 App 节点 → 回写绑定 |
| Web | src/api/infra/formDesign/index.ts:229-243,294-306 | PublishNavNodeConfigVO 类型与 bindAppNav |
| Web | src/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.ts | hasAccessByCodes(),读 userStore.permissions |
渲染器放在 pages-bpm/ 下,但在线表单也在用它
src/pages-bpm/components/form-create/ 这个路径是历史遗留——它最初只为 BPM 流程表单而写。实测 6 个消费方里有 3 个属于在线表单:
| 消费方 | 属于 |
|---|---|
pages-bpm/processInstance/create/form.vue | BPM 发起流程 |
pages-bpm/processInstance/detail/audit/index.vue | BPM 审批 |
pages-bpm/processInstance/detail/components/form-detail.vue | BPM 详情 |
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),然后把「该建什么」算好回传:
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=true | createNavNode(...) → bindAppNav(designId, navNodeId) | 'created' |
false,且表单上有 appNavNodeId | getNavNode(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)里最值得注意的一行:
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):
/** 权限串由发布时登记的菜单按钮生成,服务端每个端点各查各的,这里只管显隐 */
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:
/** 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 / type | rule.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: true | 55 |
| 移动端能渲染 | 43(basic 11 / select 8 / advanced 6 / display 9 / layout 9) |
| 移动端渲染不出来 | 12 |
缺的 12 个,按影响面排:
| type | 组 | 名称 | 缺了会怎样 |
|---|---|---|---|
Formula | advanced | 计算公式 | 值算不出来(Web 端由 FormFormula 驱动) |
RelationForm | select | 关联表单 | 选不了关联记录 |
RemotePicker | select | 远程选择器 | 选不了远程数据 |
BillNumberInput | system | 单据编号 | 取不到号 |
SystemValue | system | 系统值 | 当前用户/时间等填不进去 |
OrgPicker | system | 组织选择器 | 选不了组织 |
timeRange | basic | 时间区间 | 填不了时间段 |
elTreeSelect | select | 树形选择 | 选不了树(注意 treeSelect / TreeSelect / tree 是认得的,缺的只是 elTreeSelect 这一种写法) |
CodeImage | display | 条码/二维码 | 显示不出来 |
Link | display | 超链接 | 显示不出来 |
FormSteps | layout | 步骤条 | 分步表单会退化 |
FormStep | layout | 步骤 | 同上 |
前 6 个是功能性的(填不出正确的值),后 6 个是展示/布局性的(值本身不受影响)。补齐优先级应该按这个分界走,而不是按列表顺序。
🔴 反向也成立:移动端还留着 3 个平台已删除的控件渲染器
契约把 UserSelect、DeptSelect、ApiSelect 标为 supported: false,label 直接写着「已删除」。Web 侧 src/components/FormWidgets/ 里已经没有这三个控件的任何实现(grep 只在契约 JSON 里命中)。
但移动端完整保留着它们:
| 文件 | 行数 |
|---|---|
custom/deptSelect.vue | 604 |
custom/apiSelect.vue | 156 |
custom/userSelect.vue | 133 |
| 合计 | 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 的兜底分支:
<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 子句。
所以代码是:
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_node | App 导航节点,见 nav-node |
FormRuntimeConfig(移动端 src/api/infra/form-design/index.ts:5-30)是 /config 端点的返回形状,也是渲染器需要的全部输入:
| 字段 | 用途 |
|---|---|
code / name / formType | formType:1=纯表单,2=表单+列表 |
formConf | form-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}/* 端点:
| 移动端调用 | 端点 | 用途 |
|---|---|---|
✅ getFormRuntimeConfig | GET /config | 渲染配置 |
✅ getFormRuntimeDataPage | POST /page | 列表分页 |
✅ getFormRuntimeData | GET /get?id= | 单条详情 |
✅ createFormRuntimeData | POST /create | 新增 |
✅ updateFormRuntimeData | PUT /update?id= | 修改 |
✅ deleteFormRuntimeData | DELETE /delete?id= | 删除 |
✅ getFormRuntimeDistinctValues | GET /distinct-values?field= | 查询项候选值 |
✅ fetchDataInterfaceOptions | POST /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)→ 确定。之后移动端工作台就会出现入口,不需要发版。
发布后移动端看不到入口,按这个顺序查(四步覆盖绝大多数):
SELECT app_nav_node_id FROM infra_form_design WHERE code='xxx'—— 是 NULL 说明第 4/5 步没成,属于「半发布」;- 拿到的
app_nav_node_id去system_nav_node查deleted/status/visible; - 该节点的
permissions是infra:form-design-run:{code}:query——查当前用户有没有这个权限码(它由发布时建的按钮菜单产生,得授给角色); - 移动端强刷工作台(
loadNavTree(true)),别用持久化的旧树。
给移动端补一个缺失控件(以 Link 为例):
packages/core/src/registry.ts的FORM_CREATE_TYPE_GROUPS里加一组link: ['Link', 'link'],并导出isLinkTypeName;packages/wot-ui/src/core/utils.ts加isLinkType(rule)包装;- 写
packages/wot-ui/src/components/link.vue(或直接用wd-*); fieldRenderer.vue的v-else-if链里在兜底分支之前插一条;- 如果是布局容器而非字段,改的是
parsers/而不是fieldRenderer; - 用一张真的含该控件的表单在 H5 上验一遍——没有单测能替你验。
补完记得回头更新本页那张「缺 12 个」的表。
改控件契约:见核心机制 8 的流程,先改后端、再 cp、再全量 mvn install。
测试
| 层 | 有什么 | 怎么跑 |
|---|---|---|
| 后端 infra | FormDesignUnpublishTest(4 个用例)、WidgetContractRegistryTest、CellFormatContractRegistryTest、SubFieldSeparatorSingleSourceTest | mvn -pl enlev-module-infra test -Dtest=FormDesignUnpublishTest |
| Web | src/components/Form* 下 31 个 vitest spec(FormWidgets 21 / FormLogic 3 / FormCreate-subtable 3 / FormScript 2 / FormFormula 2) | pnpm test:unit |
| 移动端渲染器 | 🔴 0 个 | —— |
| 契约两份副本一致性 | 🔴 无(声称存在的 e2e 测试是空的) | —— |
FormDesignUnpublishTest 的 4 个用例,@DisplayName 直接把坑写在标题里:
- 「🔴 下线之后
menu_id必须为 NULL(MP 的NOT_NULL策略会静默吞掉setMenuId(null)),且表单随后可删」 - 「🔴 下线之后
app_nav_node_id必须为 NULL,且编号交还前端去删导航节点」 - 「菜单关联仍存活时,删除照样被拒(
BIND_MENU门禁没有因为本次修复而放宽)」 - 「对已下线的表单再下线,抛
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.vue604 +apiSelect.vue156 +userSelect.vue133)。删之前先查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被消费。其余几个是「读了没用」还是「压根没读」,需要逐个 greprun/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说它是「运行时出口」用于诊断,但移动端没调它。
相关页面
- 服务端驱动导航:system_nav_node 全链路 —— App 入口的建、剪、删
- 表单运行时与扩展点 —— Web 侧运行时机制与扩展点全清单
- 数据模型(开发视角) ——
form_fields里的字段怎么落到物理表 - infra 域开发 —— infra 模块的 23 个 Controller / 218 个端点全景
- 请求封装与登录态 —— 运行时接口走的那条 http 链路
- 移动端页面开发范式 ——
run/index.vue用的 z-paging 与卡片范式 - 移动端坑位 —— 移动端侧的排错清单
- 坑位索引 —— 按症状查的全域清单
