From f2ee7ae7b522d0fe78f354de531d6cbd6116ab4b Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 31 Aug 2026 18:30:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(form):=20=E7=BB=9F=E4=B8=80=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=80=BC=20effect=20=E6=9C=BA=E5=88=B6=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=20nested=20=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BA=20inn?= =?UTF-8?q?erConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将字段挂载时的 model 写入迁移至 registerField effect, 渲染与无渲染校验共用 applyMountValueEffects; nested 重命名为 innerConfig 以区分配置派生与值写入。 --- docs/.vitepress/config.ts | 1 + docs/api/form/submit-form.md | 41 +- docs/form-config/rules.md | 4 +- docs/guide/advanced/tmagic-form.md | 2 +- eslint-config/flat/import-sort.mjs | 2 +- .../editor/src/components/CompareForm.vue | 5 +- packages/editor/src/fields/CodeSelect.vue | 5 +- .../editor/src/fields/configs/codeSelect.ts | 6 +- .../editor/src/fields/headless-validation.ts | 65 +- .../tests/unit/fields/CodeSelect.spec.ts | 11 +- .../unit/fields/headless-validation.spec.ts | 52 +- packages/editor/tests/unit/plugin.spec.ts | 14 +- packages/form/src/Form.vue | 6 + packages/form/src/containers/Container.vue | 2 +- packages/form/src/containers/Tabs.vue | 11 + .../table-group-list/TableGroupList.vue | 5 +- .../src/containers/table-group-list/useAdd.ts | 7 +- .../src/containers/table/ActionsColumn.vue | 2 +- packages/form/src/containers/table/Table.vue | 4 +- .../src/containers/table/usePagination.ts | 2 +- .../form/src/containers/table/useSortable.ts | 2 +- .../src/containers/table/useTableColumns.ts | 9 +- .../Index.vue} | 9 +- .../form/src/fields/CheckboxGroup/effect.ts | 27 + .../src/fields/{Date.vue => Date/Index.vue} | 7 +- packages/form/src/fields/Date/effect.ts | 29 + .../{DateTime.vue => DateTime/Index.vue} | 7 +- packages/form/src/fields/DateTime/effect.ts | 36 ++ .../fields/{Display.vue => Display/Index.vue} | 9 +- packages/form/src/fields/Display/effect.ts | 28 + .../Index.vue} | 9 +- .../form/src/fields/DynamicField/effect.ts | 74 +++ .../Index.vue} | 7 +- .../form/src/fields/NumberRange/effect.ts | 27 + packages/form/src/headless.ts | 9 +- packages/form/src/index.ts | 21 +- packages/form/src/plugin.ts | 14 +- packages/form/src/utils/builtInFields.ts | 15 +- packages/form/src/utils/collectFields.ts | 219 ++++++- ...eldNestedConfig.ts => fieldInnerConfig.ts} | 72 +-- packages/form/src/utils/fieldValueEffects.ts | 189 +----- packages/form/src/utils/form.ts | 10 +- packages/form/src/utils/registerField.ts | 51 +- packages/form/src/utils/validateValues.ts | 20 +- packages/form/tests/node/headless.spec.ts | 1 + packages/form/tests/unit/Form.extra.spec.ts | 110 +++- packages/form/tests/unit/Form.spec.ts | 3 +- packages/form/tests/unit/FormDialog.spec.ts | 3 +- .../tests/unit/containers/GroupList.spec.ts | 3 +- .../form/tests/unit/containers/Panel.spec.ts | 3 +- .../form/tests/unit/containers/Step.spec.ts | 3 +- .../form/tests/unit/containers/Table.spec.ts | 5 +- .../form/tests/unit/containers/Tabs.spec.ts | 69 ++- .../form/tests/unit/containers/useAdd.spec.ts | 150 +++++ .../form/tests/unit/fields/Cascader.spec.ts | 3 +- .../form/tests/unit/fields/Checkbox.spec.ts | 3 +- .../tests/unit/fields/CheckboxGroup.spec.ts | 3 +- .../tests/unit/fields/ColorPicker.spec.ts | 3 +- packages/form/tests/unit/fields/Date.spec.ts | 3 +- .../form/tests/unit/fields/DateTime.spec.ts | 3 +- .../form/tests/unit/fields/Daterange.spec.ts | 3 +- .../form/tests/unit/fields/Display.spec.ts | 3 +- .../form/tests/unit/fields/Hidden.spec.ts | 3 +- packages/form/tests/unit/fields/Link.spec.ts | 3 +- .../form/tests/unit/fields/Number.spec.ts | 3 +- .../tests/unit/fields/NumberRange.spec.ts | 3 +- .../form/tests/unit/fields/RadioGroup.spec.ts | 3 +- .../form/tests/unit/fields/Select.spec.ts | 5 +- .../form/tests/unit/fields/Switch.spec.ts | 3 +- packages/form/tests/unit/fields/Text.spec.ts | 3 +- .../form/tests/unit/fields/Textarea.spec.ts | 3 +- packages/form/tests/unit/fields/Time.spec.ts | 3 +- .../form/tests/unit/fields/Timerange.spec.ts | 3 +- .../form/tests/unit/helpers/formValidation.ts | 3 +- packages/form/tests/unit/submitForm.spec.ts | 1 + .../unit/utils/applyMountValueEffects.spec.ts | 566 ++++++++++++++++++ packages/form/tests/unit/utils/config.spec.ts | 1 + packages/form/tests/unit/utils/form.spec.ts | 1 + .../tests/unit/utils/registerField.spec.ts | 25 +- .../tests/unit/utils/tableGroupList.spec.ts | 1 + .../form/tests/unit/utils/typeMatch.spec.ts | 7 +- .../tests/unit/utils/validateValues.spec.ts | 40 +- packages/form/tests/unit/validateForm.spec.ts | 1 + playground/tsconfig.json | 1 + playground/vite.config.ts | 1 + scripts/build.mjs | 1 + 86 files changed, 1704 insertions(+), 501 deletions(-) rename packages/form/src/fields/{CheckboxGroup.vue => CheckboxGroup/Index.vue} (81%) create mode 100644 packages/form/src/fields/CheckboxGroup/effect.ts rename packages/form/src/fields/{Date.vue => Date/Index.vue} (74%) create mode 100644 packages/form/src/fields/Date/effect.ts rename packages/form/src/fields/{DateTime.vue => DateTime/Index.vue} (76%) create mode 100644 packages/form/src/fields/DateTime/effect.ts rename packages/form/src/fields/{Display.vue => Display/Index.vue} (62%) create mode 100644 packages/form/src/fields/Display/effect.ts rename packages/form/src/fields/{DynamicField.vue => DynamicField/Index.vue} (91%) create mode 100644 packages/form/src/fields/DynamicField/effect.ts rename packages/form/src/fields/{NumberRange.vue => NumberRange/Index.vue} (84%) create mode 100644 packages/form/src/fields/NumberRange/effect.ts rename packages/form/src/utils/{fieldNestedConfig.ts => fieldInnerConfig.ts} (57%) create mode 100644 packages/form/tests/unit/containers/useAdd.spec.ts create mode 100644 packages/form/tests/unit/utils/applyMountValueEffects.spec.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3804fb3a..61c23c5a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -588,6 +588,7 @@ export default defineConfig({ }, resolve: { alias:[ + { find: /^@form/, replacement: path.join(__dirname, '../../packages/form/src/') }, { 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') }, diff --git a/docs/api/form/submit-form.md b/docs/api/form/submit-form.md index b937b5a3..3227b0c6 100644 --- a/docs/api/form/submit-form.md +++ b/docs/api/form/submit-form.md @@ -15,18 +15,22 @@ 无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。 -字段只要带了 `rules`(会包 FormItem),就会校验自身,不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { nested })`,把内部会挂到父表单上的配置交出来。nested 回调自身抛错时,会以 `FieldNestedConfigError`(`code: 'FIELD_NESTED_CONFIG'`)reject。 +字段只要带了 `rules`(会包 FormItem),就会校验自身,不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { innerConfig })`,把内部会挂到父表单上的配置交出来。innerConfig 回调自身抛错时,会以 `FieldInnerConfigError`(`code: 'FIELD_INNER_CONFIG'`)reject。 + +innerConfig 回调在校验和表单值初始化两条链路上都会被调用(后者用于走到复合字段内部、找出需要执行 `effect` 的子字段),所以它应当只做配置派生、可重复调用、不要做重活。在表单值初始化链路上,回调抛错只会记录到 console 并跳过该子树,不会让表单渲染不出来。 自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`。 +字段对表单值的初始化写入统一登记为 `effect`,渲染与无渲染共用同一份登记表,执行点也只有一个:表单值初始化完成后(`MForm` 内部、`validateValues`、以及 tab / table 新增行时)各执行一次 `applyMountValueEffects`,字段组件自身不要在 `setup` 里改写 `model`。因此 effect 有两个约束:一是必须幂等,同一份值可能被执行多次(如 `initValues` 变化后重新初始化);二是不看 `display`(`display: false` 或函数返回假的字段也会被规整,避免字段由隐藏转为显示时漏掉)。`type: 'hidden'` 不同:遍历在该节点停止、不往下分派,内部字段不会执行 effect。需要按路径跨层级写值时用上下文里的 `values`(本次处理的值根对象,`prop` 即以它为根),不要用 `mForm.values`——对比模式处理的是 `lastValues` 那一份,新增行处理的则是还没挂到表单上的一行值。单个 effect 抛错只会记录到 console,不影响其余字段与表单渲染。复合字段可以同时登记 `effect` 与 `innerConfig`:前者改本字段的值,后者只派生内部配置、不要在回调里改 `model`。 + | 字段形态 | 登记方式 | | --------------------------------------------------------------------- | ------------------------------------------- | | 自身带 `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 })` | +| 叶子字段,但需要改写表单值(类似 `display` 的 `initValue`) | `registerField('my-field', { effect })` | +| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { innerConfig })`(需要改本字段的值时再加 `effect`) | | 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` | ```ts @@ -38,8 +42,7 @@ registerFields({ 'my-color-picker': { component: MyColorPicker } }); // 需要挂到当前 app 时传入第二个参数 registerFields({ 'my-color-picker': { component: MyColorPicker } }, app); -// 叶子字段,但挂载(setup)时会改写 model:传入 effect 让无渲染校验复刻这份写入, -// 否则无渲染校验拿到的值会与渲染式校验不一致 +// 叶子字段,但需要改写表单值:写成 effect,不要在组件 setup 里改 model registerField('my-status', { effect: ({ config, model }) => { if ((config as any).initValue && model) { @@ -50,55 +53,55 @@ registerField('my-status', { // 复合字段:把组件内部渲染的 MContainer 配置交出来 registerField('my-composite', { - nested: ({ config, model, prop }) => ({ - // 对应组件内部 - config: innerConfig, + innerConfig: ({ config, model, prop }) => ({ + // 对应组件内部 + config: childConfig, model: model[config.name], prop, }), }); -// typeMatch:覆盖或扩展该 type 的类型匹配校验,可与 nested / effect 同时登记 +// typeMatch:覆盖或扩展该 type 的类型匹配校验,可与 innerConfig / effect 同时登记 registerField('my-status', { typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'), }); ``` -返回的 `config` 的 `name` 会被追加到返回的 `prop` 上。因此当嵌套配置复用了字段自身的 `name`(例如内部渲染 ``)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次: +返回的 `config` 的 `name` 会被追加到返回的 `prop` 上。因此当内部配置复用了字段自身的 `name`(例如内部渲染 ``)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次: ```ts registerField('my-list', { - nested: ({ config, parentProp }) => ({ + innerConfig: ({ 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/`),避免两条链路各写一份而逐渐跑偏。 +编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`:innerConfig 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。 -`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem;需要嵌套表单项时,应对该具体组件 `registerField(type, { nested })`。 +`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem;需要嵌套表单项时,应对该具体组件 `registerField(type, { innerConfig })`。 ### 重复登记与撤销 同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key: ```ts -registerField('my-composite', { nested }); -registerField('my-composite', { component: MyComposite }); // nested 仍在 +registerField('my-composite', { innerConfig }); +registerField('my-composite', { component: MyComposite }); // innerConfig 仍在 ``` 登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text`、`tab` 等内置 type)。 -因为是合并语义,把一个已登记 `nested` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销: +因为是合并语义,把一个已登记 `innerConfig` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销: ```ts -registerField('my-composite', {}); // ✗ 合并后 nested 还在,仍会下钻 +registerField('my-composite', {}); // ✗ 合并后 innerConfig 还在,仍会下钻 unregisterField('my-composite'); // ✓ 先清掉业务层登记 registerField('my-composite', { component: MyComposite }); ``` -一次登记里同时传多个形态时的优先级:`walk` > `nested` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。 +一次登记里同时传多个形态时的优先级:`walk` > `innerConfig` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。 ## 签名 @@ -274,7 +277,7 @@ if (error) { } ``` -校验通过返回空字符串 `''`,否则返回以 `
` 拼接的错误文案。无法完成校验时才会 reject(例如嵌套配置回调失败抛出 `FieldNestedConfigError`)。 +校验通过返回空字符串 `''`,否则返回以 `
` 拼接的错误文案。无法完成校验时才会 reject(例如 innerConfig 回调失败抛出 `FieldInnerConfigError`)。 ## 运行环境 diff --git a/docs/form-config/rules.md b/docs/form-config/rules.md index d316521e..a36f386a 100644 --- a/docs/form-config/rules.md +++ b/docs/form-config/rules.md @@ -155,7 +155,7 @@ app.use(MagicForm, { ### Editor 字段内置规则 -安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless` 与 `@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key(如 `nested` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。 +安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless` 与 `@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key(如 `innerConfig` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。 | 字段 type | 期望值 | | --- | --- | @@ -174,7 +174,7 @@ app.use(MagicForm, { > 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules,避免单个子项非法导致整块表单标红。 -业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `nested` / `walk` / `effect`。 +业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `innerConfig` / `walk` / `effect`。 ## 示例 diff --git a/docs/guide/advanced/tmagic-form.md b/docs/guide/advanced/tmagic-form.md index b41c1548..450a4f07 100644 --- a/docs/guide/advanced/tmagic-form.md +++ b/docs/guide/advanced/tmagic-form.md @@ -45,7 +45,7 @@ app.use(MagicForm); app.mount("#app"); ``` -也可在安装时传入自定义字段登记(叶子 / nested / typeMatch / component),详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则): +也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component),详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则): ```javascript import MyField from './MyField.vue'; diff --git a/eslint-config/flat/import-sort.mjs b/eslint-config/flat/import-sort.mjs index d1365851..0214f0d1 100644 --- a/eslint-config/flat/import-sort.mjs +++ b/eslint-config/flat/import-sort.mjs @@ -22,7 +22,7 @@ export default { ['^(@tencent)(/.*|$)'], ['^(@tmagic)(/.*|$)'], // Internal packages. - ['^(@|src|editor-page|@editor|@data-source)(/.*|$)'], + ['^(@|src|editor-page|@editor|@form|@data-source)(/.*|$)'], // Side effect imports. ['^\\u0000'], // Parent imports. Put `..` last. diff --git a/packages/editor/src/components/CompareForm.vue b/packages/editor/src/components/CompareForm.vue index a0bb4ff9..a88ac775 100644 --- a/packages/editor/src/components/CompareForm.vue +++ b/packages/editor/src/components/CompareForm.vue @@ -60,9 +60,8 @@ const lastValuesProcessed = computed(() => { /** * `code-select` 字段在历史数据中存在两种"语义为空"的形态: * - 字符串 `''`(旧数据 / 用户从未配置过钩子); - * - `{ hookType: HookType.CODE, hookData: [] }`(CodeSelect.vue 在挂载时 - * 写入的默认结构,参见 packages/editor/src/fields/CodeSelect.vue 中 - * `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`)。 + * - `{ hookType: HookType.CODE, hookData: [] }`(`normalizeCodeSelectValue` + * 写入的默认结构)。 * * 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件 * 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。 diff --git a/packages/editor/src/fields/CodeSelect.vue b/packages/editor/src/fields/CodeSelect.vue index 84ed19be..7bf8faf3 100644 --- a/packages/editor/src/fields/CodeSelect.vue +++ b/packages/editor/src/fields/CodeSelect.vue @@ -52,15 +52,12 @@ const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues const codeConfig = computed(() => createCodeSelectConfig(props.config)); +// 挂载时的归一化由 applyMountValueEffects → code-select 的 effect 完成,这里只兜运行期被置空 watch( () => props.model[props.name], () => { - // 兼容旧的数据结构 normalizeCodeSelectValue(props.model, props.name); }, - { - immediate: true, - }, ); const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData); diff --git a/packages/editor/src/fields/configs/codeSelect.ts b/packages/editor/src/fields/configs/codeSelect.ts index b318338d..155bf13b 100644 --- a/packages/editor/src/fields/configs/codeSelect.ts +++ b/packages/editor/src/fields/configs/codeSelect.ts @@ -109,10 +109,10 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi }; /** - * `fields/CodeSelect.vue` 挂载时的值兼容:旧数据结构里钩子值可能是空值或空数组, - * 组件用 `watch(immediate)` 把它改写成 `{ hookType, hookData }`。 + * `code-select` 的旧数据兼容:钩子值可能是空值或空数组,改写成 `{ hookType, hookData }`。 * - * 这个写入发生在校验之前,无渲染校验必须同样执行,否则内部字段的取值层级不一致。 + * 表单值初始化时由 `code-select` 的 `effect` 执行(`applyMountValueEffects`); + * 组件里的 `watch`(无 immediate)只兜运行期被置空。必须幂等。 */ export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => { if (!model) return; diff --git a/packages/editor/src/fields/headless-validation.ts b/packages/editor/src/fields/headless-validation.ts index 73eb7618..17623b1b 100644 --- a/packages/editor/src/fields/headless-validation.ts +++ b/packages/editor/src/fields/headless-validation.ts @@ -19,7 +19,8 @@ import { type DisplayCondsConfig, type EventSelectConfig, - type FieldNestedConfig, + type FieldInnerConfig, + type FieldMountValueEffect, filterFunction, type FormItemConfig, type HeadlessFieldOptions, @@ -35,19 +36,30 @@ import { createStyleSetterConfig } from './StyleSetter/configs'; const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`; +/** `code-select` 旧数据兼容:空值改写成 `{ hookType, hookData }`。 */ +const codeSelectEffect: FieldMountValueEffect = ({ config, model }) => { + normalizeCodeSelectValue(model, getName(config)); +}; + +/** `event-select`:表单 init 对未声明 defaultValue 的自定义 type 会写成空串,统一收成数组。 */ +const eventSelectEffect: FieldMountValueEffect = ({ config, model }) => { + const name = getName(config); + if (model && !Array.isArray(model[name])) { + model[name] = []; + } +}; + /** - * `code-select` 的嵌套配置。 + * `code-select` 的内部配置。 * * 对应 `fields/CodeSelect.vue` 内部 * ``。 * - * @param ctx - 嵌套配置回调入参 + * @param ctx - innerConfig 回调入参 * @returns 内部容器的 config / model / prop */ -const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => { +const codeSelectInnerConfig: FieldInnerConfig = ({ config, model, prop }) => { const name = getName(config); - // 组件在 watch(immediate) 里做的旧数据兼容,发生在校验之前 - normalizeCodeSelectValue(model, name); return { config: createCodeSelectConfig(config as any), @@ -57,14 +69,14 @@ const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => { }; /** - * `display-conds` 的嵌套配置。 + * `display-conds` 的内部配置。 * * 对应 `fields/DisplayConds.vue` 内部把同一份 groupList 配置交给 `MGroupList`。 * - * @param ctx - 嵌套配置回调入参 + * @param ctx - innerConfig 回调入参 * @returns 内部 group-list 配置;prop 基准为 parentProp,避免 name 被拼两次 */ -const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, parentProp, mForm }) => { +const displayCondsInnerConfig: FieldInnerConfig = ({ config, model, prop, parentProp, mForm }) => { const name = getName(config); const parentFields = filterFunction(mForm, (config as DisplayCondsConfig).parentFields, { @@ -81,19 +93,16 @@ const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, pare }; /** - * `event-select` 的嵌套配置。 + * `event-select` 的内部配置。 * * 对应 `fields/EventSelect.vue`:列表走 group-list,事件名渲染在 title slot, * 无渲染校验仍把事件名当作列表项字段,路径为 `..name`。 * - * @param ctx - 嵌套配置回调入参 + * @param ctx - innerConfig 回调入参 * @returns 合成的 group-list 配置;旧数据格式返回 null,不参与校验 */ -const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }) => { +const eventSelectInnerConfig: FieldInnerConfig = ({ config, model, parentProp }) => { const name = getName(config); - if (model && !Array.isArray(model[name])) { - model[name] = []; - } const events = model?.[name]; // 旧数据格式走的是另一套表格配置,其中不含任何 rules,不参与校验 @@ -106,15 +115,15 @@ const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }; /** - * `style-setter` 的嵌套配置。 + * `style-setter` 的内部配置。 * * 对应 `fields/StyleSetter/Index.vue`:6 个面板共用 `:values="model[name]"`、`:prop="prop || name"`。 * `theme` 按 `useTheme` 缺省空串传入,只改 flexWrap 的 UI childType,不影响校验字段。 * - * @param ctx - 嵌套配置回调入参 + * @param ctx - innerConfig 回调入参 * @returns style 面板配置及 styleModel */ -const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) => { +const styleSetterInnerConfig: FieldInnerConfig = ({ config, model, prop }) => { const name = getName(config); const styleModel = (model?.[name] ?? {}) as Partial; @@ -132,10 +141,10 @@ const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) => * 安装编辑器时由 plugin 把 `component` 叠上去再传给 `@tmagic/form`。 * * - 叶子:内部只渲染叶子 UI,或把子表单渲染在独立的 MForm / MFormBox 实例里 - * - nested:内部再渲染 MContainer / MPanel / MGroupList,把运行期配置交出来 + * - innerConfig:内部再渲染 MContainer / MPanel / MGroupList,把运行期配置交出来 * - typeMatch:该 type 自身的类型匹配校验 * - * nested 返回的 config / model / prop 与组件模板里传给内部容器的那一组保持一一对应, + * innerConfig 返回的 config / model / prop 与组件模板里传给内部容器的那一组保持一一对应, * 且配置本身与组件共用同一份工厂(`fields/configs/`)。 */ export const editorFields: Record = { @@ -153,8 +162,16 @@ export const editorFields: Record = { 'data-source-methods': { typeMatch: editorTypeMatchRules['data-source-methods'] }, 'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] }, 'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] }, - 'code-select': { nested: codeSelectNestedConfig, typeMatch: editorTypeMatchRules['code-select'] }, - 'display-conds': { nested: displayCondsNestedConfig, typeMatch: editorTypeMatchRules['display-conds'] }, - 'event-select': { nested: eventSelectNestedConfig, typeMatch: editorTypeMatchRules['event-select'] }, - 'style-setter': { nested: styleSetterNestedConfig, typeMatch: editorTypeMatchRules['style-setter'] }, + 'code-select': { + effect: codeSelectEffect, + innerConfig: codeSelectInnerConfig, + typeMatch: editorTypeMatchRules['code-select'], + }, + 'display-conds': { innerConfig: displayCondsInnerConfig, typeMatch: editorTypeMatchRules['display-conds'] }, + 'event-select': { + effect: eventSelectEffect, + innerConfig: eventSelectInnerConfig, + typeMatch: editorTypeMatchRules['event-select'], + }, + 'style-setter': { innerConfig: styleSetterInnerConfig, typeMatch: editorTypeMatchRules['style-setter'] }, }; diff --git a/packages/editor/tests/unit/fields/CodeSelect.spec.ts b/packages/editor/tests/unit/fields/CodeSelect.spec.ts index f41dd7c7..c965948a 100644 --- a/packages/editor/tests/unit/fields/CodeSelect.spec.ts +++ b/packages/editor/tests/unit/fields/CodeSelect.spec.ts @@ -108,10 +108,13 @@ describe('CodeSelect', () => { expect(title).toBe('unknown'); }); - test('空 model 时初始化为 { hookType, hookData }', () => { - const props = baseProps({ model: { cs: undefined } }); - mount(CodeSelect, { props: props as any }); - expect((props.model.cs as any).hookData).toEqual([]); + test('运行期被置空时补成 { hookType, hookData }', async () => { + const wrapper = mount(CodeSelect, { props: baseProps() as any }); + const model: Record = { cs: '' }; + + await wrapper.setProps({ model }); + + expect(model.cs).toEqual({ hookType: 'code', hookData: [] }); }); test('codeType items 配置正确', () => { diff --git a/packages/editor/tests/unit/fields/headless-validation.spec.ts b/packages/editor/tests/unit/fields/headless-validation.spec.ts index 71c7f655..2afa9df0 100644 --- a/packages/editor/tests/unit/fields/headless-validation.spec.ts +++ b/packages/editor/tests/unit/fields/headless-validation.spec.ts @@ -4,9 +4,14 @@ * Copyright (C) 2025 Tencent. */ import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { computed } from 'vue'; + +import { NODE_CONDS_KEY } from '@tmagic/core'; + +import { editorFields } from '@editor/fields/headless-validation'; +import { fillConfig } from '@editor/utils/props'; // 走 @form 源码别名而非 @tmagic/form:编辑器包在单测里解析到的是 form 的构建产物 import { + applyMountValueEffects, builtInFields, clearFields, collectValidatableFields, @@ -15,11 +20,6 @@ import { registerFields, } from '@form/index'; -import { NODE_CONDS_KEY } from '@tmagic/core'; - -import { editorFields } from '@editor/fields/headless-validation'; -import { fillConfig } from '@editor/utils/props'; - /** * 无渲染校验只遍历 config 树,看不到复合字段在组件内部渲染出来的嵌套字段。 * 这些用例锁定嵌套配置交出来的 prop 路径——它必须与组件真实渲染出的 FormItem 路径一致, @@ -29,12 +29,7 @@ const collect = (config: any[], values: any, typeMatchValid = true) => { const formState = createHeadlessFormState({ config, initValues: values }); formState.values = values; - const fields = collectValidatableFields( - formState, - config, - values, - computed(() => typeMatchValid), - ); + const fields = collectValidatableFields(formState, config, values, typeMatchValid); return { props: fields.map((field) => field.prop) }; }; @@ -82,12 +77,23 @@ describe('code-select', () => { ]); }); - test('空值按组件的旧数据兼容改写为 { hookType, hookData }', () => { + test('collect 只读,空值不会在收集字段时被改写', () => { const config = [{ type: 'code-select', name: 'created' }]; const values: any = { created: [] }; collect(config, values); + expect(values.created).toEqual([]); + }); + + test('applyMountValueEffects 把空值改写成 { hookType, hookData }', () => { + const config = [{ type: 'code-select', name: 'created' }] as any; + const values: any = { created: [] }; + const formState = createHeadlessFormState({ config, initValues: values }); + formState.values = values; + + applyMountValueEffects(formState, config, values); + expect(values.created).toEqual({ hookType: 'code', hookData: [] }); }); @@ -199,6 +205,17 @@ describe('event-select', () => { ]); }); + test('applyMountValueEffects 把非数组收成空数组', () => { + const config = [{ type: 'event-select', name: 'events' }] as any; + const values: any = {}; + const formState = createHeadlessFormState({ config, initValues: values }); + formState.values = values; + + applyMountValueEffects(formState, config, values); + + expect(values.events).toEqual([]); + }); + test('旧数据格式(列表项没有 actions)不含校验规则,不产出字段', () => { const config = [{ type: 'event-select', name: 'events' }]; const values = { events: [{ name: 'click', to: 'node_1', method: 'show' }] }; @@ -295,14 +312,7 @@ describe('fillConfig 通用属性表单', () => { // 关掉独立样式面板,让 fillConfig 注入的 style tab 走 display,从而覆盖 style-setter (formState as any).services = { uiService: { get: (key: string) => key !== 'showStylePanel' } }; - expect(() => - collectValidatableFields( - formState, - config, - values, - computed(() => true), - ), - ).not.toThrow(); + expect(() => collectValidatableFields(formState, config, values, true)).not.toThrow(); }); test('显示条件 tab 按 groupList 展开条件组与组内条件', () => { diff --git a/packages/editor/tests/unit/plugin.spec.ts b/packages/editor/tests/unit/plugin.spec.ts index 3c258f88..91a7ef35 100644 --- a/packages/editor/tests/unit/plugin.spec.ts +++ b/packages/editor/tests/unit/plugin.spec.ts @@ -83,7 +83,7 @@ describe('plugin install', () => { expect(formInstall).toBeDefined(); const fields = formInstall[1].fields as Record< string, - { component?: unknown; container?: unknown; nested?: unknown; typeMatch?: unknown } + { component?: unknown; container?: unknown; innerConfig?: unknown; typeMatch?: unknown } >; expect(formInstall[1].someOption).toBe(true); expect(Object.keys(fields)).toEqual( @@ -117,11 +117,14 @@ describe('plugin install', () => { } expect(fields[type].component, `${type} 缺少 component`).toBeDefined(); } - expect(fields['code-select'].nested).toEqual(expect.any(Function)); + expect(fields['code-select'].effect).toEqual(expect.any(Function)); + expect(fields['code-select'].innerConfig).toEqual(expect.any(Function)); expect(fields['code-select'].typeMatch).toEqual(expect.any(Function)); - expect(fields['style-setter'].nested).toEqual(expect.any(Function)); + expect(fields['event-select'].effect).toEqual(expect.any(Function)); + expect(fields['event-select'].innerConfig).toEqual(expect.any(Function)); + expect(fields['style-setter'].innerConfig).toEqual(expect.any(Function)); expect(fields['ui-select'].typeMatch).toEqual(expect.any(Function)); - expect(fields['vs-code'].nested).toBeUndefined(); + expect(fields['vs-code'].innerConfig).toBeUndefined(); expect(components.MEditor).toBeDefined(); expect(components['magic-code-editor']).toBeDefined(); expect(Object.keys(components)).toEqual(['MEditor', 'magic-code-editor']); @@ -138,7 +141,8 @@ describe('plugin install', () => { } as any); const formOpt = (app.use as any).mock.calls.find((call: any[]) => call[0] === formPlugin)[1]; expect(formOpt.fields['code-select'].component).toEqual({ name: 'CustomCodeSelect' }); - expect(formOpt.fields['code-select'].nested).toEqual(expect.any(Function)); + expect(formOpt.fields['code-select'].effect).toEqual(expect.any(Function)); + expect(formOpt.fields['code-select'].innerConfig).toEqual(expect.any(Function)); expect(formOpt.fields['code-select'].typeMatch).toEqual(expect.any(Function)); expect(formOpt.fields['event-select'].effect).toEqual(expect.any(Function)); expect(formOpt.fields['my-field'].component).toEqual({ name: 'MyField' }); diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index 0ac70d20..e2c75e31 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -56,6 +56,7 @@ import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagi import { setValueByKeyPath } from '@tmagic/utils'; import Container from './containers/Container.vue'; +import { applyMountValueEffects } from './utils/collectFields'; import { applyExtendState, createFormStateBase, initValue } from './utils/form'; import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError'; import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema'; @@ -346,6 +347,9 @@ watch( config: props.config, }).then((value) => { values.value = value; + // 字段的值初始化写入统一在这里执行,字段组件自身不再改写 model。 + // 必须放在赋值之后:effect 与动态 type / display 回调读到的 formValue 才是这一轮的值。 + applyMountValueEffects(formState, props.config, values.value); // 非对比模式,初始化完成 initialized.value = !props.isCompare; @@ -363,6 +367,8 @@ watch( config: props.config, }).then((value) => { lastValuesProcessed.value = value; + // 对比模式下待对比的那份值同样要规整,否则会与当前值比出「格式差异」这种假差异 + applyMountValueEffects(formState, props.config, lastValuesProcessed.value); initialized.value = true; }); } diff --git a/packages/form/src/containers/Container.vue b/packages/form/src/containers/Container.vue index c73f22b2..a1e3ed80 100644 --- a/packages/form/src/containers/Container.vue +++ b/packages/form/src/containers/Container.vue @@ -358,7 +358,7 @@ const type = computed((): string => resolveItemType(mForm, props.config, props)) const tagName = computed(() => { // `type: 'component'` 把任意 Vue 组件当字段渲染。无渲染校验把它当叶子、不遍历内部结构, // 因此该组件不得再向父表单注册 FormItem(不要在内部挂会 addField 的 MContainer)。 - // 需要嵌套表单项时,应对该具体组件 registerField(type, { nested })。 + // 需要嵌套表单项时,应对该具体组件 registerField(type, { innerConfig })。 if (type.value === 'component' && (props.config as ComponentConfig).component) { return (props.config as ComponentConfig).component; } diff --git a/packages/form/src/containers/Tabs.vue b/packages/form/src/containers/Tabs.vue index bc683949..b5948515 100644 --- a/packages/form/src/containers/Tabs.vue +++ b/packages/form/src/containers/Tabs.vue @@ -86,6 +86,7 @@ import { isEmpty } from 'lodash-es'; import { getDesignConfig, TMagicBadge } from '@tmagic/design'; import type { ContainerChangeEventData, FormState, TabConfig, TabPaneConfig } from '../schema'; +import { applyMountValueEffects } from '../utils/collectFields'; import { display as displayFunc, filterFunction, initValue } from '../utils/form'; import Container from './Container.vue'; @@ -205,6 +206,13 @@ const onTabAdd = async () => { prop: props.prop, config: props.config, }); + // 自定义回调可能直接 push 原始对象;对现有标签页再规整一遍(effect 必须幂等) + const tabsModel = props.model[props.name]; + if (Array.isArray(tabsModel) && Array.isArray(props.config.items)) { + for (const tab of tabsModel) { + applyMountValueEffects(mForm, props.config.items, tab); + } + } emit('change', props.model[props.name]); } else { const newObj = await initValue(mForm, { @@ -212,6 +220,9 @@ const onTabAdd = async () => { initValues: {}, }); + // 新增标签页的值在挂到表单上之前先规整一遍,与整表初始化走同一份登记表 + applyMountValueEffects(mForm, props.config.items, newObj); + newObj.title = `标签${tabs.value.length + 1}`; props.model[props.name].push(newObj); diff --git a/packages/form/src/containers/table-group-list/TableGroupList.vue b/packages/form/src/containers/table-group-list/TableGroupList.vue index e1c287c0..d6c53158 100644 --- a/packages/form/src/containers/table-group-list/TableGroupList.vue +++ b/packages/form/src/containers/table-group-list/TableGroupList.vue @@ -58,8 +58,9 @@ import { Grid, Plus } from '@element-plus/icons-vue'; import { TMagicButton } from '@tmagic/design'; import type { GroupListConfig, TableConfig } from '@tmagic/form-schema'; -import type { ContainerChangeEventData } from '../../schema'; -import { isGroupListType, toGroupListConfig, toTableConfig } from '../../utils/tableGroupList'; +import type { ContainerChangeEventData } from '@form/schema'; +import { isGroupListType, toGroupListConfig, toTableConfig } from '@form/utils/tableGroupList'; + import MFormGroupList from '../GroupList.vue'; import MFormTable from '../table/Table.vue'; diff --git a/packages/form/src/containers/table-group-list/useAdd.ts b/packages/form/src/containers/table-group-list/useAdd.ts index 4913603f..9428c73c 100644 --- a/packages/form/src/containers/table-group-list/useAdd.ts +++ b/packages/form/src/containers/table-group-list/useAdd.ts @@ -3,7 +3,9 @@ import { computed, inject } from 'vue'; import { tMagicMessage } from '@tmagic/design'; import type { FormConfig, FormState, TableConfig, TableGroupListCommonConfig } from '@tmagic/form-schema'; -import { initValue } from '../../utils/form'; +import { applyMountValueEffects } from '@form/utils/collectFields'; +import { initValue } from '@form/utils/form'; + import type { TableProps } from '../table/type'; export const useAdd = ( @@ -110,6 +112,9 @@ export const useAdd = ( }); } + // enum / Excel 导入的数组行与默认新增共用同一份规整,字段组件不再在 setup 里改 model + applyMountValueEffects(mForm, columns as FormConfig, inputs); + if (props.sortKey && length) { inputs[props.sortKey] = list[length - 1][props.sortKey] - 1; } diff --git a/packages/form/src/containers/table/ActionsColumn.vue b/packages/form/src/containers/table/ActionsColumn.vue index 5bb3e5ed..82416772 100644 --- a/packages/form/src/containers/table/ActionsColumn.vue +++ b/packages/form/src/containers/table/ActionsColumn.vue @@ -43,7 +43,7 @@ import { cloneDeep } from 'lodash-es'; import { TMagicButton, TMagicTooltip } from '@tmagic/design'; -import type { FormState, TableConfig } from '../../schema'; +import type { FormState, TableConfig } from '@form/schema'; const emit = defineEmits(['change']); diff --git a/packages/form/src/containers/table/Table.vue b/packages/form/src/containers/table/Table.vue index e0da83ec..48d3f6ba 100644 --- a/packages/form/src/containers/table/Table.vue +++ b/packages/form/src/containers/table/Table.vue @@ -92,8 +92,8 @@ import { FullScreen } from '@element-plus/icons-vue'; import { TMagicButton, TMagicPagination, TMagicTable, TMagicTooltip, TMagicUpload } from '@tmagic/design'; -import type { SortProp } from '../../schema'; -import { sortChange } from '../../utils/form'; +import type { SortProp } from '@form/schema'; +import { sortChange } from '@form/utils/form'; import type { TableProps } from './type'; import { useFullscreen } from './useFullscreen'; diff --git a/packages/form/src/containers/table/usePagination.ts b/packages/form/src/containers/table/usePagination.ts index 8c093ccc..77a527b0 100644 --- a/packages/form/src/containers/table/usePagination.ts +++ b/packages/form/src/containers/table/usePagination.ts @@ -1,6 +1,6 @@ import { computed, type Ref, ref } from 'vue'; -import { getDataByPage } from '../../utils/form'; +import { getDataByPage } from '@form/utils/form'; import type { TableProps } from './type'; diff --git a/packages/form/src/containers/table/useSortable.ts b/packages/form/src/containers/table/useSortable.ts index 7c20727e..b6c54b6a 100644 --- a/packages/form/src/containers/table/useSortable.ts +++ b/packages/form/src/containers/table/useSortable.ts @@ -4,7 +4,7 @@ import type { default as SortableType, SortableEvent } from 'sortablejs'; import { type TMagicTable } from '@tmagic/design'; import type { FormState } from '@tmagic/form-schema'; -import { sortArray } from '../../utils/form'; +import { sortArray } from '@form/utils/form'; import type { TableProps } from './type'; diff --git a/packages/form/src/containers/table/useTableColumns.ts b/packages/form/src/containers/table/useTableColumns.ts index 0a91b03c..1c892ca2 100644 --- a/packages/form/src/containers/table/useTableColumns.ts +++ b/packages/form/src/containers/table/useTableColumns.ts @@ -4,10 +4,11 @@ import { WarningFilled } from '@element-plus/icons-vue'; import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/design'; import type { FormItemConfig, FormState } from '@tmagic/form-schema'; -import type { ContainerChangeEventData } from '../../schema'; -import { isGlobalFlat } from '../../utils/config'; -import { appendProp, display as displayFunc, getDataByPage, sortArray } from '../../utils/form'; -import { isTableColumnRendered, makeTableColumnConfig } from '../../utils/tableGroupList'; +import type { ContainerChangeEventData } from '@form/schema'; +import { isGlobalFlat } from '@form/utils/config'; +import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form'; +import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList'; + import Container from '../Container.vue'; import ActionsColumn from './ActionsColumn.vue'; diff --git a/packages/form/src/fields/CheckboxGroup.vue b/packages/form/src/fields/CheckboxGroup/Index.vue similarity index 81% rename from packages/form/src/fields/CheckboxGroup.vue rename to packages/form/src/fields/CheckboxGroup/Index.vue index 1c0dc3cd..68ef43a4 100644 --- a/packages/form/src/fields/CheckboxGroup.vue +++ b/packages/form/src/fields/CheckboxGroup/Index.vue @@ -11,10 +11,9 @@ import { computed, inject } from 'vue'; import { TMagicCheckbox, TMagicCheckboxGroup } from '@tmagic/design'; -import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '../schema'; -import { initCheckboxGroupValue } from '../utils/fieldValueEffects'; -import { filterFunction } from '../utils/form'; -import { useAddField } from '../utils/useAddField'; +import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '@form/schema'; +import { filterFunction } from '@form/utils/form'; +import { useAddField } from '@form/utils/useAddField'; defineOptions({ name: 'MFormCheckGroup', @@ -26,8 +25,6 @@ const emit = defineEmits(['change']); useAddField(props.prop); -initCheckboxGroupValue(props.model, props.name); - const changeHandler = (v: Array) => { emit('change', v); }; diff --git a/packages/form/src/fields/CheckboxGroup/effect.ts b/packages/form/src/fields/CheckboxGroup/effect.ts new file mode 100644 index 00000000..65d53be9 --- /dev/null +++ b/packages/form/src/fields/CheckboxGroup/effect.ts @@ -0,0 +1,27 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects'; + +/** 空值初始化为空数组。 */ +export const effect: FieldMountValueEffect = ({ config, model }) => { + const { name } = config as any; + if (model && !model[name]) { + model[name] = []; + } +}; diff --git a/packages/form/src/fields/Date.vue b/packages/form/src/fields/Date/Index.vue similarity index 74% rename from packages/form/src/fields/Date.vue rename to packages/form/src/fields/Date/Index.vue index 73055906..fc4e0476 100644 --- a/packages/form/src/fields/Date.vue +++ b/packages/form/src/fields/Date/Index.vue @@ -14,9 +14,8 @@