fix(editor): 统一数据源字段路径解析并优化 typeMatch 校验

修复 ds-field:: 前缀解析、checkStrictly 数据源取值及字段类型不匹配时的校验提示。
This commit is contained in:
roymondchen 2026-08-13 15:03:49 +08:00
parent 050814f3f8
commit 05fbdb17b5
8 changed files with 126 additions and 54 deletions

View File

@ -31,6 +31,7 @@ import { computed } from 'vue';
import { getDesignConfig, TMagicSelect } from '@tmagic/design';
import type { CondOpSelectConfig, FieldProps } from '@tmagic/form';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services';
import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils';
@ -50,8 +51,8 @@ const props = defineProps<FieldProps<CondOpSelectConfig>>();
const optionComponent = getDesignConfig('components')?.option;
const options = computed(() => {
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...props.model.field];
const ds = dataSourceService.getDataSourceById(id);
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...(props.model.field || [])];
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(id)) : undefined;
const type = getFieldType(ds, fieldNames);
return getCondOpOptionsByFieldType(type);
});

View File

@ -143,7 +143,10 @@ const checkStrictly = computed(() => {
if (typeof props.config.checkStrictly !== 'function') {
value = props.config.checkStrictly;
} else {
const dsId = removeDataSourceFieldPrefix(props.model[0]);
const fieldValue = props.model[props.name];
// dataSourceId id config
const rawDsId = props.config.dataSourceId ?? (Array.isArray(fieldValue) ? fieldValue[0] : fieldValue);
const dsId = removeDataSourceFieldPrefix(`${rawDsId}`);
const dataSource = dataSources.value.find((ds) => ds.id === dsId);
value = props.config.checkStrictly(mForm, {

View File

@ -25,6 +25,7 @@ import {
type GroupListConfig,
MGroupList,
} from '@tmagic/form';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
@ -46,9 +47,14 @@ const mForm = inject<FormState | undefined>('mForm');
const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []);
const resolveFieldPath = (path: string[]) => {
const [id, ...fieldNames] = path;
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
return { ds, fieldNames };
};
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
const [id, ...fieldNames] = [...parentFields.value, ...v];
const ds = dataSourceService.getDataSourceById(id);
const { ds, fieldNames } = resolveFieldPath([...parentFields.value, ...v]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
model.value = Number(model.value);
@ -82,14 +88,13 @@ const config = computed<GroupListConfig>(() => ({
? {
type: 'cascader',
options: () => {
const [dsId, ...keys] = parentFields.value;
const ds = dataSourceService.getDataSourceById(dsId);
const { ds, fieldNames } = resolveFieldPath(parentFields.value);
if (!ds) {
return [];
}
let fields = ds.fields || [];
keys.forEach((key) => {
fieldNames.forEach((key) => {
const field = fields.find((f) => f.name === key);
fields = field?.fields || [];
});
@ -139,8 +144,7 @@ const config = computed<GroupListConfig>(() => ({
{
name: 'value',
type: (_mForm, { model }) => {
const [id, ...fieldNames] = [...parentFields.value, ...model.field];
const ds = dataSourceService.getDataSourceById(id);
const { ds, fieldNames } = resolveFieldPath([...parentFields.value, ...(model.field || [])]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {

View File

@ -21,7 +21,7 @@ import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules';
import { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
@ -99,7 +99,7 @@ const formConfig = defineFormConfig([
return callback();
}
const result = validateDataSourceFieldSelectValue(
const result = validateDataSourceFieldSelect(
value,
{
fieldType: 'data-source-field-select',

View File

@ -209,7 +209,6 @@ const validateDataSourceFieldPath = (
path: string[],
options: {
dataSourceId?: string;
valueMode?: 'key' | 'value';
dataSourceFieldType?: DataSourceFieldType[];
message?: string;
} = {},
@ -219,19 +218,11 @@ const validateDataSourceFieldPath = (
return undefined;
}
let dsId = options.dataSourceId;
let fieldNames = path;
const dsId = options.dataSourceId;
if (!dsId) {
if (!path.length) {
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const rawDsId = path[0];
dsId =
options.valueMode === 'key' || !`${rawDsId}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX)
? `${rawDsId}`
: removeDataSourceFieldPrefix(`${rawDsId}`);
fieldNames = path.slice(1);
// 仅当取值为空数组或首项为空串时命中。表单校验管道里 validateTypeMatch 对空数组会短路,
// 因此这里只可能来自直接调用 validateDataSourceFieldSelect 的自定义 validator
return defaultMessage(options.message, '数据源不存在', dataSourceIdSuggestion());
}
const ds = findDataSource(`${dsId}`);
@ -239,11 +230,11 @@ const validateDataSourceFieldPath = (
return defaultMessage(options.message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
}
if (!fieldNames.length) {
if (!path.length) {
return undefined;
}
const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, fieldNames);
const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, path);
if (!ok) {
return defaultMessage(
options.message,
@ -258,11 +249,15 @@ const validateDataSourceFieldPath = (
}
const leafType = field?.type || 'any';
if (leafType === 'any' || allowedTypes.includes(leafType)) {
return undefined;
}
if (leafType !== 'any' && !allowedTypes.includes(leafType)) {
const fieldName = field?.name || path[path.length - 1];
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
// 文案已点明当前字段类型与要求类型,无需再列同级可选字段
return defaultMessage(
options.message,
`请选择类型为${allowedTypes.join('或')}的字段,字段(${fieldName})的类型为${leafType}`,
);
}
};
const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => {
@ -367,7 +362,7 @@ const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) =>
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 ds = id ? findDataSource(removeDataSourceFieldPrefix(`${id}`)) : undefined;
const fieldType = getFieldType(ds, fieldNames);
const allowed = getCondOpsByFieldType(fieldType);
@ -464,7 +459,7 @@ export type ValidateDataSourceFieldSelectOptions = {
/**
* data-source-field-select typeMatch rules.validator
*/
export const validateDataSourceFieldSelectValue = (
export const validateDataSourceFieldSelect = (
value: any,
context: TypeMatchValidateContext,
options?: ValidateDataSourceFieldSelectOptions,
@ -492,17 +487,22 @@ export const validateDataSourceFieldSelectValue = (
return defaultMessage(message, `${value}类型应为字符串数组`, dataSourceFieldPathSuggestion());
}
return validateDataSourceFieldPath(value, {
dataSourceId: config.dataSourceId,
valueMode: config.value,
// 未指定 dataSourceId 时,路径首项为数据源 id其余为字段名。
// value 模式的首项带 ds-field:: 前缀、key 模式不带removeDataSourceFieldPrefix 对后者是幂等的,无需分支。
let dataSourceId = config.dataSourceId ? `${config.dataSourceId}` : undefined;
let fieldNames = value;
if (!dataSourceId) {
dataSourceId = value.length ? removeDataSourceFieldPrefix(value[0]) : undefined;
fieldNames = value.slice(1);
}
return validateDataSourceFieldPath(fieldNames, {
dataSourceId,
dataSourceFieldType: config.dataSourceFieldType,
message,
});
};
const validateDataSourceFieldSelect: TypeMatchValidator = (value, context) =>
validateDataSourceFieldSelectValue(value, context);
const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => {
const config = props.config || {};
const dataSources = getDataSources();
@ -678,7 +678,8 @@ export const editorTypeMatchRules: Record<string, TypeMatchValidator> = {
'ui-select': validateUiSelect,
'data-source-input': validateDataSourceInput,
'data-source-method-select': validateDataSourceMethodSelect,
'data-source-field-select': validateDataSourceFieldSelect,
/** 注册进 typeMatch 规则表的入口,收窄为 TypeMatchValidator避免调用方误传第三个参数 */
'data-source-field-select': (value, context) => validateDataSourceFieldSelect(value, context),
'data-source-select': validateDataSourceSelect,
'code-select': validateCodeSelect,
'data-source-fields': validateDataSourceFields,

View File

@ -100,4 +100,14 @@ describe('CondOpSelect', () => {
await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', '=');
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('=');
});
test('字段路径带 ds-field:: 前缀时仍按数据源 id 查询', () => {
(getFieldType as any).mockReturnValue('number');
mount(CondOpSelect, {
props: baseProps({
model: { field: ['ds-field::ds1', 'a'], op: '' },
}) as any,
});
expect(dataSourceService.getDataSourceById).toHaveBeenCalledWith('ds1');
});
});

View File

@ -324,4 +324,17 @@ describe('DataSourceFieldSelect Index', () => {
await toggleBtn.trigger('click');
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThan(0);
});
test('checkStrictly 为函数时从当前字段值解析数据源', () => {
const checkStrictly = vi.fn(() => true);
mount(DSFSIndex, {
props: {
config: { checkStrictly },
model: { v: ['ds-ds1', 'a'] },
name: 'v',
} as any,
});
expect(checkStrictly).toHaveBeenCalled();
expect(checkStrictly.mock.calls[0][1].dataSource?.id).toBe('ds1');
});
});

View File

@ -21,7 +21,7 @@ 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, validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules';
import { ALL_COND_OPS, editorTypeMatchRules, validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
const codeDslState = vi.hoisted(() => ({ value: null as Record<string, any> | null }));
const dataSourcesState = vi.hoisted(() => ({ value: [] as any[] }));
@ -126,6 +126,9 @@ describe('editorTypeMatchRules', () => {
},
];
expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined();
expect(
run('cond-op-select', '>', {}, { field: [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'age'] }),
).toBeUndefined();
expect(firstLine(run('cond-op-select', 'include', {}, { field: ['ds1', 'age'] }))).toBe('include 不在可选项中');
expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'flag'] })).toBeUndefined();
// 未知类型与 UI 默认选项对齐:不含 boolean ops
@ -234,6 +237,11 @@ describe('editorTypeMatchRules', () => {
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();
// 未指定 dataSourceId 且路径为空:缺少数据源 id
expect(firstLine(run('data-source-field-select', [], { value: 'key' }))).toBe('数据源不存在');
// 已有 dataSourceId 时,空字段路径视为只选了数据源,不报错
expect(run('data-source-field-select', [], { dataSourceId: 'ds1' })).toBeUndefined();
expect(run('data-source-field-select', ['ds1'], { value: 'key' })).toBeUndefined();
// 数据源不存在时直出具体的数据源 id
expect(firstLine(run('data-source-field-select', ['missing', 'title'], { value: 'key' }))).toBe(
'数据源(missing)不存在',
@ -254,23 +262,55 @@ describe('editorTypeMatchRules', () => {
);
// 父字段无子字段时无建议
expect(run('data-source-field-select', ['ds1', 'title', 'x'], { value: 'key' })).toBe('数据源字段(x)不存在');
// 类型不匹配时,文案直出当前字段与其类型,不追加建议
expect(
firstLine(
run('data-source-field-select', ['title'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
),
).toBe('值不在可选项中');
run('data-source-field-select', ['title'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
).toBe('请选择类型为number的字段字段(title)的类型为string');
expect(
run('data-source-field-select', ['obj', 'a'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
).toBeUndefined();
expect(
run('data-source-field-select', ['obj', 'a'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['string'],
}),
).toBe('请选择类型为string的字段字段(a)的类型为number');
// 有 fieldConfig、未声明 value/dataSourceId 时,仍按取值中的 ds-field:: 前缀解析数据源路径
dataSourcesState.value = [
{
id: 'ds_4554243a',
type: 'base',
fields: [{ name: 'marqueeModId', type: 'string' }],
methods: [],
},
];
expect(
run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds_4554243a`, 'marqueeModId'], {
name: 'modId',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: { type: 'mod-select', modType: 100119 },
}),
).toBeUndefined();
expect(
firstLine(
run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds_4554243a`, 'missing'], {
name: 'modId',
dataSourceFieldType: ['string'],
fieldConfig: { type: 'mod-select', modType: 100119 },
}),
),
).toBe('数据源字段(missing)不存在');
});
test('validateDataSourceFieldSelectValue 支持自定义 plain 值校验并保留数据源路径校验', () => {
test('validateDataSourceFieldSelect 支持自定义 plain 值校验并保留数据源路径校验', () => {
const config = {
name: 'fontWeight',
type: 'data-source-field-select',
@ -287,9 +327,9 @@ describe('editorTypeMatchRules', () => {
},
};
expect(validateDataSourceFieldSelectValue(700, context, allowStringOrNumber)).toBeUndefined();
expect(validateDataSourceFieldSelectValue('bold', context, allowStringOrNumber)).toBeUndefined();
expect(firstLine(validateDataSourceFieldSelectValue({ a: 1 }, context, allowStringOrNumber))).toBe(
expect(validateDataSourceFieldSelect(700, context, allowStringOrNumber)).toBeUndefined();
expect(validateDataSourceFieldSelect('bold', context, allowStringOrNumber)).toBeUndefined();
expect(firstLine(validateDataSourceFieldSelect({ a: 1 }, context, allowStringOrNumber))).toBe(
'fontWeight 类型应为字符串或数字',
);
@ -306,7 +346,7 @@ describe('editorTypeMatchRules', () => {
];
expect(
validateDataSourceFieldSelectValue(
validateDataSourceFieldSelect(
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'weight'],
context,
allowStringOrNumber,
@ -314,7 +354,7 @@ describe('editorTypeMatchRules', () => {
).toBeUndefined();
expect(
firstLine(
validateDataSourceFieldSelectValue(
validateDataSourceFieldSelect(
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'missing'],
context,
allowStringOrNumber,