feat(editor): 历史记录面板支持差异对比

- 新增 HistoryDiffDialog 历史差异对比弹窗
- 新增 CompareForm 表单对比组件
- 抽取 code-block 工具函数到 utils/code-block.ts
- 历史列表面板支持选择两个版本进行对比
This commit is contained in:
roymondchen 2026-05-28 19:49:03 +08:00
parent 0f8abf7298
commit 59f4e0edac
14 changed files with 760 additions and 114 deletions

View File

@ -229,6 +229,12 @@ provide('services', services);
provide('codeOptions', props.codeOptions);
provide('stageOptions', stageOptions);
/**
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用例如历史差异对话框 HistoryDiffDialog
* 内部的 CompareForm这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
*/
provide('extendFormState', props.extendFormState);
provide<EventBus>('eventBus', new EventEmitter());

View File

@ -63,14 +63,7 @@ import { computed, inject, nextTick, Ref, ref, useTemplateRef, watch } from 'vue
import type { CodeBlockContent } from '@tmagic/core';
import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design';
import {
type ContainerChangeEventData,
defineFormConfig,
defineFormItem,
type FormConfig,
MFormBox,
type TableColumnConfig,
} from '@tmagic/form';
import { type ContainerChangeEventData, type FormConfig, MFormBox } from '@tmagic/form';
import FloatingBox from '@editor/components/FloatingBox.vue';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
@ -78,6 +71,7 @@ import { useNextFloatBoxPosition } from '@editor/hooks/use-next-float-box-positi
import { useServices } from '@editor/hooks/use-services';
import { useWindowRect } from '@editor/hooks/use-window-rect';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
import { getEditorConfig } from '@editor/utils/config';
defineOptions({
@ -119,106 +113,23 @@ const diffChange = () => {
difVisible.value = false;
};
const defaultParamColConfig = defineFormItem<TableColumnConfig>({
type: 'row',
label: '参数类型',
items: [
{
text: '参数类型',
labelWidth: '70px',
type: 'select',
name: 'type',
options: [
{
text: '数字',
label: '数字',
value: 'number',
},
{
text: '字符串',
label: '字符串',
value: 'text',
},
{
text: '组件',
label: '组件',
value: 'ui-select',
},
],
},
],
});
const codeOptions = inject<Record<string, any>>('codeOptions', {});
const functionConfig = computed(
() =>
defineFormConfig([
{
text: '名称',
name: 'name',
rules: [{ required: true, message: '请输入名称', trigger: 'blur' }],
},
{
text: '描述',
name: 'desc',
},
{
text: '执行时机',
name: 'timing',
type: 'select',
options: () => {
const options = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
];
if (props.dataSourceType !== 'base') {
options.push({ text: '请求前', value: 'beforeRequest' });
options.push({ text: '请求后', value: 'afterRequest' });
}
return options;
},
display: () => props.isDataSource,
},
{
type: 'table',
border: true,
text: '参数',
enableFullscreen: false,
enableToggleMode: false,
name: 'params',
dropSort: false,
items: [
{
type: 'text',
label: '参数名',
name: 'name',
},
{
type: 'text',
label: '描述',
name: 'extra',
},
codeBlockService.getParamsColConfig() || defaultParamColConfig,
],
},
{
name: 'content',
type: 'vs-code',
options: inject('codeOptions', {}),
autosize: { minRows: 10, maxRows: 30 },
onChange: (_formState, code: string) => {
try {
// js
getEditorConfig('parseDSL')(code);
return code;
} catch (error: any) {
tMagicMessage.error(error.message);
throw error;
}
},
},
]) as FormConfig,
/**
* 代码块编辑表单配置统一委托到 utils/code-block `getCodeBlockFormConfig`
* CompareForm 等其它使用方共享同一份 schema避免双份维护
*
* 这里以 computed 包裹是为了让 `props.isDataSource` / `props.dataSourceType` 变化时
* "执行时机"字段的可见性与可选项实时刷新
*/
const functionConfig = computed<FormConfig>(() =>
getCodeBlockFormConfig({
paramColConfig: codeBlockService.getParamsColConfig(),
isDataSource: () => Boolean(props.isDataSource),
dataSourceType: () => props.dataSourceType,
codeOptions,
editable: true,
}),
);
const parseContent = (content: any) => {

View File

@ -0,0 +1,192 @@
<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"
></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>;
});
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>

View File

@ -70,6 +70,7 @@ export { default as LayoutContainer } from './components/SplitView.vue';
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 FloatingBox } from './components/FloatingBox.vue';
export { default as Tree } from './components/Tree.vue';
export { default as TreeNode } from './components/TreeNode.vue';

View File

@ -22,12 +22,14 @@
applied: s.applied,
isCurrent: s.isCurrent,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`${prefix}-${bucketId}-${gIdx}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
/>
<!--
初始状态项永远位于该 bucket 列表底部同样按倒序展示最底部 = 最早状态
@ -68,6 +70,8 @@ const props = defineProps<{
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本。用于合并组展开后的子步列表。 */
describeStep: (_step: any) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
isStepDiffable?: (_step: any) => boolean;
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
}>();
@ -82,6 +86,8 @@ defineEmits<{
(_e: 'goto', _bucketId: string | number, _index: number): void;
/** 用户点击初始项希望该 bucket 回到未修改状态;携带 bucketId 用于上层路由到正确的 service。 */
(_e: 'goto-initial', _bucketId: string | number): void;
/** 用户点击"查看差异",携带 bucketId 与 step 索引。 */
(_e: 'diff-step', _bucketId: string | number, _index: number): void;
}>();
/** 该 bucket 是否处于初始状态(栈 cursor=0等价于全部 group 都未 applied。 */

View File

@ -10,10 +10,12 @@
:groups="bucket.groups"
:describe-group="describeCodeBlockGroup"
:describe-step="describeCodeBlockStep"
:is-step-diffable="isCodeBlockStepDiffable"
:expanded="expanded"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
/>
</TMagicScrollbar>
</template>
@ -21,7 +23,7 @@
<script lang="ts" setup>
import { TMagicScrollbar } from '@tmagic/design';
import type { CodeBlockHistoryGroup } from '@editor/type';
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import { describeCodeBlockGroup, describeCodeBlockStep } from './composables';
@ -47,5 +49,10 @@ defineEmits<{
(_e: 'goto', _codeBlockId: string | number, _index: number): void;
/** 透传 Bucket 的 goto-initial 事件,携带 codeBlock id回到该代码块未修改时的状态。 */
(_e: 'goto-initial', _codeBlockId: string | number): void;
/** 透传 Bucket 的 diff-step 事件,携带 codeBlock id 与 step 索引。 */
(_e: 'diff-step', _codeBlockId: string | number, _index: number): void;
}>();
/** 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
</script>

View File

@ -10,10 +10,12 @@
:groups="bucket.groups"
:describe-group="describeDataSourceGroup"
:describe-step="describeDataSourceStep"
:is-step-diffable="isDataSourceStepDiffable"
:expanded="expanded"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
/>
</TMagicScrollbar>
</template>
@ -21,7 +23,7 @@
<script lang="ts" setup>
import { TMagicScrollbar } from '@tmagic/design';
import type { DataSourceHistoryGroup } from '@editor/type';
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import { describeDataSourceGroup, describeDataSourceStep } from './composables';
@ -47,5 +49,10 @@ defineEmits<{
(_e: 'goto', _dataSourceId: string | number, _index: number): void;
/** 透传 Bucket 的 goto-initial 事件,携带 dataSource id回到该数据源未修改时的状态。 */
(_e: 'goto-initial', _dataSourceId: string | number): void;
/** 透传 Bucket 的 diff-step 事件,携带 dataSource id 与 step 索引。 */
(_e: 'diff-step', _dataSourceId: string | number, _index: number): void;
}>();
/** 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
</script>

View File

@ -12,6 +12,13 @@
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
<span
v-if="!merged && headDiffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(subSteps[0].index)"
>查看差异</span
>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }"></span>
</div>
@ -25,8 +32,15 @@
@click="onSubStepClick(s)"
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span>{{ s.desc }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span v-if="s.isCurrent" class="m-editor-history-list-item-current">当前</span>
<span
v-if="s.diffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(s.index)"
>查看差异</span
>
</li>
</ul>
</li>
@ -57,7 +71,7 @@ const props = defineProps<{
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
stepCount: number;
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
subSteps: { index: number; applied: boolean; desc: string; isCurrent?: boolean }[];
subSteps: { index: number; applied: boolean; desc: string; isCurrent?: boolean; diffable?: boolean }[];
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
expanded: boolean;
/** 是否为当前所在的分组包含栈中最近一次已应用步骤的那一组UI 高亮展示。 */
@ -79,6 +93,12 @@ const emit = defineEmits<{
* 当前所在的步骤isCurrent始终不会触发 goto
*/
(_e: 'goto', _index: number): void;
/**
* 用户希望查看该 step 的修改差异旧值 vs 新值
* 只在 step 满足"前后值都存在" update / 数据源代码块的 update时由父级标记 `diffable=true`
* payload 为该 step 在所属栈中的索引由上层根据 index step 内容并展示对比
*/
(_e: 'diff-step', _index: number): void;
}>();
/** 单步组:头部可点击 goto合并组头部可点击切换展开。当前组isCurrent的单步组头部不可点击。 */
@ -112,4 +132,11 @@ const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
if (s.isCurrent) return;
emit('goto', s.index);
};
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
const onDiffClick = (index: number) => {
emit('diff-step', index);
};
</script>

View File

@ -0,0 +1,157 @@
<template>
<Teleport to="body">
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
title="查看修改差异"
width="900px"
destroy-on-close
append-to-body
>
<div v-if="payload" class="m-editor-history-diff-dialog-body">
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup>
</div>
<div class="m-editor-history-diff-dialog-legend">
<TMagicTag size="small" type="info">{{ leftLabel }}</TMagicTag>
<span class="m-editor-history-diff-dialog-arrow"></span>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
当前值与该步修改后一致无差异
</span>
</div>
<CompareForm
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
height="60vh"
/>
</div>
<template #footer>
<TMagicButton size="small" @click="visible = false">关闭</TMagicButton>
</template>
</TMagicDialog>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { isEqual } from 'lodash-es';
import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagicTag } from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import CompareForm, { type CompareCategory } from '@editor/components/CompareForm.vue';
defineOptions({
name: 'MEditorHistoryDiffDialog',
});
defineProps<{
/**
* 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState
* 透传给 CompareForm从而让差异对比时表单 item 中依赖业务上下文的
* `display` / `disabled` filterFunction 正常工作
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}>();
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
category: CompareCategory;
/** 节点类型 / 数据源类型 */
type?: string;
/** 代码块场景下的数据源类型 */
dataSourceType?: string;
/** 该 step 修改前的值oldNode / oldSchema / oldContent */
lastValue: Record<string, any>;
/** 该 step 修改后的值newNode / newSchema / newContent */
value: Record<string, any>;
/** 当前编辑器中实际的最新值;不传或为 null 时禁用「与当前对比」 */
currentValue?: Record<string, any> | null;
/** 用于标题展示的目标名称 */
targetLabel?: string;
}
/**
* 差异对比模式
* - before该步骤修改前 vs 该步骤修改后默认行为体现这一步带来的变化
* - current该步骤修改后 vs 当前最新值用于查看该步骤之后是否还被改过
*/
type DiffMode = 'before' | 'current';
const visible = ref(false);
const payload = ref<DiffDialogPayload | null>(null);
const mode = ref<DiffMode>('before');
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
/** 左侧(旧/参照)值 */
const leftValue = computed<Record<string, any>>(() => {
if (!payload.value) return {};
if (mode.value === 'current') return payload.value.value;
return payload.value.lastValue;
});
/** 右侧(新/对比)值 */
const rightValue = computed<Record<string, any>>(() => {
if (!payload.value) return {};
if (mode.value === 'current') return payload.value.currentValue || {};
return payload.value.value;
});
const leftLabel = computed(() => (mode.value === 'current' ? '该步修改后' : '修改前'));
const rightLabel = computed(() => (mode.value === 'current' ? '当前' : '修改后'));
/** 「与当前对比」模式下,若当前值与该步修改后值相等,则展示提示 */
const isSameAsCurrent = computed(() => {
if (mode.value !== 'current' || !payload.value) return false;
return isEqual(payload.value.value, payload.value.currentValue);
});
const targetText = computed(() => {
if (!payload.value) return '';
const categoryText: Record<CompareCategory, string> = {
node: '节点',
'data-source': '数据源',
'code-block': '代码块',
};
const prefix = categoryText[payload.value.category] || '';
const label = payload.value.targetLabel || payload.value.type || '';
return [prefix, label].filter(Boolean).join('');
});
const open = (p: DiffDialogPayload) => {
payload.value = p;
// ""退
mode.value = 'before';
visible.value = true;
};
const close = () => {
visible.value = false;
};
// payload
watch(visible, (v) => {
if (!v) {
payload.value = null;
}
});
defineExpose({
open,
close,
});
</script>

View File

@ -12,6 +12,7 @@
@toggle="toggleGroup"
@goto="onPageGoto"
@goto-initial="onPageGotoInitial"
@diff-step="onPageDiff"
/>
</component>
@ -25,6 +26,7 @@
@toggle="toggleGroup"
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
@diff-step="onDataSourceDiff"
/>
</component>
@ -38,6 +40,7 @@
@toggle="toggleGroup"
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
@diff-step="onCodeBlockDiff"
/>
</component>
</TMagicTabs>
@ -53,6 +56,8 @@
</TMagicTooltip>
</template>
</TMagicPopover>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
</template>
<script lang="ts" setup>
@ -70,13 +75,17 @@
*
* 这里的 targetCursor = 用户点击的 step.index + 1"应用至此步完成的状态"
*
* 此外每条 step 上提供"查看差异"入口仅在前后值都存在的 update 步骤显示
* 点击后弹出 HistoryDiffDialog使用 CompareForm 组件以表单形式展示新旧值差异
*
* tab 的内容拆分为独立的 SFCPageTab / DataSourceTab / CodeBlockTab
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
import { markRaw, ref } from 'vue';
import { inject, markRaw, ref, useTemplateRef } from 'vue';
import { Clock } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
@ -84,6 +93,7 @@ import { useServices } from '@editor/hooks/use-services';
import CodeBlockTab from './CodeBlockTab.vue';
import { useHistoryList } from './composables';
import DataSourceTab from './DataSourceTab.vue';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
import PageTab from './PageTab.vue';
defineOptions({
@ -95,7 +105,17 @@ const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
const tabPaneComponent = getDesignConfig('components')?.tabPane;
const { editorService, dataSourceService, codeBlockService } = useServices();
const { editorService, dataSourceService, codeBlockService, historyService } = useServices();
/**
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`转交给 HistoryDiffDialog
* 内部的 CompareForm使差异对比表单的 filterFunction 能拿到完整的业务上下文
* 未提供时为 undefinedCompareForm/MForm 会跳过 extendState 处理
*/
const extendFormState = inject<((_state: FormState) => Record<string, any> | Promise<Record<string, any>>) | undefined>(
'extendFormState',
undefined,
);
const {
expanded,
@ -138,4 +158,74 @@ const onDataSourceGotoInitial = (id: string | number) => {
const onCodeBlockGotoInitial = (id: string | number) => {
codeBlockService.goto(id, 0);
};
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
/**
* 页面 step 差异 update 单节点修改可对比传入旧/新节点
* 节点类型 `type` 优先取 newNode.type再回退 oldNode.type
* `currentValue` 取自 editorService 中该节点当前实际值用于支持与当前对比
*/
const onPageDiff = (index: number) => {
const groups = historyService.getPageHistoryGroups();
for (const group of groups) {
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const item = entry.step.updatedItems?.[0];
if (!item?.oldNode || !item?.newNode) return;
const type = (item.newNode.type as string) || (item.oldNode.type as string) || '';
const nodeId = item.newNode.id ?? item.oldNode.id;
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
diffDialogRef.value?.open({
category: 'node',
type,
lastValue: item.oldNode as Record<string, any>,
value: item.newNode as Record<string, any>,
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
});
return;
}
};
const onDataSourceDiff = (id: string | number, index: number) => {
const groups = historyService.getDataSourceHistoryGroups();
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const { oldSchema, newSchema } = entry.step;
if (!oldSchema || !newSchema) return;
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
diffDialogRef.value?.open({
category: 'data-source',
type: newSchema.type || oldSchema.type || 'base',
lastValue: oldSchema as Record<string, any>,
value: newSchema as Record<string, any>,
currentValue: (currentSchema as Record<string, any>) || null,
targetLabel: newSchema.title || oldSchema.title || `${id}`,
});
return;
}
};
const onCodeBlockDiff = (id: string | number, index: number) => {
const groups = historyService.getCodeBlockHistoryGroups();
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const { oldContent, newContent } = entry.step;
if (!oldContent || !newContent) return;
const currentContent = codeBlockService.getCodeContentById(id);
diffDialogRef.value?.open({
category: 'code-block',
lastValue: oldContent as Record<string, any>,
value: newContent as Record<string, any>,
currentValue: (currentContent as Record<string, any>) || null,
targetLabel: newContent.name || oldContent.name || `${id}`,
});
return;
}
};
</script>

View File

@ -17,12 +17,14 @@
applied: s.applied,
isCurrent: s.isCurrent,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${gIdx}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
@ -38,7 +40,7 @@ import { computed } from 'vue';
import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup } from '@editor/type';
import type { PageHistoryGroup, StepValue } from '@editor/type';
import { describePageGroup, describePageStep } from './composables';
import GroupRow from './GroupRow.vue';
@ -62,8 +64,23 @@ defineEmits<{
(_e: 'goto', _index: number): void;
/** 用户点击初始项希望回到未修改的状态cursor=0。 */
(_e: 'goto-initial'): void;
/** 用户点击"查看差异"按钮,携带目标 step 在栈中的索引。 */
(_e: 'diff-step', _index: number): void;
}>();
/**
* 当前 step 是否可查看差异
* - update 操作
* - 单节点更新updatedItems.length === 1 oldNode / newNode 都存在
* 多节点更新难以选定单一对比目标统一不展示差异入口
*/
const isPageStepDiffable = (step: StepValue): boolean => {
if (step.opType !== 'update') return false;
const items = step.updatedItems ?? [];
if (items.length !== 1) return false;
return Boolean(items[0]?.oldNode && items[0]?.newNode);
};
/**
* 是否处于"初始状态"即对应页面历史栈 cursor===0
* list 中所有 group applied 都为 false 时即为该状态

View File

@ -213,6 +213,29 @@
background-color: rgba(230, 162, 60, 0.12);
}
.m-editor-history-list-item-diff {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #409eff;
background-color: rgba(64, 158, 255, 0.1);
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(64, 158, 255, 0.2);
}
}
.m-editor-history-list-substep-desc {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-editor-history-list-bucket {
margin-bottom: 8px;
@ -250,3 +273,54 @@
font-size: 11px;
}
}
.m-editor-history-diff-dialog {
.m-editor-history-diff-dialog-body {
display: flex;
flex-direction: column;
}
.m-editor-history-diff-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 8px 12px;
background-color: #f5f7fa;
border-radius: 4px;
gap: 12px;
}
.m-editor-history-diff-dialog-mode {
flex: 0 0 auto;
}
.m-editor-history-diff-dialog-target {
flex: 1 1 auto;
font-size: 13px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-editor-history-diff-dialog-legend {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 0 4px;
}
.m-editor-history-diff-dialog-arrow {
color: #909399;
font-size: 12px;
}
.m-editor-history-diff-dialog-tip {
margin-left: 8px;
color: #e6a23c;
font-size: 12px;
}
}

View File

@ -0,0 +1,150 @@
/*
* 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 { tMagicMessage } from '@tmagic/design';
import { defineFormConfig, defineFormItem, type FormConfig, type TableColumnConfig } from '@tmagic/form';
import { getEditorConfig } from './config';
/**
*
*
*
* - `CodeBlockEditor.vue`/
* - `CompareForm.vue`"代码块"
*
* / vs-code onChange / dataSource
* schema
*/
export interface GetCodeBlockFormConfigOptions {
/**
* `params` "参数类型"
* `codeBlockService.getParamsColConfig()`
*/
paramColConfig?: TableColumnConfig;
/**
* 使
* - true`执行时机` `请求前 / 请求后` `dataSourceType !== 'base'`
* - false`执行时机`
*
* `display` / `options`
* `props.isDataSource` `dataSourceType`
*/
isDataSource?: () => boolean;
/** 当 isDataSource 为 true 时使用:返回当前数据源类型(`base` / `http` / ...),决定时机选项是否包含请求前后。 */
dataSourceType?: () => string | undefined;
/** vs-code 编辑器的额外 monaco options。一般传 `inject('codeOptions', {})` 的结果。 */
codeOptions?: Record<string, any>;
/**
*
* - true`name` vs-code `onChange` `parseDSL`
* - false/
*/
editable?: boolean;
}
/** 默认的"参数类型"列配置:数字 / 字符串 / 组件 三选一。 */
const defaultParamColConfig = () =>
defineFormItem<TableColumnConfig>({
type: 'row',
label: '参数类型',
items: [
{
text: '参数类型',
labelWidth: '70px',
type: 'select',
name: 'type',
options: [
{ text: '数字', label: '数字', value: 'number' },
{ text: '字符串', label: '字符串', value: 'text' },
{ text: '组件', label: '组件', value: 'ui-select' },
],
},
],
});
/**
* `<MForm :config>`
* props dataSourceType `computed`
*/
export const getCodeBlockFormConfig = (options: GetCodeBlockFormConfigOptions = {}): FormConfig => {
const { paramColConfig, isDataSource, dataSourceType, codeOptions = {}, editable = true } = options;
return defineFormConfig([
{
text: '名称',
name: 'name',
...(editable ? { rules: [{ required: true, message: '请输入名称', trigger: 'blur' }] } : {}),
},
{
text: '描述',
name: 'desc',
},
{
text: '执行时机',
name: 'timing',
type: 'select',
options: () => {
const list = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
];
if (dataSourceType?.() !== 'base') {
list.push({ text: '请求前', value: 'beforeRequest' });
list.push({ text: '请求后', value: 'afterRequest' });
}
return list;
},
display: () => Boolean(isDataSource?.()),
},
{
type: 'table',
border: true,
text: '参数',
enableFullscreen: false,
enableToggleMode: false,
name: 'params',
dropSort: false,
items: [
{ type: 'text', label: '参数名', name: 'name' },
{ type: 'text', label: '描述', name: 'extra' },
paramColConfig || defaultParamColConfig(),
],
},
{
name: 'content',
type: 'vs-code',
options: codeOptions,
autosize: { minRows: 10, maxRows: 30 },
...(editable
? {
onChange: (_formState: any, code: string) => {
try {
// 检测 js 代码是否存在语法错误
getEditorConfig('parseDSL')(code);
return code;
} catch (error: any) {
tMagicMessage.error(error.message);
throw error;
}
},
}
: {}),
},
]) as FormConfig;
};

View File

@ -18,6 +18,7 @@
export * from './config';
export * from './props';
export * from './code-block';
export * from './logger';
export * from './editor';
export * from './operator';