roymondchen f0c66427b8 feat: form 新增 showDiff prop 支持自定义对比判断
- form: MForm/Container 新增 showDiff prop,允许调用方自定义

  '是否展示对比内容' 的判断逻辑,并在嵌套 Container 中自动透传;

  不传时沿用默认的 isEqual 行为

- editor: CompareForm 利用该能力处理 code-select 字段中 '' 与

  { hookType: 'code', hookData: [] } 两种语义为空形态被 isEqual 误判为差异的问题

- docs: 补充 form-props.md 中 showDiff 的说明与示例

- test: 补充 Code 字段相关单测
2026-05-28 20:30:05 +08:00

221 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="m-editor-compare-form-wrapper" :style="wrapperStyle">
<MForm
v-if="config.length"
ref="form"
class="m-editor-compare-form"
:config="config"
:init-values="currentValues"
:last-values="lastValuesProcessed"
:is-compare="true"
:disabled="true"
:label-width="labelWidth"
:extend-state="extendState"
:show-diff="showDiff"
></MForm>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, ref, useTemplateRef, watch, watchEffect } from 'vue';
import { isEqual } from 'lodash-es';
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
defineOptions({
name: 'MEditorCompareForm',
});
/**
* 对比类型:
* - node: 节点组件,按 `type` 从 propsService 获取属性表单配置
* - data-source: 数据源,按 `type`(base/http/...) 从 dataSourceService 获取数据源表单配置
* - code-block: 数据源代码块,使用内置的代码块表单配置
*/
export type CompareCategory = 'node' | 'data-source' | 'code-block';
const props = withDefaults(
defineProps<{
/** 当前值(修改后的值) */
value: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/** 用于对比的旧值(修改前的值) */
lastValue?: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/**
* 类型说明:
* - `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;
/**
* 外层容器高度。设置后表单内容超出时会在 CompareForm 内部出现滚动条,
* 避免 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<string, any> | Promise<Record<string, any>>;
}>(),
{
category: 'node',
labelWidth: '120px',
},
);
const { propsService, dataSourceService, codeBlockService, editorService } = useServices();
const services = useServices();
const config = ref<FormConfig>([]);
/** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
const codeOptions = inject<Record<string, any>>('codeOptions', {});
/** 将代码块的 content 字段统一成字符串,便于在表单/对比中展示 */
const normalizeCodeBlockValue = (
v: Partial<CodeBlockContent> | Record<string, any> | undefined,
): Record<string, any> => {
if (!v) return {};
const next: Record<string, any> = { ...v };
if (next.content && typeof next.content !== 'string') {
try {
next.content = next.content.toString();
} catch {
next.content = '';
}
}
return next;
};
const currentValues = computed<FormValue>(() => {
if (props.category === 'code-block') {
return normalizeCodeBlockValue(props.value as Partial<CodeBlockContent>);
}
return (props.value || {}) as FormValue;
});
const lastValuesProcessed = computed<FormValue>(() => {
if (props.category === 'code-block') {
return normalizeCodeBlockValue(props.lastValue as Partial<CodeBlockContent>);
}
return (props.lastValue || {}) as FormValue;
});
/**
* 外层包裹层的样式:当传入 `height` 时启用固定高度 + 内部滚动,
* 这样滚动条会出现在 CompareForm 内部,避免父容器(如 Dialog自身也产生滚动。
*/
const wrapperStyle = computed(() => {
if (!props.height) return undefined;
return {
height: props.height,
overflow: 'auto',
} as Record<string, string>;
});
/**
* `code-select` 字段在历史数据中存在两种"语义为空"的形态:
* - 字符串 `''`(旧数据 / 用户从未配置过钩子);
* - `{ hookType: HookType.CODE, hookData: [] }`CodeSelect.vue 在挂载时
* 写入的默认结构,参见 packages/editor/src/fields/CodeSelect.vue 中
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`)。
*
* 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件
* 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。
*
* 其它类型字段沿用 MForm/Container 的默认 `!isEqual` 判断逻辑。
*/
const isEmptyCodeSelectValue = (v: any): boolean => {
if (v === '' || v === undefined || v === null) return true;
return typeof v === 'object' && v.hookType === HookType.CODE && Array.isArray(v.hookData) && v.hookData.length === 0;
};
const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: any; config: any }) => {
if (config?.type === 'code-select') {
// 双方都是"空形态",视为相等,不展示对比
if (isEmptyCodeSelectValue(curValue) && isEmptyCodeSelectValue(lastValue)) {
return false;
}
}
return !isEqual(curValue, lastValue);
};
const loadConfig = async () => {
switch (props.category) {
case 'node': {
if (!props.type) {
config.value = [];
return;
}
config.value = await propsService.getPropsConfig(props.type);
break;
}
case 'data-source': {
config.value = dataSourceService.getFormConfig(props.type || 'base');
break;
}
case 'code-block': {
config.value = getCodeBlockFormConfig({
paramColConfig: codeBlockService.getParamsColConfig(),
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比场景下 props.dataSourceType
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
isDataSource: () => Boolean(props.dataSourceType),
dataSourceType: () => props.dataSourceType,
codeOptions,
// 对比模式只读,不需要校验/语法检查
editable: false,
});
break;
}
default:
config.value = [];
}
};
watch(
[() => props.category, () => props.type, () => props.dataSourceType],
() => {
loadConfig();
},
{ immediate: true },
);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
/**
* 把 services / stage 注入 MForm 的 formState避免 propsService 注入的表单配置中
* 形如 `display: ({ services }) => services.uiService.get(...)` 的 filterFunction
* 在执行时拿不到 `formState.services` 而报错。
*
* 与 props-panel/FormPanel.vue 中的注入方式保持一致:
* - services整个 useServices() 返回的服务集合;
* - stage当前 editorService.get('stage') 的最新值。
*/
const stage = computed(() => editorService.get('stage'));
watchEffect(() => {
if (formRef.value) {
formRef.value.formState.stage = stage.value;
formRef.value.formState.services = services;
}
});
defineExpose({
form: formRef,
config,
reload: loadConfig,
});
</script>