表单运行时与扩展点
适用端:Web | 类型:机制
在线表单的「运行态」是这套低代码平台最长的一条链路:后端菜单里的一个 component 字符串,最终变成浏览器里一张能提交、能校验、能按人过滤行、按人裁剪列的表单。本页把这条链路从路由解析一直走到落库。
前置阅读:表单设计后端(设计态与发布链路)、路由与权限机制。
它解决什么问题
三件事要先记住:
- 前端没有「渲染引擎」,只有
render/index.vue里十几步就地改写规则树的命令式代码,最后交给 vendored 的 form-create。 - 行过滤在 SQL 构建层,列裁剪在 Service 层 —— 两个不同的位置、两种不同的失败降级方向。
- 运行时权限是编程式校验,不是
@PreAuthorize,因为权限串里含表单编码。
代码在哪
前端前缀 ainb-ui-admin-vue3/src/,后端前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/:
| 职责 | 文件 | 关键位置 |
|---|---|---|
| 路由→组件解析 | utils/routerHelper.ts | import.meta.glob:13;generateRoute:74,命中在 :160-163、:197-200 |
| 运行时渲染页 | views/infra/formDesign/render/index.vue | FormDesignRender:773、loadConfig:2660、applyScene:2300、openForm:3004、handleSubmit:3145 |
| schema→rule 翻译层 | utils/formCreate.ts | decodeConf、decodeFields:46、decodeFieldsAsync:61、encodeFields |
| 运行时 API | api/infra/formDesign/runtime.ts | getRenderConfig:126、getDataPage:131、createData:231、updateData:235 |
| 运行时校验派生 | components/FormValidation/{fieldRules.ts, index.ts} | deriveValidateRules、deriveUniqueValidateRule、applyValidateRules |
| 折叠分组裁剪 | components/FormWidgets/collapseGroup.ts | filterRulesByCollapse:130 |
| 控件契约(前端副本) | components/FormWidgets/contracts/index.ts | getWidgetContract、listUnsupportedWidgetTypes、resolveJdbcTypeFamily |
| 后端运行时入口 | controller/admin/formdesign/FormDesignRuntimeController.java | checkPermission:363-368(权限串 :364)、create:117、update:125 |
| 后端提交管线 | service/formdesign/runtime/FormSubmitProcessor.java | before:95-180 |
| 后端长度守卫 | framework/formdesign/core/length/ValueLengthGuard.java | 错误码 FORM_DESIGN_VALUE_TOO_LONG = 1_001_204_082 |
| 行过滤 | framework/permission/core/FormRowFilter.java | resolve: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 逐字:
/** 解码表单 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」这层对齐。对齐靠三件事:
- 前后端各自实现同一套求值器(逻辑、公式、校验正则);
- 三份共享用例 JSON ——
logic-cases.json、formula-cases.json、validation-patterns.json,已 md5 校验前后端逐字节一致; - 两份契约 JSON(控件契约、单元格格式契约)。
后端 FormSubmitProcessor.before(:95-180)按 8 步跑:
SystemValueGuard → FormLogicGuard → SubTableGuard → RelationFormFiller
→ formulaRecalculator → RichContentGuard → fileMetaEnricher → FormValidationGuard
→ 扩展钩子ValueLengthGuard 单独在 FormDesignRuntimeServiceImpl 里跑,不在管线内。
前端放行、后端 400 的两条真实路径
- 长度:
ValueLengthGuard服务端独有,校验的是序列化后的落库形态(varchar/char 按码点、TEXT 家族按 UTF-8 字节)。而runtime.ts:10明写渲染配置「不含物理表结构 —— 那是服务端实现细节」,前端拿不到列长,无从预校验。 - 场景 × 必填:后端完全没有
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 page | query |
create | create |
update | update |
delete / delete-batch | delete |
export | export |
import/preview | execute | template | import |
🔴 /{code}/plain-field | update(刻意不是 query,与「能改这条」对齐) |
/{code}/reference-options | 源表单 query 且 目标表单 reference 或 query |
/{code}/invoke-button | update(临时状态,无独立权限模型) |
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 到另一边)。
坑与注意
- 🔴 契约同步没有任何自动化守护。
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规范化后零差异。即「后端改了、忘了复制」今天不会让任何测试变红。 contracts/index.ts:160附近的映射是逐字段手写的,该处有 🔴 注释:新增契约字段不会自动生效。- 场景与折叠是删规则,被裁掉的字段无法被联动复活(见上文)。
applyScene不能改成 computed(见上文)。decodeFieldsAsync的时机:必须在交给 form-create 之前(深拷贝)。- 行过滤的两个降级方向相反(
pass()vsdenyAll()),别「统一」它们。 - COUNT 与列表必须同条件,6 个注入点一个都不能漏。
- 列裁剪三个出口必须一起裁,只裁 config 等于没裁。
reference动作只能放开不能收紧,是刻意取舍。extension-status接口不存在,第二道告警无后端支撑。SYNC.md提醒structural.subFieldSeparator被enlev-module-bpm的BpmTaskServiceImpl.SUB_FIELD_SEPARATOR消费(现已改指FormResourceDescriptorDTO.SUB_FIELD_SEPARATOR,由SubFieldSeparatorSingleSourceTest钉住)。按 JLS 15.28,编译期常量会被内联,只做局部mvn install会静默漂移。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等关键项。
相关页面
- 表单设计后端 —— 设计态、发布链路、菜单与资源派生
- 自研控件开发 —— 规则树里的
type是怎么变成组件的 - 权限体系总览 ——
FormRowFilter与FormFieldCropper背后的决策引擎 - 取号管理 ——
create的三次撞号重试 - 使用手册 02 基础设施 · 表单渲染 —— 界面上长什么样
