@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.
@@ -2,6 +2,178 @@ import Form_default from "./Form.js";
2
2
  import { createApp, defineComponent, h, nextTick, ref, watch } from "vue";
3
3
  //#region packages/form/src/submitForm.ts
4
4
  /**
5
+ * submitForm / validateForm 的公共脚手架:
6
+ *
7
+ * 创建临时容器 → 挂载一个 wrapper 组件(内含 MForm)→ 提供统一的 cleanup / timeout / appContext 继承 →
8
+ * 由 `createWrapper` 决定「何时调用 MForm 的哪个方法、如何 resolve/reject」。
9
+ *
10
+ * 调用方只需关注差异逻辑(调用 `submitForm` 还是 `validate`、结果如何包装、是否需要调试弹层),
11
+ * 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
12
+ */
13
+ var mountFormInstance = (options) => {
14
+ const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, createWrapper } = options;
15
+ return new Promise((resolve, reject) => {
16
+ const container = document.createElement("div");
17
+ if (hidden) container.style.display = "none";
18
+ document.body.appendChild(container);
19
+ let cleaned = false;
20
+ let timer = null;
21
+ const instance = { app: null };
22
+ const cleanup = () => {
23
+ if (cleaned) return;
24
+ cleaned = true;
25
+ if (timer) {
26
+ clearTimeout(timer);
27
+ timer = null;
28
+ }
29
+ try {
30
+ instance.app?.unmount();
31
+ } catch {}
32
+ container.parentNode?.removeChild(container);
33
+ };
34
+ const formRef = ref(null);
35
+ const { extendState, ...restFormProps } = formProps;
36
+ const userWrapper = createWrapper({
37
+ formRef,
38
+ formProps: restFormProps,
39
+ cleanup,
40
+ resolve,
41
+ reject
42
+ });
43
+ const app = createApp(typeof extendState === "function" ? defineComponent({
44
+ name: "MFormExtendStateInjector",
45
+ setup() {
46
+ watch(() => formRef.value, (form) => {
47
+ if (!form) return;
48
+ let result;
49
+ try {
50
+ result = extendState(form.formState);
51
+ } catch (e) {
52
+ console.error("[MForm] extendState failed:", e);
53
+ return;
54
+ }
55
+ const apply = (state) => {
56
+ if (!state) return;
57
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) if ("value" in descriptor) form.formState[key] = state[key];
58
+ else {
59
+ descriptor.configurable = true;
60
+ Object.defineProperty(form.formState, key, descriptor);
61
+ }
62
+ };
63
+ if (result && typeof result.then === "function") result.then(apply, (e) => console.error("[MForm] extendState failed:", e));
64
+ else apply(result);
65
+ }, {
66
+ flush: "sync",
67
+ immediate: true
68
+ });
69
+ return () => h(userWrapper);
70
+ }
71
+ }) : userWrapper);
72
+ instance.app = app;
73
+ if (appContext) Object.assign(app._context, appContext);
74
+ if (timeout > 0 && !skipTimeout) timer = setTimeout(() => {
75
+ if (!cleaned) {
76
+ reject(new Error(timeoutMessage));
77
+ cleanup();
78
+ }
79
+ }, timeout);
80
+ try {
81
+ app.mount(container);
82
+ } catch (err) {
83
+ reject(err);
84
+ cleanup();
85
+ }
86
+ });
87
+ };
88
+ /**
89
+ * 构造调试模式下的可见弹层 wrapper:以 fixed 遮罩居中渲染 MForm,提供「确定 / 取消」按钮与错误展示区。
90
+ *
91
+ * submitForm 与 validateForm 的调试弹层 UI 完全一致,仅「确定」时调用的实例方法、错误处理与取消文案不同,
92
+ * 这些差异通过 `onConfirm` / `onCancel` 注入,弹层结构在此统一收口。
93
+ */
94
+ var createDebugWrapper = (options) => {
95
+ const { formRef, formProps, title, name, onConfirm, onCancel } = options;
96
+ const btnBase = {
97
+ padding: "8px 20px",
98
+ fontSize: "14px",
99
+ lineHeight: "1",
100
+ border: "1px solid #dcdfe6",
101
+ borderRadius: "4px",
102
+ cursor: "pointer"
103
+ };
104
+ return defineComponent({
105
+ name,
106
+ setup() {
107
+ const errorMsg = ref("");
108
+ const setError = (msg) => {
109
+ errorMsg.value = msg;
110
+ };
111
+ return () => h("div", { style: {
112
+ position: "fixed",
113
+ inset: "0",
114
+ zIndex: "10000",
115
+ display: "flex",
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ background: "rgba(0, 0, 0, 0.5)"
119
+ } }, [h("div", { style: {
120
+ display: "flex",
121
+ flexDirection: "column",
122
+ width: "600px",
123
+ maxWidth: "90vw",
124
+ maxHeight: "85vh",
125
+ background: "#fff",
126
+ borderRadius: "8px",
127
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.2)",
128
+ overflow: "hidden"
129
+ } }, [
130
+ h("div", { style: {
131
+ padding: "16px 20px",
132
+ fontSize: "16px",
133
+ fontWeight: "600",
134
+ borderBottom: "1px solid #ebeef5"
135
+ } }, title),
136
+ h("div", { style: {
137
+ flex: "1",
138
+ padding: "20px",
139
+ overflow: "auto"
140
+ } }, [h(Form_default, {
141
+ ...formProps,
142
+ ref: formRef
143
+ }), errorMsg.value ? h("div", {
144
+ style: {
145
+ marginTop: "12px",
146
+ color: "#f56c6c",
147
+ fontSize: "13px",
148
+ lineHeight: "1.5"
149
+ },
150
+ innerHTML: errorMsg.value
151
+ }) : null]),
152
+ h("div", { style: {
153
+ display: "flex",
154
+ justifyContent: "flex-end",
155
+ gap: "12px",
156
+ padding: "12px 20px",
157
+ borderTop: "1px solid #ebeef5"
158
+ } }, [h("button", {
159
+ type: "button",
160
+ onClick: onCancel,
161
+ style: { ...btnBase }
162
+ }, "取消"), h("button", {
163
+ type: "button",
164
+ onClick: () => onConfirm(setError),
165
+ style: {
166
+ ...btnBase,
167
+ color: "#fff",
168
+ background: "#409eff",
169
+ borderColor: "#409eff"
170
+ }
171
+ }, "确定")])
172
+ ])]);
173
+ }
174
+ });
175
+ };
176
+ /**
5
177
  * 以命令式方式调用 Form.vue 完成一次表单校验/提交。
6
178
  *
7
179
  * 类似 ElMessage 的用法:传入 props(包含 `config`/`initValues` 等),函数内部会临时挂载
@@ -39,152 +211,212 @@ import { createApp, defineComponent, h, nextTick, ref, watch } from "vue";
39
211
  */
40
212
  var submitForm = (options) => {
41
213
  const { native, appContext, timeout = 1e4, returnChangeRecords, debug = false, ...formProps } = options;
42
- return new Promise((resolve, reject) => {
43
- const container = document.createElement("div");
44
- if (!debug) container.style.display = "none";
45
- document.body.appendChild(container);
46
- let cleaned = false;
47
- let timer = null;
48
- const app = createApp(defineComponent({
49
- name: "MFormSubmitWrapper",
50
- setup() {
51
- const formRef = ref(null);
52
- const errorMsg = ref("");
53
- const doSubmit = async () => {
54
- try {
55
- await nextTick();
56
- const changeRecords = [...formRef.value?.changeRecords ?? []];
57
- const result = await formRef.value.submitForm(native);
58
- resolve(returnChangeRecords ? {
59
- values: result,
60
- changeRecords
61
- } : result);
62
- cleanup();
63
- } catch (err) {
64
- if (debug) {
65
- errorMsg.value = err instanceof Error ? err.message : String(err);
66
- return;
67
- }
68
- reject(err);
214
+ return mountFormInstance({
215
+ formProps,
216
+ appContext,
217
+ timeout,
218
+ hidden: !debug,
219
+ skipTimeout: debug,
220
+ timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`,
221
+ createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
222
+ /**
223
+ * 执行一次提交:nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve。
224
+ * 校验失败时交给 `onValidateError` 决定「保留弹层展示错误(debug)」还是「reject 并清理(普通)」,
225
+ * 从而让 debug 与普通模式共享同一份提交逻辑。
226
+ */
227
+ const doSubmit = async (onValidateError) => {
228
+ try {
229
+ await nextTick();
230
+ const changeRecords = [...formRef.value?.changeRecords ?? []];
231
+ const result = await formRef.value.submitForm(native);
232
+ resolve(returnChangeRecords ? {
233
+ values: result,
234
+ changeRecords
235
+ } : result);
236
+ cleanup();
237
+ } catch (err) {
238
+ onValidateError(err);
239
+ }
240
+ };
241
+ if (debug) return createDebugWrapper({
242
+ formRef,
243
+ formProps,
244
+ name: "MFormSubmitWrapper",
245
+ title: "submitForm 调试",
246
+ onConfirm: (setError) => doSubmit((err) => {
247
+ setError(err instanceof Error ? err.message : String(err));
248
+ }),
249
+ onCancel: () => {
250
+ reject(/* @__PURE__ */ new Error("submitForm canceled in debug mode."));
251
+ cleanup();
252
+ }
253
+ });
254
+ return defineComponent({
255
+ name: "MFormSubmitWrapper",
256
+ setup() {
257
+ const stop = watch(() => formRef.value?.initialized, (initialized) => {
258
+ if (!initialized) return;
259
+ stop();
260
+ doSubmit((err) => {
261
+ reject(err);
262
+ cleanup();
263
+ });
264
+ }, {
265
+ flush: "post",
266
+ immediate: true
267
+ });
268
+ return () => h(Form_default, {
269
+ ...formProps,
270
+ ref: formRef
271
+ });
272
+ }
273
+ });
274
+ }
275
+ });
276
+ };
277
+ /**
278
+ * 深拷贝配置值,保留函数(display / onTabClick 等回调)引用,避免破坏配置中的回调。
279
+ */
280
+ var cloneConfigValue = (value) => {
281
+ if (Array.isArray(value)) return value.map(cloneConfigValue);
282
+ if (value && typeof value === "object") return Object.keys(value).reduce((acc, key) => {
283
+ acc[key] = cloneConfigValue(value[key]);
284
+ return acc;
285
+ }, {});
286
+ return value;
287
+ };
288
+ /**
289
+ * 深度遍历配置,去掉所有 type 为 'tab' 的容器中各标签页(items)的 lazy 配置。
290
+ */
291
+ var removeTabItemsLazy = (node) => {
292
+ if (Array.isArray(node)) {
293
+ node.forEach(removeTabItemsLazy);
294
+ return;
295
+ }
296
+ if (!node || typeof node !== "object") return;
297
+ if (node.type === "tab" && Array.isArray(node.items)) node.items.forEach((pane) => {
298
+ if (pane && typeof pane === "object") delete pane.lazy;
299
+ });
300
+ Object.keys(node).forEach((key) => {
301
+ removeTabItemsLazy(node[key]);
302
+ });
303
+ };
304
+ /**
305
+ * 返回一份去除了 tab 标签页 lazy 的配置副本。
306
+ *
307
+ * tab 容器开启 lazy 时,非激活标签页的内容不会渲染,导致 validateForm 静默挂载后
308
+ * 无法校验到这些标签页内的字段。校验场景需要一次性渲染全部字段,故在此统一去除 lazy。
309
+ * 处理基于深拷贝,不会污染调用方传入的原始 config。
310
+ */
311
+ var stripTabItemsLazy = (config) => {
312
+ const cloned = cloneConfigValue(config);
313
+ removeTabItemsLazy(cloned);
314
+ return cloned;
315
+ };
316
+ /**
317
+ * 以命令式方式对一份「表单配置 + 值」做一次静默校验,**不依赖也不影响任何已渲染的表单**。
318
+ *
319
+ * 与 `submitForm` 类似,内部会临时挂载一个不可见的 MForm 实例,等待其初始化完成后调用
320
+ * 实例的 `validate` 方法,返回汇总后的错误文案,随后自动卸载实例。
321
+ *
322
+ * 与 `submitForm` 的区别:
323
+ * - 「静默」:校验失败不抛异常、不触发 `error` 事件、不返回表单值;
324
+ * - 仅用于「探测」配置是否合法,适合源码保存后校验、批量校验组件配置等场景。
325
+ *
326
+ * 由于每次都新建一个独立的 MForm 实例,调用方无需持有任何表单 ref,也不会污染
327
+ * 页面上正在展示的表单状态。
328
+ *
329
+ * @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
330
+ * 仅在初始化超时或挂载失败等异常情况下才会 reject。
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * import { validateForm } from '@tmagic/form';
335
+ *
336
+ * const error = await validateForm({
337
+ * config: [...],
338
+ * initValues: { name: 'foo' },
339
+ * appContext: getCurrentInstance()?.appContext,
340
+ * });
341
+ * if (error) {
342
+ * // 配置不合法,error 为错误文案
343
+ * }
344
+ *
345
+ * // 调试模式:可见地渲染表单,点击「确定」才校验,校验失败保留弹层可修正重试:
346
+ * const error = await validateForm({
347
+ * config: [...],
348
+ * initValues: { name: 'foo' },
349
+ * debug: true,
350
+ * });
351
+ * ```
352
+ */
353
+ var validateForm = (options) => {
354
+ const { appContext, timeout = 1e4, debug = false, config, ...rest } = options;
355
+ return mountFormInstance({
356
+ formProps: {
357
+ ...rest,
358
+ config: stripTabItemsLazy(config)
359
+ },
360
+ appContext,
361
+ timeout,
362
+ hidden: !debug,
363
+ skipTimeout: debug,
364
+ timeoutMessage: `validateForm timeout after ${timeout}ms: form is not initialized.`,
365
+ createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
366
+ /**
367
+ * 执行一次校验:nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve ''。
368
+ * 校验失败(返回非空错误文案)时交给 `onInvalid` 决定「静默 resolve 错误文案(普通)」
369
+ * 还是「在弹层展示错误并保留供重试(debug)」,从而让两种模式共享同一份校验逻辑。
370
+ */
371
+ const doValidate = async (onInvalid) => {
372
+ try {
373
+ await nextTick();
374
+ const error = await formRef.value.validate();
375
+ if (error) onInvalid(error);
376
+ else {
377
+ resolve("");
69
378
  cleanup();
70
379
  }
71
- };
72
- if (debug) {
73
- const handleCancel = () => {
74
- reject(/* @__PURE__ */ new Error("submitForm canceled in debug mode."));
75
- cleanup();
76
- };
77
- const btnBase = {
78
- padding: "8px 20px",
79
- fontSize: "14px",
80
- lineHeight: "1",
81
- border: "1px solid #dcdfe6",
82
- borderRadius: "4px",
83
- cursor: "pointer"
84
- };
85
- return () => h("div", { style: {
86
- position: "fixed",
87
- inset: "0",
88
- zIndex: "10000",
89
- display: "flex",
90
- alignItems: "center",
91
- justifyContent: "center",
92
- background: "rgba(0, 0, 0, 0.5)"
93
- } }, [h("div", { style: {
94
- display: "flex",
95
- flexDirection: "column",
96
- width: "600px",
97
- maxWidth: "90vw",
98
- maxHeight: "85vh",
99
- background: "#fff",
100
- borderRadius: "8px",
101
- boxShadow: "0 8px 24px rgba(0, 0, 0, 0.2)",
102
- overflow: "hidden"
103
- } }, [
104
- h("div", { style: {
105
- padding: "16px 20px",
106
- fontSize: "16px",
107
- fontWeight: "600",
108
- borderBottom: "1px solid #ebeef5"
109
- } }, "submitForm 调试"),
110
- h("div", { style: {
111
- flex: "1",
112
- padding: "20px",
113
- overflow: "auto"
114
- } }, [h(Form_default, {
115
- ...formProps,
116
- ref: formRef
117
- }), errorMsg.value ? h("div", {
118
- style: {
119
- marginTop: "12px",
120
- color: "#f56c6c",
121
- fontSize: "13px",
122
- lineHeight: "1.5"
123
- },
124
- innerHTML: errorMsg.value
125
- }) : null]),
126
- h("div", { style: {
127
- display: "flex",
128
- justifyContent: "flex-end",
129
- gap: "12px",
130
- padding: "12px 20px",
131
- borderTop: "1px solid #ebeef5"
132
- } }, [h("button", {
133
- type: "button",
134
- onClick: handleCancel,
135
- style: { ...btnBase }
136
- }, "取消"), h("button", {
137
- type: "button",
138
- onClick: doSubmit,
139
- style: {
140
- ...btnBase,
141
- color: "#fff",
142
- background: "#409eff",
143
- borderColor: "#409eff"
144
- }
145
- }, "确定")])
146
- ])]);
380
+ } catch (err) {
381
+ reject(err);
382
+ cleanup();
147
383
  }
148
- const stop = watch(() => formRef.value?.initialized, (initialized) => {
149
- if (!initialized) return;
150
- stop();
151
- doSubmit();
152
- }, {
153
- flush: "post",
154
- immediate: true
155
- });
156
- return () => h(Form_default, {
157
- ...formProps,
158
- ref: formRef
159
- });
160
- }
161
- }));
162
- if (appContext) Object.assign(app._context, appContext);
163
- const cleanup = () => {
164
- if (cleaned) return;
165
- cleaned = true;
166
- if (timer) {
167
- clearTimeout(timer);
168
- timer = null;
169
- }
170
- try {
171
- app.unmount();
172
- } catch {}
173
- container.parentNode?.removeChild(container);
174
- };
175
- if (timeout > 0 && !debug) timer = setTimeout(() => {
176
- if (!cleaned) {
177
- reject(/* @__PURE__ */ new Error(`submitForm timeout after ${timeout}ms: form is not initialized.`));
178
- cleanup();
179
- }
180
- }, timeout);
181
- try {
182
- app.mount(container);
183
- } catch (err) {
184
- reject(err);
185
- cleanup();
384
+ };
385
+ if (debug) return createDebugWrapper({
386
+ formRef,
387
+ formProps,
388
+ name: "MFormValidateWrapper",
389
+ title: "validateForm 调试",
390
+ onConfirm: (setError) => doValidate((error) => {
391
+ setError(error);
392
+ }),
393
+ onCancel: () => {
394
+ reject(/* @__PURE__ */ new Error("validateForm canceled in debug mode."));
395
+ cleanup();
396
+ }
397
+ });
398
+ return defineComponent({
399
+ name: "MFormValidateWrapper",
400
+ setup() {
401
+ const stop = watch(() => formRef.value?.initialized, (initialized) => {
402
+ if (!initialized) return;
403
+ stop();
404
+ doValidate((error) => {
405
+ resolve(error);
406
+ cleanup();
407
+ });
408
+ }, {
409
+ flush: "post",
410
+ immediate: true
411
+ });
412
+ return () => h(Form_default, {
413
+ ...formProps,
414
+ ref: formRef
415
+ });
416
+ }
417
+ });
186
418
  }
187
419
  });
188
420
  };
189
421
  //#endregion
190
- export { submitForm };
422
+ export { submitForm, validateForm };
@@ -1,9 +1,44 @@
1
+ import { createTypeMatchValidator } from "./typeMatch.js";
1
2
  import { readonly } from "vue";
2
3
  import { cloneDeep } from "lodash-es";
4
+ import { getDesignConfig } from "@tmagic/design";
3
5
  import { getValueByKeyPath } from "@tmagic/utils";
4
6
  import dayjs from "dayjs";
5
7
  import utc from "dayjs/plugin/utc";
6
8
  //#region packages/form/src/utils/form.ts
9
+ var isTDesignAdapter = () => getDesignConfig("adapterType") === "tdesign-vue-next";
10
+ /**
11
+ * 将 async-validator(Element Plus)风格的 validator 适配到当前 UI 库。
12
+ * TDesign 调用签名为 `(val) => boolean | CustomValidateObj | Promise`,无 callback。
13
+ */
14
+ var adaptFormValidator = (validator) => {
15
+ return (arg1, arg2, arg3, arg4, arg5) => {
16
+ if (!isTDesignAdapter()) return validator(arg1, arg2, arg3, arg4, arg5);
17
+ const value = arg1;
18
+ return new Promise((resolve) => {
19
+ let settled = false;
20
+ const callback = (error) => {
21
+ if (settled) return;
22
+ settled = true;
23
+ if (error) resolve({
24
+ result: false,
25
+ message: typeof error === "string" ? error : error.message
26
+ });
27
+ else resolve(true);
28
+ };
29
+ try {
30
+ const result = validator(void 0, value, callback);
31
+ if (result !== null && typeof result.then === "function") Promise.resolve(result).then(() => {
32
+ if (!settled) callback();
33
+ }, (err) => {
34
+ callback(err instanceof Error ? err : new Error(String(err)));
35
+ });
36
+ } catch (e) {
37
+ callback(e instanceof Error ? e : new Error(String(e)));
38
+ }
39
+ });
40
+ };
41
+ };
7
42
  var TABLE_SELECT_TYPES = /* @__PURE__ */ new Set(["table-select", "tableSelect"]);
8
43
  var isTableSelect = (type) => typeof type === "string" && TABLE_SELECT_TYPES.has(type);
9
44
  var asyncLoadConfig = (value, initValue, { asyncLoad, name, type }) => {
@@ -99,13 +134,18 @@ var display = function(mForm, config, props) {
99
134
  if (config === false) return false;
100
135
  return true;
101
136
  };
102
- var getRules = function(mForm, rules = [], props) {
103
- rules = cloneDeep(rules);
137
+ var getRules = function(mForm, r = [], props, typeMatchValid) {
138
+ let rules = cloneDeep(r);
104
139
  if (typeof rules === "object" && !Array.isArray(rules)) rules = [rules];
140
+ if (typeMatchValid?.value && !rules.some((r) => typeof r.typeMatch !== "undefined")) rules.push({ typeMatch: true });
105
141
  return rules.map((item) => {
142
+ if (item.typeMatch) {
143
+ item.validator = adaptFormValidator(createTypeMatchValidator(mForm, props, item));
144
+ return item;
145
+ }
106
146
  if (typeof item.validator === "function") {
107
147
  const fnc = item.validator;
108
- item.validator = (rule, value, callback, source, options) => fnc({
148
+ item.validator = adaptFormValidator((rule, value, callback, source, options) => fnc({
109
149
  rule,
110
150
  value: props.config.names ? props.model : value,
111
151
  callback,
@@ -118,7 +158,7 @@ var getRules = function(mForm, rules = [], props) {
118
158
  formValue: mForm?.values || props.model,
119
159
  prop: props.prop,
120
160
  config: props.config
121
- }, mForm);
161
+ }, mForm));
122
162
  }
123
163
  return item;
124
164
  });
@@ -172,4 +212,4 @@ var createObjectProp = (prop, key, name) => {
172
212
  return `${[...itemPath, key].join(".")}`;
173
213
  };
174
214
  //#endregion
175
- export { createObjectProp, createValues, datetimeFormatter, display, filterFunction, getDataByPage, getRules, initValue, sortArray, sortChange };
215
+ export { adaptFormValidator, createObjectProp, createValues, datetimeFormatter, display, filterFunction, getDataByPage, getRules, initValue, sortArray, sortChange };