Skip to content

表单运行时与扩展点 ​

适用端:Web | 类型:机制

在线表单的「运行态」是这套低代码平台最长的一条链路:后端菜单里的一个 component 字符串,最终变成浏览器里一张能提交、能校验、能按人过滤行、按人裁剪列的表单。本页把这条链路从路由解析一直走到落库。

前置阅读:表单设计后端(设计态与发布链路)、路由与权限机制。

它解决什么问题 ​

三件事要先记住:

  1. 前端没有「渲染引擎」,只有 render/index.vue 里十几步就地改写规则树的命令式代码,最后交给 vendored 的 form-create。
  2. 行过滤在 SQL 构建层,列裁剪在 Service 层 —— 两个不同的位置、两种不同的失败降级方向。
  3. 运行时权限是编程式校验,不是 @PreAuthorize,因为权限串里含表单编码。

代码在哪 ​

前端前缀 ainb-ui-admin-vue3/src/,后端前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/:

职责文件关键位置
路由→组件解析utils/routerHelper.tsimport.meta.glob:13;generateRoute:74,命中在 :160-163、:197-200
运行时渲染页views/infra/formDesign/render/index.vueFormDesignRender:773、loadConfig:2660、applyScene:2300、openForm:3004、handleSubmit:3145
schema→rule 翻译层utils/formCreate.tsdecodeConf、decodeFields:46、decodeFieldsAsync:61、encodeFields
运行时 APIapi/infra/formDesign/runtime.tsgetRenderConfig:126、getDataPage:131、createData:231、updateData:235
运行时校验派生components/FormValidation/{fieldRules.ts, index.ts}deriveValidateRules、deriveUniqueValidateRule、applyValidateRules
折叠分组裁剪components/FormWidgets/collapseGroup.tsfilterRulesByCollapse:130
控件契约(前端副本)components/FormWidgets/contracts/index.tsgetWidgetContract、listUnsupportedWidgetTypes、resolveJdbcTypeFamily
后端运行时入口controller/admin/formdesign/FormDesignRuntimeController.javacheckPermission:363-368(权限串 :364)、create:117、update:125
后端提交管线service/formdesign/runtime/FormSubmitProcessor.javabefore:95-180
后端长度守卫framework/formdesign/core/length/ValueLengthGuard.java错误码 FORM_DESIGN_VALUE_TOO_LONG = 1_001_204_082
行过滤framework/permission/core/FormRowFilter.javaresolve:91、pass()、denyAll()、executeIgnore:123
SQL 拼接(行过滤注入点)service/formdesign/runtime/FormDesignSqlBuilder.java:281,404,420,446,491,659
列/字段裁剪framework/permission/core/FormFieldCropper.java + FormDesignRuntimeServiceImpl:292,295(config)、:1236(get)、:1552(reference)、:1645、:1939
表单级扩展service/formdesign/FormDesignExtension.java、FormDesignExtensionRegistry.java:73-74、invoke:186
契约同步说明enlev-module-infra/src/main/resources/form-design/SYNC.md后端为准 + cp 流程

核心机制 ​

从菜单到组件 ​

后端菜单的 component 是个字符串(在线表单固定为 infra/formDesign/render/index)。前端 routerHelper.ts:13 用 import.meta.glob('../views/**/*.{vue,tsx}') 建一张表,generateRoute 用 findIndex(ev => ev.includes(route.component)) 反查(:160-163 顶级、:197-200 菜单)。

formCode 由 render/index.vue:784-792 三级回退求出:params → query → 路径最后一段。这就是为什么菜单路径必须长成 form-design-run/{code} —— 详见表单设计后端 · 菜单形态。

顶级动态路由 path 必须以 / 开头

routerHelper.ts:88-98 有 🔴 注释:否则 addRoute 抛错、整棵动态路由装配中断,用户卡在登录页,而且只能改数据库修。 表单发布时 buildMenuPath:676-679 顶层给绝对路径 /form-design-run/{code}、嵌套给相对路径 form-design-run/{code},就是为了这条。

loadConfig:十几步就地改写,顺序是承重的 ​

loadConfig(render/index.vue:2660-2767)的完整顺序:

RuntimeApi.getRenderConfig(formCode)
→ decodeConf(data.formConf)
→ decodeFieldsAsync(data.formFields)
→ applyThousandsFormat
→ applyRelationSource(rules, formCode)
→ applyStepValidate
→ collectDefaultValues
→ applyStepsValidate
→ applyValidateRules(rules, { check: ...checkFieldUnique })
→ applyPlainTextToggle
→ allRules.value = rules
→ collectCollapseGroups / readCollapsed
→ applyCollapseToggles()
→ applyScene()
→ createRemoteOptionRuntime(...)
→ 解析 crossFieldRules / logicRules / formScripts
→ standalonePending = data.formType === 1
→ parseListConf + Promise.all([loadTreeNodes(), loadList()])

全部是就地改写规则树,最后交给模板里三处 <form-create v-model="formValues" v-model:api="fApi" :option="formOption" :rule="formRule" />(:480-485 独立页、:506-511 详情页签、:566-572 弹窗/抽屉/内联)。

两个不能改成响应式的点:

  • applyScene 刻意不是 computed(:2300-2307 有长注释):form-create 会把规则树深度响应化,用 computed 会拿到被改写过的新树,导致旧值 bug。applyCollapseToggles / applyPlainTextToggle 同理,都是命令式就地改写。
  • decodeFieldsAsync 必须在把规则交给 form-create / 设计器之前解析完(utils/formCreate.ts:61-65 注释原文)—— 两边接收规则时都会做深拷贝,之后再解析就写不进去了。

翻译层薄得惊人,utils/formCreate.ts 逐字:

ts
/** 解码表单 Conf */
export const decodeConf = (conf: string) => {
  return formCreate.parseJson(conf)
}

反向的 encodeFields 会先 stripResolvedOptions 剥掉已解析的 options,避免把运行时数据写回库。

场景与折叠是「删规则」,不是「标隐藏」 ​

collapseGroup.ts:130-176 的 filterRulesByCollapse 直接从树上移除折叠成员(保留字符串/数字文本子节点,始终保留标题本身);filterRulesByScene 同理。

后果:被裁掉的字段无法被联动 SHOW 或脚本 setVisible 复活 —— 它已经不在树上了。折叠状态存 localStorage,键为 form-design:collapse:${formCode}:${groupKey},按表单 + 标题分组,不按用户。

校验:设计期存规则,运行期派生 ​

设计期存在 formFields 里的 validateRules → applyValidateRules 递归(含 readNestedRules(rule) 与 rule.children)派生成 form-create 的 validate 数组,用 DERIVED_FLAG = '__fromValidateRules' 保证幂等。

deriveValidateRules 刻意只产出一个 validator 承载全部规则。注释说明理由:form-create 内核会收集所有错误,而本项目的语义是「只报第一条」—— 否则会出现前端两条、后端一条的不一致。

唯一性校验走 deriveUniqueValidateRule:仅 blur 触发、请求失败放行、文案必须与后端 FORM_DESIGN_UNIQUE_VIOLATION(1_001_204_062)一致。

前后端校验怎么对齐 —— 没有 VO,靠共享用例 JSON ​

FormDesignRuntimeController.java:117/125 的 create / update 收的是 @RequestBody Map<String, Object> data,没有任何 Bean Validation VO。所以不存在「VO 注解 vs formRules」这层对齐。对齐靠三件事:

  1. 前后端各自实现同一套求值器(逻辑、公式、校验正则);
  2. 三份共享用例 JSON —— logic-cases.json、formula-cases.json、validation-patterns.json,已 md5 校验前后端逐字节一致;
  3. 两份契约 JSON(控件契约、单元格格式契约)。

后端 FormSubmitProcessor.before(:95-180)按 8 步跑:

SystemValueGuard → FormLogicGuard → SubTableGuard → RelationFormFiller
→ formulaRecalculator → RichContentGuard → fileMetaEnricher → FormValidationGuard
→ 扩展钩子

ValueLengthGuard 单独在 FormDesignRuntimeServiceImpl 里跑,不在管线内。

前端放行、后端 400 的两条真实路径

  1. 长度:ValueLengthGuard 服务端独有,校验的是序列化后的落库形态(varchar/char 按码点、TEXT 家族按 UTF-8 字节)。而 runtime.ts:10 明写渲染配置「不含物理表结构 —— 那是服务端实现细节」,前端拿不到列长,无从预校验。
  2. 场景 × 必填:后端完全没有 scenes 概念。FormLogicGuard.java:265 直接 meta.setRequired(Boolean.TRUE.equals(node.get("$required"))),:67 注释「没有联动规则时仍要校验基础必填 —— 那来自控件自身的 $required」。于是某字段配了 $required:true 但 scenes 只含 detail 时,创建/编辑页根本渲染不出它,提交却会被服务端判为缺必填。

运行时权限:编程式校验 + 动作映射 ​

FormDesignRuntimeController.checkPermission:363-368 是编程式校验,不用 @PreAuthorize —— 因为权限串 infra:form-design-run:%s:%s(:364)里的表单编码是运行期才知道的,注解的静态字符串做不到。

端点 → 动作的映射(这张表是排查 403 的第一站):

端点动作
config / get / distinct-values / data-logs / check-unique + POST pagequery
createcreate
updateupdate
delete / delete-batchdelete
exportexport
import/preview | execute | templateimport
🔴 /{code}/plain-fieldupdate(刻意不是 query,与「能改这条」对齐)
/{code}/reference-options源表单 query 且 目标表单 reference 或 query
/{code}/invoke-buttonupdate(临时状态,无独立权限模型)

reference 动作目前只能放开不能收紧(:399-402):配了 reference 的 DENY,用户若还有 query 仍选得到。源码明说这是「不破坏存量」换来的取舍,记在这里以免被当成缺陷。

缺按钮权限时前端直接不画按钮,硬调接口返回 403。

行过滤在 SQL 构建层,不在 MyBatis 拦截器 ​

定制功能

这套行级权限(sys_rule + sys_slot_mapping + FormRowFilter)是本项目自研的,详见行过滤规则。

在线表单的运行时用 JdbcTemplate 拼原生 SQL,压根不经过 MyBatis 执行器,所以 TenantDatabaseInterceptor 那一套帮不上忙(FormRowFilter:18-26 的类注释说明了这一点)。

注入点在 FormDesignSqlBuilder.java 的 6 处::281,404,420,446,491,659。

🔴 COUNT 与列表必须用完全相同的条件(:656-659)。漏一处就会出现「分页器显示 100 条、第 5 页却是空的」。

两个降级方向不同,这是本页最需要记住的一张表:

情况降级位置为什么
PermissionCommonApi 不可用pass() 不过滤FormRowFilter:96-106(只 error 一次)依赖缺失是部署问题,不该让业务全挂
规则求值抛异常denyAll() 全拒:110-114放行等于把异常变成越权

列/字段裁剪在 Service 层 ​

FormFieldCropper:27-34、resolve:79。注入点:FormDesignRuntimeServiceImpl:292,295(config)、:1236(get)、:1552(reference)、:1645、:1939。

🔴 三个出口 config / get / page 必须一起裁。只裁 config 的话,用户抓包就能从 get 或 page 的响应里看到明文字段。

表单级业务扩展:FormDesignExtension ​

实现 FormDesignExtension 接口(formCode() + beforeCreate / afterCreate / beforeUpdate / afterUpdate / beforeDelete),然后把该表单的 extension_mode 改成 2(SERVER_EXTENSION)。

注册表用 @Autowired(required = false) List<FormDesignExtension> 自动收集(FormDesignExtensionRegistry.java:73-74);invoke:186 是互斥语义的执行点。

第二道告警目前没有后端支撑

FormDesignExtensionRegistry:45 引用的 extension-status 接口在代码里不存在(全仓 grep 只命中这条注释本身)。所以「表单配了服务端扩展但没人实现」这件事,目前只能靠运行时才发现。

前端 API 分工 ​

api/infra/formDesign/runtime.ts:

  • getDataPage:131 用 POST —— 条件是结构化数组,塞不进 query;
  • createData:231 / updateData:235 / deleteData:239 / deleteDataBatch:250;
  • exportData:283 用裸 axios POST blob,并校验 "PK" 魔数(确认拿到的是 xlsx 而不是错误 JSON);
  • importPreview:510 必须走 request.upload;
  • checkFieldUnique:206、getDistinctValues:260、getReferenceOptions:150、invokeButton:395。

没有草稿接口 —— 草稿只存在于设计期。乐观锁 versionField 放在独立 ref,提交时手动并进 payload,不是表单字段。

openForm 为什么不用 setValue / resetFields ​

render/index.vue:3004-3094 先把所有值攒进局部 nextValues,再一次性 fApi.coverValue(nextValues) + clearValidateState()。注释解释单独 setValue 与 resetFields 都是错的。

机制根因在 vendored 源码里,四个位置串起来才看得懂:

位置行为
lib/form-create/core/src/frame/api.js:148-163 coverValue先 h.appendData = {} 再重填
api.js:165-178 setValue把非 ctx 键塞进 appendData
lib/form-create/core/src/handler/input.js:88-111 syncForm()用 fields() 加上 appendData 中不在 fields 里的键组成 h.form
api.js:122-136 formData()只读 h.form

所以 setValue 会让被移除字段的值残留在 appendData 里、继续出现在 formData() 的返回值中。coverValue 清空 appendData 才是干净的整体替换。

standalonePending 的 watch 也不能省(:2644-2658,注释说漏掉它是「运行时 e2e 抓到的第一个真问题」):独立表单(formType===1)必须等配置与列表都就绪后再放行。

接口清单 ​

运行时端点全部挂在 FormDesignRuntimeController 下,路径前缀 /infra/form-design/runtime/{code}/...。完整清单由 docs/.generated/api-infra.md 脚本生成;动作映射见上面「运行时权限」一节。

怎么扩展 ​

加一条提交期的服务端校验 ​

在 framework/formdesign/core/ 下新增或扩展一个 Guard,然后挂进 FormSubmitProcessor.before:95-180 的 8 步序列。

⚠️ 序列顺序有意义:SystemValueGuard 必须最先(覆写系统值,后面几步要读它);formulaRecalculator 必须在 FormValidationGuard 之前(否则校验的是未重算的公式值);扩展钩子在最后。

加一个表单的业务钩子 ​

见上面「表单级业务扩展」。注意 extension_mode 是按表单配的,实现类放在哪个模块都行,但 formCode() 必须与表单编码逐字一致。

加一种前端校验规则 ​

改 components/FormValidation/fieldRules.ts 的 deriveValidateRules,不用改渲染页 —— applyValidateRules 会自动递归到嵌套规则。若新规则需要后端同样校验,记得同步 validation-patterns.json(前后端逐字节一致,改一边要 cp 到另一边)。

坑与注意 ​

  1. 🔴 契约同步没有任何自动化守护。components/FormWidgets/contracts/index.ts:6 声称一致性由 tests/e2e/form-widget-contract-sync.spec.ts 守护,但 tests/ 目录不存在(已核实);后端 form-design/SYNC.md 也直说前端没有一致性测试。实测漂移:form-widget-contracts.json 前后端语义差异恰好一行 —— 后端写 enlev-module-bpm,前端仍是改名前的 yudao-module-bpm(已 grep 核实,各 1 处);且前端副本是 CRLF、后端是 LF。cell-format-contracts.json 规范化后零差异。即「后端改了、忘了复制」今天不会让任何测试变红。
  2. contracts/index.ts:160 附近的映射是逐字段手写的,该处有 🔴 注释:新增契约字段不会自动生效。
  3. 场景与折叠是删规则,被裁掉的字段无法被联动复活(见上文)。
  4. applyScene 不能改成 computed(见上文)。
  5. decodeFieldsAsync 的时机:必须在交给 form-create 之前(深拷贝)。
  6. 行过滤的两个降级方向相反(pass() vs denyAll()),别「统一」它们。
  7. COUNT 与列表必须同条件,6 个注入点一个都不能漏。
  8. 列裁剪三个出口必须一起裁,只裁 config 等于没裁。
  9. reference 动作只能放开不能收紧,是刻意取舍。
  10. extension-status 接口不存在,第二道告警无后端支撑。
  11. SYNC.md 提醒 structural.subFieldSeparator 被 enlev-module-bpm 的 BpmTaskServiceImpl.SUB_FIELD_SEPARATOR 消费(现已改指 FormResourceDescriptorDTO.SUB_FIELD_SEPARATOR,由 SubFieldSeparatorSingleSourceTest 钉住)。按 JLS 15.28,编译期常量会被内联,只做局部 mvn install 会静默漂移。
  12. SYNC.md 与 contracts/index.ts 里的规范路径写作 yudao-module-infra(改名前模块名),与实际 enlev-module-infra 不符 —— 文档陈旧。

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

测试 ​

前端 58 个 .spec.ts 中,33 个在 src/components/Form* 下:FormCreate/src/subtable/{useSubTableEnhance, useSubTableRuntime, useVirtualScroll}、FormCreate/src/utils/remoteOptionRuntime、FormFormula/{formula, useFormulaRuntime}、FormInput/input、FormLogic/{evaluator, invalidFieldRefs, useLogicRuntime}、FormScript/{sandbox, useScriptRuntime}、FormValidation/validation、FormWidgets/ 下 20 个纯函数。另有 src/utils/routerHelper.spec.ts、src/utils/commentLineRefBan.spec.ts。

🔴 运行时渲染页 render/index.vue 本身没有单测。tests/ 目录不存在,pnpm test:e2e 不可用(见测试规范)。

vitest.config.ts 刻意不装自动导入插件(environment: 'node'),这正是组件无法在测试里 mount(useMessage undefined ⇒ ReferenceError)的原因,也是所有逻辑都被抽成纯 .ts 模块的原因。

后端 infra 侧有 60 个测试文件在 src/test/java/.../formdesign/,全部纯 Mockito。

未证实 ​

  • 运行时折叠分组是否会丢值:机制事实全部证实(h.form = 树内字段 + appendData;coverValue 清空 appendData;render/index.vue:2345-2359 的折叠开关只改 collapsedGroups 并调 applyScene(),没有重新 cover 值),但「:rule 整体替换后 form-create 对被移除规则的既有值如何处置」没有从源码定位到(reloadRule 的定义处未找到)。结论待定。
  • 两份 VENDOR.md 都引用 docs/form-create-源码集成方案.md(designer 版还引用 docs/form-design-guide/),但 ainb-ui-admin-vue3/docs/ 目录不存在。
  • 未逐一核对 50+ 个 components[] 条目的 name 与契约 type 是否全部一一对应,只抽验了 RemotePicker、Link、FcTitle、FcTable、TableForm、group/fcGroup 等关键项。

相关页面 ​