@tmagic/form 1.8.0-beta.16 → 1.8.0-beta.18
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/style.css +5 -1
- package/dist/es/submitForm.js +73 -52
- package/dist/es/utils/form.js +4 -3
- package/dist/es/utils/typeMatch.js +98 -21
- package/dist/style.css +5 -1
- package/dist/themes/magic-admin.css +11 -1
- package/dist/tmagic-form.umd.cjs +175 -76
- package/package.json +4 -4
- package/src/submitForm.ts +131 -78
- package/src/theme/group-list.scss +11 -2
- package/src/theme/themes/magic-admin/index.scss +11 -0
- package/src/utils/form.ts +7 -4
- package/src/utils/typeMatch.ts +135 -31
- package/types/index.d.ts +22 -3
package/dist/tmagic-form.umd.cjs
CHANGED
|
@@ -3088,6 +3088,11 @@
|
|
|
3088
3088
|
return;
|
|
3089
3089
|
}
|
|
3090
3090
|
};
|
|
3091
|
+
/**
|
|
3092
|
+
* 校验取值与字段 type 是否匹配;通过返回 undefined,否则返回错误文案。
|
|
3093
|
+
*
|
|
3094
|
+
* 命中的自定义规则是异步校验器时返回 Promise,内置规则始终同步返回。
|
|
3095
|
+
*/
|
|
3091
3096
|
var validateTypeMatch = (value, mForm, props, message) => {
|
|
3092
3097
|
if (isEmptyValue(value) || isEmptyArray(value)) return;
|
|
3093
3098
|
if (!props.config?.name) return;
|
|
@@ -3103,34 +3108,106 @@
|
|
|
3103
3108
|
});
|
|
3104
3109
|
return validateBuiltinTypeMatch(value, fieldType, mForm, props, message);
|
|
3105
3110
|
};
|
|
3111
|
+
var toError = (error) => error instanceof Error ? error : /* @__PURE__ */ new Error(`${error}`);
|
|
3106
3112
|
var createTypeMatchValidator = (mForm, props, rule) => {
|
|
3107
3113
|
const originalValidator = typeof rule.validator === "function" ? rule.validator : void 0;
|
|
3114
|
+
/**
|
|
3115
|
+
* 每次校验取一个自增序号,只有最新一轮的结论会被采用。
|
|
3116
|
+
*
|
|
3117
|
+
* 旧值的在途校验不能用自己的结论结算:它可能晚于新校验结束,用过期结论覆盖新结论
|
|
3118
|
+
* (表单取最后结束的那次结果);也不能在新校验开始时无条件按通过结算,否则
|
|
3119
|
+
* `form.validate()` 会对一个还没校验过的值返回成功。因此所有尚未结算的调用都登记在
|
|
3120
|
+
* pendingSettlers 中,等最新一轮出结论后用同一个结论一起结算。
|
|
3121
|
+
*
|
|
3122
|
+
* 不比较取值本身:配了 names 或取值为对象/数组时拿到的是同一个引用,就地修改后比较不出变化。
|
|
3123
|
+
*/
|
|
3124
|
+
let generation = 0;
|
|
3125
|
+
let pendingSettlers = [];
|
|
3108
3126
|
return (asyncValidatorRule, value, callback, source, options) => {
|
|
3109
3127
|
const actualValue = props.config?.names ? props.model : value;
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3128
|
+
generation += 1;
|
|
3129
|
+
const currentGeneration = generation;
|
|
3130
|
+
/** 已有更新的一轮校验开始,本轮结论作废 */
|
|
3131
|
+
const isStale = () => currentGeneration !== generation;
|
|
3132
|
+
pendingSettlers.push((args) => callback(...args));
|
|
3133
|
+
/**
|
|
3134
|
+
* 结算本轮与被本轮取代的所有在途校验。
|
|
3135
|
+
*
|
|
3136
|
+
* async-validator 的 callback 不幂等:重复调用会让它的内部计数提前满足、错误信息重复;
|
|
3137
|
+
* 原始 validator 写成 async 时很容易既调 callback 又返回 Promise,故所有回调都收敛到这里,
|
|
3138
|
+
* 由「取出即出队」保证每个调用的 callback 只会被调用一次。
|
|
3139
|
+
*/
|
|
3140
|
+
const conclude = (...args) => {
|
|
3141
|
+
if (isStale()) return;
|
|
3142
|
+
const settlers = pendingSettlers;
|
|
3143
|
+
pendingSettlers = [];
|
|
3144
|
+
for (const settle of settlers) settle(args);
|
|
3145
|
+
};
|
|
3146
|
+
/**
|
|
3147
|
+
* 执行原始 validator 并把结果统一转成 callback。
|
|
3148
|
+
*
|
|
3149
|
+
* async-validator 只解析同步返回给它的返回值(true / false / Error / 错误数组 / Promise,
|
|
3150
|
+
* 抛错转成错误信息),异步路径已脱离它的调用栈,这些约定会失效导致校验永不结束,故复刻一份。
|
|
3151
|
+
*/
|
|
3152
|
+
const settleWithOriginalValidator = () => {
|
|
3153
|
+
if (isStale()) return;
|
|
3154
|
+
if (!originalValidator) {
|
|
3155
|
+
conclude();
|
|
3114
3156
|
return;
|
|
3115
3157
|
}
|
|
3116
|
-
|
|
3117
|
-
|
|
3158
|
+
let result;
|
|
3159
|
+
try {
|
|
3160
|
+
result = originalValidator({
|
|
3161
|
+
rule: asyncValidatorRule,
|
|
3162
|
+
value: actualValue,
|
|
3163
|
+
callback: conclude,
|
|
3164
|
+
source,
|
|
3165
|
+
options
|
|
3166
|
+
}, {
|
|
3167
|
+
values: mForm?.initValues || {},
|
|
3168
|
+
model: props.model,
|
|
3169
|
+
parent: mForm?.parentValues || {},
|
|
3170
|
+
formValue: mForm?.values || props.model,
|
|
3171
|
+
prop: props.prop,
|
|
3172
|
+
config: props.config
|
|
3173
|
+
}, mForm);
|
|
3174
|
+
} catch (err) {
|
|
3175
|
+
conclude(toError(err));
|
|
3176
|
+
return;
|
|
3177
|
+
}
|
|
3178
|
+
if (isPromise(result)) result.then(() => conclude(), (err) => conclude(toError(err)));
|
|
3179
|
+
else if (result === true) conclude();
|
|
3180
|
+
else if (result === false) {
|
|
3181
|
+
const field = asyncValidatorRule?.fullField || asyncValidatorRule?.field || props.prop;
|
|
3182
|
+
conclude(new Error(rule.message || `${field} fails`));
|
|
3183
|
+
} else if (result instanceof Error || Array.isArray(result)) conclude(result);
|
|
3184
|
+
};
|
|
3185
|
+
const skipFailedTypeMatch = (err) => {
|
|
3186
|
+
console.error(err);
|
|
3187
|
+
settleWithOriginalValidator();
|
|
3188
|
+
};
|
|
3189
|
+
let error;
|
|
3190
|
+
try {
|
|
3191
|
+
error = validateTypeMatch(actualValue, mForm, props, rule.message);
|
|
3192
|
+
} catch (err) {
|
|
3193
|
+
skipFailedTypeMatch(err);
|
|
3194
|
+
return;
|
|
3118
3195
|
}
|
|
3119
|
-
if (
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
}
|
|
3133
|
-
|
|
3196
|
+
if (isPromise(error)) {
|
|
3197
|
+
error.then((asyncMessage) => {
|
|
3198
|
+
if (asyncMessage) {
|
|
3199
|
+
conclude(new Error(asyncMessage));
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
settleWithOriginalValidator();
|
|
3203
|
+
}, skipFailedTypeMatch);
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
if (error) {
|
|
3207
|
+
conclude(new Error(error));
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
settleWithOriginalValidator();
|
|
3134
3211
|
};
|
|
3135
3212
|
};
|
|
3136
3213
|
//#endregion
|
|
@@ -3149,15 +3226,16 @@
|
|
|
3149
3226
|
const callback = (error) => {
|
|
3150
3227
|
if (settled) return;
|
|
3151
3228
|
settled = true;
|
|
3152
|
-
|
|
3229
|
+
const first = Array.isArray(error) ? error[0] : error;
|
|
3230
|
+
if (first) resolve({
|
|
3153
3231
|
result: false,
|
|
3154
|
-
message: typeof
|
|
3232
|
+
message: typeof first === "string" ? first : first.message
|
|
3155
3233
|
});
|
|
3156
3234
|
else resolve(true);
|
|
3157
3235
|
};
|
|
3158
3236
|
try {
|
|
3159
3237
|
const result = validator(void 0, value, callback);
|
|
3160
|
-
if (result
|
|
3238
|
+
if (result && typeof result.then === "function") Promise.resolve(result).then(() => {
|
|
3161
3239
|
if (!settled) callback();
|
|
3162
3240
|
}, (err) => {
|
|
3163
3241
|
callback(err instanceof Error ? err : new Error(String(err)));
|
|
@@ -4420,6 +4498,8 @@
|
|
|
4420
4498
|
});
|
|
4421
4499
|
//#endregion
|
|
4422
4500
|
//#region packages/form/src/submitForm.ts
|
|
4501
|
+
/** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */
|
|
4502
|
+
var DEFAULT_MOUNT_TIMEOUT = 1e4;
|
|
4423
4503
|
/**
|
|
4424
4504
|
* submitForm / validateForm 的公共脚手架:
|
|
4425
4505
|
*
|
|
@@ -4430,14 +4510,19 @@
|
|
|
4430
4510
|
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
|
|
4431
4511
|
*/
|
|
4432
4512
|
var mountFormInstance = (options) => {
|
|
4433
|
-
const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, createWrapper } = options;
|
|
4513
|
+
const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, signal, createWrapper } = options;
|
|
4434
4514
|
return new Promise((resolve, reject) => {
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4515
|
+
if (signal?.aborted) {
|
|
4516
|
+
reject(signal.reason ?? /* @__PURE__ */ new Error("mountFormInstance aborted"));
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4438
4519
|
let cleaned = false;
|
|
4439
4520
|
let timer = null;
|
|
4521
|
+
let onAbort = null;
|
|
4440
4522
|
const instance = { app: null };
|
|
4523
|
+
const container = document.createElement("div");
|
|
4524
|
+
if (hidden) container.style.display = "none";
|
|
4525
|
+
document.body.appendChild(container);
|
|
4441
4526
|
const cleanup = () => {
|
|
4442
4527
|
if (cleaned) return;
|
|
4443
4528
|
cleaned = true;
|
|
@@ -4445,59 +4530,71 @@
|
|
|
4445
4530
|
clearTimeout(timer);
|
|
4446
4531
|
timer = null;
|
|
4447
4532
|
}
|
|
4533
|
+
if (signal && onAbort) {
|
|
4534
|
+
signal.removeEventListener("abort", onAbort);
|
|
4535
|
+
onAbort = null;
|
|
4536
|
+
}
|
|
4448
4537
|
try {
|
|
4449
4538
|
instance.app?.unmount();
|
|
4450
4539
|
} catch {}
|
|
4451
4540
|
container.parentNode?.removeChild(container);
|
|
4452
4541
|
};
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
formProps: restFormProps,
|
|
4458
|
-
cleanup,
|
|
4459
|
-
resolve,
|
|
4460
|
-
reject
|
|
4461
|
-
});
|
|
4462
|
-
const wrapperComponent = typeof extendState === "function" ? (0, vue.defineComponent)({
|
|
4463
|
-
name: "MFormExtendStateInjector",
|
|
4464
|
-
setup() {
|
|
4465
|
-
(0, vue.watch)(() => formRef.value, (form) => {
|
|
4466
|
-
if (!form) return;
|
|
4467
|
-
let result;
|
|
4468
|
-
try {
|
|
4469
|
-
result = extendState(form.formState);
|
|
4470
|
-
} catch (e) {
|
|
4471
|
-
console.error("[MForm] extendState failed:", e);
|
|
4472
|
-
return;
|
|
4473
|
-
}
|
|
4474
|
-
const reservedStateKeys = new Set(Reflect.ownKeys(form.formState));
|
|
4475
|
-
const apply = (state) => applyExtendState(form.formState, state, reservedStateKeys);
|
|
4476
|
-
if (result && typeof result.then === "function") result.then(apply, (e) => console.error("[MForm] extendState failed:", e));
|
|
4477
|
-
else apply(result);
|
|
4478
|
-
}, {
|
|
4479
|
-
flush: "sync",
|
|
4480
|
-
immediate: true
|
|
4481
|
-
});
|
|
4482
|
-
return () => (0, vue.h)(userWrapper);
|
|
4483
|
-
}
|
|
4484
|
-
}) : userWrapper;
|
|
4485
|
-
const app = (0, vue.createApp)(hidden ? (0, vue.defineComponent)({
|
|
4486
|
-
name: "MFormSilentProvider",
|
|
4487
|
-
setup() {
|
|
4488
|
-
(0, vue.provide)(FORM_SILENT_MODE_KEY, true);
|
|
4489
|
-
return () => (0, vue.h)(wrapperComponent);
|
|
4490
|
-
}
|
|
4491
|
-
}) : wrapperComponent);
|
|
4492
|
-
instance.app = app;
|
|
4493
|
-
if (appContext) Object.assign(app._context, appContext);
|
|
4494
|
-
if (timeout > 0 && !skipTimeout) timer = setTimeout(() => {
|
|
4495
|
-
if (!cleaned) {
|
|
4496
|
-
reject(new Error(timeoutMessage));
|
|
4542
|
+
if (signal) {
|
|
4543
|
+
onAbort = () => {
|
|
4544
|
+
if (cleaned) return;
|
|
4545
|
+
reject(signal.reason ?? /* @__PURE__ */ new Error("mountFormInstance aborted"));
|
|
4497
4546
|
cleanup();
|
|
4498
|
-
}
|
|
4499
|
-
|
|
4547
|
+
};
|
|
4548
|
+
signal.addEventListener("abort", onAbort);
|
|
4549
|
+
}
|
|
4500
4550
|
try {
|
|
4551
|
+
const formRef = (0, vue.ref)(null);
|
|
4552
|
+
const { extendState, ...restFormProps } = formProps;
|
|
4553
|
+
const userWrapper = createWrapper({
|
|
4554
|
+
formRef,
|
|
4555
|
+
formProps: restFormProps,
|
|
4556
|
+
cleanup,
|
|
4557
|
+
resolve,
|
|
4558
|
+
reject
|
|
4559
|
+
});
|
|
4560
|
+
const wrapperComponent = typeof extendState === "function" ? (0, vue.defineComponent)({
|
|
4561
|
+
name: "MFormExtendStateInjector",
|
|
4562
|
+
setup() {
|
|
4563
|
+
(0, vue.watch)(() => formRef.value, (form) => {
|
|
4564
|
+
if (!form) return;
|
|
4565
|
+
let result;
|
|
4566
|
+
try {
|
|
4567
|
+
result = extendState(form.formState);
|
|
4568
|
+
} catch (e) {
|
|
4569
|
+
console.error("[MForm] extendState failed:", e);
|
|
4570
|
+
return;
|
|
4571
|
+
}
|
|
4572
|
+
const reservedStateKeys = new Set(Reflect.ownKeys(form.formState));
|
|
4573
|
+
const apply = (state) => applyExtendState(form.formState, state, reservedStateKeys);
|
|
4574
|
+
if (result && typeof result.then === "function") result.then(apply, (e) => console.error("[MForm] extendState failed:", e));
|
|
4575
|
+
else apply(result);
|
|
4576
|
+
}, {
|
|
4577
|
+
flush: "sync",
|
|
4578
|
+
immediate: true
|
|
4579
|
+
});
|
|
4580
|
+
return () => (0, vue.h)(userWrapper);
|
|
4581
|
+
}
|
|
4582
|
+
}) : userWrapper;
|
|
4583
|
+
const app = (0, vue.createApp)(hidden ? (0, vue.defineComponent)({
|
|
4584
|
+
name: "MFormSilentProvider",
|
|
4585
|
+
setup() {
|
|
4586
|
+
(0, vue.provide)(FORM_SILENT_MODE_KEY, true);
|
|
4587
|
+
return () => (0, vue.h)(wrapperComponent);
|
|
4588
|
+
}
|
|
4589
|
+
}) : wrapperComponent);
|
|
4590
|
+
instance.app = app;
|
|
4591
|
+
if (appContext) Object.assign(app._context, appContext);
|
|
4592
|
+
if (!skipTimeout) timer = setTimeout(() => {
|
|
4593
|
+
if (!cleaned) {
|
|
4594
|
+
reject(new Error(timeoutMessage));
|
|
4595
|
+
cleanup();
|
|
4596
|
+
}
|
|
4597
|
+
}, timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT);
|
|
4501
4598
|
app.mount(container);
|
|
4502
4599
|
} catch (err) {
|
|
4503
4600
|
reject(err);
|
|
@@ -4630,11 +4727,12 @@
|
|
|
4630
4727
|
* ```
|
|
4631
4728
|
*/
|
|
4632
4729
|
var submitForm = (options) => {
|
|
4633
|
-
const { native, appContext, timeout = 1e4, returnChangeRecords, debug = false, ...formProps } = options;
|
|
4730
|
+
const { native, appContext, timeout = 1e4, returnChangeRecords, debug = false, signal, ...formProps } = options;
|
|
4634
4731
|
return mountFormInstance({
|
|
4635
4732
|
formProps,
|
|
4636
4733
|
appContext,
|
|
4637
4734
|
timeout,
|
|
4735
|
+
signal,
|
|
4638
4736
|
hidden: !debug,
|
|
4639
4737
|
skipTimeout: debug,
|
|
4640
4738
|
timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`,
|
|
@@ -4771,7 +4869,7 @@
|
|
|
4771
4869
|
* ```
|
|
4772
4870
|
*/
|
|
4773
4871
|
var validateForm = (options) => {
|
|
4774
|
-
const { appContext, timeout = 1e4, debug = false, config, ...rest } = options;
|
|
4872
|
+
const { appContext, timeout = 1e4, debug = false, config, signal, ...rest } = options;
|
|
4775
4873
|
return mountFormInstance({
|
|
4776
4874
|
formProps: {
|
|
4777
4875
|
...rest,
|
|
@@ -4779,6 +4877,7 @@
|
|
|
4779
4877
|
},
|
|
4780
4878
|
appContext,
|
|
4781
4879
|
timeout,
|
|
4880
|
+
signal,
|
|
4782
4881
|
hidden: !debug,
|
|
4783
4882
|
skipTimeout: debug,
|
|
4784
4883
|
timeoutMessage: `validateForm timeout after ${timeout}ms: form is not initialized.`,
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.8.0-beta.
|
|
2
|
+
"version": "1.8.0-beta.18",
|
|
3
3
|
"name": "@tmagic/form",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": [
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"vue": "^3.5.40",
|
|
54
54
|
"typescript": "^6.0.3",
|
|
55
|
-
"@tmagic/design": "1.8.0-beta.
|
|
56
|
-
"@tmagic/utils": "1.8.0-beta.
|
|
57
|
-
"@tmagic/form-schema": "1.8.0-beta.
|
|
55
|
+
"@tmagic/design": "1.8.0-beta.18",
|
|
56
|
+
"@tmagic/utils": "1.8.0-beta.18",
|
|
57
|
+
"@tmagic/form-schema": "1.8.0-beta.18"
|
|
58
58
|
},
|
|
59
59
|
"peerDependenciesMeta": {
|
|
60
60
|
"typescript": {
|
package/src/submitForm.ts
CHANGED
|
@@ -87,6 +87,11 @@ export interface SubmitFormOptions {
|
|
|
87
87
|
*/
|
|
88
88
|
debug?: boolean;
|
|
89
89
|
typeMatchValid?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。
|
|
92
|
+
* 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。
|
|
93
|
+
*/
|
|
94
|
+
signal?: AbortSignal;
|
|
90
95
|
}
|
|
91
96
|
// #endregion SubmitFormOptions
|
|
92
97
|
|
|
@@ -127,7 +132,7 @@ interface MountFormInstanceOptions<T> {
|
|
|
127
132
|
formProps: Record<string, any>;
|
|
128
133
|
/** 父级应用上下文,用于继承全局组件、指令、provide 等 */
|
|
129
134
|
appContext?: AppContext | null;
|
|
130
|
-
/** 等待表单初始化的最长时间(毫秒),<=0
|
|
135
|
+
/** 等待表单初始化的最长时间(毫秒),<=0 时回退到默认超时以保证兜底清理生效 */
|
|
131
136
|
timeout: number;
|
|
132
137
|
/** 超时 reject 的错误文案 */
|
|
133
138
|
timeoutMessage: string;
|
|
@@ -135,10 +140,15 @@ interface MountFormInstanceOptions<T> {
|
|
|
135
140
|
hidden?: boolean;
|
|
136
141
|
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
|
|
137
142
|
skipTimeout?: boolean;
|
|
143
|
+
/** 外部中断信号:abort 时会 reject 并卸载实例、移除容器,用于取消无超时(如 debug)的挂载 */
|
|
144
|
+
signal?: AbortSignal;
|
|
138
145
|
/** 构造 wrapper 组件 */
|
|
139
146
|
createWrapper: FormWrapperFactory<T>;
|
|
140
147
|
}
|
|
141
148
|
|
|
149
|
+
/** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */
|
|
150
|
+
const DEFAULT_MOUNT_TIMEOUT = 10000;
|
|
151
|
+
|
|
142
152
|
/**
|
|
143
153
|
* submitForm / validateForm 的公共脚手架:
|
|
144
154
|
*
|
|
@@ -149,20 +159,36 @@ interface MountFormInstanceOptions<T> {
|
|
|
149
159
|
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
|
|
150
160
|
*/
|
|
151
161
|
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
|
|
152
|
-
const {
|
|
162
|
+
const {
|
|
163
|
+
formProps,
|
|
164
|
+
appContext,
|
|
165
|
+
timeout,
|
|
166
|
+
timeoutMessage,
|
|
167
|
+
hidden = true,
|
|
168
|
+
skipTimeout = false,
|
|
169
|
+
signal,
|
|
170
|
+
createWrapper,
|
|
171
|
+
} = options;
|
|
153
172
|
|
|
154
173
|
return new Promise<T>((resolve, reject) => {
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
174
|
+
// 已中断则直接 reject,不创建任何容器/实例
|
|
175
|
+
if (signal?.aborted) {
|
|
176
|
+
reject(signal.reason ?? new Error('mountFormInstance aborted'));
|
|
177
|
+
return;
|
|
158
178
|
}
|
|
159
|
-
document.body.appendChild(container);
|
|
160
179
|
|
|
161
180
|
let cleaned = false;
|
|
162
181
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
182
|
+
let onAbort: (() => void) | null = null;
|
|
163
183
|
// 用 holder 持有 app,使 cleanup 可在 app 创建之前定义(const app + 无 TDZ / 无 use-before-define)
|
|
164
184
|
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
|
|
165
185
|
|
|
186
|
+
const container = document.createElement('div');
|
|
187
|
+
if (hidden) {
|
|
188
|
+
container.style.display = 'none';
|
|
189
|
+
}
|
|
190
|
+
document.body.appendChild(container);
|
|
191
|
+
|
|
166
192
|
const cleanup = () => {
|
|
167
193
|
if (cleaned) return;
|
|
168
194
|
cleaned = true;
|
|
@@ -170,6 +196,10 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
|
|
170
196
|
clearTimeout(timer);
|
|
171
197
|
timer = null;
|
|
172
198
|
}
|
|
199
|
+
if (signal && onAbort) {
|
|
200
|
+
signal.removeEventListener('abort', onAbort);
|
|
201
|
+
onAbort = null;
|
|
202
|
+
}
|
|
173
203
|
try {
|
|
174
204
|
instance.app?.unmount();
|
|
175
205
|
} catch {
|
|
@@ -178,85 +208,101 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
|
|
178
208
|
container.parentNode?.removeChild(container);
|
|
179
209
|
};
|
|
180
210
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
// 支持外部通过 AbortSignal 主动中断:debug 模式无超时兜底,若调用方放弃了该 Promise,
|
|
212
|
+
// 可通过 abort 卸载实例、移除遮罩/容器,避免无限驻留在 DOM 中。
|
|
213
|
+
if (signal) {
|
|
214
|
+
onAbort = () => {
|
|
215
|
+
if (cleaned) return;
|
|
216
|
+
reject(signal.reason ?? new Error('mountFormInstance aborted'));
|
|
217
|
+
cleanup();
|
|
218
|
+
};
|
|
219
|
+
signal.addEventListener('abort', onAbort);
|
|
220
|
+
}
|
|
190
221
|
|
|
191
|
-
|
|
192
|
-
|
|
222
|
+
// 从容器创建到 mount 的全流程统一 try/catch:任一步骤(createWrapper/createApp/上下文合并/mount)
|
|
223
|
+
// 抛错都会走到 cleanup,避免已插入 body 的 container 及未挂载的 app 残留导致泄漏。
|
|
224
|
+
try {
|
|
225
|
+
const formRef = ref<any>(null);
|
|
226
|
+
|
|
227
|
+
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
|
|
228
|
+
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState,
|
|
229
|
+
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
|
|
230
|
+
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
|
|
231
|
+
const { extendState, ...restFormProps } = formProps;
|
|
232
|
+
|
|
233
|
+
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
|
|
234
|
+
|
|
235
|
+
const wrapperComponent =
|
|
236
|
+
typeof extendState === 'function'
|
|
237
|
+
? defineComponent({
|
|
238
|
+
name: 'MFormExtendStateInjector',
|
|
239
|
+
setup() {
|
|
240
|
+
watch(
|
|
241
|
+
() => formRef.value,
|
|
242
|
+
(form) => {
|
|
243
|
+
if (!form) return;
|
|
244
|
+
let result: any;
|
|
245
|
+
try {
|
|
246
|
+
result = extendState(form.formState);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error('[MForm] extendState failed:', e);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// formState 的内置 key 快照:在 extendState 合并前捕获,
|
|
252
|
+
// 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增),
|
|
253
|
+
// 与 Form.vue 中 reservedStateKeys 的语义保持一致。
|
|
254
|
+
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(form.formState));
|
|
255
|
+
// 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段
|
|
256
|
+
// (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
|
|
257
|
+
const apply = (state: Record<string, any> | null | undefined) =>
|
|
258
|
+
applyExtendState(form.formState, state, reservedStateKeys);
|
|
259
|
+
if (result && typeof result.then === 'function') {
|
|
260
|
+
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
|
261
|
+
} else {
|
|
262
|
+
apply(result);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
{ flush: 'sync', immediate: true },
|
|
266
|
+
);
|
|
267
|
+
return () => h(userWrapper);
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
: userWrapper;
|
|
271
|
+
|
|
272
|
+
// 静默(隐藏挂载)模式下注入静默标记:vs-code 等重型字段组件可据此跳过自身渲染,
|
|
273
|
+
// 校验/取值依赖 FormItem 与 model 值,与叶子 UI 组件无关(见 FORM_SILENT_MODE_KEY 注释)。
|
|
274
|
+
// 用组件级 provide 而非 app.provide:appContext 合并后 app._context.provides 与父级应用
|
|
275
|
+
// 共享引用,app.provide 会把标记泄漏到父级应用。
|
|
276
|
+
const rootComponent = hidden
|
|
193
277
|
? defineComponent({
|
|
194
|
-
name: '
|
|
278
|
+
name: 'MFormSilentProvider',
|
|
195
279
|
setup() {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
(form) => {
|
|
199
|
-
if (!form) return;
|
|
200
|
-
let result: any;
|
|
201
|
-
try {
|
|
202
|
-
result = extendState(form.formState);
|
|
203
|
-
} catch (e) {
|
|
204
|
-
console.error('[MForm] extendState failed:', e);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
// formState 的内置 key 快照:在 extendState 合并前捕获,
|
|
208
|
-
// 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增),
|
|
209
|
-
// 与 Form.vue 中 reservedStateKeys 的语义保持一致。
|
|
210
|
-
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(form.formState));
|
|
211
|
-
// 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段
|
|
212
|
-
// (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
|
|
213
|
-
const apply = (state: Record<string, any> | null | undefined) =>
|
|
214
|
-
applyExtendState(form.formState, state, reservedStateKeys);
|
|
215
|
-
if (result && typeof result.then === 'function') {
|
|
216
|
-
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
|
217
|
-
} else {
|
|
218
|
-
apply(result);
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
{ flush: 'sync', immediate: true },
|
|
222
|
-
);
|
|
223
|
-
return () => h(userWrapper);
|
|
280
|
+
provide(FORM_SILENT_MODE_KEY, true);
|
|
281
|
+
return () => h(wrapperComponent);
|
|
224
282
|
},
|
|
225
283
|
})
|
|
226
|
-
:
|
|
227
|
-
|
|
228
|
-
// 静默(隐藏挂载)模式下注入静默标记:vs-code 等重型字段组件可据此跳过自身渲染,
|
|
229
|
-
// 校验/取值依赖 FormItem 与 model 值,与叶子 UI 组件无关(见 FORM_SILENT_MODE_KEY 注释)。
|
|
230
|
-
// 用组件级 provide 而非 app.provide:appContext 合并后 app._context.provides 与父级应用
|
|
231
|
-
// 共享引用,app.provide 会把标记泄漏到父级应用。
|
|
232
|
-
const rootComponent = hidden
|
|
233
|
-
? defineComponent({
|
|
234
|
-
name: 'MFormSilentProvider',
|
|
235
|
-
setup() {
|
|
236
|
-
provide(FORM_SILENT_MODE_KEY, true);
|
|
237
|
-
return () => h(wrapperComponent);
|
|
238
|
-
},
|
|
239
|
-
})
|
|
240
|
-
: wrapperComponent;
|
|
284
|
+
: wrapperComponent;
|
|
241
285
|
|
|
242
|
-
|
|
243
|
-
|
|
286
|
+
const app = createApp(rootComponent);
|
|
287
|
+
instance.app = app;
|
|
244
288
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
// 继承父级应用上下文(components / directives / provides / config 等)
|
|
290
|
+
if (appContext) {
|
|
291
|
+
Object.assign(app._context, appContext);
|
|
292
|
+
}
|
|
249
293
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
294
|
+
// 非 debug(未跳过超时)场景始终注册超时兜底:timeout 为非正数时回退到默认值,
|
|
295
|
+
// 避免表单永不初始化时实例/容器/watcher 无限驻留而泄漏。
|
|
296
|
+
if (!skipTimeout) {
|
|
297
|
+
const effectiveTimeout = timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT;
|
|
298
|
+
timer = setTimeout(() => {
|
|
299
|
+
if (!cleaned) {
|
|
300
|
+
reject(new Error(timeoutMessage));
|
|
301
|
+
cleanup();
|
|
302
|
+
}
|
|
303
|
+
}, effectiveTimeout);
|
|
304
|
+
}
|
|
258
305
|
|
|
259
|
-
try {
|
|
260
306
|
app.mount(container);
|
|
261
307
|
} catch (err) {
|
|
262
308
|
reject(err);
|
|
@@ -454,12 +500,13 @@ const createDebugWrapper = (options: DebugWrapperOptions): Component => {
|
|
|
454
500
|
* ```
|
|
455
501
|
*/
|
|
456
502
|
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
|
|
457
|
-
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, ...formProps } = options;
|
|
503
|
+
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, signal, ...formProps } = options;
|
|
458
504
|
|
|
459
505
|
return mountFormInstance<any>({
|
|
460
506
|
formProps,
|
|
461
507
|
appContext,
|
|
462
508
|
timeout,
|
|
509
|
+
signal,
|
|
463
510
|
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
|
464
511
|
hidden: !debug,
|
|
465
512
|
// 调试模式等待人工操作,不应用超时
|
|
@@ -563,6 +610,11 @@ export interface ValidateFormOptions {
|
|
|
563
610
|
*/
|
|
564
611
|
debug?: boolean;
|
|
565
612
|
typeMatchValid?: boolean;
|
|
613
|
+
/**
|
|
614
|
+
* 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。
|
|
615
|
+
* 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。
|
|
616
|
+
*/
|
|
617
|
+
signal?: AbortSignal;
|
|
566
618
|
}
|
|
567
619
|
// #endregion ValidateFormOptions
|
|
568
620
|
|
|
@@ -658,7 +710,7 @@ export const stripTabItemsLazy = (config: FormConfig): FormConfig => {
|
|
|
658
710
|
* ```
|
|
659
711
|
*/
|
|
660
712
|
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
|
|
661
|
-
const { appContext, timeout = 10000, debug = false, config, ...rest } = options;
|
|
713
|
+
const { appContext, timeout = 10000, debug = false, config, signal, ...rest } = options;
|
|
662
714
|
|
|
663
715
|
// 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验
|
|
664
716
|
const formProps = { ...rest, config: stripTabItemsLazy(config) };
|
|
@@ -667,6 +719,7 @@ export const validateForm = (options: ValidateFormOptions): Promise<string> => {
|
|
|
667
719
|
formProps,
|
|
668
720
|
appContext,
|
|
669
721
|
timeout,
|
|
722
|
+
signal,
|
|
670
723
|
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
|
671
724
|
hidden: !debug,
|
|
672
725
|
// 调试模式等待人工操作,不应用超时
|