diff --git a/packages/design/src/FormItem.vue b/packages/design/src/FormItem.vue
index ce7d7b0c..5a83bf83 100644
--- a/packages/design/src/FormItem.vue
+++ b/packages/design/src/FormItem.vue
@@ -8,6 +8,10 @@
+
+
+ {{ resolveErrorText(error) }}
+
@@ -15,6 +19,7 @@
import { computed } from 'vue';
import { getDesignConfig } from './config';
+import { stripValidateSuggestion } from './formValidateMessage';
import type { FormItemProps } from './types';
defineOptions({
@@ -33,4 +38,10 @@ const uiProps = computed(() => {
const { extra, ...rest } = ui?.props(props) || props;
return rest;
});
+
+/**
+ * 校验错误文案中,「修改建议」仅用于错误汇总展示。
+ * form-item 行内错误只展示主错误描述,不展示修改建议。
+ */
+const resolveErrorText = (error?: string) => stripValidateSuggestion(error);
diff --git a/packages/design/src/formValidateMessage.ts b/packages/design/src/formValidateMessage.ts
new file mode 100644
index 00000000..7c4735c2
--- /dev/null
+++ b/packages/design/src/formValidateMessage.ts
@@ -0,0 +1,47 @@
+/*
+ * 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.
+ */
+
+/**
+ * 校验错误文案中,「主错误描述」与「修改建议」之间的分隔符。
+ *
+ * 约定:typeMatch 等校验器产出的文案形如
+ * `主错误描述${VALIDATE_SUGGESTION_SEPARATOR}修改建议`。
+ * - 行内 form-item 错误、组件树 tooltip 仅展示「主错误描述」;
+ * - 错误汇总(如属性面板报错弹窗)展示完整文案(含修改建议)。
+ *
+ * 该常量为「主错误描述 / 修改建议」这一隐式协议的唯一真源,
+ * 所有生产/消费该文案的位置都应复用这里的常量与 helper,避免魔法字符串散落。
+ */
+export const VALIDATE_SUGGESTION_SEPARATOR = '\n\n';
+
+/**
+ * 在「主错误描述」后追加「修改建议」;无建议时原样返回主错误描述。
+ *
+ * @param message 主错误描述
+ * @param suggestion 可选的修改建议(示例值等)
+ */
+export const appendValidateSuggestion = (message: string, suggestion?: string): string =>
+ suggestion ? `${message}${VALIDATE_SUGGESTION_SEPARATOR}${suggestion}` : message;
+
+/**
+ * 去掉校验文案中的「修改建议」部分,仅保留「主错误描述」。
+ *
+ * @param text 完整校验文案(可能形如 `主错误描述\n\n修改建议`)
+ */
+export const stripValidateSuggestion = (text?: string): string =>
+ String(text ?? '').split(VALIDATE_SUGGESTION_SEPARATOR)[0];
diff --git a/packages/design/src/index.ts b/packages/design/src/index.ts
index c439c316..e9cfd324 100644
--- a/packages/design/src/index.ts
+++ b/packages/design/src/index.ts
@@ -8,6 +8,7 @@ import './theme/index.scss';
export * from './types';
export * from './config';
+export * from './formValidateMessage';
export { default as TMagicAutocomplete } from './Autocomplete.vue';
export { default as TMagicBadge } from './Badge.vue';
diff --git a/packages/editor/src/fields/StyleSetter/Index.vue b/packages/editor/src/fields/StyleSetter/Index.vue
index 13aa759a..9c02d637 100644
--- a/packages/editor/src/fields/StyleSetter/Index.vue
+++ b/packages/editor/src/fields/StyleSetter/Index.vue
@@ -7,6 +7,7 @@
v-if="item.component"
:is="item.component"
:values="model[name]"
+ :prop="prop ? `${prop}.${name}` : name"
:last-values="lastValues?.[name]"
:is-compare="isCompare"
:size="size"
@@ -35,7 +36,7 @@ defineOptions({
name: 'MFieldsStyleSetter',
});
-const props = defineProps>();
+defineProps>();
const emit = defineEmits<{
change: [v: any, eventData: ContainerChangeEventData];
@@ -77,13 +78,6 @@ const collapseValue = shallowRef(
);
const change = (v: any, eventData: ContainerChangeEventData) => {
- eventData.changeRecords?.forEach((record) => {
- if (props.prop) {
- record.propPath = `${props.prop}.${record.propPath}`;
- } else if (props.name) {
- record.propPath = `${props.name}.${record.propPath}`;
- }
- });
emit('change', v, eventData);
};
diff --git a/packages/editor/src/fields/StyleSetter/pro/Background.vue b/packages/editor/src/fields/StyleSetter/pro/Background.vue
index 601dfa6b..fa0adb5e 100644
--- a/packages/editor/src/fields/StyleSetter/pro/Background.vue
+++ b/packages/editor/src/fields/StyleSetter/pro/Background.vue
@@ -1,6 +1,9 @@
import { markRaw } from 'vue';
-import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
+import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import BackgroundPosition from '../components/BackgroundPosition.vue';
@@ -26,6 +29,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
+ prop?: string;
}>();
const emit = defineEmits<{
@@ -33,60 +37,95 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
-const config = defineFormItem({
- items: [
- {
- name: 'backgroundColor',
- text: '背景色',
- labelWidth: '68px',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'colorPicker',
+const formConfig = defineFormConfig([
+ {
+ name: 'backgroundColor',
+ text: '背景色',
+ labelWidth: '68px',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'colorPicker',
+ },
+ },
+ {
+ name: 'backgroundImage',
+ text: '背景图',
+ labelWidth: '68px',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'img-upload',
+ } as any,
+ },
+ {
+ name: 'backgroundSize',
+ text: '背景尺寸',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'auto', text: '默认', tooltip: '默认 auto' },
+ { value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
+ { value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
+ ],
+ rules: [
+ {
+ typeMatch: false,
},
- },
- {
- name: 'backgroundImage',
- text: '背景图',
- labelWidth: '68px',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'img-upload',
- } as any,
- },
- {
- name: 'backgroundSize',
- text: '背景尺寸',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'auto', text: '默认', tooltip: '默认 auto' },
- { value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
- { value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
- ],
- },
- {
- name: 'backgroundRepeat',
- text: '重复显示',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'repeat', icon: markRaw(BackgroundRepeat), tooltip: '垂直和水平方向重复 repeat' },
- { value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
- { value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
- { value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
- ],
- },
- {
- name: 'backgroundPosition',
- text: '背景定位',
- type: 'component',
- component: BackgroundPosition,
- labelWidth: '68px',
- },
- ],
-});
+ {
+ validator: ({ value, callback }) => {
+ if (value === '' || value === null || value === undefined) {
+ return callback();
+ }
+
+ const keywords = ['auto', 'cover', 'contain', 'inherit', 'initial', 'revert', 'unset'];
+ // 单值:关键字 或 长度/百分比
+ const lengthPercent = /^-?\d+(\.\d+)?(px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc|%)$/;
+ const singleValue = (v: string) => keywords.includes(v) || lengthPercent.test(v);
+
+ const str = String(value).trim();
+ const parts = str.split(/\s+/);
+
+ // cover / contain 不能与其他值组合
+ if (parts.length > 1 && (parts.includes('cover') || parts.includes('contain'))) {
+ return callback('cover/contain 不能与其他值组合');
+ }
+
+ // 多值最多两个
+ if (parts.length > 2) {
+ return callback('backgroundSize 最多支持两个值');
+ }
+
+ // 关键字 auto 在多值场景中允许与其他长度/百分比组合
+ if (parts.every((part) => singleValue(part))) {
+ return callback();
+ }
+
+ return callback('backgroundSize 值不合法');
+ },
+ },
+ ],
+ },
+ {
+ name: 'backgroundRepeat',
+ text: '重复显示',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'repeat', icon: markRaw(BackgroundRepeat), tooltip: '垂直和水平方向重复 repeat' },
+ { value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
+ { value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
+ { value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
+ ],
+ },
+ {
+ name: 'backgroundPosition',
+ text: '背景定位',
+ type: 'component',
+ component: BackgroundPosition,
+ labelWidth: '68px',
+ },
+]);
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
diff --git a/packages/editor/src/fields/StyleSetter/pro/Border.vue b/packages/editor/src/fields/StyleSetter/pro/Border.vue
index b7b7c17f..4d2581c5 100644
--- a/packages/editor/src/fields/StyleSetter/pro/Border.vue
+++ b/packages/editor/src/fields/StyleSetter/pro/Border.vue
@@ -1,5 +1,6 @@
();
const emit = defineEmits<{
@@ -40,17 +42,13 @@ const emit = defineEmits<{
}>();
const config = defineFormItem({
- items: [
- {
- labelWidth: '68px',
- name: 'borderRadius',
- text: '圆角',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'text',
- },
- },
- ],
+ labelWidth: '68px',
+ name: 'borderRadius',
+ text: '圆角',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'text',
+ },
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
diff --git a/packages/editor/src/fields/StyleSetter/pro/Font.vue b/packages/editor/src/fields/StyleSetter/pro/Font.vue
index d3174693..6d59e0e7 100644
--- a/packages/editor/src/fields/StyleSetter/pro/Font.vue
+++ b/packages/editor/src/fields/StyleSetter/pro/Font.vue
@@ -1,6 +1,9 @@
import { markRaw } from 'vue';
-import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
+import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
@@ -25,6 +28,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
+ prop?: string;
}>();
const emit = defineEmits<{
@@ -32,73 +36,71 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
-const config = defineFormItem({
- items: [
- {
- type: 'row',
- items: [
- {
- labelWidth: '68px',
- name: 'fontSize',
- text: '字号',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'text',
- },
+const formConfig = defineFormConfig([
+ {
+ type: 'row',
+ items: [
+ {
+ labelWidth: '68px',
+ name: 'fontSize',
+ text: '字号',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'text',
},
- {
- labelWidth: '68px',
- name: 'lineHeight',
- text: '行高',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'text',
- },
+ },
+ {
+ labelWidth: '68px',
+ name: 'lineHeight',
+ text: '行高',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'text',
},
- ],
- },
- {
- name: 'fontWeight',
- text: '字重',
- labelWidth: '68px',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'select',
- options: ['normal', 'bold']
- .concat(
- Array(7)
- .fill(1)
- .map((x, i) => `${i + 1}00`),
- )
- .map((item) => ({
- value: item,
- text: item,
- })),
},
+ ],
+ },
+ {
+ name: 'fontWeight',
+ text: '字重',
+ labelWidth: '68px',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'select',
+ options: ['normal', 'bold']
+ .concat(
+ Array(7)
+ .fill(1)
+ .map((x, i) => `${i + 1}00`),
+ )
+ .map((item) => ({
+ value: item,
+ text: item,
+ })),
},
- {
- labelWidth: '68px',
- name: 'color',
- text: '颜色',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'colorPicker',
- },
+ },
+ {
+ labelWidth: '68px',
+ name: 'color',
+ text: '颜色',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'colorPicker',
},
- {
- name: 'textAlign',
- text: '对齐',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row' },
- { value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' },
- { value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' },
- ],
- },
- ],
-});
+ },
+ {
+ name: 'textAlign',
+ text: '对齐',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row' },
+ { value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' },
+ { value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' },
+ ],
+ },
+]);
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
diff --git a/packages/editor/src/fields/StyleSetter/pro/Layout.vue b/packages/editor/src/fields/StyleSetter/pro/Layout.vue
index 7d97849e..017aa588 100644
--- a/packages/editor/src/fields/StyleSetter/pro/Layout.vue
+++ b/packages/editor/src/fields/StyleSetter/pro/Layout.vue
@@ -1,6 +1,9 @@
import { markRaw } from 'vue';
-import type { ChildConfig, ContainerChangeEventData } from '@tmagic/form';
-import { defineFormItem, MContainer } from '@tmagic/form';
+import type { ContainerChangeEventData } from '@tmagic/form';
+import { defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue';
@@ -49,6 +52,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
+ prop?: string;
}>();
const emit = defineEmits<{
@@ -56,145 +60,143 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
-const config = defineFormItem({
- items: [
- {
- name: 'display',
- text: '模式',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'inline', icon: markRaw(DisplayInline), tooltip: '内联布局 inline' },
- { value: 'flex', icon: markRaw(DisplayFlex), tooltip: '弹性布局 flex' },
- { value: 'block', icon: markRaw(DisplayBlock), tooltip: '块级布局 block' },
- { value: 'inline-block', icon: markRaw(DisplayInlineBlock), tooltip: '内联块布局 inline-block' },
- { value: 'none', icon: markRaw(DisplayNone), tooltip: '隐藏 none' },
- ],
- },
- {
- name: 'flexDirection',
- text: '主轴方向',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
- { value: 'row-reverse', icon: markRaw(FlexDirectionRowReverse), tooltip: '水平方向 起点在右侧 row-reverse' },
- { value: 'column', icon: markRaw(FlexDirectionColumn), tooltip: '垂直方向 起点在上沿 column' },
- {
- value: 'column-reverse',
- icon: markRaw(FlexDirectionColumnReverse),
- tooltip: '垂直方向 起点在下沿 column-reverse',
+const formConfig = defineFormConfig([
+ {
+ name: 'display',
+ text: '模式',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'inline', icon: markRaw(DisplayInline), tooltip: '内联布局 inline' },
+ { value: 'flex', icon: markRaw(DisplayFlex), tooltip: '弹性布局 flex' },
+ { value: 'block', icon: markRaw(DisplayBlock), tooltip: '块级布局 block' },
+ { value: 'inline-block', icon: markRaw(DisplayInlineBlock), tooltip: '内联块布局 inline-block' },
+ { value: 'none', icon: markRaw(DisplayNone), tooltip: '隐藏 none' },
+ ],
+ },
+ {
+ name: 'flexDirection',
+ text: '主轴方向',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
+ { value: 'row-reverse', icon: markRaw(FlexDirectionRowReverse), tooltip: '水平方向 起点在右侧 row-reverse' },
+ { value: 'column', icon: markRaw(FlexDirectionColumn), tooltip: '垂直方向 起点在上沿 column' },
+ {
+ value: 'column-reverse',
+ icon: markRaw(FlexDirectionColumnReverse),
+ tooltip: '垂直方向 起点在下沿 column-reverse',
+ },
+ ],
+ display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
+ },
+ {
+ name: 'justifyContent',
+ text: '主轴对齐',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
+ { value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
+ { value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
+ { value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
+ { value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
+ ],
+ display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
+ },
+ {
+ name: 'alignItems',
+ text: '辅轴对齐',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
+ { value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
+ { value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
+ { value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
+ { value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
+ ],
+ display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
+ },
+ {
+ name: 'flexWrap',
+ text: '换行',
+ type: 'radioGroup',
+ childType: 'button',
+ labelWidth: '68px',
+ options: [
+ { value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
+ { value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
+ { value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
+ ],
+ display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
+ },
+ {
+ type: 'row',
+ items: [
+ {
+ name: 'width',
+ text: '宽度',
+ labelWidth: '68px',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'text',
},
- ],
- display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
- },
- {
- name: 'justifyContent',
- text: '主轴对齐',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
- { value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
- { value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
- { value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
- { value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
- ],
- display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
- },
- {
- name: 'alignItems',
- text: '辅轴对齐',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
- { value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
- { value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
- { value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
- { value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
- ],
- display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
- },
- {
- name: 'flexWrap',
- text: '换行',
- type: 'radioGroup',
- childType: 'button',
- labelWidth: '68px',
- options: [
- { value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
- { value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
- { value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
- ],
- display: (_mForm, { model }: { model: Record }) => model.display === 'flex',
- },
- {
- type: 'row',
- items: [
- {
- name: 'width',
- text: '宽度',
- labelWidth: '68px',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'text',
- },
+ },
+ {
+ name: 'height',
+ text: '高度',
+ labelWidth: '68px',
+ type: 'data-source-field-select',
+ fieldConfig: {
+ type: 'text',
},
- {
- name: 'height',
- text: '高度',
- labelWidth: '68px',
- type: 'data-source-field-select',
- fieldConfig: {
- type: 'text',
- },
+ },
+ ],
+ },
+ {
+ type: 'row',
+ items: [
+ {
+ type: 'data-source-field-select',
+ text: 'overflow',
+ name: 'overflow',
+ labelWidth: '68px',
+ checkStrictly: false,
+ dataSourceFieldType: ['string'],
+ fieldConfig: {
+ type: 'select',
+ clearable: true,
+ allowCreate: true,
+ options: [
+ { text: 'visible', value: 'visible' },
+ { text: 'hidden', value: 'hidden' },
+ { text: 'clip', value: 'clip' },
+ { text: 'scroll', value: 'scroll' },
+ { text: 'auto', value: 'auto' },
+ { text: 'overlay', value: 'overlay' },
+ { text: 'initial', value: 'initial' },
+ ],
},
- ],
- },
- {
- type: 'row',
- items: [
- {
- type: 'data-source-field-select',
- text: 'overflow',
- name: 'overflow',
- labelWidth: '68px',
- checkStrictly: false,
- dataSourceFieldType: ['string'],
- fieldConfig: {
- type: 'select',
- clearable: true,
- allowCreate: true,
- options: [
- { text: 'visible', value: 'visible' },
- { text: 'hidden', value: 'hidden' },
- { text: 'clip', value: 'clip' },
- { text: 'scroll', value: 'scroll' },
- { text: 'auto', value: 'auto' },
- { text: 'overlay', value: 'overlay' },
- { text: 'initial', value: 'initial' },
- ],
- },
+ },
+ {
+ type: 'data-source-field-select',
+ text: '透明度',
+ name: 'opacity',
+ labelWidth: '68px',
+ dataSourceFieldType: ['string', 'number'],
+ fieldConfig: {
+ type: 'text',
},
- {
- type: 'data-source-field-select',
- text: '透明度',
- name: 'opacity',
- labelWidth: '68px',
- dataSourceFieldType: ['string', 'number'],
- fieldConfig: {
- type: 'text',
- },
- },
- ],
- },
- ],
-}) as ChildConfig;
+ },
+ ],
+ },
+]);
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
diff --git a/packages/editor/src/fields/StyleSetter/pro/Position.vue b/packages/editor/src/fields/StyleSetter/pro/Position.vue
index 2d821938..69b4b264 100644
--- a/packages/editor/src/fields/StyleSetter/pro/Position.vue
+++ b/packages/editor/src/fields/StyleSetter/pro/Position.vue
@@ -1,6 +1,9 @@
diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts
index a856d278..9109892c 100644
--- a/packages/editor/src/utils/props.ts
+++ b/packages/editor/src/utils/props.ts
@@ -387,6 +387,7 @@ export interface ValidatePropsFormOptions {
* 置为 `false` 时静默挂载后自动校验。
*/
debug?: boolean;
+ typeMatchValid?: boolean;
}
// #endregion ValidatePropsFormOptions
@@ -426,10 +427,12 @@ export const validatePropsForm = ({
stage,
extendState,
debug,
+ typeMatchValid,
}: ValidatePropsFormOptions): Promise =>
validateForm({
config,
debug,
+ typeMatchValid,
initValues: values,
// 将当前组件实例的 provides(含 Editor 顶层的 services / codeOptions 等组件级 provide)
// 合入 appContext,使临时 MForm 中的编辑器字段组件(DataSourceInput 等)能正常 inject
diff --git a/packages/editor/src/utils/type-match-rules.ts b/packages/editor/src/utils/type-match-rules.ts
index ddc3508f..ec24a5fc 100644
--- a/packages/editor/src/utils/type-match-rules.ts
+++ b/packages/editor/src/utils/type-match-rules.ts
@@ -18,6 +18,7 @@
import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
+import { appendValidateSuggestion } from '@tmagic/design';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
@@ -39,7 +40,73 @@ import {
numberOptions,
} from '@editor/utils/props';
-const defaultMessage = (message: string | undefined, fallback: string) => message || fallback;
+/**
+ * 将值格式化为可读的参考示例字符串。
+ */
+const stringifyExampleValue = (value: any): string => {
+ if (typeof value === 'string') return `"${value}"`;
+ if (value === null || value === undefined) return String(value);
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+};
+
+// 参考建议中最多展示的可选值个数,超出以「等」省略。
+const MAX_SUGGESTION_OPTIONS = 5;
+
+/**
+ * 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
+ */
+const listSuggestion = (values: any[]): string => {
+ const list = values.filter((item) => typeof item !== 'undefined' && item !== null && item !== '');
+ if (!list.length) return '';
+ const shown = list.slice(0, MAX_SUGGESTION_OPTIONS).map(stringifyExampleValue);
+ const suffix = list.length > MAX_SUGGESTION_OPTIONS ? ' 等' : '';
+ return `请使用以下某一个值:${shown.join(';')}${suffix}`;
+};
+
+/**
+ * 列出当前可用数据源 id 的参考建议。
+ */
+const dataSourceIdSuggestion = (): string => listSuggestion(getDataSources().map((ds) => `${ds.id}`));
+
+// 各类型 / 结构的参考示例值
+const SUGGESTION_STRING = '请参考以下示例值:"文本内容"';
+const SUGGESTION_STRING_OR_NUMBER = '请参考以下示例值:"文本内容" 或 123';
+const SUGGESTION_OBJECT = '请参考以下示例值:{ "key": "value" }';
+const SUGGESTION_ARRAY = '请参考以下示例值:[]';
+const SUGGESTION_STRING_ARRAY = '请参考以下示例值:["字段1", "字段2"]';
+const SUGGESTION_METHOD_TUPLE = '请参考以下示例值:["数据源ID", "方法名"]';
+const SUGGESTION_DS_BINDING = '请参考以下示例值:"${数据源ID.字段名}"';
+const SUGGESTION_DS_BIND_OBJECT = '请参考以下示例值:{ "isBindDataSource": true, "dataSourceId": "数据源ID" }';
+const SUGGESTION_UI_ID = '请参考以下示例值:画布中已存在组件的 id';
+const SUGGESTION_FIELD_ITEM = '请参考以下示例值:{ "name": "字段名", "type": "string" }';
+const SUGGESTION_CODE_SELECT = '请参考以下示例值:{ "hookData": [{ "codeId": "代码块ID", "params": {} }] }';
+const SUGGESTION_HOOK_ITEM = '请参考以下示例值:{ "codeId": "代码块ID", "params": {} }';
+const SUGGESTION_MOCK_ITEM =
+ '请参考以下示例值:{ "title": "mock 名称", "enable": true, "useInEditor": false, "data": {} }';
+const SUGGESTION_METHOD_ITEM = '请参考以下示例值:{ "name": "方法名", "content": "() => {}" }';
+const SUGGESTION_EVENT_ITEM = '请参考以下示例值:{ "name": "事件名", "actions": [] }';
+const SUGGESTION_DISPLAY_COND = '请参考以下示例值:[{ "cond": [{ "field": ["数据源ID", "字段名"], "op": "==" }] }]';
+
+/**
+ * 返回最终错误文案。
+ *
+ * - 传入了自定义 `message` 时,直接使用自定义文案(不追加建议);
+ * - 否则使用默认文案 `fallback`,并在其后拼接「参考建议」。
+ * 参考建议给出一个可参考的示例值(如可选项列表、示例数据),仅用于错误汇总展示;
+ * form-item 行内错误不展示建议(由 @tmagic/design FormItem 在渲染时截断建议)。
+ * 主文案与建议的拼接/截断统一复用 @tmagic/design 的 `appendValidateSuggestion`。
+ */
+const defaultMessage = (message: string | undefined, fallback: string, suggestion?: string) => {
+ if (message) return message;
+ return appendValidateSuggestion(fallback, suggestion);
+};
const isPlainObject = (value: any): value is Record =>
Object.prototype.toString.call(value) === '[object Object]';
@@ -82,6 +149,87 @@ const getMethodNamesForDataSource = (ds: DataSourceSchema): Set => {
return names;
};
+/**
+ * 递归收集画布中真实存在的组件 id。
+ */
+const collectNodeIds = (limit = MAX_SUGGESTION_OPTIONS): Id[] => {
+ const ids: Id[] = [];
+ const walk = (nodes?: any[]): boolean => {
+ for (const node of nodes || []) {
+ if (typeof node?.id !== 'undefined') {
+ ids.push(node.id);
+ if (ids.length >= limit) return true;
+ }
+ if (Array.isArray(node?.items) && walk(node.items)) return true;
+ }
+ return false;
+ };
+ walk(editorService.get('root')?.items);
+ return ids;
+};
+
+/**
+ * 取数据源的第一个字段名,用于拼接真实的字段路径示例。
+ */
+const firstDataSourceFieldName = (ds?: DataSourceSchema): string | undefined =>
+ (ds?.fields || []).find((item) => typeof item?.name === 'string')?.name;
+
+/**
+ * 列出画布中真实存在的组件 id 作为参考建议;无组件时回退到通用示例。
+ */
+const uiIdSuggestion = (): string => listSuggestion(collectNodeIds().map((id) => `${id}`)) || SUGGESTION_UI_ID;
+
+/**
+ * 用真实数据源 id、字段名拼接数据源绑定表达式示例;无数据源时回退到通用示例。
+ */
+const dataSourceBindingSuggestion = (): string => {
+ const ds = getDataSources()[0];
+ if (!ds) return SUGGESTION_DS_BINDING;
+ const field = firstDataSourceFieldName(ds);
+ const path = field ? `${ds.id}.${field}` : `${ds.id}`;
+ return `请参考以下示例值:"\${${path}}"`;
+};
+
+/**
+ * 用真实数据源 id 拼接数据源绑定对象示例;无数据源时回退到通用示例。
+ */
+const dataSourceBindObjectSuggestion = (dataSources: DataSourceSchema[] = getDataSources()): string => {
+ const ds = dataSources[0];
+ if (!ds) return SUGGESTION_DS_BIND_OBJECT;
+ return `请参考以下示例值:${stringifyExampleValue({ isBindDataSource: true, dataSourceId: `${ds.id}` })}`;
+};
+
+/**
+ * 用真实数据源 id、字段名拼接字段路径数组示例;无数据源时回退到通用示例。
+ */
+const dataSourceFieldPathSuggestion = (): string => {
+ const ds = getDataSources()[0];
+ if (!ds) return SUGGESTION_STRING_ARRAY;
+ const field = firstDataSourceFieldName(ds);
+ return `请参考以下示例值:${stringifyExampleValue(field ? [`${ds.id}`, field] : [`${ds.id}`])}`;
+};
+
+/**
+ * 用真实数据源 id、方法名拼接方法元组示例;无数据源时回退到通用示例。
+ */
+const methodTupleSuggestion = (): string => {
+ const ds = getDataSources()[0];
+ if (!ds) return SUGGESTION_METHOD_TUPLE;
+ const [methodName] = [...getMethodNamesForDataSource(ds)];
+ return `请参考以下示例值:${stringifyExampleValue([`${ds.id}`, methodName])}`;
+};
+
+/**
+ * 用真实数据源 id、字段名拼接显示条件结构示例;无数据源时回退到通用示例。
+ */
+const displayCondSuggestion = (): string => {
+ const ds = getDataSources()[0];
+ if (!ds) return SUGGESTION_DISPLAY_COND;
+ const field = firstDataSourceFieldName(ds);
+ const fieldPath = field ? [`${ds.id}`, field] : [`${ds.id}`];
+ return `请参考以下示例值:${stringifyExampleValue([{ cond: [{ field: fieldPath, op: '==' }] }])}`;
+};
+
const validateDataSourceFieldPath = (
path: string[],
options: {
@@ -101,7 +249,7 @@ const validateDataSourceFieldPath = (
if (!dsId) {
if (!path.length) {
- return defaultMessage(options.message, '值不在可选项中');
+ return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const rawDsId = path[0];
dsId =
@@ -113,7 +261,7 @@ const validateDataSourceFieldPath = (
const ds = findDataSource(`${dsId}`);
if (!ds) {
- return defaultMessage(options.message, '值不在可选项中');
+ return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
if (!fieldNames.length) {
@@ -122,7 +270,7 @@ const validateDataSourceFieldPath = (
const { field, ok } = resolveFieldByPath(ds.fields, fieldNames);
if (!ok) {
- return defaultMessage(options.message, '值不在可选项中');
+ return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const allowedTypes = options.dataSourceFieldType || ['any'];
@@ -135,12 +283,12 @@ const validateDataSourceFieldPath = (
return undefined;
}
- return defaultMessage(options.message, '值不在可选项中');
+ return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
};
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 的字符串数组`);
+ return defaultMessage(message, `${value}类型应为长度为 2 的字符串数组`, methodTupleSuggestion());
}
const dataSources = getDataSources();
@@ -151,11 +299,15 @@ const validateDataSourceMethodTuple = (value: any, message?: string): string | u
const [dsId, methodName] = value;
const ds = findDataSource(dsId);
if (!ds) {
- return defaultMessage(message, `数据源(${dsId})不存在`);
+ return defaultMessage(message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
}
if (!getMethodNamesForDataSource(ds).has(methodName)) {
- return defaultMessage(message, `数据源方法(${methodName})不存在`);
+ return defaultMessage(
+ message,
+ `数据源方法(${methodName})不存在`,
+ listSuggestion([...getMethodNamesForDataSource(ds)]),
+ );
}
return undefined;
@@ -171,18 +323,18 @@ const validateDataSourceTemplateBindings = (value: string, message?: string): st
for (const match of matches) {
const keys = getKeysArray(match[1]);
if (!keys.length) {
- return defaultMessage(message, `数据源绑定(${value})不合法`);
+ return defaultMessage(message, `数据源绑定(${value})不合法`, dataSourceBindingSuggestion());
}
const [dsId, ...fieldNames] = keys;
const ds = findDataSource(`${dsId}`);
if (!ds) {
- return defaultMessage(message, `数据源绑定(${value})不合法`);
+ return defaultMessage(message, `数据源绑定(${value})不合法`, dataSourceBindingSuggestion());
}
const { ok } = resolveFieldByPath(ds.fields, fieldNames, { skipNumberIndices: true });
if (!ok) {
- return defaultMessage(message, `数据源绑定(${value})不合法`);
+ return defaultMessage(message, `数据源绑定(${value})不合法`, dataSourceBindingSuggestion());
}
}
@@ -191,16 +343,16 @@ const validateDataSourceTemplateBindings = (value: string, message?: string): st
const validateDataSourceFieldsItem = (item: any, message?: string): string | undefined => {
if (!isPlainObject(item) || typeof item.name !== 'string') {
- return defaultMessage(message, '字段项结构不合法');
+ return defaultMessage(message, '字段项结构不合法', SUGGESTION_FIELD_ITEM);
}
if (typeof item.type !== 'undefined' && !DATA_SOURCE_FIELD_TYPES.has(item.type)) {
- return defaultMessage(message, '字段类型不合法');
+ return defaultMessage(message, '字段类型不合法', listSuggestion([...DATA_SOURCE_FIELD_TYPES]));
}
if (typeof item.fields !== 'undefined') {
if (!Array.isArray(item.fields)) {
- return defaultMessage(message, '字段项结构不合法');
+ return defaultMessage(message, '字段项结构不合法', SUGGESTION_FIELD_ITEM);
}
for (const child of item.fields) {
const error = validateDataSourceFieldsItem(child, message);
@@ -216,21 +368,21 @@ const validateKeyValue: TypeMatchValidator = (value, { message, props }) => {
return undefined;
}
if (!isPlainObject(value)) {
- return defaultMessage(message, '值类型应为对象');
+ return defaultMessage(message, '值类型应为对象', SUGGESTION_OBJECT);
}
return undefined;
};
const validateStyleSetter: TypeMatchValidator = (value, { message }) => {
if (!isPlainObject(value)) {
- return defaultMessage(message, '值类型应为对象');
+ return defaultMessage(message, '值类型应为对象', SUGGESTION_OBJECT);
}
return undefined;
};
const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) => {
if (typeof value !== 'string') {
- return defaultMessage(message, '值类型应为字符串');
+ return defaultMessage(message, '值类型应为字符串', SUGGESTION_STRING);
}
const parentFields = props.config?.parentFields || [];
@@ -241,14 +393,14 @@ const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) =>
const allowed = getCondOpsByFieldType(fieldType);
if (!allowed.has(value)) {
- return defaultMessage(message, `${value} 不在可选项中`);
+ return defaultMessage(message, `${value} 不在可选项中`, listSuggestion([...allowed]));
}
return undefined;
};
const validateCodeSelectCol: TypeMatchValidator = (value, { message }) => {
if (typeof value !== 'string') {
- return defaultMessage(message, `${value}类型应为字符串`);
+ return defaultMessage(message, `${value}类型应为字符串`, SUGGESTION_STRING);
}
const codeDsl = codeBlockService.getCodeDsl();
@@ -257,14 +409,14 @@ const validateCodeSelectCol: TypeMatchValidator = (value, { message }) => {
}
if (!(value in codeDsl)) {
- return defaultMessage(message, `代码块(${value})不存在`);
+ return defaultMessage(message, `代码块(${value})不存在`, listSuggestion(Object.keys(codeDsl)));
}
return undefined;
};
const validatePageFragmentSelect: TypeMatchValidator = (value, { message }) => {
if (!isId(value)) {
- return defaultMessage(message, `${value}类型应为字符串或数字`);
+ return defaultMessage(message, `${value}类型应为字符串或数字`, SUGGESTION_STRING_OR_NUMBER);
}
const items = editorService.get('root')?.items;
@@ -272,16 +424,17 @@ const validatePageFragmentSelect: TypeMatchValidator = (value, { message }) => {
return undefined;
}
- const exists = items.some((item) => item.type === NodeType.PAGE_FRAGMENT && `${item.id}` === `${value}`);
+ const fragments = items.filter((item) => item.type === NodeType.PAGE_FRAGMENT);
+ const exists = fragments.some((item) => `${item.id}` === `${value}`);
if (!exists) {
- return defaultMessage(message, `页面片段(${value})不存在`);
+ return defaultMessage(message, `页面片段(${value})不存在`, listSuggestion(fragments.map((item) => `${item.id}`)));
}
return undefined;
};
const validateUiSelect: TypeMatchValidator = (value, { message }) => {
if (!isId(value)) {
- return defaultMessage(message, `${value}类型应为字符串或数字`);
+ return defaultMessage(message, `${value}类型应为字符串或数字`, SUGGESTION_STRING_OR_NUMBER);
}
const root = editorService.get('root');
@@ -290,14 +443,14 @@ const validateUiSelect: TypeMatchValidator = (value, { message }) => {
}
if (!editorService.getNodeById(value)) {
- return defaultMessage(message, `组件(${value})不存在`);
+ return defaultMessage(message, `组件(${value})不存在`, uiIdSuggestion());
}
return undefined;
};
const validateDataSourceInput: TypeMatchValidator = (value, { message }) => {
if (typeof value !== 'string') {
- return defaultMessage(message, `${value}类型应为字符串`);
+ return defaultMessage(message, `${value}类型应为字符串`, SUGGESTION_STRING);
}
return validateDataSourceTemplateBindings(value, message);
};
@@ -326,7 +479,7 @@ const validateDataSourceFieldSelect: TypeMatchValidator = (value, { message, pro
}
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
- return defaultMessage(message, `${value}类型应为字符串数组`);
+ return defaultMessage(message, `${value}类型应为字符串数组`, dataSourceFieldPathSuggestion());
}
return validateDataSourceFieldPath(value, {
@@ -344,19 +497,19 @@ const validateDataSourceSelect: TypeMatchValidator = (value, { message, props })
if (config.value === 'id') {
if (typeof value !== 'string') {
- return defaultMessage(message, `${value}类型应为字符串`);
+ return defaultMessage(message, `${value}类型应为字符串`, SUGGESTION_STRING);
}
if (!filtered.length) {
return undefined;
}
if (!filtered.some((ds) => `${ds.id}` === `${value}`)) {
- return defaultMessage(message, `${value}不在可选项中`);
+ return defaultMessage(message, `${value}不在可选项中`, listSuggestion(filtered.map((ds) => `${ds.id}`)));
}
return undefined;
}
if (!isPlainObject(value) || value.isBindDataSource !== true || typeof value.dataSourceId === 'undefined') {
- return defaultMessage(message, `${value}类型不合法`);
+ return defaultMessage(message, `${value}类型不合法`, dataSourceBindObjectSuggestion(filtered));
}
if (!filtered.length) {
@@ -365,11 +518,11 @@ const validateDataSourceSelect: TypeMatchValidator = (value, { message, props })
const ds = filtered.find((item) => `${item.id}` === `${value.dataSourceId}`);
if (!ds) {
- return defaultMessage(message, `${value}不在可选项中`);
+ return defaultMessage(message, `${value}不在可选项中`, listSuggestion(filtered.map((item) => `${item.id}`)));
}
if (typeof value.dataSourceType !== 'undefined' && value.dataSourceType !== ds.type) {
- return defaultMessage(message, `${value}不在可选项中`);
+ return defaultMessage(message, `${value}不在可选项中`, listSuggestion(filtered.map((item) => `${item.id}`)));
}
return undefined;
@@ -377,18 +530,18 @@ const validateDataSourceSelect: TypeMatchValidator = (value, { message, props })
const validateCodeSelect: TypeMatchValidator = (value, { message }) => {
if (!isPlainObject(value) || !Array.isArray(value.hookData)) {
- return defaultMessage(message, `${value}类型不合法`);
+ return defaultMessage(message, `${value}类型不合法`, SUGGESTION_CODE_SELECT);
}
// 「codeId 是否存在 / 数据源方法是否存在」由 CodeSelect 内部 code-select-col、
// data-source-method-select 单元格各自的 typeMatch 校验,只标红出错单元格。此处仅做结构校验。
for (const item of value.hookData) {
if (!isPlainObject(item)) {
- return defaultMessage(message, '钩子项结构不合法');
+ return defaultMessage(message, '钩子项结构不合法', SUGGESTION_HOOK_ITEM);
}
if (typeof item.params !== 'undefined' && !isPlainObject(item.params)) {
- return defaultMessage(message, '钩子项结构不合法');
+ return defaultMessage(message, '钩子项结构不合法', SUGGESTION_HOOK_ITEM);
}
}
@@ -397,7 +550,7 @@ const validateCodeSelect: TypeMatchValidator = (value, { message }) => {
const validateDataSourceFields: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
- return defaultMessage(message, `${value}类型应为数组`);
+ return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
for (const item of value) {
const error = validateDataSourceFieldsItem(item, message);
@@ -408,7 +561,7 @@ const validateDataSourceFields: TypeMatchValidator = (value, { message }) => {
const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
- return defaultMessage(message, `${value}类型应为数组`);
+ return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
for (const item of value) {
@@ -419,7 +572,7 @@ const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
typeof item.useInEditor !== 'boolean' ||
!isPlainObject(item.data)
) {
- return defaultMessage(message, 'mock 项结构不合法');
+ return defaultMessage(message, 'mock 项结构不合法', SUGGESTION_MOCK_ITEM);
}
}
@@ -428,18 +581,18 @@ const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
const validateDataSourceMethods: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
- return defaultMessage(message, `${value}类型应为数组`);
+ return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
for (const item of value) {
if (!isPlainObject(item) || typeof item.name !== 'string') {
- return defaultMessage(message, '方法项结构不合法');
+ return defaultMessage(message, '方法项结构不合法', SUGGESTION_METHOD_ITEM);
}
if (typeof item.content !== 'undefined' && typeof item.content !== 'string' && typeof item.content !== 'function') {
- return defaultMessage(message, '方法项结构不合法');
+ return defaultMessage(message, '方法项结构不合法', SUGGESTION_METHOD_ITEM);
}
if (typeof item.params !== 'undefined' && !Array.isArray(item.params)) {
- return defaultMessage(message, '方法项结构不合法');
+ return defaultMessage(message, '方法项结构不合法', SUGGESTION_METHOD_ITEM);
}
}
@@ -448,27 +601,27 @@ const validateDataSourceMethods: TypeMatchValidator = (value, { message }) => {
const validateEventSelect: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
- return defaultMessage(message, `${value}类型应为数组`);
+ return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
// 「事件名 / 动作名是否在可选项中」由 EventSelect 的 eventNameConfig.rules、compActionConfig.rules 单独校验,
// 以便只标红对应 select,而不是整张联动卡片。此处仅做结构校验。
for (const item of value) {
if (!isPlainObject(item) || typeof item.name !== 'string') {
- return defaultMessage(message, '事件项结构不合法');
+ return defaultMessage(message, '事件项结构不合法', SUGGESTION_EVENT_ITEM);
}
if (Array.isArray(item.actions)) {
for (const action of item.actions) {
if (!isPlainObject(action) || typeof action.actionType === 'undefined') {
- return defaultMessage(message, '事件项结构不合法');
+ return defaultMessage(message, '事件项结构不合法', SUGGESTION_EVENT_ITEM);
}
}
continue;
}
if (typeof item.to === 'undefined' || typeof item.method !== 'string') {
- return defaultMessage(message, '事件项结构不合法');
+ return defaultMessage(message, '事件项结构不合法', SUGGESTION_EVENT_ITEM);
}
}
@@ -477,7 +630,7 @@ const validateEventSelect: TypeMatchValidator = (value, { message }) => {
const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
- return defaultMessage(message, `${value}类型应为数组`);
+ return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
// 「op 是否为合法算子 / 字段路径是否存在」由 DisplayConds 内部 cond-op-select、
@@ -485,7 +638,7 @@ const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
// 此处仅做结构校验。
for (const item of value) {
if (!isPlainObject(item) || !Array.isArray(item.cond)) {
- return defaultMessage(message, '显示条件结构不合法');
+ return defaultMessage(message, '显示条件结构不合法', displayCondSuggestion());
}
for (const cond of item.cond) {
@@ -495,7 +648,7 @@ const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
cond.field.some((part: any) => typeof part !== 'string') ||
typeof cond.op !== 'string'
) {
- return defaultMessage(message, '显示条件结构不合法');
+ return defaultMessage(message, '显示条件结构不合法', displayCondSuggestion());
}
}
}
diff --git a/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts
index 6ec7ee5a..7f9eb5fc 100644
--- a/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts
+++ b/packages/editor/tests/unit/fields/StyleSetter/Index.spec.ts
@@ -34,7 +34,7 @@ vi.mock('@editor/fields/StyleSetter/pro/index', () => {
const make = (name: string) =>
defineComponent({
name,
- props: ['values', 'lastValues', 'isCompare', 'size', 'disabled'],
+ props: ['values', 'lastValues', 'isCompare', 'size', 'disabled', 'prop'],
emits: ['change', 'addDiffCount'],
setup(_p, { emit }) {
return () =>
@@ -63,26 +63,6 @@ describe('StyleSetter Index', () => {
expect(wrapper.findAll('.collapse-item').length).toBe(6);
});
- test('change 时为 propPath 添加 prop 前缀', async () => {
- const wrapper = mount(StyleSetter, {
- props: { model: { style: {} }, name: 'style', prop: 'style' } as any,
- });
- await wrapper.find('.Layout').trigger('click');
- const events = wrapper.emitted('change');
- expect(events).toBeTruthy();
- expect((events?.[0]?.[1] as any).changeRecords[0].propPath).toBe('style.foo');
- });
-
- test('prop 与 name 不一致时,propPath 使用完整的 prop 路径而非 name', async () => {
- const wrapper = mount(StyleSetter, {
- props: { model: { style: {} }, name: 'style', prop: 'data.items.0.style' } as any,
- });
- await wrapper.find('.Position').trigger('click');
- const events = wrapper.emitted('change');
- expect(events).toBeTruthy();
- expect((events?.[0]?.[1] as any).changeRecords[0].propPath).toBe('data.items.0.style.foo');
- });
-
test('change 透传原始 value 并保留 changeRecords 其他字段', async () => {
const wrapper = mount(StyleSetter, {
props: { model: { style: {} }, name: 'style', prop: 'style' } as any,
@@ -93,7 +73,7 @@ describe('StyleSetter Index', () => {
const [value, eventData] = events![0] as any[];
expect(value).toEqual({ foo: 1 });
expect(eventData.changeRecords).toHaveLength(1);
- expect(eventData.changeRecords[0]).toEqual({ propPath: 'style.foo', value: 1 });
+ expect(eventData.changeRecords[0]).toEqual({ propPath: 'foo', value: 1 });
});
test('eventData 无 changeRecords 时也能正常 emit', async () => {
diff --git a/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts
index 50fa38b4..9ebed40e 100644
--- a/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts
+++ b/packages/editor/tests/unit/fields/StyleSetter/Layout.spec.ts
@@ -11,6 +11,7 @@ import Layout from '@editor/fields/StyleSetter/pro/Layout.vue';
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
+ defineFormConfig: (cfg: any) => cfg,
MContainer: defineComponent({
name: 'FakeMContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'size', 'disabled'],
@@ -84,8 +85,8 @@ describe('StyleSetter/Layout.vue', () => {
const wrapper = mount(Layout, {
props: { values: { position: 'static', display: 'flex' } } as any,
});
- const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
- const flexItem = config.items.find((it: any) => it.name === 'flexDirection');
+ const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
+ const flexItem = configs.find((it: any) => it.name === 'flexDirection');
expect(flexItem.display(null, { model: { display: 'flex' } })).toBe(true);
expect(flexItem.display(null, { model: { display: 'block' } })).toBe(false);
});
@@ -94,9 +95,9 @@ describe('StyleSetter/Layout.vue', () => {
const wrapper = mount(Layout, {
props: { values: { position: 'static', display: 'flex' } } as any,
});
- const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
+ const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
['justifyContent', 'alignItems', 'flexWrap'].forEach((name) => {
- const item = config.items.find((it: any) => it.name === name);
+ const item = configs.find((it: any) => it.name === name);
expect(item.display(null, { model: { display: 'flex' } })).toBe(true);
expect(item.display(null, { model: { display: 'block' } })).toBe(false);
});
@@ -106,8 +107,8 @@ describe('StyleSetter/Layout.vue', () => {
const wrapper = mount(Layout, {
props: { values: { position: 'static', display: 'flex' } } as any,
});
- const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
- const displayItem = config.items.find((it: any) => it.name === 'display');
+ const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
+ const displayItem = configs.find((it: any) => it.name === 'display');
const values = displayItem.options.map((o: any) => o.value);
expect(values).toEqual(['inline', 'flex', 'block', 'inline-block', 'none']);
});
diff --git a/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts
index c3bb435b..7436fe04 100644
--- a/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts
+++ b/packages/editor/tests/unit/fields/StyleSetter/Position.spec.ts
@@ -11,6 +11,7 @@ import Position from '@editor/fields/StyleSetter/pro/Position.vue';
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
+ defineFormConfig: (cfg: any) => cfg,
MContainer: defineComponent({
name: 'FakeMContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'size', 'disabled'],
@@ -48,8 +49,8 @@ describe('StyleSetter/Position.vue', () => {
values: { position: 'static' },
} as any,
});
- const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
- const rowItems = config.items.filter((it: any) => it.type === 'row');
+ const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
+ const rowItems = configs.filter((it: any) => it.type === 'row');
expect(rowItems[0].display()).toBe(false);
});
@@ -59,8 +60,8 @@ describe('StyleSetter/Position.vue', () => {
values: { position: 'absolute' },
} as any,
});
- const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
- const rowItems = config.items.filter((it: any) => it.type === 'row');
+ const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
+ const rowItems = configs.filter((it: any) => it.type === 'row');
expect(rowItems[0].display()).toBe(true);
});
diff --git a/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts b/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts
index fca2414f..2122c7bf 100644
--- a/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts
+++ b/packages/editor/tests/unit/fields/StyleSetter/pro.spec.ts
@@ -15,6 +15,7 @@ import Transform from '@editor/fields/StyleSetter/pro/Transform.vue';
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
+ defineFormConfig: (cfg: any) => cfg,
MContainer: defineComponent({
name: 'MContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'size', 'disabled'],
diff --git a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts
index c258314b..73167b87 100644
--- a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts
+++ b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts
@@ -60,6 +60,7 @@ vi.mock('@tmagic/design', () => ({
return () => h('button', { class: 'fake-btn' }, slots.default?.());
},
}),
+ tMagicMessage: () => {},
}));
// 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败
diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts
index 498003ef..0868c4b5 100644
--- a/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts
+++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts
@@ -38,6 +38,7 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-tooltip' }, [slots.content?.(), slots.default?.()]);
},
}),
+ stripValidateSuggestion: (text?: string) => String(text ?? '').split('\n\n')[0],
}));
describe('LayerNodeContent', () => {
@@ -78,6 +79,40 @@ describe('LayerNodeContent', () => {
expect(tooltip.html()).toContain('width 非法');
});
+ test('tooltip 不展示 \\n\\n 之后的修改建议', () => {
+ invalidNodeIds.clear();
+ invalidNodeIds.set('n1', {
+ props: 'name 类型应为字符串\n\n请参考以下示例值:"文本内容"',
+ style: 'width 不在可选项中\n\n请使用以下某一个值:100',
+ });
+ const wrapper = mount(LayerNodeContent, {
+ props: { data: { id: 'n1', name: '文本', type: 'text' } as any },
+ });
+ const html = wrapper.find('.fake-tooltip').html();
+ // 错误描述保留
+ expect(html).toContain('name 类型应为字符串');
+ expect(html).toContain('width 不在可选项中');
+ // 修改建议被截断
+ expect(html).not.toContain('请参考以下示例值');
+ expect(html).not.toContain('请使用以下某一个值');
+ });
+
+ test('多条错误以
拼接时逐条截断建议', () => {
+ invalidNodeIds.clear();
+ invalidNodeIds.set('n1', {
+ props: 'name 必填\n\n建议A
id 类型应为数字\n\n建议B',
+ });
+ const wrapper = mount(LayerNodeContent, {
+ props: { data: { id: 'n1', name: '文本', type: 'text' } as any },
+ });
+ const html = wrapper.find('.fake-tooltip').html();
+ expect(html).toContain('name 必填');
+ expect(html).toContain('id 类型应为数字');
+ expect(html).toContain('
');
+ expect(html).not.toContain('建议A');
+ expect(html).not.toContain('建议B');
+ });
+
test('错误状态变化时响应式更新', async () => {
invalidNodeIds.clear();
const wrapper = mount(LayerNodeContent, {
diff --git a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts
index c481ea65..ba45e649 100644
--- a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts
+++ b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts
@@ -80,6 +80,15 @@ const run = (
message?: string,
) => editorTypeMatchRules[type](value, ctx({ ...config, type }, model, message));
+/**
+ * 模拟 @tmagic/design FormItem 渲染时的截断行为:
+ * 校验返回的完整文案以 `\n\n` 拼接了「参考建议」,行内错误只展示首行。
+ */
+const firstLine = (value: any): any => {
+ if (typeof value !== 'string') return value;
+ return value.split('\n\n')[0];
+};
+
describe('editorTypeMatchRules', () => {
beforeEach(() => {
codeDslState.value = null;
@@ -93,14 +102,14 @@ describe('editorTypeMatchRules', () => {
test('key-value / style-setter 形态校验', () => {
expect(run('key-value', { a: '1' })).toBeUndefined();
- expect(run('key-value', [])).toBe('值类型应为对象');
+ expect(firstLine(run('key-value', []))).toBe('值类型应为对象');
expect(run('key-value', () => 1, { advanced: true })).toBeUndefined();
expect(run('style-setter', { width: '1px' })).toBeUndefined();
- expect(run('style-setter', [])).toBe('值类型应为对象');
+ expect(firstLine(run('style-setter', []))).toBe('值类型应为对象');
});
test('cond-op-select 按字段类型收窄,未知类型用默认全集', () => {
- expect(run('cond-op-select', 1)).toBe('值类型应为字符串');
+ expect(firstLine(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);
@@ -117,24 +126,24 @@ describe('editorTypeMatchRules', () => {
},
];
expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined();
- expect(run('cond-op-select', 'include', {}, { field: ['ds1', 'age'] })).toBe('include 不在可选项中');
+ 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
- expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'missing'] })).toBe('is 不在可选项中');
+ expect(firstLine(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(firstLine(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)不存在');
+ expect(firstLine(run('code-select-col', 'missing'))).toBe('代码块(missing)不存在');
});
test('page-fragment-select / ui-select 校验节点存在性', () => {
- expect(run('page-fragment-select', true)).toBe('true类型应为字符串或数字');
+ expect(firstLine(run('page-fragment-select', true))).toBe('true类型应为字符串或数字');
expect(run('page-fragment-select', 'pf_1')).toBeUndefined();
rootState.value = {
@@ -144,7 +153,7 @@ describe('editorTypeMatchRules', () => {
],
};
expect(run('page-fragment-select', 'pf_1')).toBeUndefined();
- expect(run('page-fragment-select', 'page_1')).toBe('页面片段(page_1)不存在');
+ expect(firstLine(run('page-fragment-select', 'page_1'))).toBe('页面片段(page_1)不存在');
rootState.value = null;
expect(run('ui-select', 'node_1')).toBeUndefined();
@@ -152,11 +161,11 @@ describe('editorTypeMatchRules', () => {
rootState.value = { items: [] };
nodesState.value = { node_1: { id: 'node_1' } };
expect(run('ui-select', 'node_1')).toBeUndefined();
- expect(run('ui-select', 'missing')).toBe('组件(missing)不存在');
+ expect(firstLine(run('ui-select', 'missing'))).toBe('组件(missing)不存在');
});
test('data-source-input 校验模板绑定', () => {
- expect(run('data-source-input', 1)).toBe('1类型应为字符串');
+ expect(firstLine(run('data-source-input', 1))).toBe('1类型应为字符串');
expect(run('data-source-input', 'plain')).toBeUndefined();
expect(run('data-source-input', '${ds1.title}')).toBeUndefined();
@@ -173,12 +182,12 @@ describe('editorTypeMatchRules', () => {
];
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})不合法');
+ expect(firstLine(run('data-source-input', '${missing.title}'))).toBe('数据源绑定(${missing.title})不合法');
+ expect(firstLine(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(firstLine(run('data-source-method-select', ['ds1']))).toBe('ds1类型应为长度为 2 的字符串数组');
expect(run('data-source-method-select', ['ds1', 'foo'])).toBeUndefined();
dataSourcesState.value = [
@@ -194,12 +203,12 @@ describe('editorTypeMatchRules', () => {
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)不存在');
+ expect(firstLine(run('data-source-method-select', ['ds1', 'missing']))).toBe('数据源方法(missing)不存在');
+ expect(firstLine(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(firstLine(run('data-source-field-select', 'x'))).toBe('x类型应为字符串数组');
expect(run('data-source-field-select', 'text-value', { fieldConfig: { type: 'text' } })).toBeUndefined();
dataSourcesState.value = [
@@ -216,12 +225,14 @@ 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();
- expect(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' })).toBe('值不在可选项中');
+ expect(firstLine(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' }))).toBe('值不在可选项中');
expect(
- run('data-source-field-select', ['title'], {
- dataSourceId: 'ds1',
- dataSourceFieldType: ['number'],
- }),
+ firstLine(
+ run('data-source-field-select', ['title'], {
+ dataSourceId: 'ds1',
+ dataSourceFieldType: ['number'],
+ }),
+ ),
).toBe('值不在可选项中');
expect(
run('data-source-field-select', ['obj', 'a'], {
@@ -241,15 +252,17 @@ describe('editorTypeMatchRules', () => {
];
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(firstLine(run('data-source-select', 'missing', { value: 'id' }))).toBe('missing不在可选项中');
+ expect(firstLine(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(
+ expect(
+ firstLine(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'http' })),
+ ).toBe('[object Object]不在可选项中');
+ expect(firstLine(run('data-source-select', { isBindDataSource: false, dataSourceId: 'ds1' }))).toBe(
'[object Object]类型不合法',
);
});
@@ -304,34 +317,36 @@ describe('editorTypeMatchRules', () => {
test('容器类浅层结构校验', () => {
expect(run('data-source-fields', [{ name: 'a', type: 'string' }])).toBeUndefined();
- expect(run('data-source-fields', [{ name: 'a', type: 'oops' }])).toBe('字段类型不合法');
+ expect(firstLine(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(firstLine(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(
+ expect(firstLine(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(firstLine(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(firstLine(run('event-select', [{ name: 'click' }]))).toBe('事件项结构不合法');
+ expect(firstLine(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('显示条件结构不合法');
+ expect(firstLine(run('display-conds', [{ cond: 'bad' }]))).toBe('显示条件结构不合法');
+ expect(firstLine(run('display-conds', [{ cond: [{ field: 'bad', op: '=' }] }]))).toBe('显示条件结构不合法');
+ expect(firstLine(run('display-conds', [{ cond: [{ field: ['ds1'], op: 1 }] }]))).toBe('显示条件结构不合法');
});
test('自定义 message 优先', () => {
diff --git a/packages/form-schema/src/base.ts b/packages/form-schema/src/base.ts
index 86aad1b7..a0cc4615 100644
--- a/packages/form-schema/src/base.ts
+++ b/packages/form-schema/src/base.ts
@@ -896,6 +896,10 @@ export interface StepConfig extends FormItem {
}
// #endregion StepConfig
+export interface ImgUploadConfig extends FormItem {
+ type: 'img-upload';
+}
+
// #region ComponentConfig
export interface ComponentConfig extends FormItem {
type: 'component';
diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue
index 2d94f893..81de6ace 100644
--- a/packages/form/src/Form.vue
+++ b/packages/form/src/Form.vue
@@ -32,7 +32,7 @@