自研控件开发
适用端:Web | 类型:操作
定制功能
本页讲的控件几乎全部是本项目自研的。上游 form-create 只提供内核与一批 Element Plus 基础控件。
在线表单的每个字段在规则树里是一个 { type: 'RemotePicker', field: 'xxx', ... } 对象。type 怎么变成一个真实的 Vue 组件,中间有两张互不相通的注册表 —— 这是全部扩展点的地基,也是本页最该记住的一件事。
前置阅读:表单运行时与扩展点、前端工程结构、仓库结构与改名约定(vendored 纪律)。
它解决什么问题
node.js:51-64 逐字(vendored,不要改):
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.ts | setupFormCreate;components[]:111-168(约 50 项) |
| form-create 组件表补注册 | components/FormCreate/src/registerExtraWidgets.ts | EXTRA_WIDGETS、registerExtraWidgets |
| 子表控件接管 | components/FormCreate/src/subtable/registerSubTableGroup.ts | createSubTableGroup、KERNEL_GROUP_NAME = 'fcGroup' |
| 设计器侧扩展 | components/FormCreate/src/useFormCreateDesigner.ts | buildFormComponents:56、buildSystemMenu:118、initialize:169 |
| 设计器按需加载 | plugins/formCreate/designer.ts | ensureFcDesigner、whenDesignerReady |
| 属性面板追加注册表 | components/FormCreate/src/designer/useComponentPropsRegistry.ts | WeakMap<Ref, RegistryEntry> |
| 控件契约(前端副本) | components/FormWidgets/contracts/index.ts | getWidgetContract、listUnsupportedWidgetTypes、resolveJdbcTypeFamily |
| 控件契约(后端权威) | resources/form-design/form-widget-contracts.json | + SYNC.md |
| 设计器配置 | components/FormCreate/src/config/createDesignerConfig.ts | hiddenItem:82、config.formRule:141-152 |
| vendored 组件表 API | lib/form-create/core/src/frame/index.js | component(id, component):135-155 |
| vendored 渲染落点 | lib/form-create/core/src/factory/node.js | h(tag, data, children):51-64 |
| vendored 值管道 | lib/form-create/core/src/frame/api.js、handler/input.js | formData:122、coverValue:148、setValue:165;syncForm:88 |
| 上游同步说明 | lib/form-create/VENDOR.md、lib/form-designer/VENDOR.md | commit / tag / 本地改动清单 |
| alias 与分包 | vite.config.ts | :80-121 alias、:143-153 codeSplitting |
核心机制
自研控件清单
type → 组件路径 → 用途(均在 src/components/ 下):
| type | 文件 | 用途 |
|---|---|---|
group(子表) | FormCreate/src/subtable/registerSubTableGroup.ts | 包装内核 fcGroup,加「复制本行」/虚拟滚动 |
UploadFile / UploadImg / UploadImgs | UploadFile/(barrel 导出) | 附件、单图、多图 |
Editor | Editor/ | 富文本 |
Signature | FormCreate/src/components/Signature.vue | 手写签名(替代已废弃 signaturePad) |
OrgPicker | FormCreate/src/components/OrgPicker.vue | 组织/人员选择,json 值 |
SystemValue | FormCreate/src/components/SystemValue.vue | 系统值(当前用户/时间等),服务端 Guard 覆写 |
RelationForm | FormCreate/src/components/RelationForm.vue | 关联表单选择,标量 |
RemotePicker | FormCreate/src/components/RemotePicker.vue | 远程数据源下拉,标量 |
Formula | FormFormula/Formula.vue | 公式字段,number |
IframeComponent | FormCreate/src/components/IframeComponent.vue | 内嵌页面 |
AreaSelect | FormCreate/src/components/AreaSelect.vue | 省市区,逗号分隔串 |
DictSelect | FormCreate/src/components/DictSelect.vue | 字典下拉 |
BillNumberInput | FormCreate/src/components/BillNumberInput.vue | 单据编号(见取号管理) |
CodeImage | FormCreate/src/components/CodeImage.vue | 条码/二维码展示 |
Link | FormCreate/src/components/Link.vue | 超链接(🔴 必须走 form-create 表) |
FormSteps / FormStep | FormCreate/src/components/FormSteps.vue、FormStep.vue | 分步容器 |
fcTitle | lib/form-designer/components/FcTitle.vue | 分组标题(vendored) |
password / dateRange / timeRange / elImage | PasswordInput.vue、DateRangePicker.vue、TimeRangePicker.vue、element-plus ElImage | 由 registerExtraWidgets 补进 form-create 表 |
LogicEntryButton | FormCreate/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,:53exclude 两目录。
正确的打补丁方式不是改 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 注释原文:
// 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 原文:
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 定义:
addComponent = (id, component, previewComponent) => {
designerForm.component(...); formCreate.component(id, component)
}并在 :78-80 顶层调用注册这三个。设计器改为 ensureFcDesigner 动态 import 后(plugins/formCreate/designer.ts,注释说省掉首屏约 926KB),这条副作用链就断了 —— 所以 registerExtraWidgets.ts 显式补注册。
不要指望 import 设计器来顺带注册运行时控件。
怎么扩展
新增一个「设备选择器」控件
- 写渲染组件:新建
src/components/FormCreate/src/components/DevicePicker.vue,用defineOptions({ name: 'DevicePicker' })—— 名字必须与规则type完全一致(app.component(component.name!, ...)依赖它)。接收formCreateInjectprop 才能拿到fApi。 - 注册进 Vue 全局表:在
plugins/formCreate/index.ts顶部 import,并加进components[](:111-168)。 - 判断是否需要第 4 步:若
type首字母小写后与原生 HTML 标签撞名(如Link→link),或需要精确命中大小写(如elImage),则必须额外在registerExtraWidgets.ts的EXTRA_WIDGETS里加一行'DevicePicker': DevicePicker,否则resolveComponent那一支根本走不到。 - 🔴 写进控件契约:在
enlev-module-infra/src/main/resources/form-design/form-widget-contracts.json增加条目,然后按SYNC.md用cp复制到前端;再在contracts/index.ts:160附近逐字段手写映射。 - 接设计器菜单:在
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 抓出来的)。 - 加属性面板:新增
useDevicePickerRule()之类,走useComponentPropsRegistry.ts的追加注册表,不要直接调setComponentRuleConfig(同名第二次调用会静默覆盖前者)。在useFormCreateDesigner.ts:169的initialize()里挂上。 - 运行时取值/校验:如需服务端参与,在
framework/formdesign/core/下对应 Guard 加分支;前端校验规则通过components/FormValidation/fieldRules.ts的deriveValidateRules派生,无需改渲染页。 - 补单测:同目录放纯函数的
.spec.ts(参照FormWidgets/*.spec.ts的写法 —— 组件本身 mount 不了,见下)。
改一个已有控件的行为
优先在外层包装(往 form-create 组件表里塞一个新组件、type 用新名字),而不是改 vendored 源码,也不是改 components[] 里已有的那个 —— 后者会影响所有引用它的历史表单。
坑与注意
- 🔴 alias 顺序敏感(见上文),动数组必须保持前缀长度递减。
- codeSplitting 的 test 要跟着 alias 一起改,否则 form-create 散进主 chunk。
.gitignore的!src/lib/**/auto-*.d.ts例外不能删 —— 删了会把 vendored 的上游源码从 git 里踢出去。- 不要改 vendored 里的 import 为相对路径。
- 🔴
setComponentRuleConfig静默覆盖。useComponentPropsRegistry.ts头部记录了真实事故:useValidateRule与useInputEnhanceRule都为 input/textarea/password/inputNumber 注册,结果「格式校验」从这四个控件的面板上消失。 config.formRule用append: true而不是函数(createDesignerConfig.ts:141-152注释):传函数是整体替换,会丢掉上游默认项。- 契约漂移不会被任何测试抓到(见上文 danger 块)。
SYNC.md与contracts/index.ts里的规范路径写作yudao-module-infra(改名前模块名),与实际enlev-module-infra不符 —— 文档陈旧。- 注册漏了不报错:
node.js的catch只console.error并渲染空 vnode,字段静默变空白。排查时先看控制台。 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」引用的文档不存在,无从执行。
