feat(editor): 注册编辑器字段内置 typeMatch 校验规则并补充文档

- 新增 typeMatchRules.ts,为编辑器自定义字段注册内置 typeMatch 校验规则
- 抽取 event.ts 事件工具函数
- form typeMatch 适配 TDesign 校验器签名,错误消息补充实际值
- rules.md 新增「Editor 字段内置规则」章节,16 个字段文档补充校验说明
This commit is contained in:
roymondchen 2026-07-14 17:15:28 +08:00
parent bbe73aae64
commit 1c67b5e77b
47 changed files with 2056 additions and 331 deletions

View File

@ -27,6 +27,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:要求 `string`,有代码块 DSL 时须为已有 codeId。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -24,6 +24,10 @@ CodeSelect 组件支持:
- 选择数据源方法
- 配置代码块参数
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:做 `{ hookType: 'code', hookData }` 的浅层结构校验,`codeId` / 数据源方法存在性由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -41,6 +41,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:默认要求 `string``parse: true` 时跳过校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -27,6 +27,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为已知算子,能解析字段类型时按类型收窄。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -49,6 +49,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数据源路径 `string[]`,有 `fieldConfig` 且非路径值时跳过。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -16,6 +16,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `name`/`type` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -16,6 +16,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:要求 `string``${...}` 绑定须指向已有数据源 / 字段。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -27,6 +27,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `[dsId, methodName]`,方法须在该数据源可选方法集中。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -16,6 +16,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `content`/`params` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -16,6 +16,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `title`/`enable`/`data` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -38,6 +38,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:`value: 'id'` 时须为已有 ds id否则为含 `isBindDataSource` + `dataSourceId` 的对象。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -27,6 +27,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `cond[].field`/`op` 浅层结构校验,算子合法性 / 字段路径存在性由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -28,6 +28,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做兼容新旧格式的浅层结构校验,事件名 / 联动动作是否属于可选项由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -29,6 +29,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为普通对象,`advanced` 且值为 `function` 时放行。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -16,6 +16,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `string``number`,有节点树时须为已有页面片 id。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -16,6 +16,10 @@
}
```
## 值校验
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `string``number`,有节点树时须为已有组件 id。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |

View File

@ -125,6 +125,29 @@ app.use(MagicForm, {
});
```
### Editor 字段内置规则
安装 `@tmagic/editor` 时会自动 `registerTypeMatchRules` 注册编辑器自定义字段规则。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
| 字段 type | 期望值 |
| --- | --- |
| `key-value` / `style-setter` | 普通对象;`key-value` + `advanced` 且值为 `function` 时放行 |
| `cond-op-select` | 已知算子;能解析字段类型时按类型收窄 |
| `code-select-col` | `string`;有代码块 DSL 时须为已有 codeId |
| `page-fragment-select` / `ui-select` | `string \| number`;有节点树时须为已有页面片 / 组件 id |
| `data-source-input` | `string``${...}` 绑定须指向已有数据源/字段 |
| `data-source-method-select` | `[dsId, methodName]`,方法须在该数据源可选方法集中 |
| `data-source-field-select` | 数据源路径 `string[]`;有 `fieldConfig` 且非路径值时跳过 |
| `data-source-select` | `value: 'id'` 为已有 ds id否则为含 `isBindDataSource` + `dataSourceId` 的对象 |
| `code-select` | `{ hookType: 'code', hookData }` 的浅层结构校验(`codeId` 存在性 / 数据源方法存在性由内部 `code-select-col``data-source-method-select` 单元格各自校验,只标红出错单元格) |
| `data-source-fields` / `data-source-mocks` / `data-source-methods` | 数组 + 浅层结构(`name`/`type``title`/`enable`/`data``content`/`params` 等) |
| `event-select` | 数组;兼容新旧格式的浅层结构校验(`name` / 联动组件 `method` 是否 ∈ 可选项由字段内 `eventNameConfig.rules``compActionConfig.rules` 单独校验,只标红对应 select |
| `display-conds` | 数组 + `cond[].field`/`op` 浅层结构校验(`op` 是否为已知算子、字段路径是否存在由内部 `cond-op-select``field` 单元格各自校验,只标红出错单元格) |
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules避免单个子项非法导致整块表单标红。
业务仍可用 `registerTypeMatchRule` 覆盖上述任一 type。
## 示例
### select 选项匹配

View File

@ -52,7 +52,7 @@
"conventional-changelog-cli": "^5.0.0",
"cosmiconfig": "^ 9.0.0 ",
"cz-conventional-changelog": "^3.3.0",
"element-plus": "^2.11.8",
"element-plus": "^2.14.3",
"enquirer": "^2.4.1",
"eslint": "^10.3.0",
"execa": "^9.6.0",

View File

@ -93,7 +93,8 @@ const codeConfig = computed<GroupListConfig>(() => ({
{ value: HookCodeType.CODE, text: '代码块' },
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
],
defaultValue: 'code',
rules: [{ typeMatch: true, trigger: 'blur' }],
defaultValue: HookCodeType.CODE,
onChange: (_mForm, v: HookCodeType, { setModel }) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
@ -111,6 +112,7 @@ const codeConfig = computed<GroupListConfig>(() => ({
labelWidth: 0,
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
rules: [{ typeMatch: true, trigger: 'blur' }],
},
{
type: 'data-source-method-select',
@ -119,6 +121,7 @@ const codeConfig = computed<GroupListConfig>(() => ({
labelWidth: 0,
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
rules: [{ typeMatch: true }],
},
],
},

View File

@ -33,7 +33,7 @@ import { getDesignConfig, TMagicSelect } from '@tmagic/design';
import type { CondOpSelectConfig, FieldProps } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import { arrayOptions, eqOptions, getFieldType, numberOptions } from '@editor/utils';
import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils';
defineOptions({
name: 'MFieldsCondOpSelect',
@ -51,31 +51,9 @@ const optionComponent = getDesignConfig('components')?.option;
const options = computed(() => {
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...props.model.field];
const ds = dataSourceService.getDataSourceById(id);
const type = getFieldType(ds, fieldNames);
if (type === 'array') {
return arrayOptions;
}
if (type === 'boolean' || type === 'null') {
return [
{ text: '是', value: 'is' },
{ text: '不是', value: 'not' },
];
}
if (type === 'number') {
return [...eqOptions, ...numberOptions];
}
if (type === 'string') {
return [...arrayOptions, ...eqOptions];
}
return [...arrayOptions, ...eqOptions, ...numberOptions];
return getCondOpOptionsByFieldType(type);
});
const fieldChangeHandler = (v: string) => {

View File

@ -79,11 +79,11 @@ import { Coin } from '@element-plus/icons-vue';
import type { DataSchema, DataSourceSchema } from '@tmagic/core';
import { getDesignConfig, TMagicAutocomplete, TMagicInput, TMagicTag } from '@tmagic/design';
import type { DataSourceInputConfig, FieldProps } from '@tmagic/form';
import { getKeysArray, isNumber } from '@tmagic/utils';
import { getKeysArray } from '@tmagic/utils';
import Icon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import { getDisplayField } from '@editor/utils/data-source';
import { getDisplayField, resolveFieldByPath } from '@editor/utils/data-source';
defineOptions({
name: 'MFieldsDataSourceInput',
@ -251,34 +251,16 @@ const fieldQuerySearch = (
return;
}
let fields = ds.fields || [];
//
let key = keys.shift();
while (key) {
if (isNumber(key)) {
key = keys.shift();
continue;
}
for (const field of fields) {
if (field.name === key) {
fields = field.fields || [];
key = keys.shift();
break;
}
}
}
const { fields } = resolveFieldByPath(ds.fields, keys, { skipNumberIndices: true });
if (curCharIsDot(dotIndex)) {
// .
result = fields || [];
result = fields;
} else if (dotIndex > -1) {
const queryName = queryString.substring(dotIndex + 1).toLowerCase();
result =
fields.filter(
(field) => field.name?.toLowerCase().includes(queryName) || field.title?.toLowerCase().includes(queryName),
) || [];
result = fields.filter(
(field) => field.name?.toLowerCase().includes(queryName) || field.title?.toLowerCase().includes(queryName),
);
}
cb(

View File

@ -98,6 +98,10 @@ const config = computed<GroupListConfig>(() => ({
label: '字段',
checkStrictly: false,
onChange: fieldOnChange,
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'blur' },
],
}
: {
type: 'data-source-field-select',
@ -107,6 +111,10 @@ const config = computed<GroupListConfig>(() => ({
checkStrictly: false,
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
onChange: fieldOnChange,
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'blur' },
],
},
{
type: 'cond-op-select',
@ -114,6 +122,10 @@ const config = computed<GroupListConfig>(() => ({
label: '条件',
width: 140,
name: 'op',
rules: [
{ required: true, trigger: 'blur', message: '请选择条件' },
{ typeMatch: true, trigger: 'blur' },
],
},
{
label: '值',

View File

@ -67,11 +67,9 @@ import { computed } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { has } from 'lodash-es';
import type { EventOption, MComponent, MContainer } from '@tmagic/core';
import { ActionType } from '@tmagic/core';
import { TMagicButton } from '@tmagic/design';
import type {
CascaderOption,
CodeSelectColConfig,
ContainerChangeEventData,
DataSourceMethodSelectConfig,
@ -84,10 +82,15 @@ import type {
UISelectConfig,
} from '@tmagic/form';
import { defineFormItem, MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form';
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services';
import { getCascaderOptionsFromFields } from '@editor/utils';
import {
getCompActionAllowedValues,
getCompActionOptions,
getEventNameAllowedValues,
getEventNameOptions,
normalizeCompActionValue,
} from '@editor/utils';
defineOptions({
name: 'MFieldsEventSelect',
@ -118,68 +121,56 @@ const eventNameConfig = computed(() => {
labelWidth: '40px',
checkStrictly: () => props.config.src !== 'component',
valueSeparator: '.',
options: (mForm: FormState, { formValue }: any) => {
let events: EventOption[] | CascaderOption[] = [];
if (props.config.src === 'component') {
events = eventsService.getEvent(formValue.type);
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId);
if (pageFragment) {
events = [
{
label: pageFragment.name || '页面片容器',
value: pageFragment.id,
children: events,
},
];
pageFragment.items.forEach((node) => {
traverseNode<MComponent | MContainer>(node, (node) => {
const nodeEvents = (node.type && eventsService.getEvent(node.type)) || [];
events.push({
label: `${node.name}_${node.id}`,
value: `${node.id}`,
children: nodeEvents,
});
});
});
return events;
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(props.config.src, formValue),
rules: [
{
validator: ({ value, callback }: any, { formValue }: any) => {
const allowedNames = getEventNameAllowedValues(props.config as any, formValue);
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
return callback(`事件名(${value})不存在`);
}
}
return events.map((option) => ({
text: option.label,
value: option.value,
}));
}
if (props.config.src === 'datasource') {
//
events = dataSourceService.getFormEvent(formValue.type);
//
const dataSource = dataSourceService.getDataSourceById(formValue.id);
const fields = dataSource?.fields || [];
if (fields.length > 0) {
return [
...events,
{
label: '数据变化',
value: DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX,
children: getCascaderOptionsFromFields(fields),
},
];
}
return events;
}
},
callback();
},
},
],
};
return { ...defaultEventNameConfig, ...props.config.eventNameConfig };
});
const actionTypeOptions = computed(() => {
const o: {
text: string;
label: string;
value: string;
disabled?: boolean;
}[] = [
{
text: '组件',
label: '组件',
value: ActionType.COMP,
},
];
if (!propsService.getDisabledCodeBlock()) {
o.push({
text: '代码',
label: '代码',
disabled: !Object.keys(codeBlockService.getCodeDsl() || {}).length,
value: ActionType.CODE,
});
}
if (!propsService.getDisabledDataSource()) {
o.push({
text: '数据源',
label: '数据源',
value: ActionType.DATA_SOURCE,
});
}
return o;
});
//
const actionTypeConfig = computed(() => {
const defaultActionTypeConfig = {
@ -187,39 +178,17 @@ const actionTypeConfig = computed(() => {
text: '联动类型',
type: 'select',
defaultValue: ActionType.COMP,
options: () => {
const o: {
text: string;
label: string;
value: string;
disabled?: boolean;
}[] = [
{
text: '组件',
label: '组件',
value: ActionType.COMP,
},
];
if (!propsService.getDisabledCodeBlock()) {
o.push({
text: '代码',
label: '代码',
disabled: !Object.keys(codeBlockService.getCodeDsl() || {}).length,
value: ActionType.CODE,
});
}
if (!propsService.getDisabledDataSource()) {
o.push({
text: '数据源',
label: '数据源',
value: ActionType.DATA_SOURCE,
});
}
return o;
},
options: actionTypeOptions.value,
rules: [
{
required: true,
message: '联动类型不能为空',
},
{
typeMatch: true,
trigger: 'blur',
},
],
};
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
});
@ -234,6 +203,12 @@ const targetCompConfig = computed(() => {
onChange: (_MForm, _v, { setModel }) => {
setModel('method', '');
},
rules: [
{
typeMatch: true,
trigger: 'blur',
},
],
};
return { ...defaultTargetCompConfig, ...props.config.targetCompConfig };
});
@ -254,41 +229,20 @@ const compActionConfig = computed(() => {
},
checkStrictly: () => props.config.src !== 'component',
display: (mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
options: (mForm: FormState, { model }: any) => {
const node = editorService.getNodeById(model.to);
if (!node?.type) return [];
let methods: EventOption[] | CascaderOption[] = [];
methods = eventsService.getMethod(node.type, model.to);
if (node.type === 'page-fragment-container' && node.pageFragmentId) {
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
if (pageFragment) {
methods = [];
pageFragment.items.forEach((node: MComponent | MContainer) => {
traverseNode<MComponent | MContainer>(node, (node) => {
const nodeMethods = (node.type && eventsService.getMethod(node.type, node.id)) || [];
if (nodeMethods.length) {
methods.push({
label: `${node.name}_${node.id}`,
value: `${node.id}`,
children: nodeMethods,
});
}
});
});
return methods;
}
}
return methods.map((method) => ({
text: method.label,
value: method.value,
}));
},
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
rules: [
{
trigger: 'blur',
validator: ({ value, callback }: any, { model }: any) => {
const allowedMethods = getCompActionAllowedValues(props.config as any, model);
const normalized = normalizeCompActionValue(value);
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
return callback(`动作名(${normalized})不存在`);
}
callback();
},
},
],
};
return { ...defaultCompActionConfig, ...props.config.compActionConfig };
});

View File

@ -21,7 +21,7 @@ import type { App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design';
import type { FormInstallOptions } from '@tmagic/form';
import formPlugin from '@tmagic/form';
import formPlugin, { registerTypeMatchRules } from '@tmagic/form';
import tablePlugin from '@tmagic/table';
import Code from './fields/Code.vue';
@ -44,6 +44,7 @@ import StyleSetter from './fields/StyleSetter/Index.vue';
import uiSelect from './fields/UISelect.vue';
import CodeEditor from './layouts/CodeEditor.vue';
import { setEditorConfig } from './utils/config';
import { editorTypeMatchRules } from './utils/typeMatchRules';
import Editor from './Editor.vue';
import type { EditorInstallOptions } from './type';
@ -64,6 +65,7 @@ export default {
app.use(designPlugin, opt || {});
app.use(formPlugin, opt || {});
app.use(tablePlugin);
registerTypeMatchRules(editorTypeMatchRules);
app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option);

View File

@ -35,3 +35,11 @@
margin-right: 5px;
}
}
.el-form-item {
.el-form-item {
&.is-error {
margin-bottom: 18px;
}
}
}

View File

@ -235,19 +235,37 @@ export const getCascaderOptionsFromFields = (
return result;
};
export const getFieldType = (ds: DataSourceSchema | undefined, fieldNames: string[]) => {
let fields = ds?.fields;
let type = '';
/**
* DataSchema
* @param skipNumberIndices true arr[0].x
*/
export const resolveFieldByPath = (
fields: DataSchema[] | undefined,
fieldNames: string[],
options: { skipNumberIndices?: boolean } = {},
): { ok: boolean; field?: DataSchema; fields: DataSchema[] } => {
let currentFields = fields || [];
let field: DataSchema | undefined;
for (const fieldName of fieldNames) {
if (!fields?.length) return '';
const field = fields.find((f) => f.name === fieldName);
if (!field) return '';
type = field.type || '';
fields = field.fields;
for (const name of fieldNames) {
if (options.skipNumberIndices && isNumber(name)) {
continue;
}
if (!currentFields.length) {
return { ok: false, fields: currentFields };
}
field = currentFields.find((item) => item.name === name);
if (!field) {
return { ok: false, fields: currentFields };
}
currentFields = field.fields || [];
}
return type;
return { field, ok: true, fields: currentFields };
};
export const getFieldType = (ds: DataSourceSchema | undefined, fieldNames: string[]) => {
const { ok, field } = resolveFieldByPath(ds?.fields, fieldNames);
if (!ok) return '';
return field?.type || '';
};

View File

@ -0,0 +1,224 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { EventOption, Id, MComponent, MContainer } from '@tmagic/core';
import type { CascaderOption } from '@tmagic/form';
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
import dataSourceService from '@editor/services/dataSource';
import editorService from '@editor/services/editor';
import eventsService from '@editor/services/events';
import { getCascaderOptionsFromFields } from '@editor/utils/data-source';
export const EVENT_NAME_VALUE_SEPARATOR = '.';
export type EventNameSelectOption = { text: string; value: string };
export type EventNameOption = EventOption | CascaderOption | EventNameSelectOption;
/** 与 EventSelect 中 checkStrictly 一致component 为 false其余为 true */
export const isEventNameCheckStrictly = (src?: string) => src !== 'component';
/** 将动作 method 值规范为与 collectEventNameOptionValues 一致的字符串cascader 无 valueSeparator 时存数组) */
export const normalizeCompActionValue = (value: unknown): string => {
if (Array.isArray(value)) {
return value.map((item) => `${item}`).join(EVENT_NAME_VALUE_SEPARATOR);
}
if (value === null || value === undefined || value === '') {
return '';
}
return `${value}`;
};
/**
* / options EventSelect eventNameConfig.options
*/
export const getEventNameOptions = (
src: string | undefined,
formValue: Record<string, any> = {},
): EventNameOption[] => {
if (!formValue.type) {
return [];
}
if (src === 'component') {
let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type) || [];
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId);
if (!pageFragment) {
return [];
}
events = [
{
label: pageFragment.name || '页面片容器',
value: pageFragment.id,
children: events,
},
];
(pageFragment.items || []).forEach((node) => {
traverseNode<MComponent | MContainer>(node, (current) => {
const nodeEvents = (current.type && eventsService.getEvent(current.type)) || [];
(events as CascaderOption[]).push({
label: `${current.name}_${current.id}`,
value: `${current.id}`,
children: nodeEvents,
});
});
});
return events;
}
return (events as EventOption[]).map((option) => ({
text: option.label,
value: option.value,
}));
}
if (src === 'datasource') {
const events: EventNameOption[] = dataSourceService.getFormEvent(formValue.type) || [];
const dataSource = dataSourceService.getDataSourceById(formValue.id);
const fields = dataSource?.fields || [];
if (fields.length > 0) {
return [
...events,
{
label: '数据变化',
value: DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX,
children: getCascaderOptionsFromFields(fields),
},
];
}
return events;
}
return [];
};
/** 将 select / cascader options 展平为最终写入 name 的字符串集合cascader 用 `.` 拼接) */
export const collectEventNameOptionValues = (
options: EventNameOption[],
checkStrictly: boolean,
prefix: any[] = [],
result: Set<string> = new Set(),
): Set<string> => {
options.forEach((option) => {
if (typeof option?.value === 'undefined') {
return;
}
const path = [...prefix, option.value];
const joined = path.map((item) => `${item}`).join(EVENT_NAME_VALUE_SEPARATOR);
const children = 'children' in option ? option.children : undefined;
if (Array.isArray(children) && children.length) {
if (checkStrictly) {
result.add(joined);
}
collectEventNameOptionValues(children as EventNameOption[], checkStrictly, path, result);
return;
}
result.add(joined);
});
return result;
};
/**
* event-select name
* eventNameConfig.options null
*/
export const getEventNameAllowedValues = (
config: { src?: string; eventNameConfig?: { options?: unknown } } = {},
formValue: Record<string, any> = {},
): Set<string> | null => {
if (typeof config.eventNameConfig?.options !== 'undefined') {
return null;
}
if (config.src !== 'component' && config.src !== 'datasource') {
return null;
}
return collectEventNameOptionValues(getEventNameOptions(config.src, formValue), isEventNameCheckStrictly(config.src));
};
/**
* / options EventSelect compActionConfig.options
*/
export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
if (typeof toId === 'undefined' || toId === null || toId === '') {
return [];
}
const node = editorService.getNodeById(toId);
if (!node?.type) {
return [];
}
let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, toId) || [];
if (node.type === 'page-fragment-container' && node.pageFragmentId) {
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
if (!pageFragment) {
return [];
}
methods = [];
(pageFragment.items || []).forEach((item: MComponent | MContainer) => {
traverseNode<MComponent | MContainer>(item, (current) => {
const nodeMethods = (current.type && eventsService.getMethod(current.type, current.id)) || [];
if (nodeMethods.length) {
(methods as CascaderOption[]).push({
label: `${current.name}_${current.id}`,
value: `${current.id}`,
children: nodeMethods,
});
}
});
});
return methods;
}
return (methods as EventOption[]).map((method) => ({
text: method.label,
value: method.value,
}));
};
/**
* event-select method
* compActionConfig.options null
*/
export const getCompActionAllowedValues = (
config: { src?: string; compActionConfig?: { options?: unknown } } = {},
model: { to?: Id } = {},
): Set<string> | null => {
if (typeof config.compActionConfig?.options !== 'undefined') {
return null;
}
return collectEventNameOptionValues(getCompActionOptions(model.to), isEventNameCheckStrictly(config.src));
};

View File

@ -30,4 +30,6 @@ export * from './undo-redo';
export * from './indexed-db';
export * from './history';
export * from './const';
export * from './typeMatchRules';
export * from './event';
export { default as loadMonaco } from './monaco-editor';

View File

@ -45,6 +45,32 @@ export const numberOptions = [
{ text: '不在范围内', value: 'not_between' },
];
export const booleanOptions = [
{ text: '是', value: 'is' },
{ text: '不是', value: 'not' },
];
/** 按字段类型返回条件运算符选项UI 与 typeMatch 校验共用) */
export const getCondOpOptionsByFieldType = (type: string) => {
if (type === 'array') {
return arrayOptions;
}
if (type === 'boolean' || type === 'null') {
return booleanOptions;
}
if (type === 'number') {
return [...eqOptions, ...numberOptions];
}
if (type === 'string') {
return [...arrayOptions, ...eqOptions];
}
return [...arrayOptions, ...eqOptions, ...numberOptions];
};
export const styleTabConfig: TabPaneConfig = {
title: '样式',
lazy: true,
@ -127,6 +153,7 @@ export const eventTabConfig: TabPaneConfig = {
src: 'component',
labelWidth: '100px',
type: 'event-select',
rules: [{ typeMatch: true }],
},
],
};
@ -155,6 +182,7 @@ export const advancedTabConfig: TabPaneConfig = {
labelPosition: 'top',
type: 'code-select',
extra: '组件初始化时执行',
rules: [{ typeMatch: true }],
},
{
name: 'mounted',
@ -162,6 +190,7 @@ export const advancedTabConfig: TabPaneConfig = {
labelPosition: 'top',
type: 'code-select',
extra: '组件挂载到dom时执行',
rules: [{ typeMatch: true }],
},
{
name: 'display',
@ -169,6 +198,7 @@ export const advancedTabConfig: TabPaneConfig = {
extra: '控制组件是否渲染关系的代码块返回值为false时不渲染',
labelPosition: 'top',
type: 'code-select',
rules: [{ typeMatch: true }],
},
],
};
@ -194,6 +224,7 @@ export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
name: NODE_CONDS_KEY,
titlePrefix: '条件组',
defaultValue: [],
rules: [{ typeMatch: true }],
},
],
};

View File

@ -0,0 +1,545 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { HookCodeType, HookType, NodeType } from '@tmagic/core';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
DATA_SOURCE_SET_DATA_METHOD_NAME,
dataSourceTemplateRegExp,
getKeysArray,
removeDataSourceFieldPrefix,
} from '@tmagic/utils';
import codeBlockService from '@editor/services/codeBlock';
import dataSourceService from '@editor/services/dataSource';
import editorService from '@editor/services/editor';
import { getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
import {
arrayOptions,
booleanOptions,
eqOptions,
getCondOpOptionsByFieldType,
numberOptions,
} from '@editor/utils/props';
const defaultMessage = (message: string | undefined, fallback: string) => message || fallback;
const isPlainObject = (value: any): value is Record<string, any> =>
Object.prototype.toString.call(value) === '[object Object]';
const isId = (value: any): value is Id => typeof value === 'string' || typeof value === 'number';
const DATA_SOURCE_FIELD_TYPES = new Set<DataSourceFieldType>([
'null',
'boolean',
'object',
'array',
'number',
'string',
'any',
]);
export const ALL_COND_OPS = new Set<string>([
...arrayOptions.map((item) => item.value),
...eqOptions.map((item) => item.value),
...numberOptions.map((item) => item.value),
...booleanOptions.map((item) => item.value),
]);
const getCondOpsByFieldType = (type: string): Set<string> =>
new Set(getCondOpOptionsByFieldType(type).map((item) => item.value));
const getDataSources = (): DataSourceSchema[] => dataSourceService.get('dataSources') || [];
const findDataSource = (id: string): DataSourceSchema | undefined =>
getDataSources().find((ds) => `${ds.id}` === `${id}`);
const getMethodNamesForDataSource = (ds: DataSourceSchema): Set<string> => {
const names = new Set<string>([DATA_SOURCE_SET_DATA_METHOD_NAME]);
(dataSourceService.getFormMethod(ds.type) || []).forEach((item) => {
if (item?.value) names.add(item.value);
});
(ds.methods || []).forEach((method) => {
if (method?.name) names.add(method.name);
});
return names;
};
const validateDataSourceFieldPath = (
path: string[],
options: {
dataSourceId?: string;
valueMode?: 'key' | 'value';
dataSourceFieldType?: DataSourceFieldType[];
message?: string;
} = {},
): string | undefined => {
const dataSources = getDataSources();
if (!dataSources.length) {
return undefined;
}
let dsId = options.dataSourceId;
let fieldNames = path;
if (!dsId) {
if (!path.length) {
return defaultMessage(options.message, '值不在可选项中');
}
const rawDsId = path[0];
dsId =
options.valueMode === 'key' || !`${rawDsId}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX)
? `${rawDsId}`
: removeDataSourceFieldPrefix(`${rawDsId}`);
fieldNames = path.slice(1);
}
const ds = findDataSource(`${dsId}`);
if (!ds) {
return defaultMessage(options.message, '值不在可选项中');
}
if (!fieldNames.length) {
return undefined;
}
const { field, ok } = resolveFieldByPath(ds.fields, fieldNames);
if (!ok) {
return defaultMessage(options.message, '值不在可选项中');
}
const allowedTypes = options.dataSourceFieldType || ['any'];
if (!allowedTypes.length || allowedTypes.includes('any')) {
return undefined;
}
const leafType = field?.type || 'any';
if (leafType === 'any' || allowedTypes.includes(leafType)) {
return undefined;
}
return defaultMessage(options.message, '值不在可选项中');
};
const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => {
if (!Array.isArray(value) || value.length !== 2 || value.some((item) => typeof item !== 'string')) {
return defaultMessage(message, `${value}类型应为长度为 2 的字符串数组`);
}
const dataSources = getDataSources();
if (!dataSources.length) {
return undefined;
}
const [dsId, methodName] = value;
const ds = findDataSource(dsId);
if (!ds) {
return defaultMessage(message, `数据源(${dsId})不存在`);
}
if (!getMethodNamesForDataSource(ds).has(methodName)) {
return defaultMessage(message, `数据源方法(${methodName})不存在`);
}
return undefined;
};
const validateDataSourceTemplateBindings = (value: string, message?: string): string | undefined => {
const dataSources = getDataSources();
if (!dataSources.length) {
return undefined;
}
const matches = value.matchAll(dataSourceTemplateRegExp);
for (const match of matches) {
const keys = getKeysArray(match[1]);
if (!keys.length) {
return defaultMessage(message, `数据源绑定(${value})不合法`);
}
const [dsId, ...fieldNames] = keys;
const ds = findDataSource(`${dsId}`);
if (!ds) {
return defaultMessage(message, `数据源绑定(${value})不合法`);
}
const { ok } = resolveFieldByPath(ds.fields, fieldNames, { skipNumberIndices: true });
if (!ok) {
return defaultMessage(message, `数据源绑定(${value})不合法`);
}
}
return undefined;
};
const validateDataSourceFieldsItem = (item: any, message?: string): string | undefined => {
if (!isPlainObject(item) || typeof item.name !== 'string') {
return defaultMessage(message, '字段项结构不合法');
}
if (typeof item.type !== 'undefined' && !DATA_SOURCE_FIELD_TYPES.has(item.type)) {
return defaultMessage(message, '字段类型不合法');
}
if (typeof item.fields !== 'undefined') {
if (!Array.isArray(item.fields)) {
return defaultMessage(message, '字段项结构不合法');
}
for (const child of item.fields) {
const error = validateDataSourceFieldsItem(child, message);
if (error) return error;
}
}
return undefined;
};
const validateKeyValue: TypeMatchValidator = (value, { message, props }) => {
if (props.config?.advanced && typeof value === 'function') {
return undefined;
}
if (!isPlainObject(value)) {
return defaultMessage(message, '值类型应为对象');
}
return undefined;
};
const validateStyleSetter: TypeMatchValidator = (value, { message }) => {
if (!isPlainObject(value)) {
return defaultMessage(message, '值类型应为对象');
}
return undefined;
};
const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) => {
if (typeof value !== 'string') {
return defaultMessage(message, '值类型应为字符串');
}
const parentFields = props.config?.parentFields || [];
const fieldPath = Array.isArray(props.model?.field) ? props.model.field : [];
const [id, ...fieldNames] = [...parentFields, ...fieldPath];
const ds = id ? findDataSource(`${id}`) : undefined;
const fieldType = getFieldType(ds, fieldNames);
const allowed = getCondOpsByFieldType(fieldType);
if (!allowed.has(value)) {
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
};
const validateCodeSelectCol: TypeMatchValidator = (value, { message }) => {
if (typeof value !== 'string') {
return defaultMessage(message, `${value}类型应为字符串`);
}
const codeDsl = codeBlockService.getCodeDsl();
if (!codeDsl || !Object.keys(codeDsl).length) {
return undefined;
}
if (!(value in codeDsl)) {
return defaultMessage(message, `代码块(${value})不存在`);
}
return undefined;
};
const validatePageFragmentSelect: TypeMatchValidator = (value, { message }) => {
if (!isId(value)) {
return defaultMessage(message, `${value}类型应为字符串或数字`);
}
const items = editorService.get('root')?.items;
if (!items?.length) {
return undefined;
}
const exists = items.some((item) => item.type === NodeType.PAGE_FRAGMENT && `${item.id}` === `${value}`);
if (!exists) {
return defaultMessage(message, `页面片段(${value})不存在`);
}
return undefined;
};
const validateUiSelect: TypeMatchValidator = (value, { message }) => {
if (!isId(value)) {
return defaultMessage(message, `${value}类型应为字符串或数字`);
}
const root = editorService.get('root');
if (!root) {
return undefined;
}
if (!editorService.getNodeById(value)) {
return defaultMessage(message, `组件(${value})不存在`);
}
return undefined;
};
const validateDataSourceInput: TypeMatchValidator = (value, { message }) => {
if (typeof value !== 'string') {
return defaultMessage(message, `${value}类型应为字符串`);
}
return validateDataSourceTemplateBindings(value, message);
};
const validateDataSourceMethodSelect: TypeMatchValidator = (value, { message }) =>
validateDataSourceMethodTuple(value, message);
const isDataSourceFieldPathValue = (value: any, config: any): value is string[] => {
if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== 'string')) {
return false;
}
if (config.dataSourceId) {
return true;
}
if (config.value === 'key') {
return true;
}
return `${value[0]}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX);
};
const validateDataSourceFieldSelect: TypeMatchValidator = (value, { message, props }) => {
const config = props.config || {};
if (config.fieldConfig && !isDataSourceFieldPathValue(value, config)) {
return undefined;
}
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
return defaultMessage(message, `${value}类型应为字符串数组`);
}
return validateDataSourceFieldPath(value, {
dataSourceId: config.dataSourceId,
valueMode: config.value,
dataSourceFieldType: config.dataSourceFieldType,
message,
});
};
const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => {
const config = props.config || {};
const dataSources = getDataSources();
const filtered = config.dataSourceType ? dataSources.filter((ds) => ds.type === config.dataSourceType) : dataSources;
if (config.value === 'id') {
if (typeof value !== 'string') {
return defaultMessage(message, `${value}类型应为字符串`);
}
if (!filtered.length) {
return undefined;
}
if (!filtered.some((ds) => `${ds.id}` === `${value}`)) {
return defaultMessage(message, `${value}不在可选项中`);
}
return undefined;
}
if (!isPlainObject(value) || value.isBindDataSource !== true || typeof value.dataSourceId === 'undefined') {
return defaultMessage(message, `${value}类型不合法`);
}
if (!filtered.length) {
return undefined;
}
const ds = filtered.find((item) => `${item.id}` === `${value.dataSourceId}`);
if (!ds) {
return defaultMessage(message, `${value}不在可选项中`);
}
if (typeof value.dataSourceType !== 'undefined' && value.dataSourceType !== ds.type) {
return defaultMessage(message, `${value}不在可选项中`);
}
return undefined;
};
const validateCodeSelect: TypeMatchValidator = (value, { message }) => {
if (!isPlainObject(value) || value.hookType !== HookType.CODE || !Array.isArray(value.hookData)) {
return defaultMessage(message, `${value}类型不合法`);
}
// 「codeId 是否存在 / 数据源方法是否存在」由 CodeSelect 内部 code-select-col、
// data-source-method-select 单元格各自的 typeMatch 校验,只标红出错单元格。此处仅做结构校验。
for (const item of value.hookData) {
if (!isPlainObject(item)) {
return defaultMessage(message, '钩子项结构不合法');
}
if (item.codeType !== HookCodeType.CODE && item.codeType !== HookCodeType.DATA_SOURCE_METHOD) {
return defaultMessage(message, '钩子项结构不合法');
}
if (typeof item.params !== 'undefined' && !isPlainObject(item.params)) {
return defaultMessage(message, '钩子项结构不合法');
}
if (item.codeType === HookCodeType.CODE) {
if (typeof item.codeId !== 'string') {
return defaultMessage(message, '钩子项结构不合法');
}
continue;
}
// DATA_SOURCE_METHOD仅校验元组形态存在性交给单元格
if (
!Array.isArray(item.codeId) ||
item.codeId.length !== 2 ||
item.codeId.some((part: any) => typeof part !== 'string')
) {
return defaultMessage(message, '钩子项结构不合法');
}
}
return undefined;
};
const validateDataSourceFields: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
}
for (const item of value) {
const error = validateDataSourceFieldsItem(item, message);
if (error) return error;
}
return undefined;
};
const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
}
for (const item of value) {
if (
!isPlainObject(item) ||
typeof item.title !== 'string' ||
typeof item.enable !== 'boolean' ||
typeof item.useInEditor !== 'boolean' ||
!isPlainObject(item.data)
) {
return defaultMessage(message, 'mock 项结构不合法');
}
}
return undefined;
};
const validateDataSourceMethods: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
}
for (const item of value) {
if (!isPlainObject(item) || typeof item.name !== 'string') {
return defaultMessage(message, '方法项结构不合法');
}
if (typeof item.content !== 'undefined' && typeof item.content !== 'string' && typeof item.content !== 'function') {
return defaultMessage(message, '方法项结构不合法');
}
if (typeof item.params !== 'undefined' && !Array.isArray(item.params)) {
return defaultMessage(message, '方法项结构不合法');
}
}
return undefined;
};
const validateEventSelect: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
}
// 「事件名 / 动作名是否在可选项中」由 EventSelect 的 eventNameConfig.rules、compActionConfig.rules 单独校验,
// 以便只标红对应 select而不是整张联动卡片。此处仅做结构校验。
for (const item of value) {
if (!isPlainObject(item) || typeof item.name !== 'string') {
return defaultMessage(message, '事件项结构不合法');
}
if (Array.isArray(item.actions)) {
for (const action of item.actions) {
if (!isPlainObject(action) || typeof action.actionType === 'undefined') {
return defaultMessage(message, '事件项结构不合法');
}
}
continue;
}
if (typeof item.to === 'undefined' || typeof item.method !== 'string') {
return defaultMessage(message, '事件项结构不合法');
}
}
return undefined;
};
const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
}
// 「op 是否为合法算子 / 字段路径是否存在」由 DisplayConds 内部 cond-op-select、
// field 单元格data-source-field-select / cascader各自的 typeMatch 校验,只标红出错单元格。
// 此处仅做结构校验。
for (const item of value) {
if (!isPlainObject(item) || !Array.isArray(item.cond)) {
return defaultMessage(message, '显示条件结构不合法');
}
for (const cond of item.cond) {
if (
!isPlainObject(cond) ||
!Array.isArray(cond.field) ||
cond.field.some((part: any) => typeof part !== 'string') ||
typeof cond.op !== 'string'
) {
return defaultMessage(message, '显示条件结构不合法');
}
}
}
return undefined;
};
export const editorTypeMatchRules: Record<string, TypeMatchValidator> = {
'key-value': validateKeyValue,
'style-setter': validateStyleSetter,
'cond-op-select': validateCondOpSelect,
'code-select-col': validateCodeSelectCol,
'page-fragment-select': validatePageFragmentSelect,
'ui-select': validateUiSelect,
'data-source-input': validateDataSourceInput,
'data-source-method-select': validateDataSourceMethodSelect,
'data-source-field-select': validateDataSourceFieldSelect,
'data-source-select': validateDataSourceSelect,
'code-select': validateCodeSelect,
'data-source-fields': validateDataSourceFields,
'data-source-mocks': validateDataSourceMocks,
'data-source-methods': validateDataSourceMethods,
'event-select': validateEventSelect,
'display-conds': validateDisplayConds,
};
export type { TypeMatchValidateContext };

View File

@ -137,6 +137,19 @@ describe('CodeSelect', () => {
expect(dsCol.display(undefined, { model: { codeType: 'code' } })).toBe(false);
});
test('codeId 单元格开启 typeMatch存在性校验下沉到单元格', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
const row = config.items[0];
const codeIdCol = row.items[1];
const dsCol = row.items[2];
expect(codeIdCol.type).toBe('code-select-col');
expect(codeIdCol.rules).toEqual([{ typeMatch: true, trigger: 'blur' }]);
expect(dsCol.type).toBe('data-source-method-select');
expect(dsCol.rules).toEqual([{ typeMatch: true }]);
});
test('notEditable 调用各服务', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
dataSourceService.get.mockReturnValue(false);

View File

@ -23,9 +23,6 @@ vi.mock('@editor/utils', async () => {
return {
...actual,
getFieldType: vi.fn(),
arrayOptions: [{ text: 'in', value: 'in' }],
eqOptions: [{ text: 'eq', value: 'eq' }],
numberOptions: [{ text: 'gt', value: 'gt' }],
};
});
@ -61,7 +58,9 @@ describe('CondOpSelect', () => {
test('array 类型展示 arrayOptions', () => {
(getFieldType as any).mockReturnValue('array');
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
expect(wrapper.findAll('option, .tmagic-design-option, [label]').length).toBeGreaterThan(0);
const html = wrapper.html();
expect(html).toContain('label="包含"');
expect(html).toContain('label="不包含"');
});
test('boolean/null 类型展示 是/不是', () => {
@ -74,31 +73,31 @@ describe('CondOpSelect', () => {
(getFieldType as any).mockReturnValue('number');
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
const html = wrapper.html();
expect(html).toContain('label="eq"');
expect(html).toContain('label="gt"');
expect(html).toContain('label="等于"');
expect(html).toContain('label="大于"');
});
test('string 类型 options 包含 array+eq', () => {
(getFieldType as any).mockReturnValue('string');
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
const html = wrapper.html();
expect(html).toContain('label="in"');
expect(html).toContain('label="eq"');
expect(html).toContain('label="包含"');
expect(html).toContain('label="等于"');
});
test('其他类型展示 array+eq+number', () => {
(getFieldType as any).mockReturnValue('any');
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
const html = wrapper.html();
expect(html).toContain('label="in"');
expect(html).toContain('label="eq"');
expect(html).toContain('label="gt"');
expect(html).toContain('label="包含"');
expect(html).toContain('label="等于"');
expect(html).toContain('label="大于"');
});
test('change 事件 emit', async () => {
(getFieldType as any).mockReturnValue('string');
const wrapper = mount(CondOpSelect, { props: baseProps() as any });
await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', 'eq');
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('eq');
await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', '=');
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('=');
});
});

View File

@ -29,12 +29,16 @@ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService, propsService }),
}));
vi.mock('@editor/utils/data-source', () => ({
getDisplayField: vi.fn((_dss: any, value: string) => {
if (!value) return [];
return [{ value, type: 'text' }];
}),
}));
vi.mock('@editor/utils/data-source', async (importOriginal) => {
const actual = await importOriginal<typeof import('@editor/utils/data-source')>();
return {
...actual,
getDisplayField: vi.fn((_dss: any, value: string) => {
if (!value) return [];
return [{ value, type: 'text' }];
}),
};
});
vi.mock('@editor/components/Icon.vue', () => ({
default: defineComponent({ name: 'IconStub', setup: () => () => h('i') }),

View File

@ -100,6 +100,35 @@ describe('DisplayConds', () => {
expect(item.type).toBe('data-source-field-select');
});
test('field / op 单元格开启 typeMatch枚举校验下沉到单元格', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
});
const cascaderField = capturedConfig.items[0].items[0];
const opItem = capturedConfig.items[0].items[1];
expect(cascaderField.rules).toEqual([
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'blur' },
]);
expect(opItem.type).toBe('cond-op-select');
expect(opItem.rules).toEqual([
{ required: true, trigger: 'blur', message: '请选择条件' },
{ typeMatch: true, trigger: 'blur' },
]);
// parentFields 为空时 field 走 data-source-field-select同样开启 typeMatch
capturedConfig = null;
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
});
const dsField = capturedConfig.items[0].items[0];
expect(dsField.type).toBe('data-source-field-select');
expect(dsField.rules).toEqual([
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'blur' },
]);
});
test('value 字段类型 - number', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,

View File

@ -9,34 +9,42 @@ import { mount } from '@vue/test-utils';
import EventSelect from '@editor/fields/EventSelect.vue';
const editorService = {
get: vi.fn(),
getNodeById: vi.fn(),
};
const dataSourceService = {
get: vi.fn(),
getDataSourceById: vi.fn(),
getFormEvent: vi.fn(() => []),
};
const eventsService = {
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
};
const codeBlockService = {
getCodeDsl: vi.fn(() => ({ c1: {} })),
getEditStatus: vi.fn(() => true),
};
const propsService = {
getDisabledCodeBlock: vi.fn(() => false),
getDisabledDataSource: vi.fn(() => false),
};
const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = vi.hoisted(() => ({
editorService: {
get: vi.fn(),
getNodeById: vi.fn(),
},
dataSourceService: {
get: vi.fn(),
getDataSourceById: vi.fn(),
getFormEvent: vi.fn(() => []),
},
eventsService: {
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
},
codeBlockService: {
getCodeDsl: vi.fn(() => ({ c1: {} })),
getEditStatus: vi.fn(() => true),
},
propsService: {
getDisabledCodeBlock: vi.fn(() => false),
getDisabledDataSource: vi.fn(() => false),
},
}));
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
}));
vi.mock('@editor/utils', async () => {
const actual = await vi.importActual<any>('@editor/utils');
vi.mock('@editor/services/editor', () => ({ default: editorService }));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/events', () => ({ default: eventsService }));
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
vi.mock('@editor/services/props', () => ({ default: propsService }));
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) };
});
@ -192,6 +200,44 @@ describe('EventSelect', () => {
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
});
test('eventNameConfig.rules 仅校验事件名是否在可选项中', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const [rule] = cfg.rules;
const okCb = vi.fn();
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } });
expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在');
// 空值不做枚举校验
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } });
expect(emptyCb).toHaveBeenCalledWith();
});
test('eventNameConfig.rules 自定义 options 时跳过枚举', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
config: { type: 'event-select', src: 'component', eventNameConfig: { options: () => [{ value: 'x' }] } },
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const [rule] = cfg.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } });
expect(cb).toHaveBeenCalledWith();
});
test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => {
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] });
const wrapper = mount(EventSelect, {
@ -244,7 +290,7 @@ describe('EventSelect', () => {
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const groupItems = panelCfg.items[0].items;
const actionType = groupItems[0];
const opts = actionType.options();
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
});
@ -258,7 +304,7 @@ describe('EventSelect', () => {
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const actionType = panelCfg.items[0].items[0];
const opts = actionType.options();
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
@ -318,6 +364,74 @@ describe('EventSelect', () => {
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
});
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } });
expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在');
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } });
expect(emptyCb).toHaveBeenCalledWith();
});
test('compActionConfig.rules 自定义 options 时跳过枚举', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
const wrapper = mount(EventSelect, {
props: baseProps({
config: {
type: 'event-select',
src: 'component',
compActionConfig: { options: () => [{ text: 'x', value: 'x' }] },
},
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const [rule] = compAction.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } });
expect(cb).toHaveBeenCalledWith();
});
test('compActionConfig.rules 页面片 cascader 支持数组 method', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } });
expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在');
});
test('codeActionConfig display/notEditable', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
const wrapper = mount(EventSelect, {

View File

@ -10,9 +10,14 @@ import editorPlugin from '@editor/plugin';
vi.mock('@tmagic/design', () => ({
default: { install: vi.fn() },
}));
vi.mock('@tmagic/form', () => ({
default: { install: vi.fn() },
}));
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tmagic/form')>();
return {
...actual,
default: { install: vi.fn() },
registerTypeMatchRules: vi.fn(),
};
});
vi.mock('@tmagic/table', () => ({
default: { install: vi.fn() },
}));
@ -70,10 +75,13 @@ describe('plugin install', () => {
};
};
test('install 调用 design/form/table 插件并注册全局组件', () => {
test('install 调用 design/form/table 插件并注册全局组件', async () => {
const { registerTypeMatchRules } = await import('@tmagic/form');
const { editorTypeMatchRules } = await import('@editor/utils/typeMatchRules');
const { app, components } = buildApp();
editorPlugin.install(app, { someOption: true } as any);
expect(app.use).toHaveBeenCalledTimes(3);
expect(registerTypeMatchRules).toHaveBeenCalledWith(editorTypeMatchRules);
expect(Object.keys(components).length).toBeGreaterThan(10);
expect(components.MEditor).toBeDefined();
});

View File

@ -11,6 +11,7 @@ import {
getFieldType,
getFormConfig,
getFormValue,
resolveFieldByPath,
} from '@editor/utils/data-source';
describe('data-source utils', () => {
@ -84,6 +85,48 @@ describe('data-source utils', () => {
expect(opts.map((o) => o.value)).toEqual(['a']);
});
test('resolveFieldByPath 沿 path 下钻', () => {
const fields: any = [
{
name: 'obj',
type: 'object',
fields: [{ name: 'name', type: 'string' }],
},
{
name: 'arr',
type: 'array',
fields: [{ name: 'item', type: 'number' }],
},
];
expect(resolveFieldByPath(fields, ['obj', 'name'])).toMatchObject({
ok: true,
field: { name: 'name', type: 'string' },
fields: [],
});
expect(resolveFieldByPath(fields, ['obj']).field?.name).toBe('obj');
expect(resolveFieldByPath(fields, ['unknown'])).toEqual({ ok: false, fields });
expect(resolveFieldByPath(undefined, ['x'])).toEqual({ ok: false, fields: [] });
expect(resolveFieldByPath(fields, []).ok).toBe(true);
});
test('resolveFieldByPath 可跳过数字下标', () => {
const fields: any = [
{
name: 'arr',
type: 'array',
fields: [{ name: 'item', type: 'number', fields: [{ name: 'n', type: 'string' }] }],
},
];
const result = resolveFieldByPath(fields, ['arr', '0', 'item'], { skipNumberIndices: true });
expect(result.ok).toBe(true);
expect(result.field?.name).toBe('item');
expect(result.fields).toEqual([{ name: 'n', type: 'string' }]);
expect(resolveFieldByPath(fields, ['arr', '0', 'missing'], { skipNumberIndices: true }).ok).toBe(false);
});
test('getFieldType 沿 path 取最终类型', () => {
const ds: any = {
fields: [{ name: 'obj', type: 'object', fields: [{ name: 'name', type: 'string' }] }],

View File

@ -0,0 +1,107 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
collectEventNameOptionValues,
getCompActionAllowedValues,
getCompActionOptions,
getEventNameAllowedValues,
getEventNameOptions,
normalizeCompActionValue,
} from '@editor/utils/event';
const { editorService, dataSourceService, eventsService } = vi.hoisted(() => ({
editorService: {
get: vi.fn(),
getNodeById: vi.fn(),
},
dataSourceService: {
getDataSourceById: vi.fn(),
getFormEvent: vi.fn(() => []),
},
eventsService: {
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
},
}));
vi.mock('@editor/services/editor', () => ({ default: editorService }));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/events', () => ({ default: eventsService }));
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]) };
});
describe('event utils', () => {
beforeEach(() => {
vi.clearAllMocks();
eventsService.getEvent.mockReturnValue([{ label: 'click', value: 'click' }]);
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
});
test('normalizeCompActionValue 支持字符串与数组', () => {
expect(normalizeCompActionValue('open')).toBe('open');
expect(normalizeCompActionValue(['c1', 'open'])).toBe('c1.open');
expect(normalizeCompActionValue('')).toBe('');
expect(normalizeCompActionValue(undefined)).toBe('');
});
test('getCompActionOptions 普通组件返回 select options', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]);
});
test('getCompActionOptions 无节点返回空', () => {
editorService.getNodeById.mockReturnValue(null);
expect(getCompActionOptions('missing')).toEqual([]);
expect(getCompActionOptions(undefined)).toEqual([]);
});
test('getCompActionOptions 页面片容器返回 cascader options', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
expect(getCompActionOptions('1')).toEqual([
{
label: 'b_c1',
value: 'c1',
children: [{ label: 'open', value: 'open' }],
},
]);
});
test('getCompActionAllowedValues 枚举合法动作', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['open']));
});
test('getCompActionAllowedValues 自定义 options 返回 null', () => {
expect(
getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }),
).toBeNull();
});
test('getCompActionAllowedValues 页面片路径用 . 拼接', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['c1.open']));
});
test('getEventNameAllowedValues / collectEventNameOptionValues', () => {
expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' })).toEqual(new Set(['click']));
expect(
getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }),
).toBeNull();
expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b']));
});
test('getEventNameOptions datasource 追加数据变化', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' });
expect(opts.some((item: any) => item.value === 'ds-field-changed' || item.label === '数据变化')).toBe(true);
});
});

View File

@ -10,10 +10,12 @@ import { NODE_CONDS_RESULT_KEY } from '@tmagic/core';
import {
advancedTabConfig,
arrayOptions,
booleanOptions,
displayTabConfig,
eqOptions,
eventTabConfig,
fillConfig,
getCondOpOptionsByFieldType,
numberOptions,
styleTabConfig,
} from '@editor/utils/props';
@ -26,10 +28,31 @@ vi.mock('@tmagic/design', () => ({
}));
describe('props 选项常量', () => {
test('eqOptions / arrayOptions / numberOptions 内容稳定', () => {
test('eqOptions / arrayOptions / numberOptions / booleanOptions 内容稳定', () => {
expect(eqOptions.map((o) => o.value)).toEqual(['=', '!=']);
expect(arrayOptions.map((o) => o.value)).toEqual(['include', 'not_include']);
expect(numberOptions.map((o) => o.value)).toEqual(['>', '>=', '<', '<=', 'between', 'not_between']);
expect(booleanOptions.map((o) => o.value)).toEqual(['is', 'not']);
});
test('getCondOpOptionsByFieldType 按类型返回选项', () => {
expect(getCondOpOptionsByFieldType('array')).toEqual(arrayOptions);
expect(getCondOpOptionsByFieldType('boolean')).toEqual(booleanOptions);
expect(getCondOpOptionsByFieldType('null')).toEqual(booleanOptions);
expect(getCondOpOptionsByFieldType('number').map((o) => o.value)).toEqual([
...eqOptions.map((o) => o.value),
...numberOptions.map((o) => o.value),
]);
expect(getCondOpOptionsByFieldType('string').map((o) => o.value)).toEqual([
...arrayOptions.map((o) => o.value),
...eqOptions.map((o) => o.value),
]);
expect(getCondOpOptionsByFieldType('any').map((o) => o.value)).toEqual([
...arrayOptions.map((o) => o.value),
...eqOptions.map((o) => o.value),
...numberOptions.map((o) => o.value),
]);
expect(getCondOpOptionsByFieldType('').map((o) => o.value)).not.toContain('is');
});
test('styleTabConfig / eventTabConfig / displayTabConfig 基础结构', () => {

View File

@ -0,0 +1,340 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { HookCodeType, HookType, NodeType } from '@tmagic/core';
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
import { ALL_COND_OPS, editorTypeMatchRules } from '@editor/utils/typeMatchRules';
const codeDslState = vi.hoisted(() => ({ value: null as Record<string, any> | null }));
const dataSourcesState = vi.hoisted(() => ({ value: [] as any[] }));
const formMethodsState = vi.hoisted(() => ({ value: [] as { label: string; value: string }[] }));
const formEventsState = vi.hoisted(() => ({ value: [] as { label: string; value: string }[] }));
const componentEventsState = vi.hoisted((): { value: Record<string, { label: string; value: string }[]> } => ({
value: {},
}));
const rootState = vi.hoisted(() => ({ value: null as any }));
const nodesState = vi.hoisted((): { value: Record<string, any> } => ({ value: {} }));
vi.mock('@editor/services/codeBlock', () => ({
default: {
getCodeDsl: () => codeDslState.value,
},
}));
vi.mock('@editor/services/dataSource', () => ({
default: {
get: (key: string) => {
if (key === 'dataSources') return dataSourcesState.value;
if (key === 'events') return {};
return undefined;
},
getFormMethod: () => formMethodsState.value,
getFormEvent: () => formEventsState.value,
getDataSourceById: (id: string) => dataSourcesState.value.find((ds) => `${ds.id}` === `${id}`),
},
}));
vi.mock('@editor/services/editor', () => ({
default: {
get: (key: string) => (key === 'root' ? rootState.value : undefined),
getNodeById: (id: string | number) => nodesState.value[`${id}`] || null,
},
}));
vi.mock('@editor/services/events', () => ({
default: {
getEvent: (type: string) => componentEventsState.value[type] || [],
},
}));
const ctx = (config: Record<string, any> = {}, model: Record<string, any> = {}, message?: string) => ({
fieldType: config.type || '',
mForm: undefined,
props: { config, model, prop: config.name || 'field' },
message,
});
const run = (
type: string,
value: any,
config: Record<string, any> = {},
model: Record<string, any> = {},
message?: string,
) => editorTypeMatchRules[type](value, ctx({ ...config, type }, model, message));
describe('editorTypeMatchRules', () => {
beforeEach(() => {
codeDslState.value = null;
dataSourcesState.value = [];
formMethodsState.value = [];
formEventsState.value = [];
componentEventsState.value = {};
rootState.value = null;
nodesState.value = {};
});
test('key-value / style-setter 形态校验', () => {
expect(run('key-value', { a: '1' })).toBeUndefined();
expect(run('key-value', [])).toBe('值类型应为对象');
expect(run('key-value', () => 1, { advanced: true })).toBeUndefined();
expect(run('style-setter', { width: '1px' })).toBeUndefined();
expect(run('style-setter', [])).toBe('值类型应为对象');
});
test('cond-op-select 按字段类型收窄,未知类型用默认全集', () => {
expect(run('cond-op-select', 1)).toBe('值类型应为字符串');
expect(run('cond-op-select', '=')).toBeUndefined();
expect(ALL_COND_OPS.has('between')).toBe(true);
expect(ALL_COND_OPS.has('is')).toBe(true);
dataSourcesState.value = [
{
id: 'ds1',
type: 'base',
fields: [
{ name: 'age', type: 'number' },
{ name: 'flag', type: 'boolean' },
],
methods: [],
},
];
expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined();
expect(run('cond-op-select', 'include', {}, { field: ['ds1', 'age'] })).toBe('include 不在可选项中');
expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'flag'] })).toBeUndefined();
// 未知类型与 UI 默认选项对齐:不含 boolean ops
expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'missing'] })).toBe('is 不在可选项中');
expect(run('cond-op-select', 'include', {}, { field: ['ds1', 'missing'] })).toBeUndefined();
});
test('code-select-col 有 DSL 时校验 key', () => {
expect(run('code-select-col', 1)).toBe('1类型应为字符串');
expect(run('code-select-col', 'code_1')).toBeUndefined();
codeDslState.value = { code_1: { name: 'A' } };
expect(run('code-select-col', 'code_1')).toBeUndefined();
expect(run('code-select-col', 'missing')).toBe('代码块(missing)不存在');
});
test('page-fragment-select / ui-select 校验节点存在性', () => {
expect(run('page-fragment-select', true)).toBe('true类型应为字符串或数字');
expect(run('page-fragment-select', 'pf_1')).toBeUndefined();
rootState.value = {
items: [
{ id: 'pf_1', type: NodeType.PAGE_FRAGMENT },
{ id: 'page_1', type: NodeType.PAGE },
],
};
expect(run('page-fragment-select', 'pf_1')).toBeUndefined();
expect(run('page-fragment-select', 'page_1')).toBe('页面片段(page_1)不存在');
rootState.value = null;
expect(run('ui-select', 'node_1')).toBeUndefined();
rootState.value = { items: [] };
nodesState.value = { node_1: { id: 'node_1' } };
expect(run('ui-select', 'node_1')).toBeUndefined();
expect(run('ui-select', 'missing')).toBe('组件(missing)不存在');
});
test('data-source-input 校验模板绑定', () => {
expect(run('data-source-input', 1)).toBe('1类型应为字符串');
expect(run('data-source-input', 'plain')).toBeUndefined();
expect(run('data-source-input', '${ds1.title}')).toBeUndefined();
dataSourcesState.value = [
{
id: 'ds1',
type: 'base',
fields: [
{ name: 'title', type: 'string' },
{ name: 'arr', type: 'array', fields: [{ name: 'item', type: 'string' }] },
],
methods: [],
},
];
expect(run('data-source-input', 'hello ${ds1.title}')).toBeUndefined();
expect(run('data-source-input', '${ds1.arr[0].item}')).toBeUndefined();
expect(run('data-source-input', '${missing.title}')).toBe('数据源绑定(${missing.title})不合法');
expect(run('data-source-input', '${ds1.missing}')).toBe('数据源绑定(${ds1.missing})不合法');
});
test('data-source-method-select 校验方法元组', () => {
expect(run('data-source-method-select', ['ds1'])).toBe('ds1类型应为长度为 2 的字符串数组');
expect(run('data-source-method-select', ['ds1', 'foo'])).toBeUndefined();
dataSourcesState.value = [
{
id: 'ds1',
type: 'base',
fields: [],
methods: [{ name: 'custom' }],
},
];
formMethodsState.value = [{ label: '内置', value: 'builtin' }];
expect(run('data-source-method-select', ['ds1', DATA_SOURCE_SET_DATA_METHOD_NAME])).toBeUndefined();
expect(run('data-source-method-select', ['ds1', 'builtin'])).toBeUndefined();
expect(run('data-source-method-select', ['ds1', 'custom'])).toBeUndefined();
expect(run('data-source-method-select', ['ds1', 'missing'])).toBe('数据源方法(missing)不存在');
expect(run('data-source-method-select', ['missing', 'custom'])).toBe('数据源(missing)不存在');
});
test('data-source-field-select 校验路径与 fieldConfig 跳过', () => {
expect(run('data-source-field-select', 'x')).toBe('x类型应为字符串数组');
expect(run('data-source-field-select', 'text-value', { fieldConfig: { type: 'text' } })).toBeUndefined();
dataSourcesState.value = [
{
id: 'ds1',
type: 'base',
fields: [
{ name: 'title', type: 'string' },
{ name: 'obj', type: 'object', fields: [{ name: 'a', type: 'number' }] },
],
methods: [],
},
];
expect(run('data-source-field-select', ['ds1', 'title'], { value: 'key' })).toBeUndefined();
expect(run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'title'])).toBeUndefined();
expect(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' })).toBe('值不在可选项中');
expect(
run('data-source-field-select', ['title'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
).toBe('值不在可选项中');
expect(
run('data-source-field-select', ['obj', 'a'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
).toBeUndefined();
});
test('data-source-select id / 对象形态', () => {
expect(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1' })).toBeUndefined();
expect(run('data-source-select', 'ds1', { value: 'id' })).toBeUndefined();
dataSourcesState.value = [
{ id: 'ds1', type: 'base', fields: [], methods: [] },
{ id: 'ds2', type: 'http', fields: [], methods: [] },
];
expect(run('data-source-select', 'ds1', { value: 'id' })).toBeUndefined();
expect(run('data-source-select', 'missing', { value: 'id' })).toBe('missing不在可选项中');
expect(run('data-source-select', 'ds2', { value: 'id', dataSourceType: 'base' })).toBe('ds2不在可选项中');
expect(
run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'base' }),
).toBeUndefined();
expect(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'http' })).toBe(
'[object Object]不在可选项中',
);
expect(run('data-source-select', { isBindDataSource: false, dataSourceId: 'ds1' })).toBe(
'[object Object]类型不合法',
);
});
test('code-select 校验 hookData', () => {
expect(run('code-select', { hookType: HookType.CODE, hookData: [] })).toBeUndefined();
expect(run('code-select', { hookType: 'x', hookData: [] })).toBe('[object Object]类型不合法');
codeDslState.value = { code_1: { name: 'A' } };
dataSourcesState.value = [{ id: 'ds1', type: 'base', fields: [], methods: [{ name: 'custom' }] }];
expect(
run('code-select', {
hookType: HookType.CODE,
hookData: [{ codeType: HookCodeType.CODE, codeId: 'code_1', params: {} }],
}),
).toBeUndefined();
// 存在性codeId 是否在 DSL 中)已下沉到 code-select-col 单元格校验,容器级仅校验结构
expect(
run('code-select', {
hookType: HookType.CODE,
hookData: [{ codeType: HookCodeType.CODE, codeId: 'missing' }],
}),
).toBeUndefined();
expect(
run('code-select', {
hookType: HookType.CODE,
hookData: [{ codeType: HookCodeType.DATA_SOURCE_METHOD, codeId: ['ds1', 'custom'] }],
}),
).toBeUndefined();
// 数据源方法存在性已下沉到 data-source-method-select 单元格校验
expect(
run('code-select', {
hookType: HookType.CODE,
hookData: [{ codeType: HookCodeType.DATA_SOURCE_METHOD, codeId: ['ds1', 'missing'] }],
}),
).toBeUndefined();
expect(
run('code-select', {
hookType: HookType.CODE,
hookData: [{ codeType: 'bad', codeId: 'code_1' }],
}),
).toBe('钩子项结构不合法');
// DATA_SOURCE_METHOD 的 codeId 元组形态仍在容器级校验
expect(
run('code-select', {
hookType: HookType.CODE,
hookData: [{ codeType: HookCodeType.DATA_SOURCE_METHOD, codeId: 'not-tuple' }],
}),
).toBe('钩子项结构不合法');
});
test('容器类浅层结构校验', () => {
expect(run('data-source-fields', [{ name: 'a', type: 'string' }])).toBeUndefined();
expect(run('data-source-fields', [{ name: 'a', type: 'oops' }])).toBe('字段类型不合法');
expect(
run('data-source-fields', [{ name: 'obj', type: 'object', fields: [{ name: 'child', type: 'number' }] }]),
).toBeUndefined();
expect(run('data-source-fields', [{ name: 'obj', type: 'object', fields: 'bad' }])).toBe('字段项结构不合法');
expect(
run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: { a: 1 } }]),
).toBeUndefined();
expect(run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: [] }])).toBe(
'mock 项结构不合法',
);
expect(run('data-source-methods', [{ name: 'fn', content: '() => {}', params: [] }])).toBeUndefined();
expect(run('data-source-methods', [{ name: 'fn', content: 1 }])).toBe('方法项结构不合法');
expect(run('event-select', [{ name: 'click', actions: [{ actionType: 'comp' }] }])).toBeUndefined();
expect(run('event-select', [{ name: 'click', to: 'node_1', method: 'show' }])).toBeUndefined();
expect(run('event-select', [{ name: 'click' }])).toBe('事件项结构不合法');
expect(run('event-select', [{ name: 'click', actions: [{}] }])).toBe('事件项结构不合法');
expect(run('display-conds', [{ cond: [{ field: ['ds1', 'title'], op: '=' }] }])).toBeUndefined();
// op 是否合法、字段路径是否存在已下沉到 cond-op-select / field 单元格校验,容器级仅校验结构
expect(run('display-conds', [{ cond: [{ field: ['ds1'], op: 'bad' }] }])).toBeUndefined();
expect(run('display-conds', [{ cond: [{ field: ['ds1', 'missing'], op: '=' }] }])).toBeUndefined();
expect(run('display-conds', [{ cond: 'bad' }])).toBe('显示条件结构不合法');
expect(run('display-conds', [{ cond: [{ field: 'bad', op: '=' }] }])).toBe('显示条件结构不合法');
expect(run('display-conds', [{ cond: [{ field: ['ds1'], op: 1 }] }])).toBe('显示条件结构不合法');
});
test('自定义 message 优先', () => {
expect(run('key-value', [], {}, {}, '自定义错误')).toBe('自定义错误');
});
});

View File

@ -7,7 +7,7 @@
padding: 0 20px;
}
.el-table .m-form-item .el-form-item {
.el-table .m-form-item .el-form-item:not(.is-error) {
margin-bottom: 0;
}
}

View File

@ -26,6 +26,16 @@
}
}
.el-form-item {
.m-fields-table {
.el-form-item {
&.is-error {
margin-bottom: 18px;
}
}
}
}
.m-fields-table {
width: 100%;
@ -62,7 +72,7 @@
}
}
.el-form-item {
.el-form-item:not(.is-error) {
margin-bottom: 0;
}

View File

@ -21,6 +21,7 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { cloneDeep } from 'lodash-es';
import { getDesignConfig } from '@tmagic/design';
import { getValueByKeyPath } from '@tmagic/utils';
import type {
@ -41,6 +42,56 @@ import type {
import { createTypeMatchValidator } from './typeMatch';
type AsyncValidatorFn = (rule: any, value: any, callback: Function, source?: any, options?: any) => any;
const isTDesignAdapter = () => getDesignConfig('adapterType') === 'tdesign-vue-next';
/**
* async-validatorElement Plus validator UI
* TDesign `(val) => boolean | CustomValidateObj | Promise` callback
*/
export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorFn => {
return (arg1: any, arg2?: any, arg3?: any, arg4?: any, arg5?: any) => {
if (!isTDesignAdapter()) {
return validator(arg1, arg2, arg3, arg4, arg5);
}
// TDesign: validator(val)
const value = arg1;
return new Promise((resolve) => {
let settled = false;
const callback = (error?: Error | string) => {
if (settled) return;
settled = true;
if (error) {
resolve({
result: false,
message: typeof error === 'string' ? error : error.message,
});
} else {
resolve(true);
}
};
try {
const result = validator(undefined, value, callback);
if (result !== null && typeof (result as PromiseLike<unknown>).then === 'function') {
Promise.resolve(result).then(
() => {
if (!settled) callback();
},
(err) => {
callback(err instanceof Error ? err : new Error(String(err)));
},
);
}
} catch (e) {
callback(e instanceof Error ? e : new Error(String(e)));
}
});
};
};
interface DefaultItem {
defaultValue: any;
type: string;
@ -264,32 +315,34 @@ export const getRules = function (mForm: FormState | undefined, rules: Rule[] |
return rules.map((item) => {
if (item.typeMatch) {
(item as any).validator = createTypeMatchValidator(mForm, props, item);
(item as any).validator = adaptFormValidator(createTypeMatchValidator(mForm, props, item));
return item;
}
if (typeof item.validator === 'function') {
const fnc = item.validator;
(item as any).validator = (rule: any, value: any, callback: Function, source: any, options: any) =>
fnc(
{
rule,
value: props.config.names ? props.model : value,
callback,
source,
options,
},
{
values: mForm?.initValues || {},
model: props.model,
parent: mForm?.parentValues || {},
formValue: mForm?.values || props.model,
prop: props.prop,
config: props.config,
},
mForm,
);
(item as any).validator = adaptFormValidator(
(rule: any, value: any, callback: Function, source: any, options: any) =>
fnc(
{
rule,
value: props.config.names ? props.model : value,
callback,
source,
options,
},
{
values: mForm?.initValues || {},
model: props.model,
parent: mForm?.parentValues || {},
formValue: mForm?.values || props.model,
prop: props.prop,
config: props.config,
},
mForm,
),
);
}
return item;
});

View File

@ -239,29 +239,29 @@ const validateSelectValue = (
if (config.allowCreate || config.remote) {
if (config.multiple) {
if (!Array.isArray(value)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
return undefined;
}
if (typeof value === 'object') {
return defaultMessage(message, '值类型不合法');
return defaultMessage(message, `${value} 类型不合法`);
}
return undefined;
}
if (config.multiple) {
if (!Array.isArray(value)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
if (value.some((item) => !includesOptionValue(optionValues, item))) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
}
if (!includesOptionValue(optionValues, value)) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
};
@ -279,14 +279,14 @@ const validateCascaderValue = (
if (valueSeparator) {
if (typeof value !== 'string' && !Array.isArray(value)) {
return defaultMessage(message, '值类型应为字符串或数组');
return defaultMessage(message, `${value} 类型应为字符串或数组`);
}
} else if (multiple) {
if (!Array.isArray(value)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
} else if (emitPath && !Array.isArray(value)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
if (config.remote) {
@ -302,7 +302,7 @@ const validateCascaderValue = (
if (multiple) {
if (!Array.isArray(normalizedValue)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
const invalid = normalizedValue.some((item) => {
@ -313,20 +313,20 @@ const validateCascaderValue = (
});
if (invalid) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
}
if (emitPath) {
if (!Array.isArray(normalizedValue) || !isValidCascaderPath(options, normalizedValue)) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
}
if (!includesOptionValue(collectCascaderLeafValues(options), normalizedValue)) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
};
@ -347,7 +347,7 @@ const validateBuiltinTypeMatch = (
if (STRING_TYPES.has(fieldType)) {
if (config.filter === 'number') {
if (typeof value !== 'number' || Number.isNaN(value)) {
return defaultMessage(message, '值类型应为数字');
return defaultMessage(message, `${value} 类型应为数字`);
}
return undefined;
}
@ -358,7 +358,7 @@ const validateBuiltinTypeMatch = (
}
if (typeof value !== 'string') {
return defaultMessage(message, '值类型应为字符串');
return defaultMessage(message, `${value} 类型应为字符串`);
}
return undefined;
}
@ -373,7 +373,7 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'number') {
if (typeof value !== 'number' || Number.isNaN(value)) {
return defaultMessage(message, '值类型应为数字');
return defaultMessage(message, `${value} 类型应为数字`);
}
return undefined;
}
@ -384,7 +384,7 @@ const validateBuiltinTypeMatch = (
value.length !== 2 ||
value.some((item) => typeof item !== 'number' || Number.isNaN(item))
) {
return defaultMessage(message, '值类型应为长度为 2 的数字数组');
return defaultMessage(message, `${value} 类型应为长度为 2 的数字数组`);
}
return undefined;
}
@ -392,7 +392,7 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'switch' || fieldType === 'checkbox') {
const { activeValue, inactiveValue } = resolveToggleValues(config);
if (!Object.is(value, activeValue) && !Object.is(value, inactiveValue)) {
return defaultMessage(message, '值不在合法开关值中');
return defaultMessage(message, `${value} 不在合法开关值中`);
}
return undefined;
}
@ -405,18 +405,18 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'radio-group') {
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
if (!includesOptionValue(optionValues, value)) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
}
if (fieldType === 'checkbox-group') {
if (!Array.isArray(value)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
if (value.some((item) => !includesOptionValue(optionValues, item))) {
return defaultMessage(message, '值不在可选项中');
return defaultMessage(message, `${value} 不在可选项中`);
}
return undefined;
}
@ -438,8 +438,8 @@ const validateBuiltinTypeMatch = (
return defaultMessage(
message,
isTimestampValueFormat(valueFormat)
? '值类型应为长度为 2 的时间戳数字数组'
: `格式应为长度为 2 的 ${valueFormat} 数组`,
? `${value} 类型应为长度为 2 的时间戳数字数组`
: `${value} 格式应为长度为 2 的 ${valueFormat} 数组`,
);
}
return undefined;
@ -447,7 +447,7 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'table' || fieldType === 'group-list' || fieldType === 'grouplist') {
if (!Array.isArray(value)) {
return defaultMessage(message, '值类型应为数组');
return defaultMessage(message, `${value} 类型应为数组`);
}
return undefined;
}

View File

@ -28,6 +28,8 @@ import {
validateTypeMatch,
} from '@form/utils/typeMatch';
import { setDesignConfig } from '@tmagic/design';
const mForm: FormState = {
config: [],
initValues: {},
@ -74,15 +76,15 @@ describe('validateTypeMatch', () => {
test('text 期望 string', () => {
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'text' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe('类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe('1 类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }), '自定义错误')).toBe('自定义错误');
});
test('text filter=number 时期望 number', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch(0, mForm, propsOf({ type: 'text', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch('1', mForm, propsOf({ type: 'text', filter: 'number' }))).toBe('类型应为数字');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe('类型应为数字');
expect(validateTypeMatch('1', mForm, propsOf({ type: 'text', filter: 'number' }))).toBe('1 类型应为数字');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe('NaN 类型应为数字');
});
test('text 自定义 filter 函数时跳过内置类型校验', () => {
@ -91,8 +93,8 @@ describe('validateTypeMatch', () => {
test('number 期望 number', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'number' }))).toBeUndefined();
expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('类型应为数字');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe('类型应为数字');
expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('1 类型应为数字');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe('NaN 类型应为数字');
});
test('date / time / datetime 按 valueFormatDay.js format校验', () => {
@ -124,20 +126,20 @@ describe('validateTypeMatch', () => {
test('switch / checkbox 默认 true/false', () => {
expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch' }))).toBeUndefined();
expect(validateTypeMatch(false, mForm, propsOf({ type: 'checkbox' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch' }))).toBe('不在合法开关值中');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch' }))).toBe('1 不在合法开关值中');
});
test('switch / checkbox filter=number 时为 1/0', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch(0, mForm, propsOf({ type: 'checkbox', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBe('不在合法开关值中');
expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBe('true 不在合法开关值中');
});
test('switch / checkbox 自定义 activeValue/inactiveValue', () => {
const config = { type: 'switch', activeValue: 'on', inactiveValue: 'off' };
expect(validateTypeMatch('on', mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch('off', mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch('maybe', mForm, propsOf(config))).toBe('不在合法开关值中');
expect(validateTypeMatch('maybe', mForm, propsOf(config))).toBe('maybe 不在合法开关值中');
});
test('select 单选值必须在 options 中', () => {
@ -149,7 +151,7 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(1, mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('不在可选项中');
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('3 不在可选项中');
});
test('select multiple 校验数组元素', () => {
@ -162,8 +164,8 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(['a'], mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('不在可选项中');
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('类型应为数组');
expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('a,c 不在可选项中');
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组');
});
test('select options 为函数 / group', () => {
@ -172,7 +174,7 @@ describe('validateTypeMatch', () => {
options: () => [{ text: 'A', value: 1 }],
};
expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('不在可选项中');
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('2 不在可选项中');
const groupConfig = {
type: 'select',
@ -186,15 +188,17 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch('a', mForm, propsOf(groupConfig))).toBeUndefined();
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('不在可选项中');
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中');
});
test('select allowCreate / remote 不做枚举', () => {
expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法');
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe(
'[object Object] 类型不合法',
);
expect(validateTypeMatch(['x'], mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined();
expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBe(
'类型应为数组',
'x 类型应为数组',
);
});
@ -207,7 +211,7 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(1, mForm, propsOf(radio))).toBeUndefined();
expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('不在可选项中');
expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('3 不在可选项中');
const checkboxGroup = {
type: 'checkbox-group',
@ -217,7 +221,7 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(['a', 'b'], mForm, propsOf(checkboxGroup))).toBeUndefined();
expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('不在可选项中');
expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('c 不在可选项中');
});
test('cascader 静态路径与 valueSeparator', () => {
@ -236,33 +240,33 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options }))).toBeUndefined();
expect(validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options }))).toBe(
'不在可选项中',
'zhejiang,ningbo 不在可选项中',
);
expect(
validateTypeMatch('zhejiang/hangzhou', mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })),
).toBeUndefined();
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('类型应为数组');
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('bad 类型应为数组');
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'cascader', remote: true }))).toBeUndefined();
});
test('number-range / daterange / table', () => {
expect(validateTypeMatch([1, 2], mForm, propsOf({ type: 'number-range' }))).toBeUndefined();
expect(validateTypeMatch([1], mForm, propsOf({ type: 'number-range' }))).toBe('类型应为长度为 2 的数字数组');
expect(validateTypeMatch([1], mForm, propsOf({ type: 'number-range' }))).toBe('1 类型应为长度为 2 的数字数组');
expect(
validateTypeMatch(['2020/01/01 00:00:00', '2020/01/02 00:00:00'], mForm, propsOf({ type: 'daterange' })),
).toBeUndefined();
expect(validateTypeMatch(['2020-01-01', '2020-01-02'], mForm, propsOf({ type: 'daterange' }))).toBe(
'格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组',
'2020-01-01,2020-01-02 格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组',
);
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'daterange' }))).toBe(
'格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组',
'a 格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组',
);
expect(validateTypeMatch([1, 2], mForm, propsOf({ type: 'daterange', valueFormat: 'timestamp' }))).toBeUndefined();
expect(validateTypeMatch('x', mForm, propsOf({ type: 'daterange', names: ['a', 'b'] }))).toBeUndefined();
expect(validateTypeMatch([{ id: 1 }], mForm, propsOf({ type: 'table' }))).toBeUndefined();
expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe('类型应为数组');
expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe('[object Object] 类型应为数组');
});
test('容器类字段 no-op', () => {
@ -272,25 +276,25 @@ describe('validateTypeMatch', () => {
test('无 type 默认按 text 校验', () => {
expect(validateTypeMatch('ok', mForm, propsOf({}))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('1 类型应为字符串');
});
test('textarea / color-picker / html 期望 string', () => {
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'textarea' }))).toBeUndefined();
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'color-picker' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'html' }))).toBe('类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'html' }))).toBe('1 类型应为字符串');
});
test('checkbox-group 非数组', () => {
expect(
validateTypeMatch('a', mForm, propsOf({ type: 'checkbox-group', options: [{ text: 'A', value: 'a' }] })),
).toBe('类型应为数组');
).toBe('a 类型应为数组');
});
test('timerange 按 valueFormat 校验', () => {
expect(validateTypeMatch(['12:00:00', '13:00:00'], mForm, propsOf({ type: 'timerange' }))).toBeUndefined();
expect(validateTypeMatch(['bad'], mForm, propsOf({ type: 'timerange' }))).toBe(
'格式应为长度为 2 的 HH:mm:ss 数组',
'bad 格式应为长度为 2 的 HH:mm:ss 数组',
);
});
@ -306,7 +310,7 @@ describe('validateTypeMatch', () => {
validateTypeMatch('hangzhou', mForm, propsOf({ type: 'cascader', options, emitPath: false })),
).toBeUndefined();
expect(validateTypeMatch('ningbo', mForm, propsOf({ type: 'cascader', options, emitPath: false }))).toBe(
'不在可选项中',
'ningbo 不在可选项中',
);
});
@ -323,7 +327,7 @@ describe('validateTypeMatch', () => {
).toBeUndefined();
expect(
validateTypeMatch(['ningbo'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })),
).toBe('不在可选项中');
).toBe('ningbo 不在可选项中');
});
test('cascader valueSeparator 时数组值', () => {
@ -340,7 +344,9 @@ describe('validateTypeMatch', () => {
});
test('select allowCreate 对象值非法', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法');
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe(
'[object Object] 类型不合法',
);
});
test('动态 type 函数解析', () => {
@ -394,6 +400,50 @@ describe('getRules typeMatch', () => {
});
});
describe('getRules tdesign validator', () => {
beforeEach(() => {
setDesignConfig({ adapterType: 'tdesign-vue-next' });
});
afterEach(() => {
setDesignConfig({});
});
test('typeMatch 校验失败时返回 CustomValidateObj', async () => {
const rules: any = [{ typeMatch: true, message: '类型错误' }];
const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' }));
// TDesign 调用签名validator(val)
await expect(newRules[0].validator(123)).resolves.toEqual({
result: false,
message: '类型错误',
});
});
test('typeMatch 校验通过时返回 true', async () => {
const rules: any = [{ typeMatch: true }];
const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' }));
await expect(newRules[0].validator('ok')).resolves.toBe(true);
});
test('自定义 validator 通过 callback 报告错误', async () => {
const rules: any = {
validator: ({ value, callback }: any) => {
if (value < 0) {
callback(new Error('不能为负'));
return;
}
callback();
},
};
const newRules: any = getRules(mForm, rules, { config: {} });
await expect(newRules[0].validator(-1)).resolves.toEqual({
result: false,
message: '不能为负',
});
await expect(newRules[0].validator(1)).resolves.toBe(true);
});
});
describe('typeMatch 扩展注册', () => {
beforeEach(() => {
clearTypeMatchRules();

View File

@ -18,7 +18,7 @@
"@tmagic/element-plus-adapter": "1.8.0-beta.11",
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.11",
"@tmagic/tmagic-form-runtime": "1.1.3",
"element-plus": "^2.11.8",
"element-plus": "^2.14.3",
"lodash-es": "^4.17.21",
"monaco-editor": "^0.55.1",
"serialize-javascript": "^7.0.0",

112
pnpm-lock.yaml generated
View File

@ -60,8 +60,8 @@ importers:
specifier: ^3.3.0
version: 3.3.0(@types/node@24.0.10)(typescript@6.0.3)
element-plus:
specifier: ^2.11.8
version: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
specifier: ^2.14.3
version: 2.14.3(vue@3.5.38(typescript@6.0.3))
enquirer:
specifier: ^2.4.1
version: 2.4.1
@ -573,16 +573,16 @@ importers:
version: 1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
'@tmagic/element-plus-adapter':
specifier: 1.8.0-beta.11
version: 1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
version: 1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
'@tmagic/tdesign-vue-next-adapter':
specifier: 1.8.0-beta.11
version: 1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(tdesign-vue-next@1.17.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
'@tmagic/tmagic-form-runtime':
specifier: 1.1.3
version: 1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
version: 1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
element-plus:
specifier: ^2.11.8
version: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
specifier: ^2.14.3
version: 2.14.3(vue@3.5.38(typescript@6.0.3))
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@ -923,7 +923,7 @@ importers:
version: 1.8.0-beta.11(typescript@6.0.3)
'@tmagic/react-runtime-help':
specifier: 0.2.2
version: 0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)
version: 0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.18.1)(react@18.3.1)(typescript@6.0.3)
'@tmagic/stage':
specifier: 1.8.0-beta.11
version: 1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3)
@ -2048,6 +2048,10 @@ packages:
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'}
'@ctrl/tinycolor@4.2.0':
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
'@daybrush/utils@1.13.0':
resolution: {integrity: sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==}
@ -2457,12 +2461,21 @@ packages:
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -2922,6 +2935,9 @@ packages:
'@sxzz/popperjs-es@2.11.7':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
'@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@tmagic/cli@1.8.0-beta.11':
resolution: {integrity: sha512-j0yKe2+wVlSeSo/VuieaYh+WDQVEx2U5XA3FfGwusllgvInLB0DUNRcPyhdEtwckl4bDI7p+vvcmlMyVDeYXcg==}
engines: {node: '>=18'}
@ -3348,6 +3364,9 @@ packages:
'@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -4326,6 +4345,9 @@ packages:
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@ -4447,6 +4469,11 @@ packages:
peerDependencies:
vue: ^3.2.0
element-plus@2.14.3:
resolution: {integrity: sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==}
peerDependencies:
vue: ^3.3.7
emmet-monaco-es@5.7.0:
resolution: {integrity: sha512-q8jtgsEWWaXHnAJ6HAcwMB7WHraRtf032TWpeOpN6SGLtwxr7hd6RgF3Kbj2rPxb6Q19fYVVafAFODYRYUS8NQ==}
peerDependencies:
@ -5455,6 +5482,9 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
@ -5498,6 +5528,9 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
@ -6902,6 +6935,9 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.3.7:
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@ -8113,6 +8149,8 @@ snapshots:
'@ctrl/tinycolor@3.6.1': {}
'@ctrl/tinycolor@4.2.0': {}
'@daybrush/utils@1.13.0': {}
'@docsearch/css@4.6.3': {}
@ -8379,13 +8417,24 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.12': {}
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@ -8743,6 +8792,8 @@ snapshots:
'@sxzz/popperjs-es@2.11.7': {}
'@sxzz/popperjs-es@2.11.8': {}
'@tmagic/cli@1.8.0-beta.11(typescript@6.0.3)':
dependencies:
cac: 6.7.14
@ -8906,10 +8957,10 @@ snapshots:
- '@tmagic/form-schema'
- '@tmagic/schema'
'@tmagic/element-plus-adapter@1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))':
'@tmagic/element-plus-adapter@1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
element-plus: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
element-plus: 2.14.3(vue@3.5.38(typescript@6.0.3))
vue: 3.5.38(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@ -8954,9 +9005,9 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)':
'@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.18.1)(react@18.3.1)(typescript@6.0.3)':
dependencies:
lodash-es: 4.17.21
lodash-es: 4.18.1
react: 18.3.1
optionalDependencies:
'@tmagic/core': 1.8.0-beta.11(typescript@6.0.3)
@ -9042,10 +9093,10 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/tmagic-form-runtime@1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))':
'@tmagic/tmagic-form-runtime@1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@tmagic/editor': 1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
element-plus: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
element-plus: 2.14.3(vue@3.5.38(typescript@6.0.3))
vue: 3.5.38(typescript@6.0.3)
optionalDependencies:
'@tmagic/core': 1.8.0-beta.11(typescript@6.0.3)
@ -9131,6 +9182,8 @@ snapshots:
'@types/lodash@4.17.20': {}
'@types/lodash@4.17.24': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
@ -10369,6 +10422,8 @@ snapshots:
dayjs@1.11.19: {}
dayjs@1.11.21: {}
debug@3.2.7:
dependencies:
ms: 2.1.3
@ -10477,6 +10532,25 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)):
dependencies:
'@ctrl/tinycolor': 4.2.0
'@element-plus/icons-vue': 2.3.2(vue@3.5.38(typescript@6.0.3))
'@floating-ui/dom': 1.8.0
'@popperjs/core': '@sxzz/popperjs-es@2.11.8'
'@types/lodash': 4.17.24
'@types/lodash-es': 4.17.12
'@vueuse/core': 14.3.0(vue@3.5.38(typescript@6.0.3))
async-validator: 4.2.5
dayjs: 1.11.21
lodash: 4.18.1
lodash-es: 4.18.1
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.38(typescript@6.0.3)
vue-component-type-helpers: 3.3.7
emmet-monaco-es@5.7.0(monaco-editor@0.55.1):
dependencies:
emmet: 2.4.11
@ -11658,12 +11732,20 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.18.1: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.17.21
lodash-es: 4.17.21
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.18.1
lodash-es: 4.18.1
lodash.camelcase@4.3.0: {}
lodash.debounce@4.0.8: {}
@ -11688,6 +11770,8 @@ snapshots:
lodash@4.17.21: {}
lodash@4.18.1: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
@ -13140,6 +13224,8 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.3.7: {}
vue-demi@0.14.10(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)):
dependencies:
vue: 3.5.38(typescript@6.0.3)