From 93dc0d2187046d5d5da9409cc67e21afd01a0abe Mon Sep 17 00:00:00 2001 From: roymondchen Date: Fri, 28 Aug 2026 15:33:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(form):=20=E6=96=B0=E5=A2=9E=E6=97=A0?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=A0=A1=E9=AA=8C=E5=85=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20Node/CI=20=E7=8E=AF=E5=A2=83=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E8=A1=A8=E5=8D=95=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 submitForm/validateForm 改为无 DOM 的 headless 实现,并抽取字段登记与编辑器复合字段配置,便于脚本与 CI 批量校验。 --- docs/.vitepress/config.ts | 2 + docs/api/form/submit-form.md | 201 +++-- docs/form-config/rules.md | 73 +- docs/guide/advanced/tmagic-form.md | 17 +- package.json | 3 +- packages/design/package.json | 5 + packages/design/src/headless.ts | 35 + packages/editor/package.json | 5 + packages/editor/src/fields/Code.vue | 12 +- packages/editor/src/fields/CodeSelect.vue | 89 +- packages/editor/src/fields/CodeSelectCol.vue | 6 +- .../fields/DataSourceFieldSelect/Index.vue | 8 +- .../editor/src/fields/DataSourceFields.vue | 7 - .../src/fields/DataSourceMethodSelect.vue | 5 +- .../editor/src/fields/DataSourceMethods.vue | 7 +- .../editor/src/fields/DataSourceMocks.vue | 11 +- packages/editor/src/fields/DisplayConds.vue | 144 +--- packages/editor/src/fields/EventSelect.vue | 278 +------ packages/editor/src/fields/KeyValue.vue | 7 +- .../fields/StyleSetter/components/Border.vue | 42 +- .../editor/src/fields/StyleSetter/configs.ts | 690 ++++++++++++++++ .../src/fields/StyleSetter/pro/Background.vue | 108 +-- .../src/fields/StyleSetter/pro/Border.vue | 15 +- .../src/fields/StyleSetter/pro/Font.vue | 127 +-- .../src/fields/StyleSetter/pro/Layout.vue | 223 +---- .../src/fields/StyleSetter/pro/Position.vue | 116 +-- .../src/fields/StyleSetter/pro/Transform.vue | 34 +- .../editor/src/fields/configs/codeSelect.ts | 122 +++ .../editor/src/fields/configs/displayConds.ts | 178 ++++ .../editor/src/fields/configs/eventSelect.ts | 296 +++++++ .../editor/src/fields/headless-validation.ts | 163 ++++ packages/editor/src/headless.ts | 33 + packages/editor/src/index.ts | 2 + .../src/layouts/props-panel/FormPanel.vue | 16 +- packages/editor/src/plugin.ts | 65 +- packages/editor/src/utils/type-match-rules.ts | 11 +- .../editor/tests/unit/fields/Code.spec.ts | 24 - .../tests/unit/fields/CodeSelect.spec.ts | 25 +- .../tests/unit/fields/CodeSelectCol.spec.ts | 12 - .../unit/fields/DataSourceFieldSelect.spec.ts | 12 - .../unit/fields/DataSourceFields.spec.ts | 10 - .../fields/DataSourceMethodSelect.spec.ts | 12 - .../unit/fields/DataSourceMethods.spec.ts | 22 - .../tests/unit/fields/DataSourceMocks.spec.ts | 10 - .../tests/unit/fields/DisplayConds.spec.ts | 12 +- .../editor/tests/unit/fields/KeyValue.spec.ts | 13 - .../unit/fields/headless-validation.spec.ts | 307 +++++++ .../layouts/props-panel/FormPanel.spec.ts | 19 +- packages/editor/tests/unit/plugin.spec.ts | 77 +- packages/form/package.json | 6 + packages/form/src/Form.vue | 93 +-- packages/form/src/containers/Container.vue | 230 ++---- .../form/src/containers/GroupListItem.vue | 17 +- .../table-group-list/TableGroupList.vue | 57 +- .../src/containers/table/useTableColumns.ts | 23 +- packages/form/src/fields/CheckboxGroup.vue | 6 +- packages/form/src/fields/Date.vue | 4 +- packages/form/src/fields/DateTime.vue | 15 +- packages/form/src/fields/Display.vue | 5 +- packages/form/src/fields/DynamicField.vue | 15 +- packages/form/src/fields/NumberRange.vue | 5 +- packages/form/src/headless.ts | 73 ++ packages/form/src/index.ts | 34 +- packages/form/src/plugin.ts | 93 ++- packages/form/src/schema.ts | 12 - packages/form/src/submitForm.ts | 444 ++-------- packages/form/src/utils/builtInFields.ts | 70 ++ packages/form/src/utils/collectFields.ts | 408 ++++++++++ packages/form/src/utils/config.ts | 16 +- packages/form/src/utils/fieldNestedConfig.ts | 127 +++ packages/form/src/utils/fieldValueEffects.ts | 287 +++++++ packages/form/src/utils/form.ts | 162 +++- packages/form/src/utils/registerField.ts | 309 +++++++ .../form/src/utils/silentLeafFieldTypes.ts | 71 -- packages/form/src/utils/submitHeadless.ts | 205 +++++ packages/form/src/utils/tableGroupList.ts | 119 +++ packages/form/src/utils/typeMatch.ts | 34 +- packages/form/src/utils/validateError.ts | 92 +++ packages/form/src/utils/validateValues.ts | 189 +++++ packages/form/tests/node/headless.spec.ts | 53 ++ .../form/tests/unit/helpers/formValidation.ts | 89 ++ packages/form/tests/unit/submitForm.spec.ts | 432 +++++----- packages/form/tests/unit/utils/config.spec.ts | 15 +- .../tests/unit/utils/registerField.spec.ts | 243 ++++++ .../form/tests/unit/utils/typeMatch.spec.ts | 41 +- .../tests/unit/utils/validateValues.spec.ts | 764 ++++++++++++++++++ packages/form/tests/unit/validateForm.spec.ts | 589 +++++--------- playground/vite.config.ts | 8 + pnpm-lock.yaml | 3 + rolldown.dts.config.mjs | 18 +- scripts/build.mjs | 98 ++- scripts/check-headless-dist.mjs | 92 +++ tsconfig.json | 3 + vitest.config.ts | 13 +- 94 files changed, 6326 insertions(+), 3067 deletions(-) create mode 100644 packages/design/src/headless.ts create mode 100644 packages/editor/src/fields/StyleSetter/configs.ts create mode 100644 packages/editor/src/fields/configs/codeSelect.ts create mode 100644 packages/editor/src/fields/configs/displayConds.ts create mode 100644 packages/editor/src/fields/configs/eventSelect.ts create mode 100644 packages/editor/src/fields/headless-validation.ts create mode 100644 packages/editor/src/headless.ts create mode 100644 packages/editor/tests/unit/fields/headless-validation.spec.ts create mode 100644 packages/form/src/headless.ts create mode 100644 packages/form/src/utils/builtInFields.ts create mode 100644 packages/form/src/utils/collectFields.ts create mode 100644 packages/form/src/utils/fieldNestedConfig.ts create mode 100644 packages/form/src/utils/fieldValueEffects.ts create mode 100644 packages/form/src/utils/registerField.ts delete mode 100644 packages/form/src/utils/silentLeafFieldTypes.ts create mode 100644 packages/form/src/utils/submitHeadless.ts create mode 100644 packages/form/src/utils/tableGroupList.ts create mode 100644 packages/form/src/utils/validateError.ts create mode 100644 packages/form/src/utils/validateValues.ts create mode 100644 packages/form/tests/node/headless.spec.ts create mode 100644 packages/form/tests/unit/helpers/formValidation.ts create mode 100644 packages/form/tests/unit/utils/registerField.spec.ts create mode 100644 packages/form/tests/unit/utils/validateValues.spec.ts create mode 100644 scripts/check-headless-dist.mjs diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1eaca05f..3804fb3a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -589,9 +589,11 @@ export default defineConfig({ resolve: { alias:[ { find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../../packages/form-schema/src/index.ts') }, + { find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') }, { find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') }, { find: /^@tmagic\/utils/, replacement: path.join(__dirname, '../../packages/utils/src/index.ts') }, { find: /^@tmagic\/schema/, replacement: path.join(__dirname, '../../packages/schema/src/index.ts') }, + { find: /^@tmagic\/design\/headless$/, replacement: path.join(__dirname, '../../packages/design/src/headless.ts') }, { find: /^@tmagic\/design/, replacement: path.join(__dirname, '../../packages/design/src/index.ts') }, { find: /^@tmagic\/element-plus-adapter/, replacement: path.join(__dirname, '../../packages/element-plus-adapter/src/index.ts') }, ] diff --git a/docs/api/form/submit-form.md b/docs/api/form/submit-form.md index 8de8a6bc..b937b5a3 100644 --- a/docs/api/form/submit-form.md +++ b/docs/api/form/submit-form.md @@ -1,14 +1,104 @@ # submitForm 函数 -以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。 +以命令式方式对一份「表单配置 + 表单值」执行一次校验并取回表单值,类似 `ElMessage` 的用法。 -调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM。 +走**无渲染**实现:不创建任何 DOM 容器、不实例化任何组件,而是直接遍历 `config` 树收集带规则的字段,交给 [`async-validator`](https://github.com/yiminghe/async-validator)(`element-plus` 内部用的也是它)执行。因此它可以在 Node / CI 等没有 DOM 的环境中使用,也省去了挂载整棵表单的开销。校验通过则 `resolve` 表单值,失败则 `reject` 错误信息。纯 Node 请从 `@tmagic/form/headless` 引入,避免加载 Vue 组件和样式。 适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如: - 通过快捷菜单/命令面板触发一次性表单 - 在脚本/服务层完成一次表单值校验后再发请求 - 把 `config` 配置当作"可执行的校验规则"使用 +- 在 Node 脚本 / CI 中批量校验组件配置 + +## 无渲染校验与自定义字段登记 + +无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。 + +字段只要带了 `rules`(会包 FormItem),就会校验自身,不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { nested })`,把内部会挂到父表单上的配置交出来。nested 回调自身抛错时,会以 `FieldNestedConfigError`(`code: 'FIELD_NESTED_CONFIG'`)reject。 + +自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`。 + +| 字段形态 | 登记方式 | +| --------------------------------------------------------------------- | ------------------------------------------- | +| 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 | +| 内部只渲染叶子 UI,或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) | +| 同时需要渲染组件 | `registerField('my-field', { component })` | +| 容器组件(`m-form-*`) | `registerField('my-box', { container, walk })` | +| 叶子字段,但挂载时会改写 model(类似 `display` 的 `initValue`) | `registerField('my-field', { effect })` | +| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { nested })` | +| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` | + +```ts +import { registerField, registerFields } from '@tmagic/form'; +import MyColorPicker from './MyColorPicker.vue'; + +// 叶子字段:内部没有嵌套的表单项;带 component 时即可渲染 +registerFields({ 'my-color-picker': { component: MyColorPicker } }); +// 需要挂到当前 app 时传入第二个参数 +registerFields({ 'my-color-picker': { component: MyColorPicker } }, app); + +// 叶子字段,但挂载(setup)时会改写 model:传入 effect 让无渲染校验复刻这份写入, +// 否则无渲染校验拿到的值会与渲染式校验不一致 +registerField('my-status', { + effect: ({ config, model }) => { + if ((config as any).initValue && model) { + model[(config as any).name] = (config as any).initValue; + } + }, +}); + +// 复合字段:把组件内部渲染的 MContainer 配置交出来 +registerField('my-composite', { + nested: ({ config, model, prop }) => ({ + // 对应组件内部 + config: innerConfig, + model: model[config.name], + prop, + }), +}); + +// typeMatch:覆盖或扩展该 type 的类型匹配校验,可与 nested / effect 同时登记 +registerField('my-status', { + typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'), +}); +``` + +返回的 `config` 的 `name` 会被追加到返回的 `prop` 上。因此当嵌套配置复用了字段自身的 `name`(例如内部渲染 ``)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次: + +```ts +registerField('my-list', { + nested: ({ config, parentProp }) => ({ + config: { type: 'group-list', name: config.name, items: innerItems }, + prop: parentProp, + }), +}); +``` + +编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`:nested 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。 + +`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem;需要嵌套表单项时,应对该具体组件 `registerField(type, { nested })`。 + +### 重复登记与撤销 + +同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key: + +```ts +registerField('my-composite', { nested }); +registerField('my-composite', { component: MyComposite }); // nested 仍在 +``` + +登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text`、`tab` 等内置 type)。 + +因为是合并语义,把一个已登记 `nested` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销: + +```ts +registerField('my-composite', {}); // ✗ 合并后 nested 还在,仍会下钻 +unregisterField('my-composite'); // ✓ 先清掉业务层登记 +registerField('my-composite', { component: MyComposite }); +``` + +一次登记里同时传多个形态时的优先级:`walk` > `nested` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。 ## 签名 @@ -18,7 +108,7 @@ function submitForm(options: SubmitFormOptions): Promise; ## 参数 -`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`appContext`、`timeout` 等参数。 +`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`dialog`、`signal` 等参数。`appContext` 仅 `dialog: true` 时生效。 | 名称 | 类型 | 默认值 | 说明 | | ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- | @@ -41,19 +131,20 @@ function submitForm(options: SubmitFormOptions): Promise; | `extendState` | `(state: FormState) => Record \| Promise>` | — | 扩展 `formState` | | `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` | | `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` | -| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 | -| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 | +| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。仅 `dialog: true` 时生效,用于继承全局组件、指令、provide 等,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 | +| `dialog` | `boolean` | `false` | `true` 时把表单以弹层形式渲染出来,点击「确定」才提交,「取消」则以 reject 中断;校验失败会保留弹层并展示错误,便于修正后重试。等待人工操作,可用 `signal` 中断 | +| `title` | `string` | `'submitForm'` / `'validateForm'` | 弹层标题,仅 `dialog: true` 时生效 | +| `signal` | `AbortSignal` | — | 外部中断信号。abort 时立即以 `signal.reason` reject,并卸载 `dialog` 模式下已挂载的临时表单实例 | ## 返回值 - `校验通过` — `Promise` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }` - `校验失败` — `Promise` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `
` 分隔) -- `初始化超时` — `Promise` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')` -无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。 +`dialog: true` 时无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。 ::: tip 关于 changeRecords -`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。 +`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。无渲染校验没有用户交互,因此固定返回空数组;只有 `dialog: true` 时才可能有内容(`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先做快照)。 ::: ## 基础用法 @@ -99,9 +190,9 @@ console.log(values); // { username: 'foo' } console.log(changeRecords); // ChangeRecord[] ``` -## 在组件中继承父级应用上下文 +## 弹层模式(`dialog: true`)下继承父级应用上下文 -`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要宿主应用先完成相应的 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去: +默认路径不挂载组件,不需要 `appContext`。只有 `dialog: true` 会渲染弹层,此时 `MForm` 要用到 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要把宿主应用的上下文带过去: ```vue