roymondchen b02aa75ddc feat(editor): 历史记录面板支持单步回滚(类 git revert)
将目标历史步骤的修改作为一次新操作反向应用,不破坏原有栈结构,
page/dataSource/codeBlock 三类 service 均提供 revert 能力;
面板新增关闭按钮、步骤编号展示与合并组卡片样式优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 14:19:44 +08:00

222 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;
if (Array.isArray(v) && v.length === 0) 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>