@tmagic/form 1.8.0-beta.25 → 1.8.0-beta.27
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 +47 -120
- package/dist/es/FormBox.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/es/FormDialog.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/es/FormDrawer.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/es/containers/Col.vue_vue_type_script_setup_true_lang.js +3 -0
- package/dist/es/containers/Container.vue_vue_type_script_setup_true_lang.js +36 -51
- package/dist/es/containers/Fieldset.vue_vue_type_script_setup_true_lang.js +6 -0
- package/dist/es/containers/FlexLayout.vue_vue_type_script_setup_true_lang.js +4 -1
- package/dist/es/containers/GroupList.vue_vue_type_script_setup_true_lang.js +37 -15
- package/dist/es/containers/GroupListItem.vue_vue_type_script_setup_true_lang.js +109 -97
- package/dist/es/containers/Panel.vue_vue_type_script_setup_true_lang.js +7 -2
- package/dist/es/containers/Row.vue_vue_type_script_setup_true_lang.js +3 -0
- package/dist/es/containers/Step.vue_vue_type_script_setup_true_lang.js +4 -1
- package/dist/es/containers/Tabs.vue_vue_type_script_setup_true_lang.js +7 -0
- package/dist/es/containers/table/Table.vue_vue_type_script_setup_true_lang.js +7 -5
- package/dist/es/containers/table/useTableColumns.js +5 -13
- package/dist/es/containers/table-group-list/TableGroupList.vue_vue_type_script_setup_true_lang.js +71 -78
- package/dist/es/containers/table-group-list/useAdd.js +26 -16
- package/dist/es/containers/table-group-list/useScrollLastItemIntoView.js +72 -0
- package/dist/es/fields/Cascader.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/es/fields/CheckboxGroup/Index.js +5 -0
- 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
- package/dist/es/fields/CheckboxGroup/effect.js +8 -0
- package/dist/es/fields/Date/Index.js +5 -0
- 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
- package/dist/es/fields/Date/effect.js +10 -0
- package/dist/es/fields/DateTime/Index.js +5 -0
- 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
- package/dist/es/fields/DateTime/effect.js +15 -0
- package/dist/es/fields/Display/Index.js +5 -0
- 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
- package/dist/es/fields/Display/effect.js +8 -0
- package/dist/es/fields/DynamicField/Index.js +5 -0
- 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
- package/dist/es/fields/DynamicField/effect.js +40 -0
- package/dist/es/fields/Link.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/es/fields/Number.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/es/fields/NumberRange/Index.js +5 -0
- 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
- package/dist/es/fields/NumberRange/effect.js +8 -0
- package/dist/es/fields/Select.vue_vue_type_script_setup_true_lang.js +13 -6
- package/dist/es/fields/Text.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/es/headless.js +13 -0
- package/dist/es/index.js +17 -12
- package/dist/es/plugin.js +57 -41
- package/dist/es/schema.js +6 -8
- package/dist/es/style.css +81 -1
- package/dist/es/submitForm.js +44 -194
- package/dist/es/utils/builtInFields.js +49 -0
- package/dist/es/utils/collectFields.js +407 -0
- package/dist/es/utils/config.js +1 -8
- package/dist/es/utils/fieldInnerConfig.js +42 -0
- package/dist/es/utils/fieldValueEffects.js +80 -0
- package/dist/es/utils/form.js +96 -43
- package/dist/es/utils/formStateProxy.js +111 -0
- package/dist/es/utils/registerField.js +158 -0
- package/dist/es/utils/submitHeadless.js +54 -0
- package/dist/es/utils/tableGroupList.js +85 -0
- package/dist/es/utils/typeMatch.js +17 -19
- package/dist/es/utils/validateError.js +57 -0
- package/dist/es/utils/validateValues.js +115 -0
- package/dist/style.css +81 -1
- package/dist/themes/magic-admin.css +83 -7
- package/dist/tmagic-form-headless.umd.cjs +4080 -0
- package/dist/tmagic-form.umd.cjs +2020 -876
- package/package.json +10 -4
- package/src/Form.vue +49 -149
- package/src/FormBox.vue +5 -4
- package/src/FormDialog.vue +5 -5
- package/src/FormDrawer.vue +5 -5
- package/src/containers/Col.vue +2 -0
- package/src/containers/Container.vue +86 -157
- package/src/containers/Fieldset.vue +5 -0
- package/src/containers/FlexLayout.vue +2 -0
- package/src/containers/GroupList.vue +41 -11
- package/src/containers/GroupListItem.vue +83 -80
- package/src/containers/Panel.vue +3 -0
- package/src/containers/Row.vue +2 -0
- package/src/containers/Step.vue +2 -0
- package/src/containers/Tabs.vue +13 -0
- package/src/containers/table/ActionsColumn.vue +1 -1
- package/src/containers/table/Table.vue +4 -3
- package/src/containers/table/usePagination.ts +1 -1
- package/src/containers/table/useSortable.ts +1 -1
- package/src/containers/table/useTableColumns.ts +9 -19
- package/src/containers/table-group-list/TableGroupList.vue +43 -71
- package/src/containers/table-group-list/useAdd.ts +40 -25
- package/src/containers/table-group-list/useScrollLastItemIntoView.ts +94 -0
- package/src/fields/{CheckboxGroup.vue → CheckboxGroup/Index.vue} +3 -8
- package/src/fields/CheckboxGroup/effect.ts +27 -0
- package/src/fields/{Date.vue → Date/Index.vue} +2 -5
- package/src/fields/Date/effect.ts +29 -0
- package/src/fields/{DateTime.vue → DateTime/Index.vue} +2 -16
- package/src/fields/DateTime/effect.ts +36 -0
- package/src/fields/{Display.vue → Display/Index.vue} +3 -7
- package/src/fields/Display/effect.ts +28 -0
- package/src/fields/{DynamicField.vue → DynamicField/Index.vue} +11 -11
- package/src/fields/DynamicField/effect.ts +74 -0
- package/src/fields/{NumberRange.vue → NumberRange/Index.vue} +2 -6
- package/src/fields/NumberRange/effect.ts +27 -0
- package/src/fields/Select.vue +13 -4
- package/src/headless.ts +78 -0
- package/src/index.ts +32 -15
- package/src/plugin.ts +63 -42
- package/src/schema.ts +6 -8
- package/src/submitForm.ts +81 -409
- package/src/theme/group-list.scss +107 -1
- package/src/theme/themes/magic-admin/index.scss +2 -8
- package/src/utils/builtInFields.ts +69 -0
- package/src/utils/collectFields.ts +561 -0
- package/src/utils/config.ts +1 -15
- package/src/utils/fieldInnerConfig.ts +127 -0
- package/src/utils/fieldValueEffects.ts +140 -0
- package/src/utils/form.ts +141 -75
- package/src/utils/formStateProxy.ts +149 -0
- package/src/utils/registerField.ts +308 -0
- package/src/utils/submitHeadless.ts +206 -0
- package/src/utils/tableGroupList.ts +120 -0
- package/src/utils/typeMatch.ts +19 -15
- package/src/utils/validateError.ts +92 -0
- package/src/utils/validateValues.ts +177 -0
- package/types/headless.d.ts +682 -0
- package/types/index.d.ts +631 -272
- package/dist/es/fields/CheckboxGroup.js +0 -5
- package/dist/es/fields/Date.js +0 -5
- package/dist/es/fields/DateTime.js +0 -5
- package/dist/es/fields/Display.js +0 -5
- package/dist/es/fields/DynamicField.js +0 -5
- package/dist/es/fields/NumberRange.js +0 -5
- package/dist/es/utils/silentLeafFieldTypes.js +0 -49
- package/src/utils/silentLeafFieldTypes.ts +0 -71
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
|
3
|
+
*
|
|
4
|
+
* Copyright (C) 2025 Tencent. All rights reserved.
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { cloneDeep } from 'lodash-es';
|
|
20
|
+
|
|
21
|
+
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* table / group-list 是同一份配置的两种展示形态,可以互相切换(`TableGroupList.vue`)。
|
|
25
|
+
*
|
|
26
|
+
* 本模块收口两种形态之间的配置派生,以及各形态下「子项配置怎么算出来」的规则,
|
|
27
|
+
* 供渲染(`TableGroupList.vue` / `GroupListItem.vue` / `useTableColumns.ts`)与无渲染校验
|
|
28
|
+
* (`collectValidatableFields`)共用,避免两条链路各写一份而产生偏差。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** group-list 形态的 type(兼容驼峰与中划线两种写法) */
|
|
32
|
+
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
|
|
33
|
+
|
|
34
|
+
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px,最小 80px) */
|
|
35
|
+
export const calcLabelWidth = (label: string): string => {
|
|
36
|
+
if (!label) return '0px';
|
|
37
|
+
const zhLength = label.match(/[^\x00-\xff]/g)?.length || 0;
|
|
38
|
+
const chLength = label.length - zhLength;
|
|
39
|
+
return `${Math.max(chLength * 8 + zhLength * 20, 80)}px`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 由 group-list 形态的配置派生出 table 形态所需的配置;本身已是 table 形态则原样返回 */
|
|
43
|
+
export const toTableConfig = (config: TableConfig | GroupListConfig): TableConfig => {
|
|
44
|
+
if (!isGroupListType(config.type)) return config as TableConfig;
|
|
45
|
+
|
|
46
|
+
const source = config as GroupListConfig;
|
|
47
|
+
return {
|
|
48
|
+
...config,
|
|
49
|
+
type: 'table',
|
|
50
|
+
groupItems: source.items,
|
|
51
|
+
items:
|
|
52
|
+
source.tableItems ||
|
|
53
|
+
(source.items as any[]).map((item: any) => ({
|
|
54
|
+
...item,
|
|
55
|
+
label: item.label || item.text,
|
|
56
|
+
text: null,
|
|
57
|
+
})),
|
|
58
|
+
} as any as TableConfig;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** 由 table 形态的配置派生出 group-list 形态所需的配置;本身已是 group-list 形态则原样返回 */
|
|
62
|
+
export const toGroupListConfig = (config: TableConfig | GroupListConfig): GroupListConfig => {
|
|
63
|
+
if (isGroupListType(config.type)) return config as GroupListConfig;
|
|
64
|
+
|
|
65
|
+
const source = config as TableConfig;
|
|
66
|
+
return {
|
|
67
|
+
...config,
|
|
68
|
+
type: 'groupList',
|
|
69
|
+
tableItems: source.items,
|
|
70
|
+
items:
|
|
71
|
+
source.groupItems ||
|
|
72
|
+
(source.items as any[]).map((item: any) => {
|
|
73
|
+
const text = item.text || item.label;
|
|
74
|
+
return {
|
|
75
|
+
...item,
|
|
76
|
+
text,
|
|
77
|
+
labelWidth: calcLabelWidth(text),
|
|
78
|
+
span: item.span || 12,
|
|
79
|
+
};
|
|
80
|
+
}),
|
|
81
|
+
} as any as GroupListConfig;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* group-list 每一行渲染的 row 容器配置(`GroupListItem.vue` 渲染的 `Container` 的 config)。
|
|
86
|
+
*
|
|
87
|
+
* `keyProp` 由调用方从 `mForm.keyProp` 取,默认 `__key`。
|
|
88
|
+
*/
|
|
89
|
+
export const getGroupListRowConfig = (config: GroupListConfig, index: number, keyProp?: string) => {
|
|
90
|
+
const key = keyProp || '__key';
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
type: 'row',
|
|
94
|
+
span: config.span || 24,
|
|
95
|
+
items: config.items,
|
|
96
|
+
labelWidth: config.labelWidth,
|
|
97
|
+
labelPosition: config.labelPosition,
|
|
98
|
+
[key]: `${(config as Record<string, any>)[key]}${String(index)}`,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 列是否会被渲染成一列(`hidden` 列不渲染,`display` 为假的列也不渲染)。
|
|
104
|
+
*
|
|
105
|
+
* `display` 的求值上下文是表格自身的 props,由调用方通过 `evalDisplay` 注入。
|
|
106
|
+
*/
|
|
107
|
+
export const isTableColumnRendered = (column: TableColumnConfig, evalDisplay: (_display: any) => any): boolean =>
|
|
108
|
+
column.type !== 'hidden' && Boolean(evalDisplay(column.display));
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 单元格内 `Container` 实际收到的列配置:按行求值 `itemsFunction`,并去掉已在列层面消费过的 `display`。
|
|
112
|
+
*/
|
|
113
|
+
export const makeTableColumnConfig = (column: TableColumnConfig, row: any): TableColumnConfig => {
|
|
114
|
+
const newConfig = cloneDeep(column);
|
|
115
|
+
if (typeof column.itemsFunction === 'function') {
|
|
116
|
+
newConfig.items = column.itemsFunction(row);
|
|
117
|
+
}
|
|
118
|
+
delete newConfig.display;
|
|
119
|
+
return newConfig;
|
|
120
|
+
};
|
package/src/utils/typeMatch.ts
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
import { readonly } from 'vue';
|
|
20
20
|
import dayjs from 'dayjs';
|
|
21
|
-
|
|
21
|
+
// dayjs 没有 exports 映射,原生 Node ESM 不会补扩展名,深路径必须写全 .js
|
|
22
|
+
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
|
|
22
23
|
|
|
23
|
-
import { appendValidateSuggestion } from '@tmagic/design';
|
|
24
|
+
import { appendValidateSuggestion } from '@tmagic/design/headless';
|
|
24
25
|
import { getValueByKeyPath, toLine } from '@tmagic/utils';
|
|
25
26
|
|
|
26
27
|
import type { CascaderOption, FormState, Rule } from '../schema';
|
|
@@ -49,33 +50,36 @@ export type TypeMatchValidator = (
|
|
|
49
50
|
) => string | undefined | Promise<string | undefined>;
|
|
50
51
|
// #endregion TypeMatchValidator
|
|
51
52
|
|
|
52
|
-
const
|
|
53
|
+
const extraTypeMatchRules = new Map<string, TypeMatchValidator>();
|
|
54
|
+
const builtInTypeMatchRules = new Map<string, TypeMatchValidator>();
|
|
53
55
|
|
|
54
56
|
const isPromise = (value: any): value is Promise<unknown> =>
|
|
55
57
|
typeof value === 'object' && value !== null && typeof value.then === 'function';
|
|
56
58
|
|
|
57
|
-
/** 注册或覆盖某个字段 type 的 typeMatch
|
|
58
|
-
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator): void => {
|
|
59
|
-
|
|
59
|
+
/** 注册或覆盖某个字段 type 的 typeMatch 校验规则。`builtIn` 登记不受 `clearTypeMatchRules` / `deleteTypeMatchRule` 影响。 */
|
|
60
|
+
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator, builtIn = false): void => {
|
|
61
|
+
(builtIn ? builtInTypeMatchRules : extraTypeMatchRules).set(toLine(type), validator);
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
/** 批量注册 typeMatch 校验规则 */
|
|
63
|
-
export const registerTypeMatchRules = (rules: Record<string, TypeMatchValidator
|
|
65
|
+
export const registerTypeMatchRules = (rules: Record<string, TypeMatchValidator>, builtIn = false): void => {
|
|
64
66
|
Object.entries(rules).forEach(([type, validator]) => {
|
|
65
|
-
registerTypeMatchRule(type, validator);
|
|
67
|
+
registerTypeMatchRule(type, validator, builtIn);
|
|
66
68
|
});
|
|
67
69
|
};
|
|
68
70
|
|
|
69
|
-
/** 获取某个字段 type
|
|
70
|
-
export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined =>
|
|
71
|
-
|
|
71
|
+
/** 获取某个字段 type 的 typeMatch 校验规则(业务侧优先于内置) */
|
|
72
|
+
export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined => {
|
|
73
|
+
const key = toLine(type);
|
|
74
|
+
return extraTypeMatchRules.get(key) ?? builtInTypeMatchRules.get(key);
|
|
75
|
+
};
|
|
72
76
|
|
|
73
|
-
/**
|
|
74
|
-
export const deleteTypeMatchRule = (type: string): boolean =>
|
|
77
|
+
/** 删除业务侧 typeMatch 校验规则(不影响内置) */
|
|
78
|
+
export const deleteTypeMatchRule = (type: string): boolean => extraTypeMatchRules.delete(toLine(type));
|
|
75
79
|
|
|
76
|
-
/**
|
|
80
|
+
/** 清空业务侧 typeMatch 校验规则(不影响内置) */
|
|
77
81
|
export const clearTypeMatchRules = (): void => {
|
|
78
|
-
|
|
82
|
+
extraTypeMatchRules.clear();
|
|
79
83
|
};
|
|
80
84
|
|
|
81
85
|
/** 本地解析配置函数,避免与 form.ts 循环依赖 */
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
|
3
|
+
*
|
|
4
|
+
* Copyright (C) 2025 Tencent. All rights reserved.
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { FormConfig, ValidateError } from '../schema';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 通过 name 从 config 中查找对应的 text
|
|
23
|
+
*
|
|
24
|
+
* @param name - 字段名,支持点分隔的路径格式,如 'a.b.c'
|
|
25
|
+
* @param config - 表单配置数组
|
|
26
|
+
* @returns 找到的 text 值,如果未找到则返回 undefined
|
|
27
|
+
*/
|
|
28
|
+
export const getTextByName = (name: string, config: FormConfig = []): string | undefined => {
|
|
29
|
+
if (!name || !Array.isArray(config)) return undefined;
|
|
30
|
+
|
|
31
|
+
const nameParts = name.split('.');
|
|
32
|
+
|
|
33
|
+
const findInConfig = (configs: FormConfig, parts: string[]): string | undefined => {
|
|
34
|
+
if (parts.length === 0) return undefined;
|
|
35
|
+
|
|
36
|
+
const [currentPart, ...remainingParts] = parts;
|
|
37
|
+
|
|
38
|
+
for (const item of configs) {
|
|
39
|
+
if (item.name === currentPart) {
|
|
40
|
+
if (remainingParts.length === 0) {
|
|
41
|
+
return typeof item.text === 'string' ? item.text : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
45
|
+
const result = findInConfig(item.items, remainingParts);
|
|
46
|
+
if (result !== undefined) return result;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ('items' in item && Array.isArray(item.items)) {
|
|
51
|
+
const result = findInConfig(item.items, parts);
|
|
52
|
+
if (result !== undefined) return result;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return findInConfig(config, nameParts);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 将校验返回的 invalidFields 汇总为可读的错误文案(多条以 `<br>` 拼接)。
|
|
64
|
+
*
|
|
65
|
+
* `useFieldTextInError` 为 `true`(默认)时用字段的 text 文案作为错误前缀,找不到则回退为字段 name。
|
|
66
|
+
*
|
|
67
|
+
* 由渲染式校验(Form.vue 的 `submitForm` / `validate`)与无渲染校验(`validateValues`)共用,
|
|
68
|
+
* 保证两条校验链路产出的错误文案格式完全一致。
|
|
69
|
+
*
|
|
70
|
+
* @param invalidFields - async-validator 产出的字段错误表
|
|
71
|
+
* @param [options] - 文案选项
|
|
72
|
+
* @param [options.config] - 用于查找 text 的表单配置
|
|
73
|
+
* @param [options.useFieldTextInError=true] - 错误前缀是否使用字段 text
|
|
74
|
+
* @returns 拼接后的错误文案;无错误时为空串
|
|
75
|
+
*/
|
|
76
|
+
export const formatValidateError = (
|
|
77
|
+
invalidFields: Record<string, any>,
|
|
78
|
+
{ config = [], useFieldTextInError = true }: { config?: FormConfig; useFieldTextInError?: boolean } = {},
|
|
79
|
+
): string => {
|
|
80
|
+
const error: string[] = [];
|
|
81
|
+
|
|
82
|
+
Object.entries(invalidFields).forEach(([prop, validateError]) => {
|
|
83
|
+
(validateError as ValidateError[]).forEach(({ field, message }) => {
|
|
84
|
+
const name = field || prop;
|
|
85
|
+
const text = (useFieldTextInError ? getTextByName(name, config) : undefined) || name;
|
|
86
|
+
|
|
87
|
+
error.push(`${text} -> ${message}`);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return error.join('<br>');
|
|
92
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
|
3
|
+
*
|
|
4
|
+
* Copyright (C) 2025 Tencent. All rights reserved.
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { reactive } from 'vue';
|
|
20
|
+
import Schema from 'async-validator';
|
|
21
|
+
|
|
22
|
+
import type { FormConfig, FormContext, FormState, FormValue } from '../schema';
|
|
23
|
+
|
|
24
|
+
import { applyMountValueEffects, type CollectedField, collectValidatableFields } from './collectFields';
|
|
25
|
+
import { createFormStateBase, createFormStateProxy, initValue } from './form';
|
|
26
|
+
import { formatValidateError } from './validateError';
|
|
27
|
+
|
|
28
|
+
// #region HeadlessFormStateOptions
|
|
29
|
+
/** 构造无渲染 formState 所需的参数(与 MForm 的同名 props 对齐) */
|
|
30
|
+
export interface HeadlessFormStateOptions {
|
|
31
|
+
config: FormConfig;
|
|
32
|
+
initValues?: FormValue;
|
|
33
|
+
parentValues?: FormValue;
|
|
34
|
+
keyProp?: string;
|
|
35
|
+
popperClass?: string;
|
|
36
|
+
/** 宿主业务上下文,与 MForm 的同名 prop 语义一致 */
|
|
37
|
+
context?: FormContext;
|
|
38
|
+
}
|
|
39
|
+
// #endregion HeadlessFormStateOptions
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 构造一个不依赖组件实例的 `formState`。
|
|
43
|
+
*
|
|
44
|
+
* 字段结构与 `Form.vue` 中 provide 给子树的 `mForm` 保持一致,使配置里的
|
|
45
|
+
* `display` / `rules.validator` / `filter` 等回调拿到的上下文与渲染式校验相同。
|
|
46
|
+
*
|
|
47
|
+
* 差异:没有真实的表单实例,因此 `$emit` 为空实现、`fields` 注册表始终为空
|
|
48
|
+
* (该注册表在整个仓库中只写不读,不参与校验)。
|
|
49
|
+
*/
|
|
50
|
+
export const createHeadlessFormState = (options: HeadlessFormStateOptions): FormState => {
|
|
51
|
+
const context = options.context ?? {};
|
|
52
|
+
const state: FormState = {
|
|
53
|
+
keyProp: options.keyProp ?? '__key',
|
|
54
|
+
popperClass: options.popperClass ?? '',
|
|
55
|
+
config: options.config,
|
|
56
|
+
initValues: options.initValues ?? {},
|
|
57
|
+
isCompare: false,
|
|
58
|
+
lastValues: {},
|
|
59
|
+
parentValues: options.parentValues ?? {},
|
|
60
|
+
values: {},
|
|
61
|
+
lastValuesProcessed: {},
|
|
62
|
+
$emit: () => undefined,
|
|
63
|
+
...createFormStateBase(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return createFormStateProxy(reactive(state), () => context);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 按 prop 路径从表单值中取字段值,路径中断时返回 `undefined`。
|
|
71
|
+
*
|
|
72
|
+
* 不复用 `@tmagic/utils` 的 `getValueByKeyPath`:后者在路径中断时抛错,
|
|
73
|
+
* 而 Element Plus 的 FormItem 取值是宽松的(拿不到就是 `undefined`),此处与之对齐。
|
|
74
|
+
*/
|
|
75
|
+
const getFieldValue = (prop: string, values: FormValue): any => {
|
|
76
|
+
if (!prop) return undefined;
|
|
77
|
+
|
|
78
|
+
return prop.split('.').reduce<any>((acc, key) => {
|
|
79
|
+
if (acc === null || typeof acc !== 'object') return undefined;
|
|
80
|
+
return acc[key];
|
|
81
|
+
}, values);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 校验单个字段,返回 async-validator 的 `fields` 错误映射(通过则返回 `null`)。
|
|
86
|
+
*
|
|
87
|
+
* 与 Element Plus 的 FormItem 逐字段校验保持一致:
|
|
88
|
+
* 每个字段独立构造一个 `Schema({ [prop]: rules })`,以字段自身的值为 source,
|
|
89
|
+
* 并开启 `firstFields` 只取每个字段的首条错误;`trigger` 是 FormItem 用来筛选规则的
|
|
90
|
+
* 元信息,交给 async-validator 前必须剔除,否则会改变规则的匹配方式。
|
|
91
|
+
*/
|
|
92
|
+
const validateField = async (field: CollectedField, values: FormValue): Promise<Record<string, any> | null> => {
|
|
93
|
+
const rules = field.rules.map(({ trigger, ...rule }: any) => rule);
|
|
94
|
+
if (!rules.length) return null;
|
|
95
|
+
|
|
96
|
+
const value = getFieldValue(field.prop, values);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await new Schema({ [field.prop]: rules } as any).validate({ [field.prop]: value }, { firstFields: true });
|
|
100
|
+
return null;
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
// async-validator reject 的形态为 { errors, fields }
|
|
103
|
+
if (err?.fields) return err.fields;
|
|
104
|
+
return { [field.prop]: [{ field: field.prop, message: err?.message ?? `${err}` }] };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// #region ValidateValuesOptions
|
|
109
|
+
/** `validateValues` 参数 */
|
|
110
|
+
export interface ValidateValuesOptions extends HeadlessFormStateOptions {
|
|
111
|
+
/** 是否开启类型匹配校验 */
|
|
112
|
+
typeMatchValid?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* 校验失败时错误提示前缀是否使用字段的 text 文案。默认 `true`。
|
|
115
|
+
*/
|
|
116
|
+
useFieldTextInError?: boolean;
|
|
117
|
+
}
|
|
118
|
+
// #endregion ValidateValuesOptions
|
|
119
|
+
|
|
120
|
+
// #region ValidateValuesResult
|
|
121
|
+
/** `validateValues` 结果 */
|
|
122
|
+
export interface ValidateValuesResult {
|
|
123
|
+
/** 经 `initValue` 初始化并执行字段值初始化写入后的表单值 */
|
|
124
|
+
values: FormValue;
|
|
125
|
+
/** 汇总后的错误文案(多条以 `<br>` 拼接),校验通过为空字符串 */
|
|
126
|
+
error: string;
|
|
127
|
+
/** 原始错误映射,形如 `{ [prop]: [{ field, message }] }` */
|
|
128
|
+
invalidFields: Record<string, any>;
|
|
129
|
+
}
|
|
130
|
+
// #endregion ValidateValuesResult
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 不挂载任何组件,纯逻辑地对「一份 config + 一份值」执行一次完整校验。
|
|
134
|
+
*
|
|
135
|
+
* 流程与渲染式校验一一对应,但不需要 DOM,也不会实例化任何字段组件:
|
|
136
|
+
*
|
|
137
|
+
* 1. 构造 headless `formState` 并挂上 `context` 读穿;
|
|
138
|
+
* 2. `initValue` 初始化表单值(默认值、嵌套结构、`onInitValue` 等);
|
|
139
|
+
* 3. `applyMountValueEffects` 执行字段登记的值初始化写入(与渲染式共用同一份登记表);
|
|
140
|
+
* 4. 遍历 config 树收集所有带规则的字段(等价于渲染出的 FormItem 集合);
|
|
141
|
+
* 5. 逐字段交给 async-validator 执行,汇总错误文案。
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* const { error, values } = await validateValues({
|
|
146
|
+
* config: [...],
|
|
147
|
+
* initValues: { name: '' },
|
|
148
|
+
* typeMatchValid: true,
|
|
149
|
+
* });
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export const validateValues = async (options: ValidateValuesOptions): Promise<ValidateValuesResult> => {
|
|
153
|
+
const { config, initValues = {}, typeMatchValid, useFieldTextInError = true } = options;
|
|
154
|
+
|
|
155
|
+
const formState = createHeadlessFormState(options);
|
|
156
|
+
|
|
157
|
+
const values = await initValue(formState, { initValues, config });
|
|
158
|
+
formState.values = values;
|
|
159
|
+
// 与 Form.vue 一致:值挂到 formState 之后再执行字段的值初始化写入
|
|
160
|
+
applyMountValueEffects(formState, config, values);
|
|
161
|
+
|
|
162
|
+
const fields = collectValidatableFields(formState, config, values, Boolean(typeMatchValid));
|
|
163
|
+
|
|
164
|
+
const invalidFields: Record<string, any> = {};
|
|
165
|
+
for (const field of fields) {
|
|
166
|
+
const fieldErrors = await validateField(field, values);
|
|
167
|
+
if (fieldErrors) {
|
|
168
|
+
Object.assign(invalidFields, fieldErrors);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
values,
|
|
174
|
+
invalidFields,
|
|
175
|
+
error: formatValidateError(invalidFields, { config, useFieldTextInError }),
|
|
176
|
+
};
|
|
177
|
+
};
|