diff --git a/packages/editor/src/components/CompareForm.vue b/packages/editor/src/components/CompareForm.vue
index d825c014..a0bb4ff9 100644
--- a/packages/editor/src/components/CompareForm.vue
+++ b/packages/editor/src/components/CompareForm.vue
@@ -19,107 +19,36 @@
diff --git a/packages/editor/src/hooks/index.ts b/packages/editor/src/hooks/index.ts
index cbc1690d..c2cea8ce 100644
--- a/packages/editor/src/hooks/index.ts
+++ b/packages/editor/src/hooks/index.ts
@@ -17,6 +17,7 @@
*/
export * from './use-code-block-edit';
+export * from './use-compare-form';
export * from './use-stage';
export * from './use-float-box';
export * from './use-window-rect';
diff --git a/packages/editor/src/hooks/use-compare-form.ts b/packages/editor/src/hooks/use-compare-form.ts
new file mode 100644
index 00000000..1884f49d
--- /dev/null
+++ b/packages/editor/src/hooks/use-compare-form.ts
@@ -0,0 +1,187 @@
+/*
+ * 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 { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch, watchEffect } from 'vue';
+
+import type { CodeBlockContent, MNode } from '@tmagic/core';
+import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
+
+import type { CompareFormBaseProps } from '@editor/type';
+import { getCodeBlockFormConfig } from '@editor/utils/code-block';
+import { removeStyleDisplayConfig } from '@editor/utils/props';
+
+export interface UseCompareFormReturn {
+ config: Ref;
+ currentValues: ComputedRef;
+ wrapperStyle: ComputedRef | undefined>;
+ mergedExtendState: (state: FormState) => Record | Promise>;
+ loadConfig: () => Promise;
+ formRef: Readonly[ | null>>;
+ normalizeCodeBlockValue: (v: Partial | Record | undefined) => Record;
+}
+
+/**
+ * CompareForm / ViewForm 共用逻辑:
+ * - 按 `category`(node / data-source / code-block) 加载 FormConfig(支持自定义 `loadConfig`);
+ * - 代码块 `content` 归一化为字符串;
+ * - 外层容器固定高度 + 内部滚动的 `wrapperStyle`;
+ * - 将 services / stage 注入 MForm.formState,保证 filterFunction 上下文一致。
+ *
+ * 两个组件的差异仅在于是否做新旧值对比,这部分逻辑保留在各自组件中。
+ */
+export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormReturn => {
+ provide('services', props.services);
+
+ const config = ref([]);
+
+ /** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
+ const codeOptions = inject>('codeOptions', {});
+
+ /** 将代码块的 content 字段统一成字符串,便于在表单 / 对比中展示 */
+ const normalizeCodeBlockValue = (
+ v: Partial | Record | undefined,
+ ): Record => {
+ if (!v) return {};
+ const next: Record = { ...v };
+ if (next.content && typeof next.content !== 'string') {
+ try {
+ next.content = next.content.toString();
+ } catch {
+ next.content = '';
+ }
+ }
+ return next;
+ };
+
+ const currentValues = computed(() => {
+ if (props.category === 'code-block') {
+ return normalizeCodeBlockValue(props.value as Partial);
+ }
+ return (props.value || {}) as FormValue;
+ });
+
+ /**
+ * 外层包裹层的样式:当传入 `height` 时启用固定高度 + 内部滚动,
+ * 这样滚动条会出现在组件内部,避免父容器(如 Dialog)自身也产生滚动。
+ */
+ const wrapperStyle = computed(() => {
+ if (!props.height) return undefined;
+ const style: Record = {
+ height: props.height,
+ overflow: 'auto',
+ };
+ return style;
+ });
+
+ const mergedExtendState = (state: FormState) => {
+ const extendState = props.extendState ?? ((s: FormState) => s);
+ return extendState(props.baseFormState || state);
+ };
+
+ /**
+ * 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
+ * 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`,方便复用与二次加工。
+ */
+ const defaultLoadConfig = async (): Promise => {
+ if (!props.services) {
+ return [];
+ }
+
+ switch (props.category) {
+ case 'node': {
+ if (!props.type) {
+ return [];
+ }
+ return removeStyleDisplayConfig(
+ await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
+ );
+ }
+ case 'data-source': {
+ const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
+ // 数据源表单外层 tab 的「数据定义」项 status 为 'fields',tab-pane name 随之为 'fields'。
+ // 未显式设置 active 时,Tabs 默认取 '0',与 'fields' 不匹配会导致打开弹窗时无默认激活项,
+ // 这里与 DataSourceConfigPanel 保持一致,默认激活「数据定义」tab。
+ return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
+ }
+ case 'code-block': {
+ return getCodeBlockFormConfig({
+ paramColConfig: props.services.codeBlockService.getParamsColConfig(),
+ // 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比 / 展示场景下 props.dataSourceType
+ // 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
+ isDataSource: () => Boolean(props.dataSourceType),
+ dataSourceType: () => props.dataSourceType,
+ codeOptions,
+ // 对比 / 展示模式只读,不需要校验/语法检查
+ editable: false,
+ });
+ }
+ default:
+ return [];
+ }
+ };
+
+ const loadConfig = async () => {
+ if (props.loadConfig) {
+ config.value = await props.loadConfig({
+ category: props.category as string,
+ type: props.type,
+ dataSourceType: props.dataSourceType,
+ defaultLoadConfig,
+ });
+ return;
+ }
+
+ config.value = await defaultLoadConfig();
+ };
+
+ watch(
+ [() => props.category, () => props.type, () => props.dataSourceType, () => props.loadConfig],
+ () => {
+ loadConfig();
+ },
+ { immediate: true },
+ );
+
+ const formRef = useTemplateRef>('form');
+
+ /**
+ * 把 services / stage 注入 MForm 的 formState,避免 propsService 注入的表单配置中
+ * 形如 `display: ({ services }) => services.uiService.get(...)` 的 filterFunction
+ * 在执行时拿不到 `formState.services` 而报错。
+ *
+ * 与 props-panel/FormPanel.vue 中的注入方式保持一致:
+ * - services:整个 useServices() 返回的服务集合;
+ * - stage:当前 editorService.get('stage') 的最新值。
+ */
+ watchEffect(() => {
+ if (formRef.value && props.services) {
+ formRef.value.formState.stage = props.services.editorService.get('stage');
+ formRef.value.formState.services = props.services;
+ }
+ });
+
+ return {
+ config,
+ currentValues,
+ wrapperStyle,
+ mergedExtendState,
+ loadConfig,
+ formRef,
+ normalizeCodeBlockValue,
+ };
+};
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index dbf543b0..6e20e8e5 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -74,6 +74,7 @@ export { default as SplitView } from './components/SplitView.vue';
export { default as Resizer } from './components/Resizer.vue';
export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue';
export { default as CompareForm } from './components/CompareForm.vue';
+export { default as ViewForm } from './components/ViewForm.vue';
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts
index 926df798..2805630a 100644
--- a/packages/editor/src/type.ts
+++ b/packages/editor/src/type.ts
@@ -566,6 +566,58 @@ export interface CompareFormLoadConfigContext {
* 可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。
*/
export type CompareFormLoadConfig = (ctx: CompareFormLoadConfigContext) => FormConfig | Promise;
+
+/**
+ * CompareForm / ViewForm 共用的基础 props。
+ * 两者都基于同一套「按 category 加载 FormConfig + 注入 services/stage」的逻辑(见 useCompareForm),
+ * 差异仅在于是否做新旧值对比。这里抽出公共字段避免重复定义。
+ */
+export interface CompareFormBaseProps {
+ /** 当前值(对比场景下为修改后的值) */
+ value: Partial | Partial | Partial | Record;
+ /**
+ * 类型说明:
+ * - `category` 为 `node` 时,`type` 为节点组件的类型,例如 'text'、'button'、'page'、'container' 等
+ * - `category` 为 `data-source` 时,`type` 为数据源类型,例如 'base'、'http'
+ * - `category` 为 `code-block` 时,`type` 可不传
+ */
+ type?: string;
+ /** 表单配置类别,决定从哪里取 FormConfig */
+ category?: CompareCategory;
+ /** 数据源代码块场景下的数据源类型(base/http),用于代码块表单中"执行时机"展示 */
+ dataSourceType?: string;
+ labelWidth?: string;
+ /**
+ * 外层容器高度。设置后表单内容超出时会在组件内部出现滚动条,
+ * 避免 dialog / 面板使用方需要自行处理滚动。可传任意 CSS 长度,例如 `60vh` / `400px` / `100%`。
+ */
+ height?: string;
+ /**
+ * 用户自定义注入到 MForm.formState 的扩展字段,与 Editor 顶层的 `extendFormState`、
+ * PropsPanel 的 `extend-state` 语义一致。表单 item 的 `display` / `disabled` 等
+ * filterFunction 经常依赖这里注入的字段(如 stage、自定义业务上下文等),
+ * 因此在对比 / 展示场景下也需要透传,避免出现 `formState.xxx is undefined` 的运行时错误。
+ */
+ extendState?: (_state: FormState) => Record | Promise>;
+ /**
+ * 外部透传的基础 formState(通常来自 PropsPanel 主属性表单)。
+ * 组件会提取其中的扩展字段覆盖到自己的 formState,保证 filterFunction 上下文一致。
+ */
+ baseFormState?: FormState;
+ /**
+ * 表单内组件的尺寸(透传给 MForm 的 `size`),可选 'large' | 'default' | 'small'。
+ * 缺省时使用 MForm 内置默认尺寸。
+ */
+ size?: FieldSize;
+ /**
+ * 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category`(node/data-source/code-block)
+ * 取配置逻辑,调用方可根据业务自行返回(或异步返回)表单配置。可通过
+ * `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。
+ */
+ loadConfig?: CompareFormLoadConfig;
+ /** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
+ services?: Services;
+}
// #endregion CompareForm
// #region SideItemKey
diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts
index 9109892c..dbef4557 100644
--- a/packages/editor/src/utils/props.ts
+++ b/packages/editor/src/utils/props.ts
@@ -362,6 +362,35 @@ export const fillConfig = (
return [tabConfig];
};
+/**
+ * 将属性表单配置中「样式」tab-pane 的 `display` 强制置为 `true`。
+ *
+ * `propsService.getPropsConfig` 返回的样式 tab 默认带有
+ * `display: ({ services }) => !(services?.uiService?.get('showStylePanel') ?? true)`,
+ * 在对比 / 只读展示场景(CompareForm / ViewForm)下并不需要跟随 uiService 状态隐藏,
+ * 这里统一放开,保证样式 tab 始终可见。
+ *
+ * @param formConfig 组件属性表单配置
+ * @returns 处理后的表单配置(不修改入参,返回浅拷贝)
+ */
+export const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
+ formConfig.map((item) => {
+ if (!('type' in item)) return item;
+ if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
+
+ return {
+ ...item,
+ items: item.items.map((tabPane) => {
+ if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
+
+ return {
+ ...tabPane,
+ display: true,
+ };
+ }),
+ };
+ });
+
// #region ValidatePropsFormOptions
/**
* validatePropsForm 参数
diff --git a/packages/editor/tests/unit/components/ViewForm.spec.ts b/packages/editor/tests/unit/components/ViewForm.spec.ts
new file mode 100644
index 00000000..a0643931
--- /dev/null
+++ b/packages/editor/tests/unit/components/ViewForm.spec.ts
@@ -0,0 +1,172 @@
+/*
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
+ *
+ * Copyright (C) 2025 Tencent.
+ */
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { defineComponent, h, nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+
+import ViewForm from '@editor/components/ViewForm.vue';
+
+const propsService = {
+ getPropsConfig: vi.fn(async () => [{ type: 'text', name: 'name' }]),
+};
+const dataSourceService = {
+ getFormConfig: vi.fn(() => [{ type: 'text', name: 'title' }]),
+};
+const codeBlockService = {
+ getParamsColConfig: vi.fn(() => null),
+};
+const editorService = {
+ get: vi.fn(() => ({ select: vi.fn() })),
+};
+
+const services = {
+ propsService,
+ dataSourceService,
+ codeBlockService,
+ editorService,
+} as any;
+
+let capturedFormProps: Record = {};
+
+vi.mock('@editor/utils/code-block', () => ({
+ getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
+}));
+
+vi.mock('@tmagic/form', () => ({
+ MForm: defineComponent({
+ name: 'MForm',
+ props: ['config', 'initValues', 'disabled', 'labelWidth', 'extendState', 'size'],
+ setup(props, { expose }) {
+ capturedFormProps = props as Record;
+ expose({ formState: {} });
+ return () => h('div', { class: 'fake-mform' });
+ },
+ }),
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ capturedFormProps = {};
+});
+
+describe('ViewForm.vue', () => {
+ test('node 类别按 type 加载配置并渲染 MForm', async () => {
+ const wrapper = mount(ViewForm, {
+ props: {
+ category: 'node',
+ type: 'text',
+ value: { id: 'n1', name: 'a' },
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'a' } });
+ expect(wrapper.find('.fake-mform').exists()).toBe(true);
+ expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'a' });
+ });
+
+ test('默认 disabled 为 true', async () => {
+ mount(ViewForm, {
+ props: {
+ category: 'node',
+ type: 'text',
+ value: { id: 'n1' },
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ expect(capturedFormProps.disabled).toBe(true);
+ });
+
+ test('可通过 disabled=false 覆盖为可编辑', async () => {
+ mount(ViewForm, {
+ props: {
+ category: 'node',
+ type: 'text',
+ value: { id: 'n1' },
+ disabled: false,
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ expect(capturedFormProps.disabled).toBe(false);
+ });
+
+ test('size 透传给 MForm', async () => {
+ mount(ViewForm, {
+ props: {
+ category: 'node',
+ type: 'text',
+ value: { id: 'n1' },
+ size: 'small',
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ expect(capturedFormProps.size).toBe('small');
+ });
+
+ test('code-block 类别会把 content 非字符串值 normalize 成字符串', async () => {
+ mount(ViewForm, {
+ props: {
+ category: 'code-block',
+ value: { id: 'cb_1', content: { toString: () => 'fn-body' } },
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ expect(capturedFormProps.initValues.content).toBe('fn-body');
+ });
+
+ test('传入 height 时外层容器启用内部滚动样式', () => {
+ const wrapper = mount(ViewForm, {
+ props: {
+ category: 'node',
+ type: 'text',
+ value: { id: 'n1' },
+ height: '400px',
+ services,
+ },
+ });
+ const style = wrapper.find('.m-editor-view-form-wrapper').attributes('style') || '';
+ expect(style).toContain('height: 400px');
+ expect(style).toContain('overflow: auto');
+ });
+
+ test('node 类别缺少 type 时不渲染 MForm', async () => {
+ const wrapper = mount(ViewForm, {
+ props: {
+ category: 'node',
+ value: { id: 'n1' },
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ expect(wrapper.find('.fake-mform').exists()).toBe(false);
+ });
+
+ test('reload 暴露方法会重新加载配置', async () => {
+ const wrapper = mount(ViewForm, {
+ props: {
+ category: 'data-source',
+ type: 'base',
+ value: { id: 'ds_1' },
+ services,
+ },
+ });
+ await nextTick();
+ await nextTick();
+ dataSourceService.getFormConfig.mockClear();
+ await (wrapper.vm as any).reload();
+ expect(dataSourceService.getFormConfig).toHaveBeenCalled();
+ });
+});
diff --git a/packages/editor/tests/unit/hooks/use-compare-form.spec.ts b/packages/editor/tests/unit/hooks/use-compare-form.spec.ts
new file mode 100644
index 00000000..8c3c7ea9
--- /dev/null
+++ b/packages/editor/tests/unit/hooks/use-compare-form.spec.ts
@@ -0,0 +1,222 @@
+/*
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
+ *
+ * Copyright (C) 2025 Tencent.
+ */
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { defineComponent, h, nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+
+import { MForm } from '@tmagic/form';
+
+import { useCompareForm } from '@editor/hooks/use-compare-form';
+
+const propsService = {
+ getPropsConfig: vi.fn(async () => [
+ {
+ type: 'tab',
+ items: [{ title: '样式', items: [{ type: 'text', name: 'color', display: false }] }],
+ },
+ { type: 'text', name: 'name' },
+ ]),
+};
+const dataSourceService = {
+ getFormConfig: vi.fn(() => [{ type: 'tab', items: [{ status: 'fields', items: [] }] }]),
+};
+const codeBlockService = {
+ getParamsColConfig: vi.fn(() => null),
+};
+const editorService = {
+ get: vi.fn(() => ({ select: vi.fn() })),
+};
+
+const services = {
+ propsService,
+ dataSourceService,
+ codeBlockService,
+ editorService,
+} as any;
+
+let capturedGetCodeBlockArgs: any;
+
+vi.mock('@editor/utils/code-block', () => ({
+ getCodeBlockFormConfig: vi.fn((args: any) => {
+ capturedGetCodeBlockArgs = args;
+ return [{ type: 'text', name: 'content' }];
+ }),
+}));
+
+vi.mock('@tmagic/form', () => ({
+ MForm: defineComponent({
+ name: 'MForm',
+ setup(_, { expose }) {
+ expose({ formState: {} });
+ return () => h('div', { class: 'fake-mform' });
+ },
+ }),
+}));
+
+const mountHook = (props: any, provideOptions?: Record) => {
+ let captured: any;
+ const comp = defineComponent({
+ setup() {
+ captured = useCompareForm(props);
+ return () => h(MForm as any, { ref: 'form' });
+ },
+ });
+ const wrapper = mount(comp, { global: { provide: provideOptions } });
+ return { captured, wrapper };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ capturedGetCodeBlockArgs = undefined;
+});
+
+describe('useCompareForm', () => {
+ test('无 services 时 config 为空数组', async () => {
+ const { captured } = mountHook({ category: 'node', type: 'text', value: {} });
+ await nextTick();
+ await nextTick();
+ expect(captured.config.value).toEqual([]);
+ });
+
+ test('node 类别加载 props 配置并把「样式」tab display 置为 true', async () => {
+ const { captured } = mountHook({ category: 'node', type: 'text', value: { id: 'n1' }, services });
+ await nextTick();
+ await nextTick();
+ expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1' } });
+ const tab = captured.config.value.find((i: any) => i.type === 'tab');
+ const stylePane = tab.items.find((p: any) => p.title === '样式');
+ expect(stylePane.display).toBe(true);
+ });
+
+ test('node 类别缺少 type 时返回空配置', async () => {
+ const { captured } = mountHook({ category: 'node', value: { id: 'n1' }, services });
+ await nextTick();
+ await nextTick();
+ expect(propsService.getPropsConfig).not.toHaveBeenCalled();
+ expect(captured.config.value).toEqual([]);
+ });
+
+ test('data-source 类别默认激活 fields tab', async () => {
+ const { captured } = mountHook({ category: 'data-source', type: 'http', value: {}, services });
+ await nextTick();
+ await nextTick();
+ expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http');
+ const tab = captured.config.value.find((i: any) => i.type === 'tab');
+ expect(tab.active).toBe('fields');
+ });
+
+ test('data-source 未传 type 时默认使用 base', async () => {
+ mountHook({ category: 'data-source', value: {}, services });
+ await nextTick();
+ await nextTick();
+ expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('base');
+ });
+
+ test('code-block 类别归一化 content 并按 dataSourceType 判定是否数据源代码块', async () => {
+ const { captured } = mountHook(
+ { category: 'code-block', dataSourceType: 'http', value: { content: { toString: () => 'body' } }, services },
+ { codeOptions: { theme: 'vs-dark' } },
+ );
+ await nextTick();
+ await nextTick();
+ expect(captured.currentValues.value.content).toBe('body');
+ expect(capturedGetCodeBlockArgs.editable).toBe(false);
+ expect(capturedGetCodeBlockArgs.isDataSource()).toBe(true);
+ expect(capturedGetCodeBlockArgs.dataSourceType()).toBe('http');
+ expect(capturedGetCodeBlockArgs.codeOptions).toEqual({ theme: 'vs-dark' });
+ });
+
+ test('未传 dataSourceType 时 isDataSource 为 false', async () => {
+ mountHook({ category: 'code-block', value: { content: 'x' }, services });
+ await nextTick();
+ await nextTick();
+ expect(capturedGetCodeBlockArgs.isDataSource()).toBe(false);
+ });
+
+ test('normalizeCodeBlockValue 处理各种输入', () => {
+ const { captured } = mountHook({ category: 'node', type: 'text', value: {}, services });
+ expect(captured.normalizeCodeBlockValue(undefined)).toEqual({});
+ expect(captured.normalizeCodeBlockValue({ content: 'x' })).toEqual({ content: 'x' });
+ expect(captured.normalizeCodeBlockValue({ content: { toString: () => 'y' } }).content).toBe('y');
+ const bad = {
+ content: {
+ toString: () => {
+ throw new Error('e');
+ },
+ },
+ };
+ expect(captured.normalizeCodeBlockValue(bad).content).toBe('');
+ });
+
+ test('currentValues 非 code-block 场景直接返回 value', async () => {
+ const { captured } = mountHook({ category: 'node', type: 'text', value: { id: 'n1', name: 'a' }, services });
+ await nextTick();
+ expect(captured.currentValues.value).toEqual({ id: 'n1', name: 'a' });
+ });
+
+ test('wrapperStyle 根据 height 生成', () => {
+ const { captured } = mountHook({ category: 'node', type: 'text', value: {}, height: '60vh', services });
+ expect(captured.wrapperStyle.value).toEqual({ height: '60vh', overflow: 'auto' });
+ const { captured: c2 } = mountHook({ category: 'node', type: 'text', value: {}, services });
+ expect(c2.wrapperStyle.value).toBeUndefined();
+ });
+
+ test('mergedExtendState 优先使用 baseFormState 并调用 extendState', () => {
+ const extendState = vi.fn((s: any) => ({ ...s, x: 1 }));
+ const base = { a: 1 } as any;
+ const { captured } = mountHook({
+ category: 'node',
+ type: 'text',
+ value: {},
+ services,
+ extendState,
+ baseFormState: base,
+ });
+ const result = captured.mergedExtendState({ b: 2 });
+ expect(extendState).toHaveBeenCalledWith(base);
+ expect(result).toEqual({ a: 1, x: 1 });
+ });
+
+ test('mergedExtendState 无 extendState 时原样返回 state', () => {
+ const { captured } = mountHook({ category: 'node', type: 'text', value: {}, services });
+ const state = { a: 1 } as any;
+ expect(captured.mergedExtendState(state)).toBe(state);
+ });
+
+ test('自定义 loadConfig 可接管配置加载并复用 defaultLoadConfig', async () => {
+ const loadConfig = vi.fn(async ({ defaultLoadConfig }: any) => {
+ await defaultLoadConfig();
+ return [{ type: 'text', name: 'custom' }];
+ });
+ const { captured } = mountHook({ category: 'node', type: 'text', value: { id: 'n1' }, services, loadConfig });
+ await nextTick();
+ await nextTick();
+ await nextTick();
+ expect(loadConfig).toHaveBeenCalled();
+ expect(propsService.getPropsConfig).toHaveBeenCalled();
+ expect(captured.config.value).toEqual([{ type: 'text', name: 'custom' }]);
+ });
+
+ test('reload 会重新加载配置', async () => {
+ const { captured } = mountHook({ category: 'data-source', type: 'base', value: {}, services });
+ await nextTick();
+ await nextTick();
+ dataSourceService.getFormConfig.mockClear();
+ await captured.loadConfig();
+ expect(dataSourceService.getFormConfig).toHaveBeenCalled();
+ });
+
+ test('formRef.formState 注入 stage / services', async () => {
+ const stage = { select: vi.fn() };
+ editorService.get.mockReturnValue(stage);
+ const { captured } = mountHook({ category: 'node', type: 'text', value: {}, services });
+ await nextTick();
+ await nextTick();
+ expect(editorService.get).toHaveBeenCalledWith('stage');
+ expect(captured.formRef.value.formState.services).toBe(services);
+ expect(captured.formRef.value.formState.stage).toBe(stage);
+ });
+});
diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts
index d41572d3..5de30fa4 100644
--- a/packages/editor/tests/unit/utils/props-config.spec.ts
+++ b/packages/editor/tests/unit/utils/props-config.spec.ts
@@ -18,6 +18,7 @@ import {
fillConfig,
getCondOpOptionsByFieldType,
numberOptions,
+ removeStyleDisplayConfig,
styleTabConfig,
validatePropsForm,
} from '@editor/utils/props';
@@ -223,3 +224,51 @@ describe('validatePropsForm', () => {
expect(state).toEqual({ stage, services });
});
});
+
+describe('removeStyleDisplayConfig', () => {
+ test('将 tab 内「样式」pane 的 display 置为 true', () => {
+ const config = [
+ {
+ type: 'tab',
+ items: [
+ { title: '属性', items: [{ name: 'a' }] },
+ { title: '样式', display: false, items: [{ name: 'color' }] },
+ ],
+ },
+ ] as any;
+
+ const result = removeStyleDisplayConfig(config) as any;
+ const stylePane = result[0].items.find((i: any) => i.title === '样式');
+ expect(stylePane.display).toBe(true);
+ // 非样式 pane 保持不变
+ expect(result[0].items.find((i: any) => i.title === '属性').display).toBeUndefined();
+ });
+
+ test('不修改入参,返回浅拷贝', () => {
+ const stylePane = { title: '样式', display: false, items: [{ name: 'color' }] };
+ const config = [{ type: 'tab', items: [stylePane] }] as any;
+
+ const result = removeStyleDisplayConfig(config) as any;
+ expect(result).not.toBe(config);
+ expect(result[0]).not.toBe(config[0]);
+ expect(stylePane.display).toBe(false);
+ });
+
+ test('非 tab 项 / 无 items 的项原样返回', () => {
+ const plain = { name: 'x', type: 'text' };
+ const tabWithoutItems = { type: 'tab' };
+ const noType = { foo: 'bar' };
+ const config = [plain, tabWithoutItems, noType] as any;
+
+ const result = removeStyleDisplayConfig(config) as any;
+ expect(result[0]).toBe(plain);
+ expect(result[1]).toBe(tabWithoutItems);
+ expect(result[2]).toBe(noType);
+ });
+
+ test('样式 pane 无 items 数组时不处理', () => {
+ const config = [{ type: 'tab', items: [{ title: '样式' }] }] as any;
+ const result = removeStyleDisplayConfig(config) as any;
+ expect(result[0].items[0].display).toBeUndefined();
+ });
+});
]