From 05fbdb17b53e688f562202a32cdb748afae65347 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 13 Aug 2026 15:03:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E7=BB=9F=E4=B8=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E5=AD=97=E6=AE=B5=E8=B7=AF=E5=BE=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=B9=B6=E4=BC=98=E5=8C=96=20typeMatch=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 ds-field:: 前缀解析、checkStrictly 数据源取值及字段类型不匹配时的校验提示。 --- packages/editor/src/fields/CondOpSelect.vue | 5 +- .../fields/DataSourceFieldSelect/Index.vue | 5 +- packages/editor/src/fields/DisplayConds.vue | 18 +++-- .../src/fields/StyleSetter/pro/Font.vue | 4 +- packages/editor/src/utils/type-match-rules.ts | 57 ++++++++-------- .../tests/unit/fields/CondOpSelect.spec.ts | 10 +++ .../unit/fields/DataSourceFieldSelect.spec.ts | 13 ++++ .../tests/unit/utils/typeMatchRules.spec.ts | 68 +++++++++++++++---- 8 files changed, 126 insertions(+), 54 deletions(-) diff --git a/packages/editor/src/fields/CondOpSelect.vue b/packages/editor/src/fields/CondOpSelect.vue index 5e59d322..7096c945 100644 --- a/packages/editor/src/fields/CondOpSelect.vue +++ b/packages/editor/src/fields/CondOpSelect.vue @@ -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>(); 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); }); diff --git a/packages/editor/src/fields/DataSourceFieldSelect/Index.vue b/packages/editor/src/fields/DataSourceFieldSelect/Index.vue index 49bc2516..2901e556 100644 --- a/packages/editor/src/fields/DataSourceFieldSelect/Index.vue +++ b/packages/editor/src/fields/DataSourceFieldSelect/Index.vue @@ -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, { diff --git a/packages/editor/src/fields/DisplayConds.vue b/packages/editor/src/fields/DisplayConds.vue index 9c9d95ca..42044763 100644 --- a/packages/editor/src/fields/DisplayConds.vue +++ b/packages/editor/src/fields/DisplayConds.vue @@ -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('mForm'); const parentFields = computed(() => filterFunction(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 }) => { - 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(() => ({ ? { 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(() => ({ { 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') { diff --git a/packages/editor/src/fields/StyleSetter/pro/Font.vue b/packages/editor/src/fields/StyleSetter/pro/Font.vue index 1165ddcb..0eacc7b5 100644 --- a/packages/editor/src/fields/StyleSetter/pro/Font.vue +++ b/packages/editor/src/fields/StyleSetter/pro/Font.vue @@ -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', diff --git a/packages/editor/src/utils/type-match-rules.ts b/packages/editor/src/utils/type-match-rules.ts index a1a87c2f..cb6c2c21 100644 --- a/packages/editor/src/utils/type-match-rules.ts +++ b/packages/editor/src/utils/type-match-rules.ts @@ -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 = { '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, diff --git a/packages/editor/tests/unit/fields/CondOpSelect.spec.ts b/packages/editor/tests/unit/fields/CondOpSelect.spec.ts index 5714f0eb..077e7a91 100644 --- a/packages/editor/tests/unit/fields/CondOpSelect.spec.ts +++ b/packages/editor/tests/unit/fields/CondOpSelect.spec.ts @@ -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'); + }); }); diff --git a/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts b/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts index a296a8ac..08458547 100644 --- a/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts +++ b/packages/editor/tests/unit/fields/DataSourceFieldSelect.spec.ts @@ -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'); + }); }); diff --git a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts index 78077b63..317fd02a 100644 --- a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts +++ b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts @@ -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 | 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,