Skip to content

自研控件开发 ​

适用端:Web | 类型:操作

定制功能

本页讲的控件几乎全部是本项目自研的。上游 form-create 只提供内核与一批 Element Plus 基础控件。

在线表单的每个字段在规则树里是一个 { type: 'RemotePicker', field: 'xxx', ... } 对象。type 怎么变成一个真实的 Vue 组件,中间有两张互不相通的注册表 —— 这是全部扩展点的地基,也是本页最该记住的一件事。

前置阅读:表单运行时与扩展点、前端工程结构、仓库结构与改名约定(vendored 纪律)。

它解决什么问题 ​

node.js:51-64 逐字(vendored,不要改):

js
h(tag, data, children) {
    const vm = this.vm || getCurrentInstance();
    const isNativeTag = vm.appContext.config.isNativeTag(tag);
    const component = this.handle.fc.prop.components[tag];
    if (!component && isNativeTag) {
        delete data.formCreateInject;
    }
    try{
        return createVNode(component || (isNativeTag ? tag : resolveComponent(tag)), data, children);
    }catch (e){
        console.error(e);
        return createVNode('');
    }
},

查找顺序决定了「撞名原生标签的控件必须走 form-create 表」

components[tag] 的查找发生在 isNativeTag 分支之前。所以:

  • type 首字母小写后与原生 HTML 标签撞名(如 Link → link),或需要精确命中大小写(如 elImage)→ 必须额外注册进 form-create 自己的表,否则 resolveComponent 那一支根本走不到,会被渲染成一个原生 <link> 元素。
  • 其余情况注册进 Vue 全局表就够了。
  • catch 分支返回 createVNode('') 并只 console.error —— 注册漏了不会抛错,字段静默变成空白。

代码在哪 ​

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

职责文件关键位置
控件注册总入口(Vue 全局表)plugins/formCreate/index.tssetupFormCreate;components[]:111-168(约 50 项)
form-create 组件表补注册components/FormCreate/src/registerExtraWidgets.tsEXTRA_WIDGETS、registerExtraWidgets
子表控件接管components/FormCreate/src/subtable/registerSubTableGroup.tscreateSubTableGroup、KERNEL_GROUP_NAME = 'fcGroup'
设计器侧扩展components/FormCreate/src/useFormCreateDesigner.tsbuildFormComponents:56、buildSystemMenu:118、initialize:169
设计器按需加载plugins/formCreate/designer.tsensureFcDesigner、whenDesignerReady
属性面板追加注册表components/FormCreate/src/designer/useComponentPropsRegistry.tsWeakMap<Ref, RegistryEntry>
控件契约(前端副本)components/FormWidgets/contracts/index.tsgetWidgetContract、listUnsupportedWidgetTypes、resolveJdbcTypeFamily
控件契约(后端权威)resources/form-design/form-widget-contracts.json+ SYNC.md
设计器配置components/FormCreate/src/config/createDesignerConfig.tshiddenItem:82、config.formRule:141-152
vendored 组件表 APIlib/form-create/core/src/frame/index.jscomponent(id, component):135-155
vendored 渲染落点lib/form-create/core/src/factory/node.jsh(tag, data, children):51-64
vendored 值管道lib/form-create/core/src/frame/api.js、handler/input.jsformData:122、coverValue:148、setValue:165;syncForm:88
上游同步说明lib/form-create/VENDOR.md、lib/form-designer/VENDOR.mdcommit / tag / 本地改动清单
alias 与分包vite.config.ts:80-121 alias、:143-153 codeSplitting

核心机制 ​

自研控件清单 ​

type → 组件路径 → 用途(均在 src/components/ 下):

type文件用途
group(子表)FormCreate/src/subtable/registerSubTableGroup.ts包装内核 fcGroup,加「复制本行」/虚拟滚动
UploadFile / UploadImg / UploadImgsUploadFile/(barrel 导出)附件、单图、多图
EditorEditor/富文本
SignatureFormCreate/src/components/Signature.vue手写签名(替代已废弃 signaturePad)
OrgPickerFormCreate/src/components/OrgPicker.vue组织/人员选择,json 值
SystemValueFormCreate/src/components/SystemValue.vue系统值(当前用户/时间等),服务端 Guard 覆写
RelationFormFormCreate/src/components/RelationForm.vue关联表单选择,标量
RemotePickerFormCreate/src/components/RemotePicker.vue远程数据源下拉,标量
FormulaFormFormula/Formula.vue公式字段,number
IframeComponentFormCreate/src/components/IframeComponent.vue内嵌页面
AreaSelectFormCreate/src/components/AreaSelect.vue省市区,逗号分隔串
DictSelectFormCreate/src/components/DictSelect.vue字典下拉
BillNumberInputFormCreate/src/components/BillNumberInput.vue单据编号(见取号管理)
CodeImageFormCreate/src/components/CodeImage.vue条码/二维码展示
LinkFormCreate/src/components/Link.vue超链接(🔴 必须走 form-create 表)
FormSteps / FormStepFormCreate/src/components/FormSteps.vue、FormStep.vue分步容器
fcTitlelib/form-designer/components/FcTitle.vue分组标题(vendored)
password / dateRange / timeRange / elImagePasswordInput.vue、DateRangePicker.vue、TimeRangePicker.vue、element-plus ElImage由 registerExtraWidgets 补进 form-create 表
LogicEntryButtonFormCreate/src/components/LogicEntryButton.vue配置面板「逻辑与扩展」入口,经 config.formRule.append 注入

配置面板类控件(BillInlineRuleEditor、DbFieldSelect、FormulaEditorField、RelationConfigField、DictSourceConfig、DataInterfaceConfig、ValidateRulesEditor、SubTableSummaryField、CollapseToggle)也在同一 components[] 里,但它们只在设计器属性面板出现,不参与运行时渲染。

已废弃 / 不支持的控件 ​

通过 createDesignerConfig.ts:82 的 hiddenItem: listUnsupportedWidgetTypes() 从设计器菜单隐藏:

signaturePad、upload、fcEditor、tableForm、subForm、UserSelect、DeptSelect、ApiSelect

清单的权威来源是后端契约里的 supported 字段,前端 listUnsupportedWidgetTypes() 从中筛出。所以「隐藏一个控件」是改后端 JSON,不是改前端数组。

控件契约:后端为准,前端是 cp 出来的副本 ​

resources/form-design/SYNC.md 规定了流程:改后端 form-widget-contracts.json → cp 到 src/components/FormWidgets/contracts/form-widget-contracts.json。

契约字段:type / label / group / supported / valueType / storage / columnTypes / caps / maskable / permissionKey / props。valueType 与 storage 决定后端 Guard 行为与列类型推导。

🔴 contracts/index.ts:160 附近的映射是逐字段手写的,该处有注释明写:新增契约字段不会自动生效。

契约同步没有任何自动化守护(已实测出漂移)

contracts/index.ts:6 声称一致性由 tests/e2e/form-widget-contract-sync.spec.ts 守护,但 tests/ 目录不存在(已核实);后端 SYNC.md 也直说前端没有一致性测试。

实测漂移:form-widget-contracts.json 前后端语义差异恰好一行 —— 后端写 enlev-module-bpm,前端仍是改名前的 yudao-module-bpm(已 grep 核实,各 1 处);且前端副本是 CRLF、后端是 LF。cell-format-contracts.json 规范化后零差异。

即「后端改了、忘了复制」今天不会让任何测试变红。 改契约后自己 diff 一遍。

vendored form-create:零 diff 纪律 ​

src/lib/form-create/ 与 src/lib/form-designer/ 是上游源码副本,不是 npm 依赖。VENDOR.md 记录上游 commit 9438905ecfa35203101b6a0c4ee2af806e2e915c / tag v3.2.38,含复制白名单表与「本地改动清单」—— form-create 侧仅 1 项:element-ui/src/core/index.js 把 ui:'process.env.UI' → 'element-ui'、version:'process.env.VERSION' → '3.2.38',原因是 Vite 的 define 不替换字符串字面量内部,否则 compareVersion() 会误判。

配套工程约束(三处,都已核实原文):

  • eslint.config.mjs:23-25 排除两个 vendored 目录(注释 // 见 src/lib/form-create/VENDOR.md);
  • package.json 完全没有任何 @form-create/* 依赖;
  • tsconfig.json:24-36 配 paths,:53 exclude 两目录。

正确的打补丁方式不是改 vendored 源码

因为要求零 diff,扩展一律在外层做:

想改什么去哪儿改
渲染行为registerExtraWidgets / registerSubTableGroup 往 form-create 组件表里塞包装组件
设计器行为useFormCreateDesigner.ts 的 addComponent / appendMenuItem / removeMenuItem / setComponentRuleConfig
设计器配置createDesignerConfig.ts

确实必须动 vendored 源码时,同时写进 VENDOR.md 的「本地改动清单」(form-create 侧目前 1 项、designer 侧 8 项)。也不要把 vendored 里的 import 改成相对路径 —— 解析靠 alias + tsconfig paths,改写会破坏零 diff、让升级无法直接覆盖。

升级流程(VENDOR.md):checkout tag → 按白名单覆盖 → 重新应用本地改动清单 → 更新 commit/tag/version → pnpm ts:check 且 dev 与 build 都要过 → 按集成方案 §9 基线跑 e2e。

⚠️ 最后一步目前无从执行:两份 VENDOR.md 都引用 docs/form-create-源码集成方案.md(designer 版还引用 docs/form-design-guide/),但 ainb-ui-admin-vue3/docs/ 目录不存在。lib/form-designer/VENDOR.md 还自相矛盾:升级步骤第 3 步写「重新应用『本地改动清单』全部 4 项」,同文件的清单表格实际列了 8 项。

alias 顺序敏感 ​

vite.config.ts:80-121 注释原文:

ts
// form-create 运行时已改为工程内源码(src/lib/form-create),不再走 npm 包。
// 这里保留上游的 `@form-create/*` 写法并转发过去,好处是 vendored 目录
// 对上游保持零 diff,升级可以直接覆盖。详见 src/lib/form-create/VENDOR.md。
//
// ⚠️ 顺序敏感:Rollup 的 alias 对字符串 find 只在 `id === find` 或
// `id.startsWith(find + '/')` 时命中,所以更长的前缀必须排在前面。

8 条 alias 按前缀长度递减排列:

@form-create/element-ui/auto-import
@form-create/element-ui
@form-create/utils/lib
@form-create/utils
@form-create/core
/^@form-create\/component-elm-(.*)/     ← 正则
@form-create/component-subform
@form-create/designer

中间那条正则的注释也是原文:「控件包是 @form-create/component-elm-<name>/src/index 这种带子路径的写法,字符串 find 在 component-elm- 这种半个路径段上匹配不到,只能用正则」。

动这个数组必须保持前缀长度递减,否则 element-ui 会吃掉 element-ui/auto-import。

vite.config.ts:143-153 的 codeSplitting 也要跟着改,注释原文:「form-create 运行时已 vendored 到 src/lib,不再在 node_modules 下,这条 test 必须跟着改,否则它会散进主 chunk」,对应 { name:'form-create', test:/src[\\/]lib[\\/]form-create[\\/]/ } 与 { name:'form-designer', test:/src[\\/]lib[\\/]form-designer[\\/]/ }。

auto-*.d.ts 的 gitignore 例外 ​

.gitignore:7-10 原文:

ini
auto-*.d.ts
# 上一行本意是忽略 unplugin 生成的 src/types/auto-{imports,components}.d.ts,
# 但它会误伤 vendored 的 @form-create/element-ui/auto-import.d.ts(那是上游源码,必须入库)
!src/lib/**/auto-*.d.ts

已用 git check-ignore -v 核实:src/lib/form-create/element-ui/auto-import.d.ts 未被忽略、且已被跟踪。

包装内核组件必须显式声明 formCreateInject ​

form-create 装了全局 mixin app.mixin({ props: ['formCreateInject'] }),所以它永远不会落到 $attrs。包装内核组件(如子表 fcGroup)时漏声明,会让 this.formCreateInject.form.$form() 抛错、整张表单渲染失败(registerSubTableGroup.ts 注释说明)。

FcTitle / FcTable / TableForm 的副作用注册断了 ​

lib/form-designer/index.js:50-53 定义:

js
addComponent = (id, component, previewComponent) => {
    designerForm.component(...); formCreate.component(id, component)
}

并在 :78-80 顶层调用注册这三个。设计器改为 ensureFcDesigner 动态 import 后(plugins/formCreate/designer.ts,注释说省掉首屏约 926KB),这条副作用链就断了 —— 所以 registerExtraWidgets.ts 显式补注册。

不要指望 import 设计器来顺带注册运行时控件。

怎么扩展 ​

新增一个「设备选择器」控件 ​

  1. 写渲染组件:新建 src/components/FormCreate/src/components/DevicePicker.vue,用 defineOptions({ name: 'DevicePicker' }) —— 名字必须与规则 type 完全一致(app.component(component.name!, ...) 依赖它)。接收 formCreateInject prop 才能拿到 fApi。
  2. 注册进 Vue 全局表:在 plugins/formCreate/index.ts 顶部 import,并加进 components[](:111-168)。
  3. 判断是否需要第 4 步:若 type 首字母小写后与原生 HTML 标签撞名(如 Link→link),或需要精确命中大小写(如 elImage),则必须额外在 registerExtraWidgets.ts 的 EXTRA_WIDGETS 里加一行 'DevicePicker': DevicePicker,否则 resolveComponent 那一支根本走不到。
  4. 🔴 写进控件契约:在 enlev-module-infra/src/main/resources/form-design/form-widget-contracts.json 增加条目,然后按 SYNC.md 用 cp 复制到前端;再在 contracts/index.ts:160 附近逐字段手写映射。
  5. 接设计器菜单:在 useFormCreateDesigner.ts 的 buildFormComponents()(:56-103)里 designer.addComponent(rule) + designer.appendMenuItem('main', {icon,name,label});系统类控件放 buildSystemMenu()(:118-144)。⚠️ 若组件自带 menu:'layout' 或 inside:true(如 FormSteps/FormStep),只准 addComponent,绝不能 appendMenuItem(:94-100 注释,重复菜单项是 e2e 抓出来的)。
  6. 加属性面板:新增 useDevicePickerRule() 之类,走 useComponentPropsRegistry.ts 的追加注册表,不要直接调 setComponentRuleConfig(同名第二次调用会静默覆盖前者)。在 useFormCreateDesigner.ts:169 的 initialize() 里挂上。
  7. 运行时取值/校验:如需服务端参与,在 framework/formdesign/core/ 下对应 Guard 加分支;前端校验规则通过 components/FormValidation/fieldRules.ts 的 deriveValidateRules 派生,无需改渲染页。
  8. 补单测:同目录放纯函数的 .spec.ts(参照 FormWidgets/*.spec.ts 的写法 —— 组件本身 mount 不了,见下)。

改一个已有控件的行为 ​

优先在外层包装(往 form-create 组件表里塞一个新组件、type 用新名字),而不是改 vendored 源码,也不是改 components[] 里已有的那个 —— 后者会影响所有引用它的历史表单。

坑与注意 ​

  1. 🔴 alias 顺序敏感(见上文),动数组必须保持前缀长度递减。
  2. codeSplitting 的 test 要跟着 alias 一起改,否则 form-create 散进主 chunk。
  3. .gitignore 的 !src/lib/**/auto-*.d.ts 例外不能删 —— 删了会把 vendored 的上游源码从 git 里踢出去。
  4. 不要改 vendored 里的 import 为相对路径。
  5. 🔴 setComponentRuleConfig 静默覆盖。useComponentPropsRegistry.ts 头部记录了真实事故:useValidateRule 与 useInputEnhanceRule 都为 input/textarea/password/inputNumber 注册,结果「格式校验」从这四个控件的面板上消失。
  6. config.formRule 用 append: true 而不是函数(createDesignerConfig.ts:141-152 注释):传函数是整体替换,会丢掉上游默认项。
  7. 契约漂移不会被任何测试抓到(见上文 danger 块)。
  8. SYNC.md 与 contracts/index.ts 里的规范路径写作 yudao-module-infra(改名前模块名),与实际 enlev-module-infra 不符 —— 文档陈旧。
  9. 注册漏了不报错:node.js 的 catch 只 console.error 并渲染空 vnode,字段静默变空白。排查时先看控制台。
  10. components[] 里的 name 必须与契约 type 逐字一致,包括大小写。

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

测试 ​

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

控件组件本身无法 mount

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

写控件测试时的现实做法:把可测逻辑抽进同目录的 .ts,只测那个文件。tests/ 目录不存在,pnpm test:e2e 不可用。

未证实 ​

  • 未逐一核对 50+ 个 components[] 条目的 name 与契约 type 是否全部一一对应,只抽验了 RemotePicker、Link、FcTitle、FcTable、TableForm、group/fcGroup 等关键项。
  • lib/form-designer/VENDOR.md 的「4 项 vs 8 项」矛盾未查证哪个是意图。该文件自我提醒第 6、7 项(去 form-create.com 文档链接、去品牌水印并把 image 默认 src 改为空串)升级时最容易漏,建议用 grep -rn "form-create\.com" src/lib/form-designer/ 自检。
  • 升级流程第 6 步「按 §9 基线跑 e2e」引用的文档不存在,无从执行。

相关页面 ​