@tmagic/form 1.8.0-manmanyu.9 → 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/src/submitForm.ts CHANGED
@@ -16,168 +16,118 @@
16
16
  * limitations under the License.
17
17
  */
18
18
 
19
- import { type AppContext, type Component, createApp, defineComponent, h, nextTick, ref, watch } from 'vue';
19
+ import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref } from 'vue';
20
20
 
21
+ import {
22
+ submitForm as submitFormHeadless,
23
+ type SubmitFormOptions,
24
+ type SubmitFormResult,
25
+ validateForm as validateFormHeadless,
26
+ type ValidateFormOptions,
27
+ } from './utils/submitHeadless';
21
28
  import Form from './Form.vue';
22
- import type { ChangeRecord, FormConfig, FormState } from './schema';
29
+ import { type ChangeRecord, type FormConfig } from './schema';
23
30
 
24
- // #region SubmitFormOptions
25
- /**
26
- * submitForm 函数参数(与 Form.vue 组件 props 对齐)
27
- */
28
- export interface SubmitFormOptions {
29
- /** 表单配置 */
30
- config: FormConfig;
31
- /** 表单初始值 */
32
- initValues?: Record<string, any>;
33
- /** 需对比的值(开启对比模式时传入) */
34
- lastValues?: Record<string, any>;
35
- /** 是否开启对比模式 */
36
- isCompare?: boolean;
37
- parentValues?: Record<string, any>;
38
- labelWidth?: string;
39
- disabled?: boolean;
40
- height?: string;
41
- stepActive?: string | number;
42
- size?: 'small' | 'default' | 'large';
43
- inline?: boolean;
44
- labelPosition?: string;
45
- keyProp?: string;
46
- popperClass?: string;
47
- preventSubmitDefault?: boolean;
48
- extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
49
- /** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
50
- native?: boolean;
51
- /**
52
- * 是否在 resolve 结果中携带 changeRecords(变更记录)。
53
- * 开启后 resolve 的结果为 `{ values, changeRecords }`,否则仅 resolve values。
54
- */
55
- returnChangeRecords?: boolean;
56
- /**
57
- * 父级应用上下文,用于继承全局组件、指令、provide 等。
58
- * 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。
59
- */
60
- appContext?: AppContext | null;
61
- /** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
62
- timeout?: number;
63
- }
64
- // #endregion SubmitFormOptions
31
+ export type { SubmitFormOptions, SubmitFormResult, ValidateFormOptions };
65
32
 
66
- // #region SubmitFormResult
33
+ // #region mountFormInstance
67
34
  /**
68
- * 开启 `returnChangeRecords` 时 submitForm 的返回结果
35
+ * 构造 wrapper 组件的工厂:在合适时机调用 MForm 实例方法并 resolve/reject、清理实例。
36
+ *
37
+ * 由调用方决定「等待 `initialized` 后自动执行」还是「等待人工触发」(`dialog: true`),
38
+ * 以及「调用 `submitForm` 还是 `validate`」「结果如何包装」,从而复用公共脚手架。
69
39
  */
70
- export interface SubmitFormResult {
71
- /** 校验通过后的表单值 */
72
- values: any;
73
- /** 表单变更记录 */
74
- changeRecords: ChangeRecord[];
40
+ type FormWrapperFactory<T> = (ctx: {
41
+ /** 透传给 Form 组件的 props(由工厂统一注入,避免调用方重复闭包) */
42
+ formProps: Record<string, any>;
43
+ /** 指向挂载的 MForm 实例 */
44
+ formRef: Ref<any>;
45
+ /** 卸载实例并移除容器,resolve/reject 后必须调用以避免泄漏 */
46
+ cleanup: () => void;
47
+ /** resolve 外层 Promise */
48
+ resolve: (value: T | PromiseLike<T>) => void;
49
+ /** reject 外层 Promise */
50
+ reject: (reason?: any) => void;
51
+ }) => Component;
52
+
53
+ interface MountFormInstanceOptions<T> {
54
+ /** 透传给 Form 组件的 props */
55
+ formProps: Record<string, any>;
56
+ /** 父级应用上下文,用于继承全局组件、指令、provide 等 */
57
+ appContext?: AppContext | null;
58
+ /** 外部中断信号:abort 时会 reject 并卸载实例、移除容器,用于取消等待人工操作的挂载 */
59
+ signal?: AbortSignal;
60
+ /** 构造 wrapper 组件 */
61
+ createWrapper: FormWrapperFactory<T>;
75
62
  }
76
- // #endregion SubmitFormResult
77
63
 
78
64
  /**
79
- * 以命令式方式调用 Form.vue 完成一次表单校验/提交。
80
- *
81
- * 类似 ElMessage 的用法:传入 props(包含 `config`/`initValues` 等),函数内部会临时挂载
82
- * 一个不可见的 Form 组件实例,等待初始化完成后调用其 `submitForm` 方法,
83
- * 校验通过则 resolve 表单值,校验失败则 reject 错误信息,最后自动卸载实例。
84
- *
85
- * @example
86
- * ```ts
87
- * import { submitForm } from '@tmagic/form';
65
+ * `dialog: true` 时 submitForm / validateForm 的公共脚手架:
88
66
  *
89
- * try {
90
- * const values = await submitForm({
91
- * config: [...],
92
- * initValues: { name: 'foo' },
93
- * });
94
- * console.log(values);
95
- * } catch (e) {
96
- * console.error(e);
97
- * }
67
+ * 创建临时容器 → 挂载一个 wrapper 组件(内含 MForm)→ 提供统一的 cleanup / appContext 继承 →
68
+ * 由 `createWrapper` 决定「何时调用 MForm 的哪个方法、如何 resolve/reject」。
98
69
  *
99
- * // 需要同时获取变更记录时:
100
- * const { values, changeRecords } = await submitForm({
101
- * config: [...],
102
- * initValues: { name: 'foo' },
103
- * returnChangeRecords: true,
104
- * });
105
- * ```
70
+ * 挂载出来的表单等待人工点击,因此没有超时兜底,靠「确定」/「取消」/`signal` 结束。
106
71
  */
107
- export const submitForm = (options: SubmitFormOptions): Promise<any> => {
108
- const { native, appContext, timeout = 10000, returnChangeRecords, ...formProps } = options;
72
+ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
73
+ const { formProps, appContext, signal, createWrapper } = options;
109
74
 
110
- return new Promise((resolve, reject) => {
111
- const container = document.createElement('div');
112
- container.style.display = 'none';
113
- document.body.appendChild(container);
75
+ return new Promise<T>((resolve, reject) => {
76
+ // 已中断则直接 reject,不创建任何容器/实例
77
+ if (signal?.aborted) {
78
+ reject(signal.reason ?? new Error('mountFormInstance aborted'));
79
+ return;
80
+ }
114
81
 
115
82
  let cleaned = false;
116
- let timer: ReturnType<typeof setTimeout> | null = null;
117
-
118
- const wrapperComponent = defineComponent({
119
- name: 'MFormSubmitWrapper',
120
- setup() {
121
- const formRef = ref<any>(null);
122
-
123
- const stop = watch(
124
- () => formRef.value?.initialized,
125
- async (initialized) => {
126
- if (!initialized) return;
127
- stop();
128
-
129
- try {
130
- // 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
131
- await nextTick();
132
- // submitForm 校验通过后会清空 changeRecords,需在调用前先做快照
133
- const changeRecords: ChangeRecord[] = [...(formRef.value.changeRecords ?? [])];
134
- const result = await formRef.value.submitForm(native);
135
- resolve(returnChangeRecords ? { values: result, changeRecords } : result);
136
- } catch (err) {
137
- reject(err);
138
- } finally {
139
- cleanup();
140
- }
141
- },
142
- { flush: 'post', immediate: true },
143
- );
144
-
145
- return () => h(Form as Component, { ...formProps, ref: formRef });
146
- },
147
- });
148
-
149
- const app = createApp(wrapperComponent);
83
+ let onAbort: (() => void) | null = null;
84
+ // 用 holder 持有 app,使 cleanup 可在 app 创建之前定义(const app + 无 TDZ / 无 use-before-define)
85
+ const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
150
86
 
151
- // 继承父级应用上下文(components / directives / provides / config 等)
152
- if (appContext) {
153
- Object.assign(app._context, appContext);
154
- }
87
+ const container = document.createElement('div');
88
+ document.body.appendChild(container);
155
89
 
156
90
  const cleanup = () => {
157
91
  if (cleaned) return;
158
92
  cleaned = true;
159
- if (timer) {
160
- clearTimeout(timer);
161
- timer = null;
93
+ if (signal && onAbort) {
94
+ signal.removeEventListener('abort', onAbort);
95
+ onAbort = null;
162
96
  }
163
97
  try {
164
- app.unmount();
98
+ instance.app?.unmount();
165
99
  } catch {
166
100
  // ignore
167
101
  }
168
102
  container.parentNode?.removeChild(container);
169
103
  };
170
104
 
171
- if (timeout > 0) {
172
- timer = setTimeout(() => {
173
- if (!cleaned) {
174
- reject(new Error(`submitForm timeout after ${timeout}ms: form is not initialized.`));
175
- cleanup();
176
- }
177
- }, timeout);
105
+ // 支持外部通过 AbortSignal 主动中断:弹层无超时兜底,若调用方放弃了该 Promise,
106
+ // 可通过 abort 卸载实例、移除遮罩/容器,避免无限驻留在 DOM 中。
107
+ if (signal) {
108
+ onAbort = () => {
109
+ if (cleaned) return;
110
+ reject(signal.reason ?? new Error('mountFormInstance aborted'));
111
+ cleanup();
112
+ };
113
+ signal.addEventListener('abort', onAbort);
178
114
  }
179
115
 
116
+ // 从容器创建到 mount 的全流程统一 try/catch:任一步骤(createWrapper/createApp/上下文合并/mount)
117
+ // 抛错都会走到 cleanup,避免已插入 body 的 container 及未挂载的 app 残留导致泄漏。
180
118
  try {
119
+ const formRef = ref<any>(null);
120
+
121
+ const wrapperComponent = createWrapper({ formRef, formProps, cleanup, resolve, reject });
122
+
123
+ const app = createApp(wrapperComponent);
124
+ instance.app = app;
125
+
126
+ // 继承父级应用上下文(components / directives / provides / config 等)
127
+ if (appContext) {
128
+ Object.assign(app._context, appContext);
129
+ }
130
+
181
131
  app.mount(container);
182
132
  } catch (err) {
183
133
  reject(err);
@@ -185,3 +135,332 @@ export const submitForm = (options: SubmitFormOptions): Promise<any> => {
185
135
  }
186
136
  });
187
137
  };
138
+ // #endregion mountFormInstance
139
+
140
+ // #region createDialogWrapper
141
+ interface DialogWrapperOptions {
142
+ /** 指向挂载的 MForm 实例 */
143
+ formRef: Ref<any>;
144
+ /** 透传给 Form 组件的 props */
145
+ formProps: Record<string, any>;
146
+ /** 弹层标题 */
147
+ title: string;
148
+ /** wrapper 组件名 */
149
+ name: string;
150
+ /**
151
+ * 点击「确定」触发;接收 `setError`,校验失败时可调用以在弹层展示错误并保留弹层供修正后重试,
152
+ * 校验通过则由调用方自行 resolve + cleanup。
153
+ */
154
+ onConfirm: (setError: (msg: string) => void) => void | Promise<void>;
155
+ /** 点击「取消」触发(通常 reject + cleanup) */
156
+ onCancel: () => void;
157
+ }
158
+
159
+ /**
160
+ * 构造可见弹层 wrapper:以 fixed 遮罩居中渲染 MForm,提供「确定 / 取消」按钮与错误展示区。
161
+ *
162
+ * submitForm 与 validateForm 的弹层 UI 完全一致,仅「确定」时调用的实例方法、错误处理与取消文案不同,
163
+ * 这些差异通过 `onConfirm` / `onCancel` 注入,弹层结构在此统一收口。
164
+ */
165
+ const createDialogWrapper = (options: DialogWrapperOptions): Component => {
166
+ const { formRef, formProps, title, name, onConfirm, onCancel } = options;
167
+
168
+ const btnBase = {
169
+ padding: '8px 20px',
170
+ fontSize: '14px',
171
+ lineHeight: '1',
172
+ border: '1px solid #dcdfe6',
173
+ borderRadius: '4px',
174
+ cursor: 'pointer',
175
+ };
176
+
177
+ return defineComponent({
178
+ name,
179
+ setup() {
180
+ // 校验失败信息展示区
181
+ const errorMsg = ref('');
182
+ const setError = (msg: string) => {
183
+ errorMsg.value = msg;
184
+ };
185
+
186
+ return () =>
187
+ h(
188
+ 'div',
189
+ {
190
+ style: {
191
+ position: 'fixed',
192
+ inset: '0',
193
+ zIndex: '10000',
194
+ display: 'flex',
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ background: 'rgba(0, 0, 0, 0.5)',
198
+ },
199
+ },
200
+ [
201
+ h(
202
+ 'div',
203
+ {
204
+ style: {
205
+ display: 'flex',
206
+ flexDirection: 'column',
207
+ width: '600px',
208
+ maxWidth: '90vw',
209
+ maxHeight: '85vh',
210
+ background: '#fff',
211
+ borderRadius: '8px',
212
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
213
+ overflow: 'hidden',
214
+ },
215
+ },
216
+ [
217
+ h(
218
+ 'div',
219
+ {
220
+ style: {
221
+ padding: '16px 20px',
222
+ fontSize: '16px',
223
+ fontWeight: '600',
224
+ borderBottom: '1px solid #ebeef5',
225
+ },
226
+ },
227
+ title,
228
+ ),
229
+ h(
230
+ 'div',
231
+ {
232
+ style: {
233
+ flex: '1',
234
+ padding: '20px',
235
+ overflow: 'auto',
236
+ },
237
+ },
238
+ [
239
+ h(Form as Component, { ...formProps, ref: formRef }),
240
+ errorMsg.value
241
+ ? h('div', {
242
+ style: {
243
+ marginTop: '12px',
244
+ color: '#f56c6c',
245
+ fontSize: '13px',
246
+ lineHeight: '1.5',
247
+ },
248
+ innerHTML: errorMsg.value,
249
+ })
250
+ : null,
251
+ ],
252
+ ),
253
+ h(
254
+ 'div',
255
+ {
256
+ style: {
257
+ display: 'flex',
258
+ justifyContent: 'flex-end',
259
+ gap: '12px',
260
+ padding: '12px 20px',
261
+ borderTop: '1px solid #ebeef5',
262
+ },
263
+ },
264
+ [
265
+ h('button', { type: 'button', onClick: onCancel, style: { ...btnBase } }, '取消'),
266
+ h(
267
+ 'button',
268
+ {
269
+ type: 'button',
270
+ onClick: () => onConfirm(setError),
271
+ style: {
272
+ ...btnBase,
273
+ color: '#fff',
274
+ background: '#409eff',
275
+ borderColor: '#409eff',
276
+ },
277
+ },
278
+ '确定',
279
+ ),
280
+ ],
281
+ ),
282
+ ],
283
+ ),
284
+ ],
285
+ );
286
+ },
287
+ });
288
+ };
289
+ // #endregion createDialogWrapper
290
+
291
+ /**
292
+ * 可见弹层提交:把表单渲染在弹层里,点击「确定」才调用挂载实例的 `submitForm`。
293
+ *
294
+ * 用于需要用户填写/确认的场景;默认路径仍是无渲染校验(见 `submitForm` 的说明)。
295
+ */
296
+ const submitFormByDialogRender = (options: SubmitFormOptions): Promise<any> => {
297
+ const { native, appContext, returnChangeRecords, signal, dialog, title, ...formProps } = options;
298
+
299
+ return mountFormInstance<any>({
300
+ formProps,
301
+ appContext,
302
+ signal,
303
+ createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
304
+ // 执行一次提交:nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve
305
+ const doSubmit = async (onValidateError: (err: any) => void) => {
306
+ try {
307
+ // 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
308
+ await nextTick();
309
+ // submitForm 校验通过后会清空 changeRecords,需在调用前先做快照
310
+ const changeRecords: ChangeRecord[] = [...(formRef.value?.changeRecords ?? [])];
311
+ const result = await formRef.value.submitForm(native);
312
+ resolve(returnChangeRecords ? { values: result, changeRecords } : result);
313
+ cleanup();
314
+ } catch (err) {
315
+ onValidateError(err);
316
+ }
317
+ };
318
+
319
+ return createDialogWrapper({
320
+ formRef,
321
+ formProps,
322
+ name: 'MFormSubmitWrapper',
323
+ title: title ?? 'submitForm',
324
+ onConfirm: (setError) =>
325
+ doSubmit((err) => {
326
+ // 校验失败时保留弹层并展示错误,便于修正后重新提交
327
+ setError(err instanceof Error ? err.message : String(err));
328
+ }),
329
+ onCancel: () => {
330
+ reject(new Error('submitForm canceled.'));
331
+ cleanup();
332
+ },
333
+ });
334
+ },
335
+ });
336
+ };
337
+
338
+ // #region stripTabLazy
339
+ /**
340
+ * 深拷贝配置值,保留函数(display / onTabClick 等回调)引用,避免破坏配置中的回调。
341
+ */
342
+ const cloneConfigValue = (value: any): any => {
343
+ if (Array.isArray(value)) {
344
+ return value.map(cloneConfigValue);
345
+ }
346
+ if (value && typeof value === 'object') {
347
+ return Object.keys(value).reduce<Record<string, any>>((acc, key) => {
348
+ acc[key] = cloneConfigValue(value[key]);
349
+ return acc;
350
+ }, {});
351
+ }
352
+ return value;
353
+ };
354
+
355
+ /**
356
+ * 深度遍历配置,去掉所有 type 为 'tab' 的容器中各标签页(items)的 lazy 配置。
357
+ */
358
+ const removeTabItemsLazy = (node: any): void => {
359
+ if (Array.isArray(node)) {
360
+ node.forEach(removeTabItemsLazy);
361
+ return;
362
+ }
363
+ if (!node || typeof node !== 'object') return;
364
+
365
+ if (node.type === 'tab' && Array.isArray(node.items)) {
366
+ node.items.forEach((pane: any) => {
367
+ if (pane && typeof pane === 'object') {
368
+ delete pane.lazy;
369
+ }
370
+ });
371
+ }
372
+
373
+ Object.keys(node).forEach((key) => {
374
+ removeTabItemsLazy(node[key]);
375
+ });
376
+ };
377
+
378
+ /**
379
+ * 返回一份去除了 tab 标签页 lazy 的配置副本。
380
+ *
381
+ * tab 容器开启 lazy 时,非激活标签页的内容不会渲染,导致 validateForm 静默挂载后
382
+ * 无法校验到这些标签页内的字段。校验场景需要一次性渲染全部字段,故在此统一去除 lazy。
383
+ * 处理基于深拷贝,不会污染调用方传入的原始 config。
384
+ */
385
+ export const stripTabItemsLazy = (config: FormConfig): FormConfig => {
386
+ const cloned = cloneConfigValue(config);
387
+ removeTabItemsLazy(cloned);
388
+ return cloned;
389
+ };
390
+ // #endregion stripTabLazy
391
+
392
+ /**
393
+ * 可见弹层校验:把表单渲染在弹层里,点击「确定」才调用挂载实例的 `validate`。
394
+ *
395
+ * 用于需要用户填写/确认的场景;默认路径仍是无渲染校验(见 `validateForm` 的说明)。
396
+ */
397
+ const validateFormByDialogRender = (options: ValidateFormOptions): Promise<string> => {
398
+ const { appContext, config, signal, dialog, title, ...rest } = options;
399
+
400
+ // 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验
401
+ const formProps = { ...rest, config: stripTabItemsLazy(config) };
402
+
403
+ return mountFormInstance<string>({
404
+ formProps,
405
+ appContext,
406
+ signal,
407
+ createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
408
+ // 执行一次校验:nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve '',失败则在弹层展示错误
409
+ const doValidate = async (onInvalid: (error: string) => void) => {
410
+ try {
411
+ // 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
412
+ await nextTick();
413
+ // 复用 Form.vue 实例的静默校验方法:校验通过返回 '',失败返回错误文案,均不抛异常
414
+ const error = await formRef.value.validate();
415
+ if (error) {
416
+ onInvalid(error);
417
+ } else {
418
+ resolve('');
419
+ cleanup();
420
+ }
421
+ } catch (err) {
422
+ reject(err);
423
+ cleanup();
424
+ }
425
+ };
426
+
427
+ return createDialogWrapper({
428
+ formRef,
429
+ formProps,
430
+ name: 'MFormValidateWrapper',
431
+ title: title ?? 'validateForm',
432
+ onConfirm: (setError) =>
433
+ doValidate((error) => {
434
+ // 校验失败时保留弹层并展示错误,便于修正后重新校验
435
+ setError(error);
436
+ }),
437
+ onCancel: () => {
438
+ reject(new Error('validateForm canceled.'));
439
+ cleanup();
440
+ },
441
+ });
442
+ },
443
+ });
444
+ };
445
+
446
+ // #region headless
447
+ /**
448
+ * 以命令式方式对一份「表单配置 + 值」做一次校验并取回表单值。
449
+ *
450
+ * 默认走无渲染实现(见 `@tmagic/form/headless`)。`dialog: true` 会把表单以弹层渲染出来。
451
+ */
452
+ export const submitForm = async (options: SubmitFormOptions): Promise<any> => {
453
+ if (options.dialog) return submitFormByDialogRender(options);
454
+ return submitFormHeadless(options);
455
+ };
456
+
457
+ /**
458
+ * 以命令式方式对一份「表单配置 + 值」做一次静默校验。
459
+ *
460
+ * 默认走无渲染实现。`dialog: true` 的处理详见 `submitForm`。
461
+ */
462
+ export const validateForm = async (options: ValidateFormOptions): Promise<string> => {
463
+ if (options.dialog) return validateFormByDialogRender(options);
464
+ return validateFormHeadless(options);
465
+ };
466
+ // #endregion headless
@@ -29,6 +29,7 @@
29
29
  legend {
30
30
  position: relative;
31
31
  top: 10px;
32
+ background-color: transparent;
32
33
  }
33
34
  }
34
35
  &.fieldset-in-card-fit-last {
@@ -5,7 +5,12 @@
5
5
  margin: 0;
6
6
  }
7
7
 
8
+ .m-box-body {
9
+ min-height: 0;
10
+ }
11
+
8
12
  .dialog-footer {
13
+ flex-shrink: 0;
9
14
  display: flex;
10
15
  align-items: center;
11
16
  justify-content: space-between;
@@ -7,7 +7,7 @@
7
7
  padding: 0 20px;
8
8
  }
9
9
 
10
- .el-table .m-form-item .el-form-item {
10
+ .el-table .m-form-item .el-form-item:not(.is-error) {
11
11
  margin-bottom: 0;
12
12
  }
13
13
  }