feat(form,editor): 静默校验模式下跳过叶子字段渲染以提升性能

引入 silentLeafFieldTypes 白名单与 FORM_SILENT_MODE_KEY 注入。
Container 与编辑器重型字段在静默校验时跳过 UI 渲染,仅保留 FormItem 校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-08-10 19:42:02 +08:00
parent 53c4763a59
commit 44cd3d480f
21 changed files with 538 additions and 77 deletions

View File

@ -15,7 +15,7 @@
:prop="prop" :prop="prop"
></MFormContainer> ></MFormContainer>
<MSelect <MSelect
v-else v-else-if="!silentMode"
class="select" class="select"
:config="selectConfig" :config="selectConfig"
:name="name" :name="name"
@ -27,7 +27,7 @@
<!-- 查看/编辑按钮对比模式为只读不展示 --> <!-- 查看/编辑按钮对比模式为只读不展示 -->
<TMagicButton <TMagicButton
v-if="!isCompareMode && model[name] && hasCodeBlockSidePanel" v-if="!isCompareMode && !silentMode && model[name] && hasCodeBlockSidePanel"
class="m-fields-select-action-button" class="m-fields-select-action-button"
:size="size" :size="size"
@click="editCode(model[name])" @click="editCode(model[name])"
@ -65,6 +65,7 @@ import {
createValues, createValues,
type FieldProps, type FieldProps,
filterFunction, filterFunction,
FORM_SILENT_MODE_KEY,
type FormItemConfig, type FormItemConfig,
type FormState, type FormState,
MContainer as MFormContainer, MContainer as MFormContainer,
@ -83,6 +84,7 @@ defineOptions({
}); });
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const { codeBlockService, uiService } = useServices(); const { codeBlockService, uiService } = useServices();
const eventBus = inject<EventBus>('eventBus'); const eventBus = inject<EventBus>('eventBus');
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -6,7 +6,7 @@
}" }"
> >
<FieldSelect <FieldSelect
v-if="isSelectValid" v-if="isSelectValid && !silentMode"
:model-value="model[name]" :model-value="model[name]"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
@ -18,7 +18,7 @@
></FieldSelect> ></FieldSelect>
<component <component
v-else v-else-if="!silentMode"
:is="tagName" :is="tagName"
:config="config.fieldConfig" :config="config.fieldConfig"
:model="model" :model="model"
@ -33,7 +33,7 @@
></component> ></component>
<TMagicTooltip <TMagicTooltip
v-if="config.fieldConfig && !disabledDataSource && !mForm?.isCompare" v-if="!silentMode && config.fieldConfig && !disabledDataSource && !mForm?.isCompare"
:disabled="showDataSourceFieldSelect" :disabled="showDataSourceFieldSelect"
content="选择数据源" content="选择数据源"
> >
@ -57,6 +57,7 @@ import {
type ContainerChangeEventData, type ContainerChangeEventData,
type DataSourceFieldSelectConfig, type DataSourceFieldSelectConfig,
type FieldProps, type FieldProps,
FORM_SILENT_MODE_KEY,
type FormState, type FormState,
getFormField, getFormField,
} from '@tmagic/form'; } from '@tmagic/form';
@ -104,6 +105,7 @@ watch(
const { dataSourceService, propsService } = useServices(); const { dataSourceService, propsService } = useServices();
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const dataSources = computed(() => dataSourceService.get('dataSources') || []); const dataSources = computed(() => dataSourceService.get('dataSources') || []);
const disabledDataSource = computed(() => propsService.getDisabledDataSource()); const disabledDataSource = computed(() => propsService.getDisabledDataSource());

View File

@ -8,6 +8,7 @@
</div> </div>
<FloatingBox <FloatingBox
v-if="!silentMode"
:body-style="{ padding: '0 16px' }" :body-style="{ padding: '0 16px' }"
v-model:visible="addDialogVisible" v-model:visible="addDialogVisible"
v-model:width="width" v-model:width="width"
@ -30,6 +31,7 @@
</FloatingBox> </FloatingBox>
<FloatingBox <FloatingBox
v-if="!silentMode"
:body-style="{ padding: '0 16px' }" :body-style="{ padding: '0 16px' }"
v-model:visible="addFromJsonDialogVisible" v-model:visible="addFromJsonDialogVisible"
v-model:width="width" v-model:width="width"
@ -60,6 +62,7 @@ import {
type ContainerChangeEventData, type ContainerChangeEventData,
type DataSourceFieldsConfig, type DataSourceFieldsConfig,
type FieldProps, type FieldProps,
FORM_SILENT_MODE_KEY,
type FormConfig, type FormConfig,
type FormState, type FormState,
MFormBox, MFormBox,
@ -89,6 +92,7 @@ const emit = defineEmits<{
const { uiService } = useServices(); const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */ /** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare)); const isCompare = computed(() => Boolean(mForm?.isCompare));
@ -379,6 +383,8 @@ provide(
); );
onMounted(() => { onMounted(() => {
if (silentMode) return;
const path = editingFieldPath.value; const path = editingFieldPath.value;
if (!path.length) return; if (!path.length) return;

View File

@ -2,6 +2,7 @@
<div class="m-fields-data-source-method-select"> <div class="m-fields-data-source-method-select">
<div class="data-source-method-select-container"> <div class="data-source-method-select-container">
<MCascader <MCascader
v-if="!silentMode"
class="select" class="select"
:config="cascaderConfig" :config="cascaderConfig"
:model="model" :model="model"
@ -13,7 +14,7 @@
></MCascader> ></MCascader>
<TMagicTooltip <TMagicTooltip
v-if="model[name] && isCustomMethod && dataSourceSidePanel && !isCompare" v-if="!silentMode && model[name] && isCustomMethod && dataSourceSidePanel && !isCompare"
:content="notEditable ? '查看' : '编辑'" :content="notEditable ? '查看' : '编辑'"
> >
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler"> <TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
@ -48,6 +49,7 @@ import {
type DataSourceMethodSelectConfig, type DataSourceMethodSelectConfig,
type FieldProps, type FieldProps,
filterFunction, filterFunction,
FORM_SILENT_MODE_KEY,
type FormItemConfig, type FormItemConfig,
type FormState, type FormState,
MCascader, MCascader,
@ -67,6 +69,7 @@ defineOptions({
const { dataSourceService, uiService } = useServices(); const { dataSourceService, uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const eventBus = inject<EventBus>('eventBus'); const eventBus = inject<EventBus>('eventBus');
const emit = defineEmits(['change']); const emit = defineEmits(['change']);

View File

@ -9,7 +9,7 @@
</div> </div>
<CodeBlockEditor <CodeBlockEditor
v-if="codeConfig" v-if="codeConfig && !silentMode"
ref="codeBlockEditor" ref="codeBlockEditor"
:disabled="disabled" :disabled="disabled"
:content="codeConfig" :content="codeConfig"
@ -26,7 +26,13 @@ import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent } from '@tmagic/core'; import type { CodeBlockContent } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox } from '@tmagic/design'; import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps, FormState } from '@tmagic/form'; import {
type ContainerChangeEventData,
type DataSourceMethodsConfig,
type FieldProps,
FORM_SILENT_MODE_KEY,
type FormState,
} from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table'; import { type ColumnConfig, MagicTable } from '@tmagic/table';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue'; import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
@ -43,6 +49,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */ /** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare)); const isCompare = computed(() => Boolean(mForm?.isCompare));
@ -170,6 +177,8 @@ const editingMethodName = inject<ComputedRef<string | undefined>>(
); );
onMounted(() => { onMounted(() => {
if (silentMode) return;
const methodName = editingMethodName.value; const methodName = editingMethodName.value;
if (!methodName) return; if (!methodName) return;

View File

@ -7,6 +7,7 @@
</div> </div>
<FloatingBox <FloatingBox
v-if="!silentMode"
:body-style="{ padding: '0 16px' }" :body-style="{ padding: '0 16px' }"
v-model:visible="addDialogVisible" v-model:visible="addDialogVisible"
v-model:width="width" v-model:width="width"
@ -34,7 +35,14 @@ import { computed, inject, Ref, ref } from 'vue';
import type { MockSchema } from '@tmagic/core'; import type { MockSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design'; import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design';
import { type DataSourceMocksConfig, type FieldProps, type FormConfig, type FormState, MFormBox } from '@tmagic/form'; import {
type DataSourceMocksConfig,
type FieldProps,
FORM_SILENT_MODE_KEY,
type FormConfig,
type FormState,
MFormBox,
} from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table'; import { type ColumnConfig, MagicTable } from '@tmagic/table';
import { getDefaultValueFromFields } from '@tmagic/utils'; import { getDefaultValueFromFields } from '@tmagic/utils';
@ -57,6 +65,7 @@ const emit = defineEmits(['change']);
const { uiService } = useServices(); const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */ /** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare)); const isCompare = computed(() => Boolean(mForm?.isCompare));

View File

@ -44,7 +44,7 @@
</div> </div>
<MagicCodeEditor <MagicCodeEditor
v-if="config.advanced && showCode" v-if="config.advanced && showCode && !silentMode"
editor-custom-type="m-fields-key-value" editor-custom-type="m-fields-key-value"
language="javascript" language="javascript"
:init-values="model[name]" :init-values="model[name]"
@ -60,7 +60,7 @@
></MagicCodeEditor> ></MagicCodeEditor>
<TMagicButton <TMagicButton
v-if="config.advanced && !isCompare" v-if="config.advanced && !isCompare && !silentMode"
size="default" size="default"
:disabled="disabled" :disabled="disabled"
link link
@ -75,7 +75,7 @@ import { computed, inject, ref, watchEffect } from 'vue';
import { Delete, Plus } from '@element-plus/icons-vue'; import { Delete, Plus } from '@element-plus/icons-vue';
import { TMagicButton, TMagicInput } from '@tmagic/design'; import { TMagicButton, TMagicInput } from '@tmagic/design';
import type { FieldProps, FormState, KeyValueConfig } from '@tmagic/form'; import { type FieldProps, FORM_SILENT_MODE_KEY, type FormState, type KeyValueConfig } from '@tmagic/form';
import CodeIcon from '@editor/icons/CodeIcon.vue'; import CodeIcon from '@editor/icons/CodeIcon.vue';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue'; import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -93,6 +93,7 @@ const emit = defineEmits<{
}>(); }>();
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏增删/代码切换等操作按钮,仅保留只读展示。 */ /** 对比模式下隐藏增删/代码切换等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare)); const isCompare = computed(() => Boolean(mForm?.isCompare));

View File

@ -21,7 +21,7 @@ import { type App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design'; import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design'; import designPlugin from '@tmagic/design';
import type { FormInstallOptions } from '@tmagic/form'; import type { FormInstallOptions } from '@tmagic/form';
import formPlugin, { registerTypeMatchRules } from '@tmagic/form'; import formPlugin, { registerSilentLeafFieldTypes, registerTypeMatchRules } from '@tmagic/form';
import tablePlugin from '@tmagic/table'; import tablePlugin from '@tmagic/table';
import Code from './fields/Code.vue'; import Code from './fields/Code.vue';
@ -68,6 +68,14 @@ export default {
app.use(formPlugin, opt || {}); app.use(formPlugin, opt || {});
app.use(tablePlugin); app.use(tablePlugin);
registerTypeMatchRules(editorTypeMatchRules); registerTypeMatchRules(editorTypeMatchRules);
registerSilentLeafFieldTypes([
'vs-code',
'ui-select',
'cond-op-select',
'page-fragment-select',
'data-source-select',
'data-source-input',
]);
app.config.globalProperties.$TMAGIC_EDITOR = option; app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option); setEditorConfig(option);

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue'; import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import CodeSelectCol from '@editor/fields/CodeSelectCol.vue'; import CodeSelectCol from '@editor/fields/CodeSelectCol.vue';
const codeBlockService = { const codeBlockService = {
@ -113,6 +115,16 @@ describe('CodeSelectCol', () => {
expect(wrapper.find('.fake-params').exists()).toBe(true); expect(wrapper.find('.fake-params').exists()).toBe(true);
}); });
test('静默模式跳过选择器和编辑按钮但保留 CodeParams', () => {
const wrapper = mount(CodeSelectCol, {
props: baseProps() as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findComponent({ name: 'MSelect' }).exists()).toBe(false);
expect(wrapper.find('button').exists()).toBe(false);
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('选择无 params 的代码块不渲染 CodeParams', () => { test('选择无 params 的代码块不渲染 CodeParams', () => {
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: 'c2', params: {} } }) as any }); const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: 'c2', params: {} } }) as any });
expect(wrapper.find('.fake-params').exists()).toBe(false); expect(wrapper.find('.fake-params').exists()).toBe(false);

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue'; import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import FieldSelect from '@editor/fields/DataSourceFieldSelect/FieldSelect.vue'; import FieldSelect from '@editor/fields/DataSourceFieldSelect/FieldSelect.vue';
import DSFSIndex from '@editor/fields/DataSourceFieldSelect/Index.vue'; import DSFSIndex from '@editor/fields/DataSourceFieldSelect/Index.vue';
@ -196,6 +198,16 @@ describe('DataSourceFieldSelect Index', () => {
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThanOrEqual(1); expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThanOrEqual(1);
}); });
test('静默模式跳过内部 FieldSelect', () => {
const wrapper = mount(DSFSIndex, {
props: { config: {}, model: { v: [] }, name: 'v' } as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
expect(wrapper.find('.fake-btn').exists()).toBe(false);
expect(wrapper.find('fake-form-field').exists()).toBe(false);
});
test('toggle showDataSourceFieldSelect', async () => { test('toggle showDataSourceFieldSelect', async () => {
const wrapper = mount(DSFSIndex, { const wrapper = mount(DSFSIndex, {
props: { props: {

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue'; import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceFields from '@editor/fields/DataSourceFields.vue'; import DataSourceFields from '@editor/fields/DataSourceFields.vue';
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({ const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
@ -132,6 +134,14 @@ describe('DataSourceFields', () => {
expect(wrapper.findAll('.fake-btn').length).toBeGreaterThanOrEqual(2); expect(wrapper.findAll('.fake-btn').length).toBeGreaterThanOrEqual(2);
}); });
test('静默模式不渲染编辑浮窗', () => {
const wrapper = mount(DataSourceFields, {
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findAll('.fake-floating')).toHaveLength(0);
});
test('点击新增字段添加', async () => { test('点击新增字段添加', async () => {
const model: any = { fields: [] }; const model: any = { fields: [] };
const wrapper = mount(DataSourceFields, { const wrapper = mount(DataSourceFields, {

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue'; import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceMethodSelect from '@editor/fields/DataSourceMethodSelect.vue'; import DataSourceMethodSelect from '@editor/fields/DataSourceMethodSelect.vue';
const dataSourceService = { const dataSourceService = {
@ -137,6 +139,16 @@ describe('DataSourceMethodSelect', () => {
expect(wrapper.find('.fake-params').exists()).toBe(true); expect(wrapper.find('.fake-params').exists()).toBe(true);
}); });
test('静默模式跳过级联选择器和编辑按钮但保留 CodeParams', () => {
const wrapper = mount(DataSourceMethodSelect, {
props: baseProps() as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findComponent({ name: 'MCascader' }).exists()).toBe(false);
expect(wrapper.find('button').exists()).toBe(false);
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('onChangeHandler emit change 包含 changeRecords', async () => { test('onChangeHandler emit change 包含 changeRecords', async () => {
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [] }); dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [] });
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any }); const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue'; import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceMethods from '@editor/fields/DataSourceMethods.vue'; import DataSourceMethods from '@editor/fields/DataSourceMethods.vue';
const { messageBoxConfirm, codeBlockEditorShow, codeBlockEditorHide } = vi.hoisted(() => ({ const { messageBoxConfirm, codeBlockEditorShow, codeBlockEditorHide } = vi.hoisted(() => ({
@ -109,6 +111,26 @@ describe('DataSourceMethods.vue', () => {
expect(codeBlockEditorShow).toHaveBeenCalled(); expect(codeBlockEditorShow).toHaveBeenCalled();
}); });
test('静默模式不根据注入的方法名打开编辑器', async () => {
const wrapper = mount(DataSourceMethods, {
props: {
name: 'methods',
prop: 'methods',
config: {} as any,
model: { methods: [{ name: 'm1', content: 'function () {}' }] } as any,
} as any,
global: {
provide: {
[FORM_SILENT_MODE_KEY as symbol]: true,
editingDataSourceMethodName: { value: 'm1' },
},
},
});
await nextTick();
expect(wrapper.find('.fake-code-block-editor').exists()).toBe(false);
expect(codeBlockEditorShow).not.toHaveBeenCalled();
});
test('编辑 action - method.content 是 string', async () => { test('编辑 action - method.content 是 string', async () => {
const wrapper = mount(DataSourceMethods, { const wrapper = mount(DataSourceMethods, {
props: { props: {

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue'; import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceMocks from '@editor/fields/DataSourceMocks.vue'; import DataSourceMocks from '@editor/fields/DataSourceMocks.vue';
const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => true) })); const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => true) }));
@ -114,6 +116,14 @@ describe('DataSourceMocks', () => {
expect(wrapper.find('.fake-add-btn').exists()).toBe(true); expect(wrapper.find('.fake-add-btn').exists()).toBe(true);
}); });
test('静默模式不渲染编辑浮窗', () => {
const wrapper = mount(DataSourceMocks, {
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.find('.fake-floating').exists()).toBe(false);
});
test('点击添加按钮显示 dialog', async () => { test('点击添加按钮显示 dialog', async () => {
const wrapper = mount(DataSourceMocks, { const wrapper = mount(DataSourceMocks, {
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any, props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,

View File

@ -7,6 +7,8 @@ import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue'; import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import KeyValue from '@editor/fields/KeyValue.vue'; import KeyValue from '@editor/fields/KeyValue.vue';
vi.mock('@tmagic/design', () => ({ vi.mock('@tmagic/design', () => ({
@ -124,6 +126,17 @@ describe('KeyValue', () => {
expect(wrapper.find('.code-editor').exists()).toBe(true); expect(wrapper.find('.code-editor').exists()).toBe(true);
}); });
test('静默模式跳过高级代码编辑器', () => {
const wrapper = mount(KeyValue, {
props: baseProps({
config: { advanced: true, type: 'key-value' },
model: { kv: () => null },
}) as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.find('.code-editor').exists()).toBe(false);
});
test('CodeEditor save emit change', async () => { test('CodeEditor save emit change', async () => {
const wrapper = mount(KeyValue, { const wrapper = mount(KeyValue, {
props: baseProps({ props: baseProps({

View File

@ -15,6 +15,7 @@ vi.mock('@tmagic/form', async (importOriginal) => {
return { return {
...actual, ...actual,
default: { install: vi.fn() }, default: { install: vi.fn() },
registerSilentLeafFieldTypes: vi.fn(),
registerTypeMatchRules: vi.fn(), registerTypeMatchRules: vi.fn(),
}; };
}); });
@ -76,12 +77,20 @@ describe('plugin install', () => {
}; };
test('install 调用 design/form/table 插件并注册全局组件', async () => { test('install 调用 design/form/table 插件并注册全局组件', async () => {
const { registerTypeMatchRules } = await import('@tmagic/form'); const { registerSilentLeafFieldTypes, registerTypeMatchRules } = await import('@tmagic/form');
const { editorTypeMatchRules } = await import('@editor/utils/type-match-rules'); const { editorTypeMatchRules } = await import('@editor/utils/type-match-rules');
const { app, components } = buildApp(); const { app, components } = buildApp();
editorPlugin.install(app, { someOption: true } as any); editorPlugin.install(app, { someOption: true } as any);
expect(app.use).toHaveBeenCalledTimes(3); expect(app.use).toHaveBeenCalledTimes(3);
expect(registerTypeMatchRules).toHaveBeenCalledWith(editorTypeMatchRules); expect(registerTypeMatchRules).toHaveBeenCalledWith(editorTypeMatchRules);
expect(registerSilentLeafFieldTypes).toHaveBeenCalledWith([
'vs-code',
'ui-select',
'cond-op-select',
'page-fragment-select',
'data-source-select',
'data-source-input',
]);
expect(Object.keys(components).length).toBeGreaterThan(10); expect(Object.keys(components).length).toBeGreaterThan(10);
expect(components.MEditor).toBeDefined(); expect(components.MEditor).toBeDefined();
}); });

View File

@ -27,7 +27,15 @@
<template v-else-if="type && display && !showDiff"> <template v-else-if="type && display && !showDiff">
<TMagicFormItem v-bind="formItemProps" :class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text }"> <TMagicFormItem v-bind="formItemProps" :class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text }">
<template #label> <template #label>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled"> <slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
@ -41,8 +49,25 @@
</slot> </slot>
</template> </template>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement"> <!-- 静默校验时只保留 FormItem校验依赖其 prop / rules model 与字段组件实例无关 -->
<template v-if="shouldRenderLeafField">
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component <component
v-else
v-bind="fieldsProps" v-bind="fieldsProps"
:is="tagName" :is="tagName"
:model="model" :model="model"
@ -51,24 +76,13 @@
@change="onChangeHandler" @change="onChangeHandler"
@addDiffCount="onAddDiffCount" @addDiffCount="onAddDiffCount"
></component> ></component>
<template #content> </template>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
></component>
</TMagicFormItem> </TMagicFormItem>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top"> <TMagicTooltip
v-if="shouldRenderLeafField && config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel"
placement="top"
>
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon> <TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content> <template #content>
<div v-html="config.tip"></div> <div v-html="config.tip"></div>
@ -88,7 +102,15 @@
}" }"
> >
<template #label> <template #label>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled"> <slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
@ -98,8 +120,23 @@
></FormLabel> ></FormLabel>
</slot> </slot>
</template> </template>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement"> <template v-if="shouldRenderLeafField">
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component <component
v-else
v-bind="fieldsProps" v-bind="fieldsProps"
:is="tagName" :is="tagName"
:model="model" :model="model"
@ -107,20 +144,7 @@
:is-compare="isCompare" :is-compare="isCompare"
@change="onChangeHandler" @change="onChangeHandler"
></component> ></component>
<template #content> </template>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
></component>
</TMagicFormItem> </TMagicFormItem>
<!-- 普通字段渲染前后两份独立的组件用于对比 --> <!-- 普通字段渲染前后两份独立的组件用于对比 -->
@ -131,7 +155,15 @@
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-before-diff': true }" :class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-before-diff': true }"
> >
<template #label> <template #label>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled"> <slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
@ -141,23 +173,28 @@
></FormLabel> ></FormLabel>
</slot> </slot>
</template> </template>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement"> <template v-if="shouldRenderLeafField">
<component v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component> <TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<template #content> <component v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component>
<div v-html="tooltip.text"></div> <template #content>
</template> <div v-html="tooltip.text"></div>
</TMagicTooltip> </template>
</TMagicTooltip>
<component <component
v-else v-else
v-bind="fieldsProps" v-bind="fieldsProps"
:is="tagName" :is="tagName"
:model="lastValues" :model="lastValues"
@change="onChangeHandler" @change="onChangeHandler"
></component> ></component>
</template>
</TMagicFormItem> </TMagicFormItem>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top"> <TMagicTooltip
v-if="shouldRenderLeafField && config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel"
placement="top"
>
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon> <TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content> <template #content>
<div v-html="config.tip"></div> <div v-html="config.tip"></div>
@ -171,7 +208,15 @@
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-after-diff': true }" :class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-after-diff': true }"
> >
<template #label> <template #label>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled"> <slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
@ -181,17 +226,22 @@
></FormLabel> ></FormLabel>
</slot> </slot>
</template> </template>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement"> <template v-if="shouldRenderLeafField">
<component v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component> <TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<template #content> <component v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
<div v-html="tooltip.text"></div> <template #content>
</template> <div v-html="tooltip.text"></div>
</TMagicTooltip> </template>
</TMagicTooltip>
<component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component> <component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
</template>
</TMagicFormItem> </TMagicFormItem>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top"> <TMagicTooltip
v-if="shouldRenderLeafField && config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel"
placement="top"
>
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon> <TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content> <template #content>
<div v-html="config.tip"></div> <div v-html="config.tip"></div>
@ -260,9 +310,10 @@ import type {
FormValue, FormValue,
ToolTipConfigType, ToolTipConfigType,
} from '../schema'; } from '../schema';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from '../schema'; import { FORM_DIFF_CONFIG_KEY, FORM_SILENT_MODE_KEY, FORM_TYPE_MATCH_VALID_KEY } from '../schema';
import { getField } from '../utils/config'; import { getField } from '../utils/config';
import { createObjectProp, display as displayFunction, filterFunction, getRules } from '../utils/form'; import { createObjectProp, display as displayFunction, filterFunction, getRules } from '../utils/form';
import { getSilentLeafFieldTypes } from '../utils/silentLeafFieldTypes';
import FormLabel from './FormLabel.vue'; import FormLabel from './FormLabel.vue';
@ -407,6 +458,11 @@ const effectiveSelfDiffFieldTypes = computed<Set<string>>(() => {
const isSelfDiffField = computed(() => effectiveSelfDiffFieldTypes.value.has(type.value)); const isSelfDiffField = computed(() => effectiveSelfDiffFieldTypes.value.has(type.value));
// registerSilentLeafFieldTypes
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const shouldRenderLeafField = computed(() => !silentMode || !getSilentLeafFieldTypes().has(type.value));
const disabled = computed(() => props.disabled || filterFunction(mForm, props.config.disabled, props)); const disabled = computed(() => props.disabled || filterFunction(mForm, props.config.disabled, props));
const text = computed(() => filterFunction(mForm, props.config.text, props)); const text = computed(() => filterFunction(mForm, props.config.text, props));

View File

@ -63,6 +63,12 @@ export {
registerField as registerFormField, registerField as registerFormField,
} from './utils/config'; } from './utils/config';
export {
clearSilentLeafFieldTypes,
getSilentLeafFieldTypes,
registerSilentLeafFieldTypes,
} from './utils/silentLeafFieldTypes';
export { export {
clearTypeMatchRules, clearTypeMatchRules,
deleteTypeMatchRule, deleteTypeMatchRule,

View File

@ -32,11 +32,12 @@ export const FORM_TYPE_MATCH_VALID_KEY: InjectionKey<ComputedRef<boolean>> = Sym
/** /**
* `submitForm` / `validateForm` providedebug * `submitForm` / `validateForm` providedebug
* *
* vs-code inject FormItem model * Container `getSilentLeafFieldTypes()` `LEAF_FIELD_TYPES`
* UI monaco worker / model * FormItem FormItem model UI
* vs-code inject
* *
* onMounted/watch immediate emit change DOM * onMounted/watch immediate emit change DOM
* * 使 `registerSilentLeafFieldTypes`
*/ */
export const FORM_SILENT_MODE_KEY: InjectionKey<boolean> = Symbol('mFormSilentMode'); export const FORM_SILENT_MODE_KEY: InjectionKey<boolean> = Symbol('mFormSilentMode');

View File

@ -0,0 +1,71 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { toLine } from '@tmagic/utils';
/**
*
*
* FormItem model
* defaultValue
*
* `registerSilentLeafFieldTypes`
*/
export const LEAF_FIELD_TYPES: ReadonlySet<string> = new Set([
'text',
'textarea',
'number',
'time',
'daterange',
'timerange',
'checkbox',
'radio-group',
'switch',
'select',
'cascader',
'color-picker',
'link',
]);
/** 业务侧追加的静默叶子字段(不含内置默认集合) */
const extraSilentLeafFieldTypes = new Set<string>();
/**
* type `LEAF_FIELD_TYPES`
*
* FormItem /
*/
export const registerSilentLeafFieldTypes = (types: Iterable<string>): void => {
for (const type of types) {
if (typeof type !== 'string' || !type) continue;
extraSilentLeafFieldTypes.add(toLine(type));
}
};
/** 当前生效的静默叶子字段集合:内置默认 已注册扩展 */
export const getSilentLeafFieldTypes = (): ReadonlySet<string> => {
if (extraSilentLeafFieldTypes.size === 0) {
return LEAF_FIELD_TYPES;
}
return new Set([...LEAF_FIELD_TYPES, ...extraSilentLeafFieldTypes]);
};
/** 清空业务侧追加的静默叶子字段(不影响内置默认集合;主要用于单测) */
export const clearSilentLeafFieldTypes = (): void => {
extraSilentLeafFieldTypes.clear();
};

View File

@ -17,7 +17,7 @@
*/ */
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue'; import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue';
import MagicForm, { validateForm } from '@form/index'; import MagicForm, { clearSilentLeafFieldTypes, registerSilentLeafFieldTypes, validateForm } from '@form/index';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
let appContext: AppContext; let appContext: AppContext;
@ -33,6 +33,7 @@ beforeAll(() => {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
clearSilentLeafFieldTypes();
}); });
// 说明validateForm 内部会新建一个独立的 MForm 实例并复用其校验方法 `validate`(返回错误文案、不抛异常), // 说明validateForm 内部会新建一个独立的 MForm 实例并复用其校验方法 `validate`(返回错误文案、不抛异常),
@ -403,3 +404,189 @@ describe('validateForm —— debug 模式', () => {
setTimeoutSpy.mockRestore(); setTimeoutSpy.mockRestore();
}); });
}); });
describe('validateForm —— 静默模式', () => {
/**
* DOM
* - `extra` Container formItemProps FormItem rules
* - `options` select setup watchEffect
*/
const createProbes = () => ({
extra: vi.fn(() => ''),
options: vi.fn(() => [{ text: 'a', value: 'a' }]),
tooltip: vi.fn(() => ({ text: 'tip' })),
});
const selectConfig = ({ extra, options, tooltip }: ReturnType<typeof createProbes>): any => [
{
type: 'select',
name: 'kind',
text: '类型',
extra,
options,
tooltip,
rules: [{ required: true, message: '类型不能为空' }],
},
];
test('开启后只挂载 FormItem不实例化内置叶子字段', async () => {
const probes = createProbes();
const error = await validateForm({
config: selectConfig(probes),
initValues: { kind: 'a' },
appContext,
});
expect(error).toBe('');
expect(probes.extra).toHaveBeenCalled();
expect(probes.options).not.toHaveBeenCalled();
expect(probes.tooltip).not.toHaveBeenCalled();
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('带挂载初始化逻辑的字段不跳过渲染,保持校验值一致', async () => {
const error = await validateForm({
config: [
{
type: 'display',
name: 'status',
text: '状态',
initValue: 'ready',
rules: [{ required: true, message: '状态不能为空' }],
},
{
type: 'date',
name: 'date',
text: '日期',
rules: [{ typeMatch: true }],
},
] as any,
initValues: { date: new Date('2021-07-17T15:37:00') },
typeMatchValid: true,
appContext,
});
expect(error).toBe('');
});
test('非内置叶子字段仍照常渲染,避免漏掉其内部嵌套字段的校验', async () => {
const customSetup = vi.fn();
const customField = defineComponent({
name: 'CustomField',
inheritAttrs: false,
props: {
model: { type: Object, default: () => ({}) },
name: { type: String, default: '' },
},
setup() {
customSetup();
return () => h('div', 'custom');
},
});
await validateForm({
config: [{ type: 'component', name: 'custom', text: '自定义', component: customField }] as any,
initValues: { custom: 'x' },
appContext,
});
expect(customSetup).toHaveBeenCalled();
});
test('debug 模式下不生效:弹层需要完整渲染供人工操作', async () => {
const probes = createProbes();
const pending = validateForm({
config: selectConfig(probes),
initValues: { kind: 'a' },
appContext,
debug: true,
});
await nextTick();
await nextTick();
expect(probes.options).toHaveBeenCalled();
const confirm = Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === '确定',
) as HTMLButtonElement;
confirm.click();
await pending;
});
});
describe('validateForm —— 可配置静默叶子字段集合', () => {
const createCustomField = (setupSpy: () => void) =>
defineComponent({
name: 'MFieldsCustomSafe',
inheritAttrs: false,
props: {
model: { type: Object, default: () => ({}) },
name: { type: String, default: '' },
},
setup() {
setupSpy();
return () => h('div', 'custom-safe');
},
});
test('registerSilentLeafFieldTypes 后,自定义 type 在静默模式跳过渲染', async () => {
const setupSpy = vi.fn();
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-custom-safe', createCustomField(setupSpy));
registerSilentLeafFieldTypes(['custom-safe']);
await validateForm({
config: [{ type: 'custom-safe', name: 'safe', text: '安全自定义' }] as any,
initValues: { safe: 'x' },
appContext: parentApp._context,
});
expect(setupSpy).not.toHaveBeenCalled();
});
test('未注册时自定义 type 仍渲染', async () => {
const setupSpy = vi.fn();
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-custom-safe', createCustomField(setupSpy));
await validateForm({
config: [{ type: 'custom-safe', name: 'safe', text: '安全自定义' }] as any,
initValues: { safe: 'x' },
appContext: parentApp._context,
});
expect(setupSpy).toHaveBeenCalled();
});
test('debug 模式下即使注册了自定义集合也完整渲染', async () => {
const setupSpy = vi.fn();
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-custom-safe', createCustomField(setupSpy));
registerSilentLeafFieldTypes(['custom-safe']);
const pending = validateForm({
config: [{ type: 'custom-safe', name: 'safe', text: '安全自定义' }] as any,
initValues: { safe: 'x' },
appContext: parentApp._context,
debug: true,
});
await nextTick();
await nextTick();
expect(setupSpy).toHaveBeenCalled();
const confirm = Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === '确定',
) as HTMLButtonElement;
confirm.click();
await pending;
});
});