@tmagic/form 1.8.0-manmanyu.8 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/es/Form.vue_vue_type_script_setup_true_lang.js +88 -107
  2. package/dist/es/FormBox.vue_vue_type_script_setup_true_lang.js +21 -5
  3. package/dist/es/FormDialog.vue_vue_type_script_setup_true_lang.js +21 -5
  4. package/dist/es/FormDrawer.vue_vue_type_script_setup_true_lang.js +46 -12
  5. package/dist/es/containers/Col.vue_vue_type_script_setup_true_lang.js +6 -2
  6. package/dist/es/containers/Container.vue_vue_type_script_setup_true_lang.js +44 -47
  7. package/dist/es/containers/Fieldset.vue_vue_type_script_setup_true_lang.js +7 -1
  8. package/dist/es/containers/FlexLayout.vue_vue_type_script_setup_true_lang.js +5 -2
  9. package/dist/es/containers/FormLabel.vue_vue_type_script_setup_true_lang.js +1 -1
  10. package/dist/es/containers/GroupList.vue_vue_type_script_setup_true_lang.js +59 -22
  11. package/dist/es/containers/GroupListItem.vue_vue_type_script_setup_true_lang.js +118 -100
  12. package/dist/es/containers/Panel.vue_vue_type_script_setup_true_lang.js +8 -3
  13. package/dist/es/containers/Row.vue_vue_type_script_setup_true_lang.js +4 -1
  14. package/dist/es/containers/Step.vue_vue_type_script_setup_true_lang.js +5 -2
  15. package/dist/es/containers/Tabs.vue_vue_type_script_setup_true_lang.js +8 -1
  16. package/dist/es/containers/table/ActionsColumn.vue_vue_type_script_setup_true_lang.js +1 -1
  17. package/dist/es/containers/table/SortColumn.vue_vue_type_script_setup_true_lang.js +1 -1
  18. package/dist/es/containers/table/Table.vue_vue_type_script_setup_true_lang.js +14 -10
  19. package/dist/es/containers/table/useTableColumns.js +10 -15
  20. package/dist/es/containers/table-group-list/TableGroupList.vue_vue_type_script_setup_true_lang.js +72 -79
  21. package/dist/es/containers/table-group-list/useAdd.js +26 -16
  22. package/dist/es/containers/table-group-list/useScrollLastItemIntoView.js +72 -0
  23. package/dist/es/fields/Cascader.vue_vue_type_script_setup_true_lang.js +2 -2
  24. package/dist/es/fields/Checkbox.vue_vue_type_script_setup_true_lang.js +1 -1
  25. package/dist/es/fields/CheckboxGroup/Index.js +5 -0
  26. package/dist/es/fields/{CheckboxGroup.vue_vue_type_script_setup_true_lang.js → CheckboxGroup/Index.vue_vue_type_script_setup_true_lang.js} +6 -7
  27. package/dist/es/fields/CheckboxGroup/effect.js +8 -0
  28. package/dist/es/fields/ColorPicker.vue_vue_type_script_setup_true_lang.js +1 -1
  29. package/dist/es/fields/Date/Index.js +5 -0
  30. package/dist/es/fields/{Date.vue_vue_type_script_setup_true_lang.js → Date/Index.vue_vue_type_script_setup_true_lang.js} +5 -7
  31. package/dist/es/fields/Date/effect.js +10 -0
  32. package/dist/es/fields/DateTime/Index.js +5 -0
  33. package/dist/es/fields/{DateTime.vue_vue_type_script_setup_true_lang.js → DateTime/Index.vue_vue_type_script_setup_true_lang.js} +5 -9
  34. package/dist/es/fields/DateTime/effect.js +15 -0
  35. package/dist/es/fields/Daterange.vue_vue_type_script_setup_true_lang.js +2 -2
  36. package/dist/es/fields/Display/Index.js +5 -0
  37. package/dist/es/fields/{Display.vue_vue_type_script_setup_true_lang.js → Display/Index.vue_vue_type_script_setup_true_lang.js} +6 -7
  38. package/dist/es/fields/Display/effect.js +8 -0
  39. package/dist/es/fields/DynamicField/Index.js +5 -0
  40. package/dist/es/fields/{DynamicField.vue_vue_type_script_setup_true_lang.js → DynamicField/Index.vue_vue_type_script_setup_true_lang.js} +11 -15
  41. package/dist/es/fields/DynamicField/effect.js +40 -0
  42. package/dist/es/fields/Hidden.vue_vue_type_script_setup_true_lang.js +1 -1
  43. package/dist/es/fields/Link.vue_vue_type_script_setup_true_lang.js +4 -4
  44. package/dist/es/fields/Number.vue_vue_type_script_setup_true_lang.js +4 -2
  45. package/dist/es/fields/NumberRange/Index.js +5 -0
  46. package/dist/es/fields/{NumberRange.vue_vue_type_script_setup_true_lang.js → NumberRange/Index.vue_vue_type_script_setup_true_lang.js} +5 -6
  47. package/dist/es/fields/NumberRange/effect.js +8 -0
  48. package/dist/es/fields/RadioGroup.vue_vue_type_script_setup_true_lang.js +5 -2
  49. package/dist/es/fields/Select.vue_vue_type_script_setup_true_lang.js +14 -6
  50. package/dist/es/fields/Switch.vue_vue_type_script_setup_true_lang.js +1 -1
  51. package/dist/es/fields/Text.vue_vue_type_script_setup_true_lang.js +13 -10
  52. package/dist/es/fields/Textarea.vue_vue_type_script_setup_true_lang.js +1 -1
  53. package/dist/es/fields/Time.vue_vue_type_script_setup_true_lang.js +1 -1
  54. package/dist/es/fields/Timerange.vue_vue_type_script_setup_true_lang.js +3 -3
  55. package/dist/es/headless.js +13 -0
  56. package/dist/es/index.js +19 -12
  57. package/dist/es/plugin.js +57 -39
  58. package/dist/es/schema.js +11 -2
  59. package/dist/es/style.css +123 -3
  60. package/dist/es/submitForm.js +270 -72
  61. package/dist/es/utils/builtInFields.js +49 -0
  62. package/dist/es/utils/collectFields.js +407 -0
  63. package/dist/es/utils/config.js +4 -8
  64. package/dist/es/utils/fieldInnerConfig.js +42 -0
  65. package/dist/es/utils/fieldValueEffects.js +80 -0
  66. package/dist/es/utils/form.js +154 -19
  67. package/dist/es/utils/formStateProxy.js +111 -0
  68. package/dist/es/utils/registerField.js +158 -0
  69. package/dist/es/utils/submitHeadless.js +54 -0
  70. package/dist/es/utils/tableGroupList.js +104 -0
  71. package/dist/es/utils/typeMatch.js +532 -0
  72. package/dist/es/utils/validateError.js +57 -0
  73. package/dist/es/utils/validateValues.js +115 -0
  74. package/dist/style.css +123 -3
  75. package/dist/themes/magic-admin.css +576 -14
  76. package/dist/tmagic-form-headless.umd.cjs +4515 -0
  77. package/dist/tmagic-form.umd.cjs +3444 -983
  78. package/package.json +16 -10
  79. package/src/Form.vue +113 -129
  80. package/src/FormBox.vue +16 -4
  81. package/src/FormDialog.vue +16 -5
  82. package/src/FormDrawer.vue +41 -13
  83. package/src/containers/Col.vue +3 -0
  84. package/src/containers/Container.vue +40 -45
  85. package/src/containers/Fieldset.vue +5 -0
  86. package/src/containers/FlexLayout.vue +2 -0
  87. package/src/containers/GroupList.vue +64 -13
  88. package/src/containers/GroupListItem.vue +88 -81
  89. package/src/containers/Panel.vue +3 -0
  90. package/src/containers/Row.vue +2 -0
  91. package/src/containers/Step.vue +2 -0
  92. package/src/containers/Tabs.vue +13 -0
  93. package/src/containers/table/ActionsColumn.vue +1 -1
  94. package/src/containers/table/Table.vue +5 -3
  95. package/src/containers/table/usePagination.ts +1 -1
  96. package/src/containers/table/useSortable.ts +1 -1
  97. package/src/containers/table/useTableColumns.ts +15 -22
  98. package/src/containers/table-group-list/TableGroupList.vue +46 -72
  99. package/src/containers/table-group-list/useAdd.ts +40 -25
  100. package/src/containers/table-group-list/useScrollLastItemIntoView.ts +94 -0
  101. package/src/fields/{CheckboxGroup.vue → CheckboxGroup/Index.vue} +3 -8
  102. package/src/fields/CheckboxGroup/effect.ts +27 -0
  103. package/src/fields/{Date.vue → Date/Index.vue} +2 -5
  104. package/src/fields/Date/effect.ts +29 -0
  105. package/src/fields/{DateTime.vue → DateTime/Index.vue} +2 -16
  106. package/src/fields/DateTime/effect.ts +36 -0
  107. package/src/fields/{Display.vue → Display/Index.vue} +3 -7
  108. package/src/fields/Display/effect.ts +28 -0
  109. package/src/fields/{DynamicField.vue → DynamicField/Index.vue} +11 -11
  110. package/src/fields/DynamicField/effect.ts +74 -0
  111. package/src/fields/Link.vue +3 -4
  112. package/src/fields/Number.vue +2 -1
  113. package/src/fields/{NumberRange.vue → NumberRange/Index.vue} +2 -6
  114. package/src/fields/NumberRange/effect.ts +27 -0
  115. package/src/fields/RadioGroup.vue +13 -1
  116. package/src/fields/Select.vue +13 -4
  117. package/src/headless.ts +78 -0
  118. package/src/index.ts +46 -9
  119. package/src/plugin.ts +68 -37
  120. package/src/schema.ts +13 -2
  121. package/src/submitForm.ts +410 -131
  122. package/src/theme/fieldset.scss +1 -0
  123. package/src/theme/form-box.scss +5 -0
  124. package/src/theme/form-dialog.scss +1 -1
  125. package/src/theme/group-list.scss +143 -2
  126. package/src/theme/table.scss +11 -1
  127. package/src/theme/themes/magic-admin/index.scss +27 -16
  128. package/src/utils/builtInFields.ts +69 -0
  129. package/src/utils/collectFields.ts +561 -0
  130. package/src/utils/config.ts +7 -15
  131. package/src/utils/fieldInnerConfig.ts +127 -0
  132. package/src/utils/fieldValueEffects.ts +140 -0
  133. package/src/utils/form.ts +230 -30
  134. package/src/utils/formStateProxy.ts +149 -0
  135. package/src/utils/registerField.ts +308 -0
  136. package/src/utils/submitHeadless.ts +206 -0
  137. package/src/utils/tableGroupList.ts +147 -0
  138. package/src/utils/typeMatch.ts +948 -0
  139. package/src/utils/validateError.ts +92 -0
  140. package/src/utils/validateValues.ts +177 -0
  141. package/types/headless.d.ts +682 -0
  142. package/types/index.d.ts +900 -180
  143. package/dist/es/_virtual/_rolldown/runtime.js +0 -27
  144. package/dist/es/fields/CheckboxGroup.js +0 -5
  145. package/dist/es/fields/Date.js +0 -5
  146. package/dist/es/fields/DateTime.js +0 -5
  147. package/dist/es/fields/Display.js +0 -5
  148. package/dist/es/fields/DynamicField.js +0 -5
  149. package/dist/es/fields/NumberRange.js +0 -5
@@ -0,0 +1,149 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
3
+ *
4
+ * Copyright (C) 2025 Tencent. All rights reserved.
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import { type Ref, unref } from 'vue';
20
+
21
+ import type { FormContext, FormState } from '../schema';
22
+
23
+ /** 无上下文时共用的空对象,避免每次读取都分配新对象 */
24
+ const EMPTY_CONTEXT: FormContext = Object.freeze({});
25
+
26
+ /**
27
+ * 按优先级分层合并上下文,**靠后的层优先**。
28
+ *
29
+ * 用读穿 Proxy 而非 `{ ...a, ...b }`:展开会立即执行 accessor,
30
+ * 而宿主允许用 `{ get stage() { ... } }` 这类读时求值的描述符。
31
+ */
32
+ export const mergeFormContexts = (...layers: (FormContext | undefined | null)[]): FormContext => {
33
+ const stack = layers.filter((layer): layer is FormContext => Boolean(layer));
34
+
35
+ if (stack.length === 0) return EMPTY_CONTEXT;
36
+ if (stack.length === 1) return stack[0];
37
+
38
+ // 反转成「高优先级在前」,查找时取第一个命中的层
39
+ const ordered = stack.slice().reverse() as Record<string | symbol, any>[];
40
+ const owner = (k: string | symbol) => ordered.find((layer) => Reflect.has(layer, k));
41
+
42
+ const target: Record<string | symbol, any> = {};
43
+
44
+ return new Proxy(target, {
45
+ get: (_t, k) => owner(k)?.[k],
46
+ has: (_t, k) => Boolean(owner(k)),
47
+ ownKeys: () => [...new Set(ordered.flatMap((layer) => Reflect.ownKeys(layer)))],
48
+ getOwnPropertyDescriptor: (_t, k) => {
49
+ for (const layer of ordered) {
50
+ const descriptor = Object.getOwnPropertyDescriptor(layer, k);
51
+ // 目标是空对象,必须报告为 configurable,否则违反 Proxy 不变式
52
+ if (descriptor) return { ...descriptor, configurable: true };
53
+ }
54
+ return undefined;
55
+ },
56
+ }) as FormContext;
57
+ };
58
+
59
+ /**
60
+ * 存量下发配置沿用 Vue2 时代的写法,把回调第一个参数当组件实例用,靠 `vm.mForm.xxx`
61
+ * 取表单状态。这类配置除了读,还会往上面挂方法做跨字段通信,例如:
62
+ *
63
+ * ```js
64
+ * vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
65
+ * await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
66
+ * ```
67
+ *
68
+ * 所以 `mForm` 必须能指回某个 formState:读能落到 core / context,写能经 `set` trap
69
+ * 落到 coreState 并持久化。
70
+ *
71
+ * 优先级是 core > context > 合成自引用,三者都是刻意的:
72
+ *
73
+ * - **core**:`formState.mForm = formState` 这类直写(如 FormPreview 用自建 services 时)
74
+ * 必须最优先。
75
+ * - **context**:唯一的生产者是把父 formState 整体当 context 传下来的嵌套表单
76
+ * (ComponentForm)。此时 `mForm` 命中 context 指向**父表单**,与 `extendState` 时代
77
+ * 把父 formState 并入子状态的结果一致,跨字段通信仍落在同一份对象上。父 formState
78
+ * 本身也是 Proxy,写入照样持久化,所以这里让 context 赢是对的,别「修正」成指向子表单。
79
+ * - **合成自引用**:前两者都没有时才兜底,让 `vm.mForm.xxx = fn` 落到自己的 coreState。
80
+ *
81
+ * 由此推出一条约束:**不要往 context 里塞普通对象充当 `mForm`**。context 通常是 computed
82
+ * 产物,依赖一变就重建,挂上去的方法会静默丢失。要么直写 core,要么什么都不放交给兜底。
83
+ *
84
+ * 枚举语义上合成的自引用表现得像原型链上的属性:`'mForm' in state` 为真,但不出现在
85
+ * `Object.keys(state)` 里,`getOwnPropertyDescriptor` 也返回 undefined。这是故意的——
86
+ * 按扩展字段打包 formState 的调用方(如发给 AI 的逻辑)会因循环引用炸掉。
87
+ */
88
+ const SELF_REF_KEY = 'mForm';
89
+
90
+ /**
91
+ * 将 coreState 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 context。
92
+ *
93
+ * - `get` 用属性访问而非 `Reflect.get(t, k, receiver)`,避免破坏 Vue reactive 的 `__v_raw`;
94
+ * - symbol 键一律只走 core,四个 trap 保持一致。context 是业务数据袋,不承载 symbol 键,
95
+ * 把 Vue / 工具链的内部 symbol 隔离在 core 上才能让 `toRaw` / `isReactive` 判定正确;
96
+ * - `ownKeys` + `getOwnPropertyDescriptor` 保证 `Object.entries(formState)` 能枚举到扩展字段
97
+ * (admin-web-next 的 `pickPanelFormStateExtendFields` 依赖此语义);
98
+ * - `set` 写入 coreState,第三方 `formState.xxx = v` 仍生效且优先于 context。
99
+ *
100
+ * 独立成文件,避免与 `form.ts` ↔ `typeMatch.ts` 形成循环依赖。
101
+ */
102
+ export const createFormStateProxy = (
103
+ coreState: FormState,
104
+ getContext: (() => FormContext) | Ref<FormContext>,
105
+ ): FormState => {
106
+ const resolve = (): Record<string | symbol, any> => {
107
+ const ctx = (typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
108
+ // 把 formState 自己当 context 传回来(`:context="formState"`)会让 get / has 无限递归,
109
+ // 直接爆栈。这种自引用本就提供不了任何额外字段,断掉即可。
110
+ return ctx === proxy ? EMPTY_CONTEXT : ctx;
111
+ };
112
+
113
+ const proxy = new Proxy(coreState as object, {
114
+ get(t, k) {
115
+ if (typeof k === 'symbol') return Reflect.get(t, k);
116
+ const v = (t as any)[k];
117
+ if (v !== undefined || Reflect.has(t, k)) return v;
118
+
119
+ const ctx = resolve();
120
+ if (k in ctx) return ctx[k];
121
+ // 自引用不进 ownKeys:枚举 formState 的调用方(如按扩展字段打包发给 AI 的逻辑)
122
+ // 会因为循环引用炸掉,这里只在显式读取时才合成
123
+ return k === SELF_REF_KEY ? proxy : undefined;
124
+ },
125
+ set(t, k, value) {
126
+ (t as any)[k] = value;
127
+ return true;
128
+ },
129
+ // `mForm` 在这里为真,但不进 ownKeys、getOwnPropertyDescriptor 也返回 undefined
130
+ // (见 SELF_REF_KEY 注释)。因此展开 / Object.entries 不会循环引用,
131
+ // 但对 proxy 直接做递归遍历(`for...in`、深拷贝、直接 JSON.stringify(formState))仍会。
132
+ has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && (k in resolve() || k === SELF_REF_KEY)),
133
+ ownKeys: (t) => [
134
+ ...new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== 'symbol')]),
135
+ ],
136
+ getOwnPropertyDescriptor: (t, k) => {
137
+ const own = Reflect.getOwnPropertyDescriptor(t, k);
138
+ if (own) return own;
139
+ if (typeof k === 'symbol') return undefined;
140
+
141
+ const ctx = resolve();
142
+ if (!(k in ctx)) return undefined;
143
+ // core 上不存在该键,必须报告为 configurable,否则违反 Proxy 不变式
144
+ return { configurable: true, enumerable: true, writable: true, value: ctx[k] };
145
+ },
146
+ }) as FormState;
147
+
148
+ return proxy;
149
+ };
@@ -0,0 +1,308 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
3
+ *
4
+ * Copyright (C) 2025 Tencent. All rights reserved.
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import type { App, Component } from 'vue';
20
+
21
+ import { toLine } from '@tmagic/utils';
22
+
23
+ import {
24
+ clearContainerWalkers,
25
+ type ContainerWalker,
26
+ deleteContainerWalker,
27
+ registerContainerWalker,
28
+ } from './collectFields';
29
+ import {
30
+ clearFieldInnerConfigs,
31
+ deleteFieldInnerConfig,
32
+ type FieldInnerConfig,
33
+ registerFieldInnerConfig,
34
+ } from './fieldInnerConfig';
35
+ import {
36
+ clearLeafFieldTypes,
37
+ deleteLeafFieldType,
38
+ type FieldMountValueEffect,
39
+ registerLeafFieldType,
40
+ } from './fieldValueEffects';
41
+ import { clearTypeMatchRules, deleteTypeMatchRule, registerTypeMatchRule, type TypeMatchValidator } from './typeMatch';
42
+
43
+ // #region FieldOptions
44
+ /**
45
+ * 自定义字段 type 的登记项。
46
+ *
47
+ * 不传或只传空对象:当作叶子,不再下钻 `items`。
48
+ */
49
+ export interface FieldOptions {
50
+ /**
51
+ * 渲染用的 Vue 组件,写入字段注册表。
52
+ * 传入 `app` 时同时 `app.component('m-fields-*')`。
53
+ */
54
+ component?: Component;
55
+ /**
56
+ * 容器组件,写入字段注册表。
57
+ * 传入 `app` 时同时 `app.component('m-form-*')`。
58
+ */
59
+ container?: Component;
60
+ /** 字段挂载时改写 model 的副作用。可与 `innerConfig` 同时登记。 */
61
+ effect?: FieldMountValueEffect;
62
+ /**
63
+ * 按容器模板遍历(tab / table 等)。
64
+ * 与 `innerConfig` / `effect` 同时传入时 `walk` 优先。
65
+ */
66
+ walk?: ContainerWalker;
67
+ /**
68
+ * 把内部会挂到父表单的配置交出来。
69
+ * 可与 `effect` 同时传入:`effect` 负责本字段的值初始化,`innerConfig` 只做配置派生。
70
+ */
71
+ innerConfig?: FieldInnerConfig;
72
+ /** 该 type 的 typeMatch 校验;可与叶子、walk 或 innerConfig 同时登记。 */
73
+ typeMatch?: TypeMatchValidator;
74
+ }
75
+
76
+ /**
77
+ * 无渲染校验用的登记项,不含 Vue 组件。
78
+ *
79
+ * 给 Node / `validateForm` / `submitForm` 用。
80
+ */
81
+ export type HeadlessFieldOptions = Omit<FieldOptions, 'component' | 'container'>;
82
+ // #endregion FieldOptions
83
+
84
+ const extraComponents = new Map<string, Component>();
85
+ const builtInComponents = new Map<string, Component>();
86
+
87
+ const applyVueComponent = (
88
+ type: string,
89
+ component: Component,
90
+ app: App | undefined,
91
+ builtIn: boolean,
92
+ kind: 'fields' | 'form',
93
+ ): void => {
94
+ const key = toLine(type);
95
+ if (builtIn) {
96
+ builtInComponents.set(key, component);
97
+ } else {
98
+ extraComponents.set(key, component);
99
+ }
100
+ app?.component(`m-${kind}-${key}`, component);
101
+ };
102
+
103
+ /**
104
+ * 把已登记字段的 Vue 组件写入注册表;传入 `app` 时同时挂 `m-fields-*` / `m-form-*`。
105
+ *
106
+ * @param fields - 字段登记表
107
+ * @param [app] - Vue 应用;省略则只写入注册表
108
+ * @param [builtIn=false] - 是否写入内置表(`clearFields` 不会清掉)
109
+ */
110
+ export const bindFieldApp = (fields: Record<string, FieldOptions>, app?: App, builtIn = false): void => {
111
+ for (const [type, options] of Object.entries(fields)) {
112
+ if (options?.component) {
113
+ applyVueComponent(type, options.component, app, builtIn, 'fields');
114
+ }
115
+ if (options?.container) {
116
+ applyVueComponent(type, options.container, app, builtIn, 'form');
117
+ }
118
+ }
119
+ };
120
+
121
+ /**
122
+ * 把多份字段登记表按 type 浅合并。后一份只覆盖自己带了的 key,未出现的 key 保留前一份。
123
+ *
124
+ * 安装插件时用:Node 侧只登记 `headless`,浏览器再补 `component` / `container`。
125
+ *
126
+ * @param tables - 待合并的登记表,`undefined` 会被跳过
127
+ * @returns 合并后的登记表
128
+ */
129
+ export const mergeFieldOptions = (
130
+ ...tables: Array<Record<string, HeadlessFieldOptions | FieldOptions> | undefined>
131
+ ): Record<string, FieldOptions> => {
132
+ const result: Record<string, FieldOptions> = {};
133
+ for (const table of tables) {
134
+ if (!table) continue;
135
+ for (const [type, options] of Object.entries(table)) {
136
+ result[type] = { ...result[type], ...pickDefinedFieldOptions(options) };
137
+ }
138
+ }
139
+ return result;
140
+ };
141
+
142
+ const FIELD_OPTION_KEYS = ['component', 'container', 'effect', 'walk', 'innerConfig', 'typeMatch'] as const;
143
+
144
+ const pickDefinedFieldOptions = (options?: FieldOptions): FieldOptions => {
145
+ if (!options) return {};
146
+ const next: FieldOptions = {};
147
+ for (const key of FIELD_OPTION_KEYS) {
148
+ if (options[key] !== undefined) {
149
+ (next as any)[key] = options[key];
150
+ }
151
+ }
152
+ return next;
153
+ };
154
+
155
+ const extraFieldOptions = new Map<string, FieldOptions>();
156
+ const builtInFieldOptions = new Map<string, FieldOptions>();
157
+
158
+ const removeFormComponent = (type: string): void => {
159
+ extraComponents.delete(toLine(type));
160
+ };
161
+
162
+ const clearFormComponents = (): void => {
163
+ extraComponents.clear();
164
+ };
165
+
166
+ /**
167
+ * 按字段 type 取已登记的渲染组件(`codeSelect` 与 `code-select` 等价)。
168
+ *
169
+ * @param type - 字段 type
170
+ * @returns 已登记的 Vue 组件;未登记则为 `undefined`
171
+ */
172
+ export const getField = (type: string): Component | undefined => {
173
+ const key = toLine(type);
174
+ return extraComponents.get(key) ?? builtInComponents.get(key);
175
+ };
176
+
177
+ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app: App | undefined, builtIn: boolean) => {
178
+ if (typeof type !== 'string' || !type) return;
179
+
180
+ const key = toLine(type);
181
+ const store = builtIn ? builtInFieldOptions : extraFieldOptions;
182
+ const incoming = pickDefinedFieldOptions(options);
183
+ const merged: FieldOptions = { ...store.get(key), ...incoming };
184
+ store.set(key, merged);
185
+
186
+ if (incoming.walk && (incoming.innerConfig || typeof incoming.effect === 'function')) {
187
+ console.warn(
188
+ `[MForm] registerField("${key}"): walk is set together with innerConfig/effect; ` +
189
+ 'headless validation will use walk and innerConfig/effect will be ignored.',
190
+ );
191
+ }
192
+
193
+ if (incoming.component && incoming.container) {
194
+ console.warn(
195
+ `[MForm] registerField("${key}"): component and container are both set; ` +
196
+ 'getField will use container, and both m-fields-* / m-form-* will be registered.',
197
+ );
198
+ }
199
+
200
+ if (merged.component) {
201
+ applyVueComponent(type, merged.component, app, builtIn, 'fields');
202
+ }
203
+
204
+ if (merged.container) {
205
+ applyVueComponent(type, merged.container, app, builtIn, 'form');
206
+ }
207
+
208
+ if (merged.typeMatch) {
209
+ registerTypeMatchRule(type, merged.typeMatch, builtIn);
210
+ }
211
+
212
+ if (merged.walk) {
213
+ registerContainerWalker(type, merged.walk, builtIn);
214
+ if (!builtIn) {
215
+ deleteLeafFieldType(type);
216
+ deleteFieldInnerConfig(type);
217
+ }
218
+ return;
219
+ }
220
+
221
+ if (!builtIn) {
222
+ deleteContainerWalker(type);
223
+ }
224
+
225
+ if (merged.innerConfig) {
226
+ registerFieldInnerConfig(type, merged.innerConfig, builtIn);
227
+ if (typeof merged.effect === 'function') {
228
+ registerLeafFieldType(type, merged.effect, builtIn);
229
+ } else if (!builtIn) {
230
+ deleteLeafFieldType(type);
231
+ }
232
+ return;
233
+ }
234
+
235
+ // 只登记了容器组件:不当叶子,dispatch 会按 items 下钻
236
+ if (merged.container && !merged.component && typeof merged.effect !== 'function') {
237
+ if (!builtIn) deleteLeafFieldType(type);
238
+ return;
239
+ }
240
+
241
+ if (!builtIn) {
242
+ deleteFieldInnerConfig(type);
243
+ }
244
+ registerLeafFieldType(type, merged.effect, builtIn);
245
+ };
246
+
247
+ /**
248
+ * 登记一个字段 type 在无渲染校验里的行为,以及可选的渲染组件。
249
+ *
250
+ * `type` 会按 Container 的规则归一化为中划线形式(`codeSelect` 与 `code-select` 等价)。
251
+ * 对同一 type 重复登记按字段浅合并:后一次只覆盖自己传入的 key,未传入的 key 保留。
252
+ * 传入 `app` 且带了 `component` / `container` 时,会同步 `app.component('m-fields-*'` / `'m-form-*')`。
253
+ *
254
+ * @param type - 字段 type
255
+ * @param [options] - 登记项;省略或空对象视为叶子
256
+ * @param [app] - Vue 应用;省略则不调用 `app.component`
257
+ */
258
+ export const registerField = (type: string, options?: FieldOptions, app?: App): void => {
259
+ registerFieldImpl(type, options, app, false);
260
+ };
261
+
262
+ /**
263
+ * 批量登记字段。
264
+ *
265
+ * @param fields - type 到登记项的映射
266
+ * @param [app] - Vue 应用;省略则不调用 `app.component`
267
+ */
268
+ export const registerFields = (fields: Record<string, FieldOptions>, app?: App): void => {
269
+ for (const [type, options] of Object.entries(fields)) {
270
+ registerFieldImpl(type, options, app, false);
271
+ }
272
+ };
273
+
274
+ /**
275
+ * 登记内置字段(`clearFields` / `unregisterField` 不会清掉)。
276
+ *
277
+ * @param fields - type 到登记项的映射
278
+ * @param [app] - Vue 应用;省略则不调用 `app.component`
279
+ */
280
+ export const registerBuiltInFields = (fields: Record<string, FieldOptions>, app?: App): void => {
281
+ for (const [type, options] of Object.entries(fields)) {
282
+ registerFieldImpl(type, options, app, true);
283
+ }
284
+ };
285
+
286
+ /**
287
+ * 删除业务侧对指定 type 的登记(不影响内置;主要用于单测)。
288
+ *
289
+ * @param type - 字段 type
290
+ */
291
+ export const unregisterField = (type: string): void => {
292
+ extraFieldOptions.delete(toLine(type));
293
+ deleteLeafFieldType(type);
294
+ deleteFieldInnerConfig(type);
295
+ deleteTypeMatchRule(type);
296
+ deleteContainerWalker(type);
297
+ removeFormComponent(type);
298
+ };
299
+
300
+ /** 清空业务侧登记(不影响内置;主要用于单测)。 */
301
+ export const clearFields = (): void => {
302
+ extraFieldOptions.clear();
303
+ clearLeafFieldTypes();
304
+ clearFieldInnerConfigs();
305
+ clearTypeMatchRules();
306
+ clearContainerWalkers();
307
+ clearFormComponents();
308
+ };
@@ -0,0 +1,206 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
3
+ *
4
+ * Copyright (C) 2025 Tencent. All rights reserved.
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import type { AppContext } from 'vue';
20
+ import { cloneDeep } from 'lodash-es';
21
+
22
+ import type { ChangeRecord, FormConfig, FormContext } from '../schema';
23
+
24
+ import { validateValues, type ValidateValuesResult } from './validateValues';
25
+
26
+ // #region SubmitFormOptions
27
+ /**
28
+ * submitForm 函数参数(与 Form.vue 组件 props 对齐)
29
+ */
30
+ export interface SubmitFormOptions {
31
+ /** 表单配置 */
32
+ config: FormConfig;
33
+ /** 表单初始值 */
34
+ initValues?: Record<string, any>;
35
+ /** 需对比的值(开启对比模式时传入) */
36
+ lastValues?: Record<string, any>;
37
+ /** 是否开启对比模式 */
38
+ isCompare?: boolean;
39
+ parentValues?: Record<string, any>;
40
+ labelWidth?: string;
41
+ disabled?: boolean;
42
+ height?: string;
43
+ stepActive?: string | number;
44
+ size?: 'small' | 'default' | 'large';
45
+ inline?: boolean;
46
+ labelPosition?: 'top' | 'left' | 'right';
47
+ keyProp?: string;
48
+ popperClass?: string;
49
+ preventSubmitDefault?: boolean;
50
+ /**
51
+ * 表单校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
52
+ * 默认 `true`,置为 `false` 时直接使用字段 name。
53
+ */
54
+ useFieldTextInError?: boolean;
55
+ /** 宿主业务上下文,与 MForm 的同名 prop 语义一致 */
56
+ context?: FormContext;
57
+ /** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
58
+ native?: boolean;
59
+ /**
60
+ * 是否在 resolve 结果中携带 changeRecords(变更记录)。
61
+ * 开启后 resolve 的结果为 `{ values, changeRecords }`,否则仅 resolve values。
62
+ */
63
+ returnChangeRecords?: boolean;
64
+ /**
65
+ * 父级应用上下文,用于继承全局组件、指令、provide 等。
66
+ * 仅 `dialog: true` 时生效。`@tmagic/form/headless` 不支持弹层。
67
+ */
68
+ appContext?: AppContext | null;
69
+ /**
70
+ * 是否以弹层展示表单。默认 `false`。
71
+ *
72
+ * `@tmagic/form/headless` 不支持 `dialog: true`,请从 `@tmagic/form` 引入。
73
+ */
74
+ dialog?: boolean;
75
+ /**
76
+ * 弹层标题,仅 `dialog: true` 时生效。
77
+ */
78
+ title?: string;
79
+ typeMatchValid?: boolean;
80
+ /**
81
+ * 外部中断信号。abort 时会立即以 `signal.reason` reject。
82
+ */
83
+ signal?: AbortSignal;
84
+ }
85
+ // #endregion SubmitFormOptions
86
+
87
+ // #region SubmitFormResult
88
+ /**
89
+ * 开启 `returnChangeRecords` 时 submitForm 的返回结果
90
+ */
91
+ export interface SubmitFormResult {
92
+ /** 校验通过后的表单值 */
93
+ values: any;
94
+ /** 表单变更记录 */
95
+ changeRecords: ChangeRecord[];
96
+ }
97
+ // #endregion SubmitFormResult
98
+
99
+ // #region ValidateFormOptions
100
+ /**
101
+ * validateForm 函数参数(与 Form.vue 组件 props 对齐,取校验所需子集)
102
+ */
103
+ export interface ValidateFormOptions {
104
+ /** 表单配置 */
105
+ config: FormConfig;
106
+ /** 待校验的表单值 */
107
+ initValues?: Record<string, any>;
108
+ parentValues?: Record<string, any>;
109
+ labelWidth?: string;
110
+ keyProp?: string;
111
+ /**
112
+ * 校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
113
+ * 默认 `true`,置为 `false` 时直接使用字段 name。
114
+ */
115
+ useFieldTextInError?: boolean;
116
+ /** 宿主业务上下文,与 MForm 的同名 prop 语义一致 */
117
+ context?: FormContext;
118
+ /**
119
+ * 父级应用上下文。仅 `dialog: true` 时生效。`@tmagic/form/headless` 不支持弹层。
120
+ */
121
+ appContext?: AppContext | null;
122
+ /**
123
+ * 是否以弹层展示表单。默认 `false`。
124
+ *
125
+ * `@tmagic/form/headless` 不支持 `dialog: true`,请从 `@tmagic/form` 引入。
126
+ */
127
+ dialog?: boolean;
128
+ /**
129
+ * 弹层标题,仅 `dialog: true` 时生效。
130
+ */
131
+ title?: string;
132
+ typeMatchValid?: boolean;
133
+ /**
134
+ * 外部中断信号。abort 时会立即以 `signal.reason` reject。
135
+ */
136
+ signal?: AbortSignal;
137
+ }
138
+ // #endregion ValidateFormOptions
139
+
140
+ const throwIfAborted = (signal: AbortSignal | undefined, fnName: string) => {
141
+ if (signal?.aborted) {
142
+ throw signal.reason ?? new Error(`${fnName} aborted`);
143
+ }
144
+ };
145
+
146
+ const throwIfDialog = (dialog: boolean | undefined, fnName: string) => {
147
+ if (dialog) {
148
+ throw new Error(
149
+ `[MForm] ${fnName}({ dialog: true }) is not available from @tmagic/form/headless. Import from @tmagic/form instead.`,
150
+ );
151
+ }
152
+ };
153
+
154
+ /**
155
+ * `submitForm` 与 `validateForm` 共用的无渲染校验流程:中断检查 → `validateValues`。
156
+ */
157
+ export const validateWithoutRender = async (
158
+ fnName: 'submitForm' | 'validateForm',
159
+ options: SubmitFormOptions | ValidateFormOptions,
160
+ ): Promise<ValidateValuesResult> => {
161
+ const { signal, config, initValues, parentValues, keyProp, typeMatchValid, useFieldTextInError, context } = options;
162
+
163
+ throwIfAborted(signal, fnName);
164
+
165
+ const result = await validateValues({
166
+ config,
167
+ initValues,
168
+ parentValues,
169
+ keyProp,
170
+ popperClass: (options as SubmitFormOptions).popperClass,
171
+ typeMatchValid,
172
+ useFieldTextInError,
173
+ context,
174
+ });
175
+
176
+ throwIfAborted(signal, fnName);
177
+
178
+ return result;
179
+ };
180
+
181
+ /**
182
+ * 以命令式方式对一份「表单配置 + 值」做一次校验并取回表单值(无渲染)。
183
+ *
184
+ * `@tmagic/form/headless` 不支持 `dialog: true`。
185
+ */
186
+ export const submitForm = async (options: SubmitFormOptions): Promise<any> => {
187
+ throwIfDialog(options.dialog, 'submitForm');
188
+
189
+ const validated = await validateWithoutRender('submitForm', options);
190
+
191
+ if (validated.error) throw new Error(validated.error);
192
+
193
+ const values = options.native ? validated.values : cloneDeep(validated.values);
194
+ return options.returnChangeRecords ? { values, changeRecords: [] as ChangeRecord[] } : values;
195
+ };
196
+
197
+ /**
198
+ * 以命令式方式对一份「表单配置 + 值」做一次静默校验(无渲染)。
199
+ *
200
+ * `@tmagic/form/headless` 不支持 `dialog: true`。
201
+ */
202
+ export const validateForm = async (options: ValidateFormOptions): Promise<string> => {
203
+ throwIfDialog(options.dialog, 'validateForm');
204
+
205
+ return (await validateWithoutRender('validateForm', options)).error;
206
+ };