@tmagic/form 1.8.0-beta.11 → 1.8.0-beta.12
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.
- package/dist/es/Form.vue_vue_type_script_setup_true_lang.js +50 -11
- package/dist/es/FormBox.vue_vue_type_script_setup_true_lang.js +6 -2
- package/dist/es/FormDialog.vue_vue_type_script_setup_true_lang.js +5 -1
- package/dist/es/FormDrawer.vue_vue_type_script_setup_true_lang.js +5 -1
- package/dist/es/containers/Container.vue_vue_type_script_setup_true_lang.js +7 -3
- package/dist/es/containers/table/Table.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/es/fields/RadioGroup.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/es/fields/Select.vue_vue_type_script_setup_true_lang.js +2 -1
- package/dist/es/index.js +5 -4
- package/dist/es/plugin.js +2 -0
- package/dist/es/schema.js +2 -1
- package/dist/es/style.css +6 -2
- package/dist/es/submitForm.js +374 -142
- package/dist/es/utils/form.js +45 -5
- package/dist/es/utils/typeMatch.js +403 -0
- package/dist/style.css +6 -2
- package/dist/tmagic-form.umd.cjs +2542 -1708
- package/package.json +5 -5
- package/src/Form.vue +63 -13
- package/src/FormBox.vue +3 -0
- package/src/FormDialog.vue +3 -0
- package/src/FormDrawer.vue +3 -0
- package/src/containers/Container.vue +4 -2
- package/src/fields/RadioGroup.vue +3 -0
- package/src/index.ts +11 -0
- package/src/plugin.ts +9 -0
- package/src/schema.ts +2 -1
- package/src/submitForm.ts +574 -197
- package/src/theme/form-dialog.scss +1 -1
- package/src/theme/table.scss +11 -1
- package/src/utils/form.ts +93 -22
- package/src/utils/typeMatch.ts +728 -0
- package/types/index.d.ts +172 -17
package/src/submitForm.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
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, watch } from 'vue';
|
|
20
20
|
|
|
21
21
|
import Form from './Form.vue';
|
|
22
22
|
import type { ChangeRecord, FormConfig, FormState } from './schema';
|
|
@@ -74,12 +74,13 @@ export interface SubmitFormOptions {
|
|
|
74
74
|
* 调试模式下 `timeout` 不生效(等待人工操作)。
|
|
75
75
|
*/
|
|
76
76
|
debug?: boolean;
|
|
77
|
+
typeMatchValid?: boolean;
|
|
77
78
|
}
|
|
78
79
|
// #endregion SubmitFormOptions
|
|
79
80
|
|
|
80
81
|
// #region SubmitFormResult
|
|
81
82
|
/**
|
|
82
|
-
* 开启 `
|
|
83
|
+
* 开启 `returnChangeNodes` 时 submitForm 的返回结果
|
|
83
84
|
*/
|
|
84
85
|
export interface SubmitFormResult {
|
|
85
86
|
/** 校验通过后的表单值 */
|
|
@@ -89,253 +90,629 @@ export interface SubmitFormResult {
|
|
|
89
90
|
}
|
|
90
91
|
// #endregion SubmitFormResult
|
|
91
92
|
|
|
93
|
+
// #region mountFormInstance
|
|
92
94
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* 类似 ElMessage 的用法:传入 props(包含 `config`/`initValues` 等),函数内部会临时挂载
|
|
96
|
-
* 一个不可见的 Form 组件实例,等待初始化完成后调用其 `submitForm` 方法,
|
|
97
|
-
* 校验通过则 resolve 表单值,校验失败则 reject 错误信息,最后自动卸载实例。
|
|
98
|
-
*
|
|
99
|
-
* @example
|
|
100
|
-
* ```ts
|
|
101
|
-
* import { submitForm } from '@tmagic/form';
|
|
95
|
+
* 构造 wrapper 组件的工厂:在合适时机调用 MForm 实例方法并 resolve/reject、清理实例。
|
|
102
96
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
97
|
+
* 由调用方决定「等待 `initialized` 后自动执行」还是「等待人工触发」(debug 模式),
|
|
98
|
+
* 以及「调用 `submitForm` 还是 `validate`」「结果如何包装」,从而复用公共脚手架。
|
|
99
|
+
*/
|
|
100
|
+
type FormWrapperFactory<T> = (ctx: {
|
|
101
|
+
/** 透传给 Form 组件的 props(由工厂统一注入,避免调用方重复闭包) */
|
|
102
|
+
formProps: Record<string, any>;
|
|
103
|
+
/** 指向挂载的 MForm 实例 */
|
|
104
|
+
formRef: Ref<any>;
|
|
105
|
+
/** 卸载实例并移除容器,resolve/reject 后必须调用以避免泄漏 */
|
|
106
|
+
cleanup: () => void;
|
|
107
|
+
/** resolve 外层 Promise */
|
|
108
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
109
|
+
/** reject 外层 Promise */
|
|
110
|
+
reject: (reason?: any) => void;
|
|
111
|
+
}) => Component;
|
|
112
|
+
|
|
113
|
+
interface MountFormInstanceOptions<T> {
|
|
114
|
+
/** 透传给 Form 组件的 props */
|
|
115
|
+
formProps: Record<string, any>;
|
|
116
|
+
/** 父级应用上下文,用于继承全局组件、指令、provide 等 */
|
|
117
|
+
appContext?: AppContext | null;
|
|
118
|
+
/** 等待表单初始化的最长时间(毫秒),<=0 表示不注册超时 */
|
|
119
|
+
timeout: number;
|
|
120
|
+
/** 超时 reject 的错误文案 */
|
|
121
|
+
timeoutMessage: string;
|
|
122
|
+
/** 是否以 `display:none` 隐藏容器。调试模式需可见,应传 `false` */
|
|
123
|
+
hidden?: boolean;
|
|
124
|
+
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
|
|
125
|
+
skipTimeout?: boolean;
|
|
126
|
+
/** 构造 wrapper 组件 */
|
|
127
|
+
createWrapper: FormWrapperFactory<T>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* submitForm / validateForm 的公共脚手架:
|
|
112
132
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* config: [...],
|
|
116
|
-
* initValues: { name: 'foo' },
|
|
117
|
-
* returnChangeRecords: true,
|
|
118
|
-
* });
|
|
133
|
+
* 创建临时容器 → 挂载一个 wrapper 组件(内含 MForm)→ 提供统一的 cleanup / timeout / appContext 继承 →
|
|
134
|
+
* 由 `createWrapper` 决定「何时调用 MForm 的哪个方法、如何 resolve/reject」。
|
|
119
135
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* config: [...],
|
|
123
|
-
* initValues: { name: 'foo' },
|
|
124
|
-
* debug: true,
|
|
125
|
-
* });
|
|
126
|
-
* ```
|
|
136
|
+
* 调用方只需关注差异逻辑(调用 `submitForm` 还是 `validate`、结果如何包装、是否需要调试弹层),
|
|
137
|
+
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
|
|
127
138
|
*/
|
|
128
|
-
|
|
129
|
-
const {
|
|
139
|
+
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
|
|
140
|
+
const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, createWrapper } = options;
|
|
130
141
|
|
|
131
|
-
return new Promise((resolve, reject) => {
|
|
142
|
+
return new Promise<T>((resolve, reject) => {
|
|
132
143
|
const container = document.createElement('div');
|
|
133
|
-
|
|
134
|
-
if (!debug) {
|
|
144
|
+
if (hidden) {
|
|
135
145
|
container.style.display = 'none';
|
|
136
146
|
}
|
|
137
147
|
document.body.appendChild(container);
|
|
138
148
|
|
|
139
149
|
let cleaned = false;
|
|
140
150
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
151
|
+
// 用 holder 持有 app,使 cleanup 可在 app 创建之前定义(const app + 无 TDZ / 无 use-before-define)
|
|
152
|
+
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
|
|
141
153
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
resolve(returnChangeRecords ? { values: result, changeRecords } : result);
|
|
157
|
-
cleanup();
|
|
158
|
-
} catch (err) {
|
|
159
|
-
// 调试模式下校验失败时保留弹层并展示错误,便于修正后重新提交
|
|
160
|
-
if (debug) {
|
|
161
|
-
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
reject(err);
|
|
165
|
-
cleanup();
|
|
166
|
-
}
|
|
167
|
-
};
|
|
154
|
+
const cleanup = () => {
|
|
155
|
+
if (cleaned) return;
|
|
156
|
+
cleaned = true;
|
|
157
|
+
if (timer) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
timer = null;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
instance.app?.unmount();
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore
|
|
165
|
+
}
|
|
166
|
+
container.parentNode?.removeChild(container);
|
|
167
|
+
};
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
169
|
+
const formRef = ref<any>(null);
|
|
170
|
+
|
|
171
|
+
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
|
|
172
|
+
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState,
|
|
173
|
+
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
|
|
174
|
+
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
|
|
175
|
+
const { extendState, ...restFormProps } = formProps;
|
|
176
|
+
|
|
177
|
+
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
|
|
178
|
+
|
|
179
|
+
const wrapperComponent =
|
|
180
|
+
typeof extendState === 'function'
|
|
181
|
+
? defineComponent({
|
|
182
|
+
name: 'MFormExtendStateInjector',
|
|
183
|
+
setup() {
|
|
184
|
+
watch(
|
|
185
|
+
() => formRef.value,
|
|
186
|
+
(form) => {
|
|
187
|
+
if (!form) return;
|
|
188
|
+
let result: any;
|
|
189
|
+
try {
|
|
190
|
+
result = extendState(form.formState);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.error('[MForm] extendState failed:', e);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const apply = (state: Record<string, any> | null | undefined) => {
|
|
196
|
+
if (!state) return;
|
|
197
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
|
|
198
|
+
if ('value' in descriptor) {
|
|
199
|
+
(form.formState as any)[key] = (state as any)[key];
|
|
200
|
+
} else {
|
|
201
|
+
descriptor.configurable = true;
|
|
202
|
+
Object.defineProperty(form.formState, key, descriptor);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (result && typeof result.then === 'function') {
|
|
207
|
+
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
|
208
|
+
} else {
|
|
209
|
+
apply(result);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
{ flush: 'sync', immediate: true },
|
|
213
|
+
);
|
|
214
|
+
return () => h(userWrapper);
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
: userWrapper;
|
|
218
|
+
|
|
219
|
+
const app = createApp(wrapperComponent);
|
|
220
|
+
instance.app = app;
|
|
221
|
+
|
|
222
|
+
// 继承父级应用上下文(components / directives / provides / config 等)
|
|
223
|
+
if (appContext) {
|
|
224
|
+
Object.assign(app._context, appContext);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (timeout > 0 && !skipTimeout) {
|
|
228
|
+
timer = setTimeout(() => {
|
|
229
|
+
if (!cleaned) {
|
|
230
|
+
reject(new Error(timeoutMessage));
|
|
231
|
+
cleanup();
|
|
232
|
+
}
|
|
233
|
+
}, timeout);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
app.mount(container);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
reject(err);
|
|
240
|
+
cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
// #endregion mountFormInstance
|
|
245
|
+
|
|
246
|
+
// #region createDebugWrapper
|
|
247
|
+
interface DebugWrapperOptions {
|
|
248
|
+
/** 指向挂载的 MForm 实例 */
|
|
249
|
+
formRef: Ref<any>;
|
|
250
|
+
/** 透传给 Form 组件的 props */
|
|
251
|
+
formProps: Record<string, any>;
|
|
252
|
+
/** 弹层标题 */
|
|
253
|
+
title: string;
|
|
254
|
+
/** wrapper 组件名 */
|
|
255
|
+
name: string;
|
|
256
|
+
/**
|
|
257
|
+
* 点击「确定」触发;接收 `setError`,校验失败时可调用以在弹层展示错误并保留弹层供修正后重试,
|
|
258
|
+
* 校验通过则由调用方自行 resolve + cleanup。
|
|
259
|
+
*/
|
|
260
|
+
onConfirm: (setError: (msg: string) => void) => void | Promise<void>;
|
|
261
|
+
/** 点击「取消」触发(通常 reject + cleanup) */
|
|
262
|
+
onCancel: () => void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 构造调试模式下的可见弹层 wrapper:以 fixed 遮罩居中渲染 MForm,提供「确定 / 取消」按钮与错误展示区。
|
|
267
|
+
*
|
|
268
|
+
* submitForm 与 validateForm 的调试弹层 UI 完全一致,仅「确定」时调用的实例方法、错误处理与取消文案不同,
|
|
269
|
+
* 这些差异通过 `onConfirm` / `onCancel` 注入,弹层结构在此统一收口。
|
|
270
|
+
*/
|
|
271
|
+
const createDebugWrapper = (options: DebugWrapperOptions): Component => {
|
|
272
|
+
const { formRef, formProps, title, name, onConfirm, onCancel } = options;
|
|
273
|
+
|
|
274
|
+
const btnBase = {
|
|
275
|
+
padding: '8px 20px',
|
|
276
|
+
fontSize: '14px',
|
|
277
|
+
lineHeight: '1',
|
|
278
|
+
border: '1px solid #dcdfe6',
|
|
279
|
+
borderRadius: '4px',
|
|
280
|
+
cursor: 'pointer',
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return defineComponent({
|
|
284
|
+
name,
|
|
285
|
+
setup() {
|
|
286
|
+
// 校验失败信息展示区
|
|
287
|
+
const errorMsg = ref('');
|
|
288
|
+
const setError = (msg: string) => {
|
|
289
|
+
errorMsg.value = msg;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return () =>
|
|
293
|
+
h(
|
|
294
|
+
'div',
|
|
295
|
+
{
|
|
296
|
+
style: {
|
|
297
|
+
position: 'fixed',
|
|
298
|
+
inset: '0',
|
|
299
|
+
zIndex: '10000',
|
|
300
|
+
display: 'flex',
|
|
301
|
+
alignItems: 'center',
|
|
302
|
+
justifyContent: 'center',
|
|
303
|
+
background: 'rgba(0, 0, 0, 0.5)',
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
[
|
|
186
307
|
h(
|
|
187
308
|
'div',
|
|
188
309
|
{
|
|
189
310
|
style: {
|
|
190
|
-
position: 'fixed',
|
|
191
|
-
inset: '0',
|
|
192
|
-
zIndex: '10000',
|
|
193
311
|
display: 'flex',
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
312
|
+
flexDirection: 'column',
|
|
313
|
+
width: '600px',
|
|
314
|
+
maxWidth: '90vw',
|
|
315
|
+
maxHeight: '85vh',
|
|
316
|
+
background: '#fff',
|
|
317
|
+
borderRadius: '8px',
|
|
318
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
|
|
319
|
+
overflow: 'hidden',
|
|
197
320
|
},
|
|
198
321
|
},
|
|
199
322
|
[
|
|
323
|
+
h(
|
|
324
|
+
'div',
|
|
325
|
+
{
|
|
326
|
+
style: {
|
|
327
|
+
padding: '16px 20px',
|
|
328
|
+
fontSize: '16px',
|
|
329
|
+
fontWeight: '600',
|
|
330
|
+
borderBottom: '1px solid #ebeef5',
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
title,
|
|
334
|
+
),
|
|
335
|
+
h(
|
|
336
|
+
'div',
|
|
337
|
+
{
|
|
338
|
+
style: {
|
|
339
|
+
flex: '1',
|
|
340
|
+
padding: '20px',
|
|
341
|
+
overflow: 'auto',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
[
|
|
345
|
+
h(Form as Component, { ...formProps, ref: formRef }),
|
|
346
|
+
errorMsg.value
|
|
347
|
+
? h('div', {
|
|
348
|
+
style: {
|
|
349
|
+
marginTop: '12px',
|
|
350
|
+
color: '#f56c6c',
|
|
351
|
+
fontSize: '13px',
|
|
352
|
+
lineHeight: '1.5',
|
|
353
|
+
},
|
|
354
|
+
innerHTML: errorMsg.value,
|
|
355
|
+
})
|
|
356
|
+
: null,
|
|
357
|
+
],
|
|
358
|
+
),
|
|
200
359
|
h(
|
|
201
360
|
'div',
|
|
202
361
|
{
|
|
203
362
|
style: {
|
|
204
363
|
display: 'flex',
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
background: '#fff',
|
|
210
|
-
borderRadius: '8px',
|
|
211
|
-
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
|
|
212
|
-
overflow: 'hidden',
|
|
364
|
+
justifyContent: 'flex-end',
|
|
365
|
+
gap: '12px',
|
|
366
|
+
padding: '12px 20px',
|
|
367
|
+
borderTop: '1px solid #ebeef5',
|
|
213
368
|
},
|
|
214
369
|
},
|
|
215
370
|
[
|
|
371
|
+
h('button', { type: 'button', onClick: onCancel, style: { ...btnBase } }, '取消'),
|
|
216
372
|
h(
|
|
217
|
-
'
|
|
218
|
-
{
|
|
219
|
-
style: {
|
|
220
|
-
padding: '16px 20px',
|
|
221
|
-
fontSize: '16px',
|
|
222
|
-
fontWeight: '600',
|
|
223
|
-
borderBottom: '1px solid #ebeef5',
|
|
224
|
-
},
|
|
225
|
-
},
|
|
226
|
-
'submitForm 调试',
|
|
227
|
-
),
|
|
228
|
-
h(
|
|
229
|
-
'div',
|
|
230
|
-
{
|
|
231
|
-
style: {
|
|
232
|
-
flex: '1',
|
|
233
|
-
padding: '20px',
|
|
234
|
-
overflow: 'auto',
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
[
|
|
238
|
-
h(Form as Component, { ...formProps, ref: formRef }),
|
|
239
|
-
errorMsg.value
|
|
240
|
-
? h('div', {
|
|
241
|
-
style: {
|
|
242
|
-
marginTop: '12px',
|
|
243
|
-
color: '#f56c6c',
|
|
244
|
-
fontSize: '13px',
|
|
245
|
-
lineHeight: '1.5',
|
|
246
|
-
},
|
|
247
|
-
innerHTML: errorMsg.value,
|
|
248
|
-
})
|
|
249
|
-
: null,
|
|
250
|
-
],
|
|
251
|
-
),
|
|
252
|
-
h(
|
|
253
|
-
'div',
|
|
373
|
+
'button',
|
|
254
374
|
{
|
|
375
|
+
type: 'button',
|
|
376
|
+
onClick: () => onConfirm(setError),
|
|
255
377
|
style: {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
borderTop: '1px solid #ebeef5',
|
|
378
|
+
...btnBase,
|
|
379
|
+
color: '#fff',
|
|
380
|
+
background: '#409eff',
|
|
381
|
+
borderColor: '#409eff',
|
|
261
382
|
},
|
|
262
383
|
},
|
|
263
|
-
|
|
264
|
-
h('button', { type: 'button', onClick: handleCancel, style: { ...btnBase } }, '取消'),
|
|
265
|
-
h(
|
|
266
|
-
'button',
|
|
267
|
-
{
|
|
268
|
-
type: 'button',
|
|
269
|
-
onClick: doSubmit,
|
|
270
|
-
style: {
|
|
271
|
-
...btnBase,
|
|
272
|
-
color: '#fff',
|
|
273
|
-
background: '#409eff',
|
|
274
|
-
borderColor: '#409eff',
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
'确定',
|
|
278
|
-
),
|
|
279
|
-
],
|
|
384
|
+
'确定',
|
|
280
385
|
),
|
|
281
386
|
],
|
|
282
387
|
),
|
|
283
388
|
],
|
|
284
|
-
)
|
|
389
|
+
),
|
|
390
|
+
],
|
|
391
|
+
);
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
// #endregion createDebugWrapper
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 以命令式方式调用 Form.vue 完成一次表单校验/提交。
|
|
399
|
+
*
|
|
400
|
+
* 类似 ElMessage 的用法:传入 props(包含 `config`/`initValues` 等),函数内部会临时挂载
|
|
401
|
+
* 一个不可见的 Form 组件实例,等待初始化完成后调用其 `submitForm` 方法,
|
|
402
|
+
* 校验通过则 resolve 表单值,校验失败则 reject 错误信息,最后自动卸载实例。
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```ts
|
|
406
|
+
* import { submitForm } from '@tmagic/form';
|
|
407
|
+
*
|
|
408
|
+
* try {
|
|
409
|
+
* const values = await submitForm({
|
|
410
|
+
* config: [...],
|
|
411
|
+
* initValues: { name: 'foo' },
|
|
412
|
+
* });
|
|
413
|
+
* console.log(values);
|
|
414
|
+
* } catch (e) {
|
|
415
|
+
* console.error(e);
|
|
416
|
+
* }
|
|
417
|
+
*
|
|
418
|
+
* // 需要同时获取变更记录时:
|
|
419
|
+
* const { values, changeRecords } = await submitForm({
|
|
420
|
+
* config: [...],
|
|
421
|
+
* initValues: { name: 'foo' },
|
|
422
|
+
* returnChangeRecords: true,
|
|
423
|
+
* });
|
|
424
|
+
*
|
|
425
|
+
* // 调试模式:可见地渲染表单,点击「确定」才提交:
|
|
426
|
+
* const values = await submitForm({
|
|
427
|
+
* config: [...],
|
|
428
|
+
* initValues: { name: 'foo' },
|
|
429
|
+
* debug: true,
|
|
430
|
+
* });
|
|
431
|
+
* ```
|
|
432
|
+
*/
|
|
433
|
+
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
|
|
434
|
+
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, ...formProps } = options;
|
|
435
|
+
|
|
436
|
+
return mountFormInstance<any>({
|
|
437
|
+
formProps,
|
|
438
|
+
appContext,
|
|
439
|
+
timeout,
|
|
440
|
+
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
|
441
|
+
hidden: !debug,
|
|
442
|
+
// 调试模式等待人工操作,不应用超时
|
|
443
|
+
skipTimeout: debug,
|
|
444
|
+
timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`,
|
|
445
|
+
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
|
|
446
|
+
/**
|
|
447
|
+
* 执行一次提交:nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve。
|
|
448
|
+
* 校验失败时交给 `onValidateError` 决定「保留弹层展示错误(debug)」还是「reject 并清理(普通)」,
|
|
449
|
+
* 从而让 debug 与普通模式共享同一份提交逻辑。
|
|
450
|
+
*/
|
|
451
|
+
const doSubmit = async (onValidateError: (err: any) => void) => {
|
|
452
|
+
try {
|
|
453
|
+
// 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
|
|
454
|
+
await nextTick();
|
|
455
|
+
// submitForm 校验通过后会清空 changeRecords,需在调用前先做快照
|
|
456
|
+
const changeRecords: ChangeRecord[] = [...(formRef.value?.changeRecords ?? [])];
|
|
457
|
+
const result = await formRef.value.submitForm(native);
|
|
458
|
+
resolve(returnChangeRecords ? { values: result, changeRecords } : result);
|
|
459
|
+
cleanup();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
onValidateError(err);
|
|
285
462
|
}
|
|
463
|
+
};
|
|
286
464
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
465
|
+
// 调试模式:可见地渲染表单,点击「确定」才提交,点击「取消」则中断
|
|
466
|
+
if (debug) {
|
|
467
|
+
return createDebugWrapper({
|
|
468
|
+
formRef,
|
|
469
|
+
formProps,
|
|
470
|
+
name: 'MFormSubmitWrapper',
|
|
471
|
+
title: 'submitForm 调试',
|
|
472
|
+
onConfirm: (setError) =>
|
|
473
|
+
doSubmit((err) => {
|
|
474
|
+
// 校验失败时保留弹层并展示错误,便于修正后重新提交
|
|
475
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
476
|
+
}),
|
|
477
|
+
onCancel: () => {
|
|
478
|
+
reject(new Error('submitForm canceled in debug mode.'));
|
|
479
|
+
cleanup();
|
|
294
480
|
},
|
|
295
|
-
|
|
296
|
-
|
|
481
|
+
});
|
|
482
|
+
}
|
|
297
483
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
484
|
+
// 普通模式:表单初始化完成后自动提交
|
|
485
|
+
return defineComponent({
|
|
486
|
+
name: 'MFormSubmitWrapper',
|
|
487
|
+
setup() {
|
|
488
|
+
const stop = watch(
|
|
489
|
+
() => formRef.value?.initialized,
|
|
490
|
+
(initialized) => {
|
|
491
|
+
if (!initialized) return;
|
|
492
|
+
stop();
|
|
493
|
+
doSubmit((err) => {
|
|
494
|
+
reject(err);
|
|
495
|
+
cleanup();
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
{ flush: 'post', immediate: true },
|
|
499
|
+
);
|
|
301
500
|
|
|
302
|
-
|
|
501
|
+
return () => h(Form as Component, { ...formProps, ref: formRef });
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
};
|
|
303
507
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
508
|
+
// #region ValidateFormOptions
|
|
509
|
+
/**
|
|
510
|
+
* validateForm 函数参数(与 Form.vue 组件 props 对齐,取校验所需子集)
|
|
511
|
+
*/
|
|
512
|
+
export interface ValidateFormOptions {
|
|
513
|
+
/** 表单配置 */
|
|
514
|
+
config: FormConfig;
|
|
515
|
+
/** 待校验的表单值 */
|
|
516
|
+
initValues?: Record<string, any>;
|
|
517
|
+
parentValues?: Record<string, any>;
|
|
518
|
+
labelWidth?: string;
|
|
519
|
+
keyProp?: string;
|
|
520
|
+
/**
|
|
521
|
+
* 校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
|
|
522
|
+
* 默认 `true`,置为 `false` 时直接使用字段 name。
|
|
523
|
+
*/
|
|
524
|
+
useFieldTextInError?: boolean;
|
|
525
|
+
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
|
526
|
+
/**
|
|
527
|
+
* 父级应用上下文,用于继承全局组件、指令、provide 等。
|
|
528
|
+
* 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。
|
|
529
|
+
*/
|
|
530
|
+
appContext?: AppContext | null;
|
|
531
|
+
/** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
|
|
532
|
+
timeout?: number;
|
|
533
|
+
/**
|
|
534
|
+
* 调试模式。默认 `false`。
|
|
535
|
+
*
|
|
536
|
+
* - `false`:以隐藏方式挂载,初始化完成后自动校验并 resolve 错误文案(原有静默行为)。
|
|
537
|
+
* - `true`:将表单以弹层形式可见地渲染在页面上,需手动点击「确定」才会触发校验,
|
|
538
|
+
* 点击「取消」则以 reject 中断;校验失败时在弹层内展示错误信息并保留弹层,便于修正后重试,
|
|
539
|
+
* 校验通过则 resolve 空字符串。调试模式下 `timeout` 不生效(等待人工操作)。
|
|
540
|
+
*/
|
|
541
|
+
debug?: boolean;
|
|
542
|
+
typeMatchValid?: boolean;
|
|
543
|
+
}
|
|
544
|
+
// #endregion ValidateFormOptions
|
|
308
545
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
546
|
+
// #region stripTabLazy
|
|
547
|
+
/**
|
|
548
|
+
* 深拷贝配置值,保留函数(display / onTabClick 等回调)引用,避免破坏配置中的回调。
|
|
549
|
+
*/
|
|
550
|
+
const cloneConfigValue = (value: any): any => {
|
|
551
|
+
if (Array.isArray(value)) {
|
|
552
|
+
return value.map(cloneConfigValue);
|
|
553
|
+
}
|
|
554
|
+
if (value && typeof value === 'object') {
|
|
555
|
+
return Object.keys(value).reduce<Record<string, any>>((acc, key) => {
|
|
556
|
+
acc[key] = cloneConfigValue(value[key]);
|
|
557
|
+
return acc;
|
|
558
|
+
}, {});
|
|
559
|
+
}
|
|
560
|
+
return value;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 深度遍历配置,去掉所有 type 为 'tab' 的容器中各标签页(items)的 lazy 配置。
|
|
565
|
+
*/
|
|
566
|
+
const removeTabItemsLazy = (node: any): void => {
|
|
567
|
+
if (Array.isArray(node)) {
|
|
568
|
+
node.forEach(removeTabItemsLazy);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (!node || typeof node !== 'object') return;
|
|
572
|
+
|
|
573
|
+
if (node.type === 'tab' && Array.isArray(node.items)) {
|
|
574
|
+
node.items.forEach((pane: any) => {
|
|
575
|
+
if (pane && typeof pane === 'object') {
|
|
576
|
+
delete pane.lazy;
|
|
320
577
|
}
|
|
321
|
-
|
|
322
|
-
|
|
578
|
+
});
|
|
579
|
+
}
|
|
323
580
|
|
|
581
|
+
Object.keys(node).forEach((key) => {
|
|
582
|
+
removeTabItemsLazy(node[key]);
|
|
583
|
+
});
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 返回一份去除了 tab 标签页 lazy 的配置副本。
|
|
588
|
+
*
|
|
589
|
+
* tab 容器开启 lazy 时,非激活标签页的内容不会渲染,导致 validateForm 静默挂载后
|
|
590
|
+
* 无法校验到这些标签页内的字段。校验场景需要一次性渲染全部字段,故在此统一去除 lazy。
|
|
591
|
+
* 处理基于深拷贝,不会污染调用方传入的原始 config。
|
|
592
|
+
*/
|
|
593
|
+
const stripTabItemsLazy = (config: FormConfig): FormConfig => {
|
|
594
|
+
const cloned = cloneConfigValue(config);
|
|
595
|
+
removeTabItemsLazy(cloned);
|
|
596
|
+
return cloned;
|
|
597
|
+
};
|
|
598
|
+
// #endregion stripTabLazy
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 以命令式方式对一份「表单配置 + 值」做一次静默校验,**不依赖也不影响任何已渲染的表单**。
|
|
602
|
+
*
|
|
603
|
+
* 与 `submitForm` 类似,内部会临时挂载一个不可见的 MForm 实例,等待其初始化完成后调用
|
|
604
|
+
* 实例的 `validate` 方法,返回汇总后的错误文案,随后自动卸载实例。
|
|
605
|
+
*
|
|
606
|
+
* 与 `submitForm` 的区别:
|
|
607
|
+
* - 「静默」:校验失败不抛异常、不触发 `error` 事件、不返回表单值;
|
|
608
|
+
* - 仅用于「探测」配置是否合法,适合源码保存后校验、批量校验组件配置等场景。
|
|
609
|
+
*
|
|
610
|
+
* 由于每次都新建一个独立的 MForm 实例,调用方无需持有任何表单 ref,也不会污染
|
|
611
|
+
* 页面上正在展示的表单状态。
|
|
612
|
+
*
|
|
613
|
+
* @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
|
|
614
|
+
* 仅在初始化超时或挂载失败等异常情况下才会 reject。
|
|
615
|
+
*
|
|
616
|
+
* @example
|
|
617
|
+
* ```ts
|
|
618
|
+
* import { validateForm } from '@tmagic/form';
|
|
619
|
+
*
|
|
620
|
+
* const error = await validateForm({
|
|
621
|
+
* config: [...],
|
|
622
|
+
* initValues: { name: 'foo' },
|
|
623
|
+
* appContext: getCurrentInstance()?.appContext,
|
|
624
|
+
* });
|
|
625
|
+
* if (error) {
|
|
626
|
+
* // 配置不合法,error 为错误文案
|
|
627
|
+
* }
|
|
628
|
+
*
|
|
629
|
+
* // 调试模式:可见地渲染表单,点击「确定」才校验,校验失败保留弹层可修正重试:
|
|
630
|
+
* const error = await validateForm({
|
|
631
|
+
* config: [...],
|
|
632
|
+
* initValues: { name: 'foo' },
|
|
633
|
+
* debug: true,
|
|
634
|
+
* });
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
|
|
638
|
+
const { appContext, timeout = 10000, debug = false, config, ...rest } = options;
|
|
639
|
+
|
|
640
|
+
// 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验
|
|
641
|
+
const formProps = { ...rest, config: stripTabItemsLazy(config) };
|
|
642
|
+
|
|
643
|
+
return mountFormInstance<string>({
|
|
644
|
+
formProps,
|
|
645
|
+
appContext,
|
|
646
|
+
timeout,
|
|
647
|
+
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
|
648
|
+
hidden: !debug,
|
|
324
649
|
// 调试模式等待人工操作,不应用超时
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
650
|
+
skipTimeout: debug,
|
|
651
|
+
timeoutMessage: `validateForm timeout after ${timeout}ms: form is not initialized.`,
|
|
652
|
+
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
|
|
653
|
+
/**
|
|
654
|
+
* 执行一次校验:nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve ''。
|
|
655
|
+
* 校验失败(返回非空错误文案)时交给 `onInvalid` 决定「静默 resolve 错误文案(普通)」
|
|
656
|
+
* 还是「在弹层展示错误并保留供重试(debug)」,从而让两种模式共享同一份校验逻辑。
|
|
657
|
+
*/
|
|
658
|
+
const doValidate = async (onInvalid: (error: string) => void) => {
|
|
659
|
+
try {
|
|
660
|
+
// 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
|
|
661
|
+
await nextTick();
|
|
662
|
+
// 复用 Form.vue 实例的静默校验方法:校验通过返回 '',失败返回错误文案,均不抛异常
|
|
663
|
+
const error = await formRef.value.validate();
|
|
664
|
+
if (error) {
|
|
665
|
+
onInvalid(error);
|
|
666
|
+
} else {
|
|
667
|
+
resolve('');
|
|
668
|
+
cleanup();
|
|
669
|
+
}
|
|
670
|
+
} catch (err) {
|
|
671
|
+
reject(err);
|
|
329
672
|
cleanup();
|
|
330
673
|
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
674
|
+
};
|
|
333
675
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
676
|
+
// 调试模式:可见地渲染表单,点击「确定」才校验,点击「取消」则中断
|
|
677
|
+
if (debug) {
|
|
678
|
+
return createDebugWrapper({
|
|
679
|
+
formRef,
|
|
680
|
+
formProps,
|
|
681
|
+
name: 'MFormValidateWrapper',
|
|
682
|
+
title: 'validateForm 调试',
|
|
683
|
+
onConfirm: (setError) =>
|
|
684
|
+
doValidate((error) => {
|
|
685
|
+
// 校验失败时保留弹层并展示错误,便于修正后重新校验
|
|
686
|
+
setError(error);
|
|
687
|
+
}),
|
|
688
|
+
onCancel: () => {
|
|
689
|
+
reject(new Error('validateForm canceled in debug mode.'));
|
|
690
|
+
cleanup();
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// 普通模式:表单初始化完成后自动校验(静默 resolve 错误文案)
|
|
696
|
+
return defineComponent({
|
|
697
|
+
name: 'MFormValidateWrapper',
|
|
698
|
+
setup() {
|
|
699
|
+
const stop = watch(
|
|
700
|
+
() => formRef.value?.initialized,
|
|
701
|
+
(initialized) => {
|
|
702
|
+
if (!initialized) return;
|
|
703
|
+
stop();
|
|
704
|
+
doValidate((error) => {
|
|
705
|
+
// 静默:校验失败也以错误文案 resolve(不抛异常)
|
|
706
|
+
resolve(error);
|
|
707
|
+
cleanup();
|
|
708
|
+
});
|
|
709
|
+
},
|
|
710
|
+
{ flush: 'post', immediate: true },
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
return () => h(Form as Component, { ...formProps, ref: formRef });
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
},
|
|
340
717
|
});
|
|
341
718
|
};
|