@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
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.0-manmanyu.8",
2
+ "version": "1.8.0",
3
3
  "name": "@tmagic/form",
4
4
  "type": "module",
5
5
  "sideEffects": [
@@ -17,6 +17,11 @@
17
17
  "import": "./dist/es/index.js",
18
18
  "require": "./dist/tmagic-form.umd.cjs"
19
19
  },
20
+ "./headless": {
21
+ "types": "./types/headless.d.ts",
22
+ "import": "./dist/es/headless.js",
23
+ "require": "./dist/tmagic-form-headless.umd.cjs"
24
+ },
20
25
  "./dist/style.css": {
21
26
  "import": "./dist/style.css",
22
27
  "require": "./dist/style.css"
@@ -40,21 +45,22 @@
40
45
  "dependencies": {
41
46
  "@element-plus/icons-vue": "^2.3.2",
42
47
  "@popperjs/core": "^2.11.8",
43
- "dayjs": "^1.11.19",
44
- "lodash-es": "^4.17.21",
45
- "sortablejs": "^1.15.6"
48
+ "async-validator": "^4.2.5",
49
+ "dayjs": "^1.11.23",
50
+ "lodash-es": "^4.18.1",
51
+ "sortablejs": "^1.15.7"
46
52
  },
47
53
  "devDependencies": {
48
- "@types/lodash-es": "^4.17.4",
54
+ "@types/lodash-es": "^4.17.12",
49
55
  "@types/sortablejs": "^1.15.9",
50
- "@vue/test-utils": "^2.4.6"
56
+ "@vue/test-utils": "^2.5.1"
51
57
  },
52
58
  "peerDependencies": {
53
- "vue": "^3.5.34",
54
59
  "typescript": "^6.0.3",
55
- "@tmagic/design": "1.8.0-manmanyu.8",
56
- "@tmagic/utils": "1.8.0-manmanyu.8",
57
- "@tmagic/form-schema": "1.8.0-manmanyu.8"
60
+ "vue": "^3.5.43",
61
+ "@tmagic/design": "1.8.0",
62
+ "@tmagic/form-schema": "1.8.0",
63
+ "@tmagic/utils": "1.8.0"
58
64
  },
59
65
  "peerDependenciesMeta": {
60
66
  "typescript": {
package/src/Form.vue CHANGED
@@ -23,6 +23,7 @@
23
23
  :last-values="lastValuesProcessed"
24
24
  :is-compare="isCompare"
25
25
  :label-width="item.labelWidth || labelWidth"
26
+ :label-position="item.labelPosition || labelPosition"
26
27
  :step-active="stepActive"
27
28
  :size="size"
28
29
  @change="changeHandler"
@@ -36,25 +37,26 @@
36
37
  </template>
37
38
 
38
39
  <script setup lang="ts">
39
- import { computed, inject, provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch, watchEffect } from 'vue';
40
- import { cloneDeep, isEqual } from 'lodash-es';
40
+ import { computed, inject, nextTick, provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch } from 'vue';
41
+ import { cloneDeep, isEqualWith } from 'lodash-es';
41
42
 
42
43
  import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
43
44
  import { setValueByKeyPath } from '@tmagic/utils';
44
45
 
45
46
  import Container from './containers/Container.vue';
46
- import { getConfig } from './utils/config';
47
- import { initValue } from './utils/form';
47
+ import { applyMountValueEffects } from './utils/collectFields';
48
+ import { createFormStateBase, createFormStateProxy, initValue, mergeFormContexts } from './utils/form';
49
+ import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
48
50
  import type {
49
51
  ChangeRecord,
50
52
  ContainerChangeEventData,
51
53
  FormConfig,
54
+ FormContext,
52
55
  FormSlots,
53
56
  FormState,
54
57
  FormValue,
55
- ValidateError,
56
58
  } from './schema';
57
- import { FORM_DIFF_CONFIG_KEY } from './schema';
59
+ import { FORM_CONTEXT_KEY, FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from './schema';
58
60
 
59
61
  defineOptions({
60
62
  name: 'MForm',
@@ -74,16 +76,37 @@ const props = withDefaults(
74
76
  isCompare?: boolean;
75
77
  parentValues?: Record<string, any>;
76
78
  labelWidth?: string;
79
+ /** 是否开启类型匹配校验 */
80
+ typeMatchValid?: boolean;
81
+ /**
82
+ * 初始化(`config` / `initValues` 就绪)后是否立即执行一次表单校验。
83
+ *
84
+ * - `false`(默认):不自动校验,避免打开表单时就展示错误态;
85
+ * - `true`:初始化完成后在 `nextTick` 中调用 `validate()`。
86
+ */
87
+ validateOnInit?: boolean;
77
88
  disabled?: boolean;
78
89
  height?: string;
79
90
  stepActive?: string | number;
80
91
  size?: 'small' | 'default' | 'large';
81
92
  inline?: boolean;
82
- labelPosition?: string;
93
+ labelPosition?: 'top' | 'left' | 'right';
83
94
  keyProp?: string;
84
95
  popperClass?: string;
85
96
  preventSubmitDefault?: boolean;
86
- extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
97
+ /**
98
+ * 表单校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
99
+ *
100
+ * - `true`(默认):错误提示形如 `字段文案 -> 错误信息`,找不到 text 时回退为字段 name;
101
+ * - `false`:跳过查找,直接使用字段 name 作为错误提示前缀(形如 `字段name -> 错误信息`)。
102
+ */
103
+ useFieldTextInError?: boolean;
104
+ /**
105
+ * 宿主业务上下文。也可由祖先 `provide(FORM_CONTEXT_KEY)` 下发,本 prop 覆盖祖先的同名字段。
106
+ *
107
+ * 配置回调通过 `mForm.xxx` 读取,由 formState 的读穿 Proxy 落到这里。
108
+ */
109
+ context?: FormContext;
87
110
  /**
88
111
  * 自定义"是否展示对比内容"的判断函数(仅在 `isCompare === true` 时生效)。
89
112
  *
@@ -134,18 +157,22 @@ const props = withDefaults(
134
157
  inline: false,
135
158
  labelPosition: 'right',
136
159
  keyProp: '__key',
160
+ useFieldTextInError: true,
161
+ validateOnInit: false,
137
162
  },
138
163
  );
139
164
 
140
165
  const emit = defineEmits(['change', 'error', 'field-input', 'field-change', 'update:stepActive']);
141
166
 
167
+ provide(
168
+ FORM_TYPE_MATCH_VALID_KEY,
169
+ computed(() => props.typeMatchValid),
170
+ );
171
+
142
172
  const tMagicFormRef = useTemplateRef('tMagicForm');
143
173
  const initialized = ref(false);
144
174
  const values = ref<FormValue>({});
145
175
  const lastValuesProcessed = ref<FormValue>({});
146
- const fields = new Map<string, any>();
147
-
148
- const requestFuc = getConfig('request') as Function;
149
176
 
150
177
  /**
151
178
  * 当前表单生效的主题名称:
@@ -178,18 +205,14 @@ const themeClass = computed(() => (effectiveTheme.value ? `m-theme--${effectiveT
178
205
  * 2. `values` / `lastValuesProcessed` 是 ref,Vue 的 `reactive` 会自动解包,因此每次
179
206
  * 访问 `formState.values` / `formState.lastValuesProcessed` 也都是当前 ref 值。
180
207
  *
181
- * 3. `extendState` 注入的字段在下方的 `watchEffect` 中合并到 `formState`:
182
- * - data 描述符(普通字段)通过 `formState[key] = value` 写入,走 reactive proxy 的
183
- * set,触发依赖通知;`extendState` 同步段读到的响应式数据变化时会自动重跑,
184
- * 把最新值刷进 formState。
185
- * - accessor 描述符(`{ get stage() { return ... } }`)按原样写入,调用方可以控制
186
- * 读时求值,每次读取都会重新执行 getter。
208
+ * 3. 宿主业务上下文不再 merge 进 coreState,而是单独放在 `contextRef` 上,由读穿
209
+ * Proxy 在 miss 时落到 context。核心字段结构性优先,`mForm.xxx` 永久兼容旧读法。
187
210
  *
188
211
  * 4. `popperClass` 会自动拼接 `themeClass`:调用方传入的 `popperClass` + 当前主题
189
212
  * 修饰类(含祖先 `<MEditor>` provide 的主题)。这样所有透传到 Element Plus 弹层
190
213
  * `popper-class` 的字段(Select / DateTime / Cascader 等)能自带主题作用域。
191
214
  */
192
- const formState: FormState = reactive<FormState>({
215
+ const coreState: FormState = reactive<FormState>({
193
216
  get keyProp() {
194
217
  return props.keyProp;
195
218
  },
@@ -218,72 +241,21 @@ const formState: FormState = reactive<FormState>({
218
241
  values,
219
242
  lastValuesProcessed,
220
243
  $emit: emit as (_event: string, ..._args: any[]) => void,
221
- fields,
222
- setField: (prop: string, field: any) => fields.set(prop, field),
223
- getField: (prop: string) => fields.get(prop),
224
- deleteField: (prop: string) => fields.delete(prop),
225
- $messageBox: tMagicMessageBox,
226
- $message: tMagicMessage,
227
- post: (options: any) => {
228
- if (requestFuc) {
229
- return requestFuc({
230
- method: 'POST',
231
- ...options,
232
- });
233
- }
234
- },
244
+ ...createFormStateBase({ $message: tMagicMessage, $messageBox: tMagicMessageBox }),
235
245
  });
236
246
 
247
+ const ancestorContext = inject(FORM_CONTEXT_KEY, undefined);
248
+
237
249
  /**
238
- * `extendState` 的同步段(直到第一个 `await` 之前)所访问的任何响应式数据,
239
- * 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 ——
240
- *
241
- * extendState: (formState) => ({
242
- * username: store.username, // 同步读 store,会被跟踪
243
- * env: store.env,
244
- * })
245
- *
246
- * 当 `store.username` 变化时,整个 effect 重跑,新值会被刷进 `formState`。
247
- *
248
- * prop 派生字段(initValues / config / ...)已经在上方用 getter 定义,
249
- * 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发
250
- * `extendState` 重跑(旧版的性能问题修复点)。
251
- *
252
- * 实现细节:
253
- * - data 描述符:通过 `formState[key] = value` 走 reactive proxy 的 set,
254
- * 触发依赖通知;与旧版「逐项赋值」语义完全等价。
255
- * - accessor 描述符(`{ get stage() {...} }`)按原样写入 formState,调用方
256
- * 可以自行控制读时求值;强制 `configurable: true` 以便下一次重跑可再 define。
250
+ * 宿主业务上下文:`props.context` 覆盖祖先注入的同名字段。
251
+ * 嵌套表单(Link / FormBox / FormDialog)通过下面的 provide 自动继承。
257
252
  */
258
- watchEffect(async (onCleanup) => {
259
- const { extendState } = props;
260
- if (typeof extendState !== 'function') return;
261
-
262
- let stale = false;
263
- onCleanup(() => {
264
- stale = true;
265
- });
266
-
267
- let state: Record<string, any> = {};
268
- try {
269
- state = (await extendState(formState)) || {};
270
- } catch (e) {
271
- console.error('[MForm] extendState failed:', e);
272
- return;
273
- }
274
- if (stale) return;
275
-
276
- for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
277
- if ('value' in descriptor) {
278
- (formState as any)[key] = (state as any)[key];
279
- } else {
280
- descriptor.configurable = true;
281
- Object.defineProperty(formState, key, descriptor);
282
- }
283
- }
284
- });
253
+ const contextRef = computed<FormContext>(() => mergeFormContexts(ancestorContext?.value, props.context));
254
+
255
+ const formState: FormState = createFormStateProxy(coreState, () => contextRef.value);
285
256
 
286
257
  provide('mForm', formState);
258
+ provide(FORM_CONTEXT_KEY, contextRef);
287
259
 
288
260
  /**
289
261
  * 把生效主题(自身或祖先)再 provide 出去,供 form 子树内含 `Teleport` 的组件
@@ -291,7 +263,7 @@ provide('mForm', formState);
291
263
  * 详见 `@tmagic/design/theme.ts`。
292
264
  */
293
265
  provide(M_THEME_KEY, effectiveTheme);
294
-
266
+ provide('formInline', props.inline);
295
267
  // 对比相关配置单独通过 provide 下发,所有层级的 Container 通过 inject 获取,无需逐层透传 prop。
296
268
  // 用 getter 对象保证读取时回到最新的 props 值,维持响应式。
297
269
  provide(FORM_DIFF_CONFIG_KEY, {
@@ -305,12 +277,23 @@ provide(FORM_DIFF_CONFIG_KEY, {
305
277
 
306
278
  const changeRecords = shallowRef<ChangeRecord[]>([]);
307
279
 
280
+ /**
281
+ * 两份配置的结构是否一致;函数一律视为相等。
282
+ *
283
+ * 宿主(如编辑器属性面板)往往在每次节点更新后整份重新生成配置,其中的
284
+ * `display` / `options` / `onChange` 都是新闭包,深比较必然判不等。若据此把 `initialized`
285
+ * 置 false,整棵表单会卸载重挂,滚动位置、展开态、输入焦点全部丢失。
286
+ * 配置是响应式 prop,闭包换了照样生效,只有结构变化(增删字段、换组件类型)才需要重挂。
287
+ */
288
+ const isSameConfigShape = (config: unknown, preConfig: unknown) =>
289
+ isEqualWith(config, preConfig, (a, b) => (typeof a === 'function' && typeof b === 'function' ? true : undefined));
290
+
308
291
  watch(
309
292
  [() => props.config, () => props.initValues],
310
- ([config], [preConfig]) => {
293
+ async ([config], [preConfig]) => {
311
294
  changeRecords.value = [];
312
295
 
313
- if (!isEqual(toRaw(config), toRaw(preConfig))) {
296
+ if (!isSameConfigShape(toRaw(config), toRaw(preConfig))) {
314
297
  initialized.value = false;
315
298
  }
316
299
 
@@ -319,8 +302,17 @@ watch(
319
302
  config: props.config,
320
303
  }).then((value) => {
321
304
  values.value = value;
305
+ // 字段的值初始化写入统一在这里执行,字段组件自身不再改写 model。
306
+ // 必须放在赋值之后:effect 与动态 type / display 回调读到的 formValue 才是这一轮的值。
307
+ applyMountValueEffects(formState, props.config, values.value);
322
308
  // 非对比模式,初始化完成
323
309
  initialized.value = !props.isCompare;
310
+
311
+ if (props.validateOnInit) {
312
+ nextTick(() => {
313
+ tMagicFormRef.value?.validate();
314
+ });
315
+ }
324
316
  });
325
317
 
326
318
  if (props.isCompare) {
@@ -330,6 +322,8 @@ watch(
330
322
  config: props.config,
331
323
  }).then((value) => {
332
324
  lastValuesProcessed.value = value;
325
+ // 对比模式下待对比的那份值同样要规整,否则会与当前值比出「格式差异」这种假差异
326
+ applyMountValueEffects(formState, props.config, lastValuesProcessed.value);
333
327
  initialized.value = true;
334
328
  });
335
329
  }
@@ -361,45 +355,17 @@ const submitHandler = (e: SubmitEvent) => {
361
355
  }
362
356
  };
363
357
 
358
+ const getTextByName = (name: string, config: FormConfig = props.config): string | undefined =>
359
+ findTextByName(name, config);
360
+
364
361
  /**
365
- * 通过 name 从 config 中查找对应的 text
366
- * @param name - 字段名,支持点分隔的路径格式,如 'a.b.c'
367
- * @param config - 表单配置数组
368
- * @returns 找到的 text 值,如果未找到则返回 undefined
362
+ * 将校验返回的 invalidFields 汇总为可读的错误文案(多条以 `<br>` 拼接)。
363
+ *
364
+ * 实现收口在 `utils/validateError`,供渲染式校验(本组件的 `submitForm` / `validate`)与
365
+ * 无渲染校验(`validateValues`)共用,保证两条链路产出的错误文案格式完全一致。
369
366
  */
370
- const getTextByName = (name: string, config: FormConfig = props.config): string | undefined => {
371
- if (!name || !Array.isArray(config)) return undefined;
372
-
373
- const nameParts = name.split('.');
374
-
375
- const findInConfig = (configs: FormConfig, parts: string[]): string | undefined => {
376
- if (parts.length === 0) return undefined;
377
-
378
- const [currentPart, ...remainingParts] = parts;
379
-
380
- for (const item of configs) {
381
- if (item.name === currentPart) {
382
- if (remainingParts.length === 0) {
383
- return typeof item.text === 'string' ? item.text : undefined;
384
- }
385
-
386
- if ('items' in item && Array.isArray(item.items)) {
387
- const result = findInConfig(item.items, remainingParts);
388
- if (result !== undefined) return result;
389
- }
390
- }
391
-
392
- if ('items' in item && Array.isArray(item.items)) {
393
- const result = findInConfig(item.items, parts);
394
- if (result !== undefined) return result;
395
- }
396
- }
397
-
398
- return undefined;
399
- };
400
-
401
- return findInConfig(config, nameParts);
402
- };
367
+ const formatValidateError = (invalidFields: Record<string, any>): string =>
368
+ formatError(invalidFields, { config: props.config, useFieldTextInError: props.useFieldTextInError });
403
369
 
404
370
  defineExpose({
405
371
  values,
@@ -428,18 +394,36 @@ defineExpose({
428
394
  } catch (invalidFields: any) {
429
395
  emit('error', invalidFields);
430
396
 
431
- const error: string[] = [];
432
-
433
- Object.entries(invalidFields).forEach(([prop, ValidateError]) => {
434
- (ValidateError as ValidateError[]).forEach(({ field, message }) => {
435
- const name = field || prop;
436
- const text = getTextByName(name, props.config) || name;
437
-
438
- error.push(`${text} -> ${message}`);
439
- });
440
- });
397
+ throw new Error(formatValidateError(invalidFields));
398
+ }
399
+ },
441
400
 
442
- throw new Error(error.join('<br>'));
401
+ /**
402
+ * 校验:对表单当前值执行校验,返回汇总后的错误文案。
403
+ *
404
+ * 与 `submitForm` 的区别:
405
+ * - 校验失败时不抛异常、不触发 `error` 事件,而是以返回值形式给出错误文案;
406
+ * - 不重置 `changeRecords`,不改变提交语义,仅用于「探测」当前配置是否合法。
407
+ *
408
+ * 注意:本方法只改变「校验结果的返回方式」,并不负责「不污染页面表单状态」——
409
+ * 若需对一份独立的「配置 + 值」做完全不影响页面上已渲染表单的校验,请使用 `validateForm`
410
+ * (内部会新建一个隐藏的 MForm 实例,通过 `initValues` 传入待校验值,用完即卸载)。
411
+ *
412
+ * 典型用途:作为 `validateForm` 内部复用的校验实现;也可在已渲染的表单实例上主动调用,
413
+ * 根据返回的错误文案自行决定后续处理(如记录节点错误状态)。
414
+ *
415
+ * @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
416
+ */
417
+ validate: async (): Promise<string> => {
418
+ try {
419
+ const result = await tMagicFormRef.value?.validate();
420
+ // tdesign 通过返回值返回校验结果,element-plus 通过 throw error
421
+ if (result !== true) {
422
+ throw result;
423
+ }
424
+ return '';
425
+ } catch (invalidFields: any) {
426
+ return formatValidateError(invalidFields);
443
427
  }
444
428
  },
445
429
 
package/src/FormBox.vue CHANGED
@@ -13,7 +13,10 @@
13
13
  :label-position="labelPosition"
14
14
  :inline="inline"
15
15
  :prevent-submit-default="preventSubmitDefault"
16
- :extend-state="extendState"
16
+ :use-field-text-in-error="useFieldTextInError"
17
+ :context="context"
18
+ :type-match-valid="typeMatchValid"
19
+ :validate-on-init="validateOnInit"
17
20
  @change="changeHandler"
18
21
  ></Form>
19
22
  <slot></slot>
@@ -41,7 +44,7 @@ import { computed, ref, watchEffect } from 'vue';
41
44
  import { TMagicButton, TMagicScrollbar } from '@tmagic/design';
42
45
 
43
46
  import Form from './Form.vue';
44
- import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from './schema';
47
+ import type { ContainerChangeEventData, FormConfig, FormContext, FormValue } from './schema';
45
48
 
46
49
  defineOptions({
47
50
  name: 'MFormBox',
@@ -55,18 +58,27 @@ const props = withDefaults(
55
58
  width?: number;
56
59
  height?: number;
57
60
  labelWidth?: string;
61
+ /** 是否开启类型匹配校验 */
62
+ typeMatchValid?: boolean;
63
+ /** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false`) */
64
+ validateOnInit?: boolean;
58
65
  disabled?: boolean;
59
66
  size?: 'small' | 'default' | 'large';
60
67
  confirmText?: string;
61
68
  inline?: boolean;
62
- labelPosition?: string;
69
+ labelPosition?: 'top' | 'left' | 'right';
63
70
  preventSubmitDefault?: boolean;
64
- extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
71
+ /** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
72
+ useFieldTextInError?: boolean;
73
+ /** 透传给内部 `MForm` 的宿主业务上下文 */
74
+ context?: FormContext;
65
75
  }>(),
66
76
  {
67
77
  config: () => [],
68
78
  values: () => ({}),
69
79
  confirmText: '确定',
80
+ useFieldTextInError: true,
81
+ validateOnInit: false,
70
82
  },
71
83
  );
72
84
 
@@ -31,7 +31,10 @@
31
31
  :label-position="labelPosition"
32
32
  :inline="inline"
33
33
  :prevent-submit-default="preventSubmitDefault"
34
- :extend-state="extendState"
34
+ :use-field-text-in-error="useFieldTextInError"
35
+ :type-match-valid="typeMatchValid"
36
+ :validate-on-init="validateOnInit"
37
+ :context="context"
35
38
  :theme="effectiveTheme"
36
39
  @change="changeHandler"
37
40
  ></Form>
@@ -70,7 +73,7 @@ import { computed, inject, provide, ref } from 'vue';
70
73
  import { M_THEME_KEY, TMagicButton, TMagicCol, TMagicDialog, TMagicRow } from '@tmagic/design';
71
74
 
72
75
  import Form from './Form.vue';
73
- import { ContainerChangeEventData, FormConfig, FormState, FormValue, StepConfig } from './schema';
76
+ import { ContainerChangeEventData, FormConfig, FormContext, FormValue, StepConfig } from './schema';
74
77
 
75
78
  defineOptions({
76
79
  name: 'MFormDialog',
@@ -83,11 +86,15 @@ const props = withDefaults(
83
86
  parentValues?: Object;
84
87
  width?: string | number;
85
88
  labelWidth?: string;
89
+ /** 是否开启类型匹配校验 */
90
+ typeMatchValid?: boolean;
91
+ /** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false`) */
92
+ validateOnInit?: boolean;
86
93
  fullscreen?: boolean;
87
94
  disabled?: boolean;
88
95
  title?: string;
89
96
  inline?: boolean;
90
- labelPosition?: string;
97
+ labelPosition?: 'top' | 'left' | 'right';
91
98
  zIndex?: number;
92
99
  size?: 'small' | 'default' | 'large';
93
100
  confirmText?: string;
@@ -97,8 +104,10 @@ const props = withDefaults(
97
104
  destroyOnClose?: boolean;
98
105
  showClose?: boolean;
99
106
  showCancel?: boolean;
100
- /** 透传给内部 `MForm`,用于扩展 `formState`(如注入 `$message` / `$store` 等) */
101
- extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
107
+ /** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
108
+ useFieldTextInError?: boolean;
109
+ /** 透传给内部 `MForm` 的宿主业务上下文 */
110
+ context?: FormContext;
102
111
  /**
103
112
  * 主题名。优先级:传入 `theme` prop > 祖先 `provide(M_THEME_KEY)` > 空串。
104
113
  * 计算结果会再次 `provide` 出去,使得 Dialog 被 Teleport 到 body 后,内部子树
@@ -115,6 +124,8 @@ const props = withDefaults(
115
124
  destroyOnClose: false,
116
125
  showClose: true,
117
126
  showCancel: true,
127
+ useFieldTextInError: true,
128
+ validateOnInit: false,
118
129
  },
119
130
  );
120
131
 
@@ -2,7 +2,7 @@
2
2
  <TMagicDrawer
3
3
  :class="['m-form-drawer', effectiveTheme ? `m-theme--${effectiveTheme}` : '']"
4
4
  ref="drawer"
5
- v-model="visible"
5
+ v-model="dialogVisible"
6
6
  :title="title"
7
7
  :close-on-press-escape="closeOnPressEscape"
8
8
  :append-to-body="true"
@@ -16,7 +16,7 @@
16
16
  @close="closeHandler"
17
17
  @closed="closedHandler"
18
18
  >
19
- <div v-if="visible" ref="drawerBody" class="m-drawer-body">
19
+ <div v-if="dialogVisible" ref="drawerBody" class="m-drawer-body">
20
20
  <Form
21
21
  ref="form"
22
22
  :size="size"
@@ -28,7 +28,10 @@
28
28
  :label-position="labelPosition"
29
29
  :inline="inline"
30
30
  :prevent-submit-default="preventSubmitDefault"
31
- :extend-state="extendState"
31
+ :use-field-text-in-error="useFieldTextInError"
32
+ :type-match-valid="typeMatchValid"
33
+ :validate-on-init="validateOnInit"
34
+ :context="context"
32
35
  :theme="effectiveTheme"
33
36
  @change="changeHandler"
34
37
  ></Form>
@@ -61,10 +64,10 @@ import { computed, inject, provide, ref, watchEffect } from 'vue';
61
64
  import { M_THEME_KEY, TMagicButton, TMagicCol, TMagicDrawer, TMagicRow } from '@tmagic/design';
62
65
 
63
66
  import Form from './Form.vue';
64
- import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from './schema';
67
+ import type { ContainerChangeEventData, FormConfig, FormContext, FormValue } from './schema';
65
68
 
66
69
  defineOptions({
67
- name: 'MFormDialog',
70
+ name: 'MFormDrawer',
68
71
  });
69
72
 
70
73
  const props = withDefaults(
@@ -74,6 +77,10 @@ const props = withDefaults(
74
77
  parentValues?: Object;
75
78
  width?: string | number;
76
79
  labelWidth?: string;
80
+ /** 是否开启类型匹配校验 */
81
+ typeMatchValid?: boolean;
82
+ /** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false`) */
83
+ validateOnInit?: boolean;
77
84
  disabled?: boolean;
78
85
  closeOnPressEscape?: boolean;
79
86
  title?: string;
@@ -81,12 +88,14 @@ const props = withDefaults(
81
88
  size?: 'small' | 'default' | 'large';
82
89
  confirmText?: string;
83
90
  inline?: boolean;
84
- labelPosition?: string;
91
+ labelPosition?: 'top' | 'left' | 'right';
85
92
  preventSubmitDefault?: boolean;
93
+ /** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
94
+ useFieldTextInError?: boolean;
86
95
  /** 关闭前的回调,会暂停 Drawer 的关闭; done 是个 function type 接受一个 boolean 参数, 执行 done 使用 true 参数或不提供参数将会终止关闭 */
87
96
  beforeClose?: (_done: (_cancel?: boolean) => void) => void;
88
- /** 透传给内部 `MForm`,用于扩展 `formState`(如注入 `$message` / `$store` 等) */
89
- extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
97
+ /** 透传给内部 `MForm` 的宿主业务上下文 */
98
+ context?: FormContext;
90
99
  /**
91
100
  * 主题名。优先级:传入 `theme` prop > 祖先 `provide(M_THEME_KEY)` > 空串。
92
101
  * 计算结果会再次 `provide` 出去,使得 Drawer 被 Teleport 到 body 后,内部子树
@@ -99,6 +108,8 @@ const props = withDefaults(
99
108
  config: () => [],
100
109
  values: () => ({}),
101
110
  confirmText: '确定',
111
+ useFieldTextInError: true,
112
+ validateOnInit: false,
102
113
  },
103
114
  );
104
115
 
@@ -111,7 +122,7 @@ const emit = defineEmits(['close', 'closed', 'submit', 'error', 'change', 'open'
111
122
  const drawer = ref<InstanceType<typeof TMagicDrawer>>();
112
123
  const form = ref<InstanceType<typeof Form>>();
113
124
  const drawerBody = ref<HTMLDivElement>();
114
- const visible = ref(false);
125
+ const dialogVisible = ref(false);
115
126
  const saveFetch = ref(false);
116
127
  const bodyHeight = ref(0);
117
128
 
@@ -152,25 +163,42 @@ const closedHandler = () => {
152
163
  };
153
164
 
154
165
  const show = () => {
155
- visible.value = true;
166
+ dialogVisible.value = true;
156
167
  };
157
168
 
158
169
  const hide = () => {
159
- visible.value = false;
170
+ dialogVisible.value = false;
171
+ };
172
+ const close = () => {
173
+ dialogVisible.value = false;
174
+ };
175
+ const cancel = () => {
176
+ hide();
160
177
  };
161
-
162
178
  /** 用于关闭 Drawer, 该方法会调用传入的 before-close 方法 */
163
179
  const handleClose = () => {
164
180
  drawer.value?.handleClose();
165
181
  };
166
182
 
183
+ const save = async () => {
184
+ try {
185
+ const changeRecords = [...(form.value?.changeRecords || [])];
186
+ const values = await form.value?.submitForm();
187
+ emit('submit', values, { changeRecords });
188
+ } catch (e) {
189
+ emit('error', e);
190
+ }
191
+ };
192
+
167
193
  defineExpose({
168
194
  form,
169
195
  saveFetch,
170
196
  bodyHeight,
171
-
197
+ close,
172
198
  show,
173
199
  hide,
200
+ save,
201
+ cancel,
174
202
  handleClose,
175
203
  });
176
204
  </script>