refactor(editor): 抽离历史记录回滚交互并开放复用入口

This commit is contained in:
roymondchen 2026-06-18 17:04:16 +08:00
parent 38065f013f
commit 9f2fa1a9c8
14 changed files with 764 additions and 354 deletions

View File

@ -67,6 +67,44 @@ const menu = ref({
- 数据源:`dataSourceService.revertById(uuids)`
- 代码块:`codeBlockService.revertById(uuids)`
#### 复用面板的「交互式回滚 / 查看差异」流程
上面的 `revert*` 方法是**静默**的:它们直接执行反向应用,不做任何校验与二次确认。如果业务方想在自己的入口(自定义按钮、右键菜单等)里复用历史面板那一套**完整交互流程**——即「目标数据已删除的前置校验 + 失败提示」「可差异步骤弹差异确认弹窗 / 其余步骤弹普通二次确认框」「用户确认后才回滚」,以及「查看差异」弹窗——可以直接 import `useHistoryRevert`
```ts
import { useHistoryRevert } from '@tmagic/editor';
// editorRef 为 <m-editor> 组件实例,其 expose 出的即各 service 集合Services
const {
onPageRevert,
onDataSourceRevert,
onCodeBlockRevert,
onPageDiff,
onDataSourceDiff,
onCodeBlockDiff,
} = useHistoryRevert(editorRef.value);
// 回滚:可差异步骤弹出差异确认弹窗、其余步骤弹普通二次确认框;用户点「确定」后回滚第 index 步,
// 命中前置校验或用户取消时不执行,返回 null
await onPageRevert(index);
await onDataSourceRevert(id, index);
await onCodeBlockRevert(id, index);
// 查看差异:可差异步骤弹出只读差异弹窗展示前后值差异,无可对比内容时不弹窗
onPageDiff(index);
onDataSourceDiff(id, index);
onCodeBlockDiff(id, index);
```
回滚确认弹窗与查看差异弹窗均由 `useHistoryRevert` 内部**按需动态挂载** `HistoryDiffDialog` 实现,业务方无需自行挂载任何弹窗组件。第二个参数 `options` 可选:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
| `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled``filterFunction` 正常工作。 |
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`
### 4. 差异对比
在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:

View File

@ -17,14 +17,14 @@
</template>
<script lang="ts" setup>
import { computed, inject, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
import { computed, inject, provide, type Ref, ref, type ShallowRef, 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 type { CompareCategory, CompareFormLoadConfig } from '@editor/type';
import type { CompareCategory, CompareFormLoadConfig, Services } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
defineOptions({
@ -67,15 +67,19 @@ const props = withDefaults(
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工返回的 config 直接用于对比展示
*/
loadConfig?: CompareFormLoadConfig;
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services: Services;
}>(),
{
category: 'node',
labelWidth: '120px',
services: () => useServices(),
},
);
const { propsService, dataSourceService, codeBlockService, editorService } = useServices();
const services = useServices();
const { propsService, dataSourceService, codeBlockService, editorService } = props.services;
provide('services', props.services);
const config = ref<FormConfig>([]);
@ -242,7 +246,7 @@ const stage = computed(() => editorService.get('stage'));
watchEffect(() => {
if (formRef.value) {
formRef.value.formState.stage = stage.value;
formRef.value.formState.services = services;
formRef.value.formState.services = props.services;
}
});

View File

@ -74,6 +74,8 @@ export { default as CompareForm } from './components/CompareForm.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';
export { confirmHistoryAction } from './layouts/history-list/composables';
export { useHistoryRevert } from './layouts/history-list/useHistoryRevert';
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

@ -46,6 +46,7 @@
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
:services="props.services"
height="70vh"
/>
@ -79,8 +80,9 @@ import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagic
import type { FormState } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
import { useServices } from '@editor/hooks/use-services';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload } from '@editor/type';
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload, Services } from '@editor/type';
defineOptions({
name: 'MEditorHistoryDiffDialog',
@ -88,6 +90,8 @@ defineOptions({
const props = withDefaults(
defineProps<{
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services: Services;
/**
* 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState
* 透传给 CompareForm从而让差异对比时表单 item 中依赖业务上下文的
@ -105,6 +109,7 @@ const props = withDefaults(
selfDiffFieldTypes?: string[];
}>(),
{
services: () => useServices(),
width: '900px',
},
);

View File

@ -91,9 +91,6 @@
</TMagicTooltip>
</template>
</TMagicPopover>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
<HistoryDiffDialog ref="confirmDialog" :is-confirm="true" :extend-state="extendFormState" />
</template>
<script lang="ts" setup>
@ -118,18 +115,10 @@
* 通过 title / prefix / describe* / isStepDiffable
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
import { computed, inject, markRaw, ref, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import {
getDesignConfig,
TMagicButton,
tMagicMessage,
tMagicMessageBox,
TMagicPopover,
TMagicTabs,
TMagicTooltip,
} from '@tmagic/design';
import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
@ -138,15 +127,15 @@ import type {
BaseStepValue,
CodeBlockStepValue,
DataSourceStepValue,
DiffDialogPayload,
HistoryBucketConfig,
HistoryListExtraTab,
} from '@editor/type';
import BucketTab from './BucketTab.vue';
import { describeStep, isSingleDiffStepRevertable, useHistoryList } from './composables';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
import { confirmHistoryAction, describeStep, isSingleDiffStepRevertable } from './composables';
import PageTab from './PageTab.vue';
import { useHistoryList } from './useHistoryList';
import { useHistoryRevert } from './useHistoryRevert';
defineOptions({
name: 'MEditorHistoryListPanel',
@ -173,8 +162,9 @@ const extraTabs = inject<HistoryListExtraTab[]>('historyListExtraTabs', []);
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
const services = useServices();
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
useServices();
services;
/**
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用
@ -294,222 +284,13 @@ const onCodeBlockGotoInitial = (id: string | number) => {
codeBlockService.goto(id, 0);
};
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
const confirmDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('confirmDialog');
/**
* 三类历史页面 / 数据源 / 代码块差异弹窗入参的构造差异收敛为一份配置
* 分组来源当前值读取类型 / 展示名提取不同定位 step校验前后值组装 payload 的流程共用
* 单步回滚查看差异的完整逻辑收敛到 useHistoryRevert面板与业务方共用
* 二者均由 useHistoryRevert 内部按需动态挂载 HistoryDiffDialog
* 业务方亦可直接 import useHistoryRevert(services) 调用无需自行挂载任何弹窗
*/
interface DiffPayloadSource {
/** 表单类别:节点 / 数据源 / 代码块。 */
category: DiffDialogPayload['category'];
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id。 */
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
}
/**
* 构造差异弹窗入参 update前后值都存在可对比
* - 页面 id在全部分组中按 index 定位 step目标 id 取自快照
* - 数据源 / 代码块 id先匹配分组 id 再按 index 定位
* 无可对比内容多节点 / add / remove或定位不到时返回 null
*/
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
for (const group of source.groups()) {
if (id !== undefined && group.id !== id) continue;
const step = group.steps.find((s) => s.index === index)?.step;
if (!step) continue;
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
if (!oldSchema || !newSchema) return null;
const targetId = id ?? newSchema.id ?? oldSchema.id;
const type = source.resolveType?.(newSchema, oldSchema);
return {
category: source.category,
...(type !== undefined ? { type } : {}),
lastValue: oldSchema,
value: newSchema,
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
id: targetId,
};
}
return null;
};
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'node',
groups: () => historyService.getPageHistoryGroups(),
getCurrent: (id) => editorService.getNodeById(id) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || '',
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
},
index,
);
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'data-source',
groups: () => historyService.getDataSourceHistoryGroups(),
getCurrent: (id) => dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || 'base',
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
},
index,
id,
);
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'code-block',
groups: () => historyService.getCodeBlockHistoryGroups(),
getCurrent: (id) => codeBlockService.getCodeContentById(id) as Record<string, any> | null,
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
},
index,
id,
);
const onPageDiff = (index: number) => {
const payload = buildPageDiffPayload(index);
if (payload) diffDialogRef.value?.open(payload);
};
const onDataSourceDiff = (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
const onCodeBlockDiff = (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
/**
* 回滚统一入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*
* 交互
* - 可差异对比的步骤单节点 / 单实体 update弹出差异弹窗供用户确认确定回滚再执行
* - 无法对比的步骤add / remove / 多节点更新payload null弹出普通二次确认框确认后执行
*
* 页面 / 数据源 / 代码块三类回滚仅差异入参构造实际 revert 调用不同
* 由调用方分别传入 payload revert公共的弹窗 / 确认流程在此收敛
*/
const runRevert = (payload: DiffDialogPayload | null): Promise<boolean> => {
if (payload && confirmDialogRef.value) {
return confirmDialogRef.value.confirm(payload);
}
return confirmRevert();
};
/**
* 回滚前置校验若该历史步骤回滚所依赖的目标数据已被删除则无法回滚
* - update把旧值写回被修改的目标必须仍存在
* - 页面 remove还原被删节点被删节点的原父容器必须仍存在否则无处插回
* add回滚即删除即使目标已不在也已达成删除目的不视为失败
*
* 命中时弹出回滚失败提示并返回 true调用方据此中止本次回滚
*/
const isPageRevertTargetMissing = (index: number): boolean => {
const step = historyService.getPageStepList()[index]?.step;
if (!step) return false;
if (step.opType === 'update') {
return (step.diff ?? []).some((item) => {
const id = item.newSchema?.id ?? item.oldSchema?.id;
return id !== undefined && !editorService.getNodeById(id, false);
});
}
if (step.opType === 'remove') {
return (step.diff ?? []).some(
(item) => item.parentId !== undefined && !editorService.getNodeById(item.parentId, false),
);
}
return false;
};
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getDataSourceStepList(id)[index]?.step;
return Boolean(step && step.opType === 'update' && !dataSourceService.getDataSourceById(`${id}`));
};
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getCodeBlockStepList(id)[index]?.step;
return Boolean(step && step.opType === 'update' && !codeBlockService.getCodeContentById(id));
};
/** 目标数据已被删除、无法回滚时的统一提示。 */
const showRevertTargetMissing = () => {
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
};
const onPageRevert = (index: number) => {
if (isPageRevertTargetMissing(index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildPageDiffPayload(index)).then((result) => (result ? editorService.revertPageStep(index) : null));
};
const onDataSourceRevert = (id: string | number, index: number) => {
if (isDataSourceRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
result ? dataSourceService.revert(id, index) : null,
);
};
const onCodeBlockRevert = (id: string | number, index: number) => {
if (isCodeBlockRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
result ? codeBlockService.revert(id, index) : null,
);
};
/**
* 回滚二次确认新增 / 删除 / 多节点更新等无法做差异对比的步骤
* 不弹差异弹窗改用一个普通确认框替代确定回滚按钮避免点击后无任何提示直接执行
* 用户取消时返回 false调用方据此中止回滚
*/
const confirmRevert = (): Promise<boolean> =>
confirmDialog(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
/**
* 通用二次确认弹窗清空历史 / 无法差异对比的回滚等会改变状态的操作先弹出确认框
* 用户点击确定返回 true取消confirm reject时返回 false 并静默忽略
*/
const confirmDialog = async (message: string): Promise<boolean> => {
try {
await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
return true;
// eslint-disable-next-line no-unused-vars
} catch (e) {
return false;
}
};
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
useHistoryRevert(services, { extendState: extendFormState });
/**
* 把内存中已清空对应类别后的历史状态重新写回 IndexedDB
@ -527,7 +308,9 @@ const syncIndexedDB = async () => {
const onPageClear = async () => {
if (
await confirmDialog('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
await confirmHistoryAction(
'确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clearPage();
await syncIndexedDB();
@ -536,7 +319,9 @@ const onPageClear = async () => {
const onDataSourceClear = async () => {
if (
await confirmDialog('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
await confirmHistoryAction(
'确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clearDataSource();
await syncIndexedDB();
@ -545,7 +330,9 @@ const onDataSourceClear = async () => {
const onCodeBlockClear = async () => {
if (
await confirmDialog('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
await confirmHistoryAction(
'确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clearCodeBlock();
await syncIndexedDB();

View File

@ -1,8 +1,6 @@
import { computed, reactive } from 'vue';
import { tMagicMessageBox } from '@tmagic/design';
import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type {
BaseStepValue,
HistoryOpSource,
@ -79,63 +77,6 @@ export interface HistoryRowGroup {
/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
/**
*
* - / /
* -
* -
*
* historyService reactive state
*/
export const useHistoryList = () => {
const { historyService } = useServices();
/**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}`
* index key
* `false`
*/
const expanded = reactive<Record<string, boolean>>({});
const toggleGroup = (key: string) => {
expanded[key] = expanded[key] === false;
};
const pageGroups = computed(() => historyService.getPageHistoryGroups());
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
/** 页面 tab 倒序展示(最新一组在最上面)。 */
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
/**
* group id bucket id
* bucket
*/
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
const map = new Map<string | number, G[]>();
groups.forEach((g) => {
const list = map.get(g.id) ?? [];
list.push(g);
map.set(g.id, list);
});
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
};
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
return {
expanded,
toggleGroup,
pageGroups,
dataSourceGroups,
codeBlockGroups,
pageGroupsDisplay,
dataSourceGroupsByTarget,
codeBlockGroupsByTarget,
};
};
/**
*
* - `HH:mm:ss`
@ -345,3 +286,20 @@ export const isSingleDiffStepRevertable = (step: BaseStepValue): boolean => {
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};
/**
* /
* trueconfirm reject false
*/
export const confirmHistoryAction = async (message: string): Promise<boolean> => {
try {
await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
return true;
} catch {
return false;
}
};

View File

@ -0,0 +1,60 @@
import { computed, reactive } from 'vue';
import { useServices } from '@editor/hooks/use-services';
/**
*
* - / /
* -
* -
*
* historyService reactive state
*/
export const useHistoryList = () => {
const { historyService } = useServices();
/**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}`
* index key
* `false`
*/
const expanded = reactive<Record<string, boolean>>({});
const toggleGroup = (key: string) => {
expanded[key] = expanded[key] === false;
};
const pageGroups = computed(() => historyService.getPageHistoryGroups());
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
/** 页面 tab 倒序展示(最新一组在最上面)。 */
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
/**
* group id bucket id
* bucket
*/
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
const map = new Map<string | number, G[]>();
groups.forEach((g) => {
const list = map.get(g.id) ?? [];
list.push(g);
map.set(g.id, list);
});
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
};
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
return {
expanded,
toggleGroup,
pageGroups,
dataSourceGroups,
codeBlockGroups,
pageGroupsDisplay,
dataSourceGroupsByTarget,
codeBlockGroupsByTarget,
};
};

View File

@ -0,0 +1,400 @@
import { createApp, getCurrentInstance } from 'vue';
import { tMagicMessage } from '@tmagic/design';
import type {
ConfirmAndRevertOptions,
CustomDiffFormOptions,
DiffDialogPayload,
Services,
UseHistoryRevertOptions,
} from '@editor/type';
import { confirmHistoryAction } from './composables';
/**
* / /
* / step payload
*/
interface DiffPayloadSource {
/** 表单类别:节点 / 数据源 / 代码块。 */
category: DiffDialogPayload['category'];
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id。 */
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
}
/**
* update
* - id index step id
* - / id id index
* / add / remove null
*/
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
for (const group of source.groups()) {
if (id !== undefined && group.id !== id) continue;
const step = group.steps.find((s) => s.index === index)?.step;
if (!step) continue;
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
if (!oldSchema || !newSchema) return null;
const targetId = id ?? newSchema.id ?? oldSchema.id;
const type = source.resolveType?.(newSchema, oldSchema);
return {
category: source.category,
...(type !== undefined ? { type } : {}),
lastValue: oldSchema,
value: newSchema,
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
id: targetId,
};
}
return null;
};
interface MountedDiffDialog {
instance: {
open: (_payload: DiffDialogPayload) => void;
confirm: (_payload: DiffDialogPayload) => Promise<boolean>;
};
/** 卸载弹窗并清理容器(延迟以等待关闭过渡播放完成,避免动画被强行打断)。 */
destroy: () => void;
}
/**
* import HistoryDiffDialog
* appContext / / provide / Element Plus@tmagic/form
* import
*/
const mountHistoryDiffDialog = async (
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
CustomDiffFormOptions & {
services: Pick<Services, 'editorService' | 'dataSourceService' | 'codeBlockService' | 'historyService'>;
isConfirm?: boolean;
onClose?: () => void;
},
): Promise<MountedDiffDialog> => {
const { default: historyDiffDialog } = await import('./HistoryDiffDialog.vue');
const container = document.createElement('div');
container.style.display = 'none';
document.body.appendChild(container);
const app = createApp(historyDiffDialog, {
services: options.services,
isConfirm: options.isConfirm,
extendState: options.extendState,
loadConfig: options.loadConfig,
selfDiffFieldTypes: options.selfDiffFieldTypes,
onClose: options.onClose,
});
if (options.appContext) {
Object.assign(app._context, options.appContext);
}
const instance = app.mount(container) as unknown as MountedDiffDialog['instance'];
const destroy = () => {
setTimeout(() => {
try {
app.unmount();
} catch {
// ignore
}
container.parentNode?.removeChild(container);
}, 300);
};
return { instance, destroy };
};
/**
* HistoryDiffDialog
* - resolve(true) / resolve(false)
* -
*/
const confirmRevertWithDiffDialog = async (
payload: DiffDialogPayload,
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
CustomDiffFormOptions & {
services: Pick<Services, 'editorService' | 'dataSourceService' | 'codeBlockService' | 'historyService'>;
},
): Promise<boolean> => {
const { instance, destroy } = await mountHistoryDiffDialog({
...options,
isConfirm: true,
});
try {
return await instance.confirm(payload);
} finally {
destroy();
}
};
/**
* HistoryDiffDialog
* close
*/
const viewHistoryDiffDialog = async (
payload: DiffDialogPayload,
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
CustomDiffFormOptions & {
services: Pick<Services, 'editorService' | 'dataSourceService' | 'codeBlockService' | 'historyService'>;
},
): Promise<void> => {
// onClose 在用户关闭弹窗时才触发,此时 handle.destroy 早已赋值。
const handle: { destroy?: () => void } = {};
const { instance, destroy } = await mountHistoryDiffDialog({
...options,
isConfirm: false,
onClose: () => handle.destroy?.(),
});
handle.destroy = destroy;
instance.open(payload);
};
/**
*
*
* git revert
* 1. update / remove
* 2. update
* 3. service revert / /
*
* HistoryDiffDialog
*
* HistoryDiffDialog
*
*
* - / / `onPageRevert` / `onDataSourceRevert` / `onCodeBlockRevert`
* `onPageDiff` / `onDataSourceDiff` / `onCodeBlockDiff` `buildXxxDiffPayload` / `isXxxRevertTargetMissing`
* - `confirmAndRevert` / `viewDiff`
* payload service.revert
*
* Editor `services` import
*
* ```ts
* import { useHistoryRevert } from '@tmagic/editor';
*
* const { onPageRevert, onPageDiff } = useHistoryRevert(editorRef.value); // editorRef.value 即 Editor 暴露的 services
* await onPageRevert(index); // 弹出差异 / 二次确认弹窗后回滚
* onPageDiff(index); // 弹出只读差异弹窗查看前后值差异
* ```
*/
export const useHistoryRevert = (
services: Pick<Services, 'editorService' | 'dataSourceService' | 'codeBlockService' | 'historyService'>,
options: UseHistoryRevertOptions = {},
) => {
const { editorService, dataSourceService, codeBlockService, historyService } = services;
// 自动捕获调用方所在组件的 appContext在 setup 中调用时),业务方亦可显式覆盖。
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
const { extendState } = options;
/** 目标数据已被删除、无法回滚时的统一提示。 */
const showRevertTargetMissing = () => {
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
};
/**
* / /
*
* false
*/
const confirmRevert = (): Promise<boolean> =>
confirmHistoryAction(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
/**
* HistoryDiffDialog
* add / remove / 退
* `extra`
*/
const runRevert = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise<boolean> => {
if (payload) {
return confirmRevertWithDiffDialog(payload, { appContext, extendState, services, ...extra });
}
return confirmRevert();
};
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'node',
groups: () => historyService.getPageHistoryGroups(),
getCurrent: (id) => editorService.getNodeById(id) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || '',
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
},
index,
);
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'data-source',
groups: () => historyService.getDataSourceHistoryGroups(),
getCurrent: (id) => dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || 'base',
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
},
index,
id,
);
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'code-block',
groups: () => historyService.getCodeBlockHistoryGroups(),
getCurrent: (id) => codeBlockService.getCodeContentById(id) as Record<string, any> | null,
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
},
index,
id,
);
/**
*
* - update
* - remove
* add使
*/
const isPageRevertTargetMissing = (index: number): boolean => {
const step = historyService.getPageStepList()[index]?.step;
if (!step) return false;
if (step.opType === 'update') {
return (step.diff ?? []).some((item) => {
const id = item.newSchema?.id ?? item.oldSchema?.id;
return id !== undefined && !editorService.getNodeById(id, false);
});
}
if (step.opType === 'remove') {
return (step.diff ?? []).some(
(item) => item.parentId !== undefined && !editorService.getNodeById(item.parentId, false),
);
}
return false;
};
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getDataSourceStepList(id)[index]?.step;
return Boolean(step?.opType === 'update' && !dataSourceService.getDataSourceById(`${id}`));
};
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getCodeBlockStepList(id)[index]?.step;
return Boolean(step?.opType === 'update' && !codeBlockService.getCodeContentById(id));
};
const onPageRevert = (index: number) => {
if (isPageRevertTargetMissing(index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildPageDiffPayload(index)).then((result) =>
result ? editorService.revertPageStep(index) : null,
);
};
const onDataSourceRevert = (id: string | number, index: number) => {
if (isDataSourceRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
result ? dataSourceService.revert(id, index) : null,
);
};
const onCodeBlockRevert = (id: string | number, index: number) => {
if (isCodeBlockRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
result ? codeBlockService.revert(id, index) : null,
);
};
/**
* update HistoryDiffDialog
* add / remove /
*/
const onPageDiff = (index: number): Promise<void> | void => {
const payload = buildPageDiffPayload(index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services });
};
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services });
};
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services });
};
/**
*
* // /
* buildXxxDiffPayload + service.revert
*
* null `revert()`
*
* ```ts
* await confirmAndRevert({
* diffPayload: oldSchema && newSchema ? { category: 'module', lastValue: oldSchema, value: newSchema, ... } : null,
* loadConfig: (ctx) => loadModFormConfig(modTypeId, values),
* selfDiffFieldTypes: ['mod-cond'],
* revert: () => moduleHistoryStore.revert(id, index),
* });
* ```
*/
const confirmAndRevert = async <T = unknown>(revertOptions: ConfirmAndRevertOptions<T>): Promise<T | null> => {
if (revertOptions.isTargetMissing?.()) {
showRevertTargetMissing();
return null;
}
const confirmed = await runRevert(revertOptions.diffPayload ?? null, {
loadConfig: revertOptions.loadConfig,
selfDiffFieldTypes: revertOptions.selfDiffFieldTypes,
});
if (!confirmed) return null;
return await revertOptions.revert();
};
/**
*
* `loadConfig` / `selfDiffFieldTypes`payload null
*/
const viewDiff = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise<void> | void => {
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, ...extra });
};
return {
onPageRevert,
onDataSourceRevert,
onCodeBlockRevert,
onPageDiff,
onDataSourceDiff,
onCodeBlockDiff,
buildPageDiffPayload,
buildDataSourceDiffPayload,
buildCodeBlockDiffPayload,
isPageRevertTargetMissing,
isDataSourceRevertTargetMissing,
isCodeBlockRevertTargetMissing,
confirmAndRevert,
viewDiff,
};
};

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import type { Component } from 'vue';
import type { AppContext, Component } from 'vue';
import type EventEmitter from 'events';
import type * as Monaco from 'monaco-editor';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
@ -33,7 +33,7 @@ import type {
MPage,
MPageFragment,
} from '@tmagic/core';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type { ChangeRecord, FormConfig, FormState, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
CanDropIn,
@ -1318,3 +1318,49 @@ export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> ex
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
}
export interface UseHistoryRevertOptions {
/**
* / / provide /
* Element Plus@tmagic/form appContext
* `getCurrentInstance()?.appContext` setup `editorApp._context`
*/
appContext?: AppContext | null;
/**
* `extendState` Editor `extendFormState`
* 使 `display` / `disabled` filterFunction
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}
/**
*
* / / `category`
* `loadConfig`
*/
export interface CustomDiffFormOptions {
/**
* c_type
* CompareForm `category`
*/
loadConfig?: CompareFormLoadConfig;
/** 需要走 self diff 的字段类型(如模块的 mod-cond。 */
selfDiffFieldTypes?: string[];
}
/**
* {@link useHistoryRevert} `confirmAndRevert`
* / / /
*
*/
export interface ConfirmAndRevertOptions<T = unknown> extends CustomDiffFormOptions {
/**
* update
* null / 退add / remove /
*/
diffPayload?: DiffDialogPayload | null;
/** 回滚前置校验:返回 true 表示目标数据已删除等不可回滚,给出统一提示并中止。 */
isTargetMissing?: () => boolean;
/** 用户确认后执行的实际回滚逻辑。 */
revert: () => T | Promise<T>;
}

View File

@ -30,18 +30,16 @@ const editorService = {
get: vi.fn(() => ({ select: vi.fn() })),
};
const services = {
propsService,
dataSourceService,
codeBlockService,
editorService,
} as any;
let capturedShowDiff: ((args: any) => boolean) | undefined;
let capturedFormProps: Record<string, any> = {};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({
propsService,
dataSourceService,
codeBlockService,
editorService,
}),
}));
vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
@ -73,6 +71,7 @@ describe('CompareForm.vue', () => {
type: 'text',
value: { id: 'n1', name: 'new' },
lastValue: { id: 'n1', name: 'old' },
services,
},
global: {
provide: {
@ -94,6 +93,7 @@ describe('CompareForm.vue', () => {
props: {
category: 'node',
value: { id: 'n1' },
services,
},
});
await nextTick();
@ -107,6 +107,7 @@ describe('CompareForm.vue', () => {
type: 'http',
value: { id: 'ds_1', title: 'A' },
lastValue: { id: 'ds_1', title: 'B' },
services,
},
});
await nextTick();
@ -120,6 +121,7 @@ describe('CompareForm.vue', () => {
category: 'code-block',
value: { id: 'cb_1', content: { toString: () => 'fn-body' } },
lastValue: { id: 'cb_1', content: '' },
services,
},
});
await nextTick();
@ -135,6 +137,7 @@ describe('CompareForm.vue', () => {
type: 'text',
value: { id: 'n1' },
height: '400px',
services,
},
});
const style = wrapper.find('.m-editor-compare-form-wrapper').attributes('style') || '';
@ -153,6 +156,7 @@ describe('CompareForm.vue', () => {
type: 'text',
value: { id: 'n1' },
loadConfig,
services,
},
});
await nextTick();
@ -168,6 +172,7 @@ describe('CompareForm.vue', () => {
category: 'node',
type: 'text',
value: { id: 'n1' },
services,
},
});
await nextTick();
@ -202,6 +207,7 @@ describe('CompareForm.vue', () => {
category: 'data-source',
type: 'base',
value: { id: 'ds_1' },
services,
},
});
await nextTick();
@ -219,6 +225,7 @@ describe('CompareForm.vue', () => {
category: 'node',
type: 'text',
value: { id: 'n1' },
services,
},
});
await nextTick();

View File

@ -82,10 +82,12 @@ vi.mock('@editor/layouts/CodeEditor.vue', () => ({
}),
}));
const services = {} as any;
const factory = () =>
mount(HistoryDiffDialog, {
// 让 Teleport 内容内联渲染,便于通过 wrapper 查询
global: { stubs: { teleport: true } },
global: { stubs: { teleport: true }, provide: { services } },
});
const basePayload = (extra: any = {}) => ({
@ -217,7 +219,7 @@ describe('HistoryDiffDialog.vue', () => {
test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => {
const wrapper = mount(HistoryDiffDialog, {
global: { stubs: { teleport: true } },
global: { stubs: { teleport: true }, provide: { services } },
props: { onConfirm: vi.fn() },
});
(wrapper.vm as any).open(basePayload());

View File

@ -9,10 +9,15 @@ import { mount } from '@vue/test-utils';
import historyService from '@editor/services/history';
const { diffDialogOpen, confirmDialogConfirm } = vi.hoisted(() => ({
diffDialogOpen: vi.fn(),
confirmDialogConfirm: vi.fn(async () => true),
}));
const { onPageDiff, onDataSourceDiff, onCodeBlockDiff, onPageRevert, onDataSourceRevert, onCodeBlockRevert } =
vi.hoisted(() => ({
onPageDiff: vi.fn(),
onDataSourceDiff: vi.fn(),
onCodeBlockDiff: vi.fn(),
onPageRevert: vi.fn(),
onDataSourceRevert: vi.fn(),
onCodeBlockRevert: vi.fn(),
}));
const stageSelect = vi.fn();
const overlayStageSelect = vi.fn();
@ -50,6 +55,17 @@ vi.mock('@editor/hooks/use-services', () => ({
}),
}));
vi.mock('@editor/layouts/history-list/useHistoryRevert', () => ({
useHistoryRevert: vi.fn(() => ({
onPageRevert,
onDataSourceRevert,
onCodeBlockRevert,
onPageDiff,
onDataSourceDiff,
onCodeBlockDiff,
})),
}));
vi.mock('@tmagic/design', () => ({
getDesignConfig: vi.fn(() => undefined),
tMagicMessage: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
@ -99,18 +115,6 @@ vi.mock('@editor/components/Icon.vue', () => ({
}),
}));
// 差异对话框有独立的单测HistoryDiffDialog.spec.ts这里 stub 掉以隔离面板自身逻辑,
// 同时避免其内部依赖monaco CodeEditor / CompareForm / 设计层弹窗组件)在本用例下未被 mock 而报错。
vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
default: defineComponent({
name: 'FakeHistoryDiffDialog',
setup(_p, { expose }) {
expose({ open: diffDialogOpen, close: vi.fn(), confirm: confirmDialogConfirm });
return () => h('div', { class: 'fake-history-diff-dialog' });
},
}),
}));
afterEach(() => {
historyService.reset();
vi.clearAllMocks();
@ -393,16 +397,10 @@ describe('HistoryListPanel.vue', () => {
await nextTick();
await wrapper.find('.m-editor-history-list-item-diff').trigger('click');
expect(diffDialogOpen).toHaveBeenCalledWith(
expect.objectContaining({
category: 'node',
targetLabel: '新按钮',
value: expect.objectContaining({ name: '新按钮' }),
}),
);
expect(onPageDiff).toHaveBeenCalledWith(0);
});
test('点击页面 update 记录的「回滚」在确认后调用 revertPageStep', async () => {
test('点击页面 update 记录的「回滚」透传到 onPageRevert', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'update',
@ -420,12 +418,11 @@ describe('HistoryListPanel.vue', () => {
await nextTick();
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
await nextTick();
expect(confirmDialogConfirm).toHaveBeenCalled();
expect(editorService.revertPageStep).toHaveBeenCalledWith(0);
expect(onPageRevert).toHaveBeenCalledWith(0);
expect(editorService.revertPageStep).not.toHaveBeenCalled();
});
test('回滚目标节点已删除时提示错误且不执行 revert', async () => {
test('回滚目标校验由 useHistoryRevert 处理,面板仅透传 index', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'update',
@ -438,15 +435,11 @@ describe('HistoryListPanel.vue', () => {
},
],
} as any);
editorService.getNodeById.mockReturnValueOnce(null);
const { tMagicMessage } = await import('@tmagic/design');
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
await nextTick();
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(onPageRevert).toHaveBeenCalledWith(0);
expect(editorService.revertPageStep).not.toHaveBeenCalled();
});
@ -505,7 +498,7 @@ describe('HistoryListPanel.vue', () => {
historyService.pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', title: '旧 DS' } as any,
newSchema: { id: 'ds_1', title: '新 DS' } as any,
changeRecords: [{ propPath: 'title' }],
changeRecords: [{ propPath: 'title', value: '新 DS' }],
});
const wrapper = await factory();
@ -513,14 +506,11 @@ describe('HistoryListPanel.vue', () => {
const diffBtn = wrapper.find('.m-editor-history-list-item-diff');
await diffBtn.trigger('click');
expect(diffDialogOpen).toHaveBeenCalledWith(
expect.objectContaining({ category: 'data-source', targetLabel: '新 DS' }),
);
expect(onDataSourceDiff).toHaveBeenCalledWith('ds_1', 1);
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
await nextTick();
expect(confirmDialogConfirm).toHaveBeenCalled();
expect(dataSourceService.revert).toHaveBeenCalledWith('ds_1', 1);
expect(onDataSourceRevert).toHaveBeenCalledWith('ds_1', 1);
expect(dataSourceService.revert).not.toHaveBeenCalled();
});
test('确认清空数据源/代码块历史后调用 clearDataSource / clearCodeBlock', async () => {

View File

@ -16,8 +16,8 @@ import {
isPageStepRevertable,
isSingleDiffStepRevertable,
opLabel,
useHistoryList,
} from '@editor/layouts/history-list/composables';
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
import historyService from '@editor/services/history';
import type { PageHistoryGroup, PageHistoryStepEntry, StepValue } from '@editor/type';

View File

@ -0,0 +1,111 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { tMagicMessage } from '@tmagic/design';
import { confirmHistoryAction } from '@editor/layouts/history-list/composables';
import { useHistoryRevert } from '@editor/layouts/history-list/useHistoryRevert';
vi.mock('@tmagic/design', () => ({
tMagicMessage: {
error: vi.fn(),
},
}));
vi.mock('@editor/layouts/history-list/composables', async () => {
const actual = await vi.importActual<object>('@editor/layouts/history-list/composables');
return {
...actual,
confirmHistoryAction: vi.fn(async () => true),
};
});
const createServices = () =>
({
editorService: {
getNodeById: vi.fn(),
revertPageStep: vi.fn(async () => null),
},
dataSourceService: {
getDataSourceById: vi.fn(),
revert: vi.fn(async () => null),
},
codeBlockService: {
getCodeContentById: vi.fn(),
revert: vi.fn(async () => null),
},
historyService: {
getPageStepList: vi.fn(() => []),
getDataSourceStepList: vi.fn(() => []),
getCodeBlockStepList: vi.fn(() => []),
getPageHistoryGroups: vi.fn(() => []),
getDataSourceHistoryGroups: vi.fn(() => []),
getCodeBlockHistoryGroups: vi.fn(() => []),
},
}) as any;
afterEach(() => {
vi.clearAllMocks();
});
describe('useHistoryRevert', () => {
test('页面 update 记录的目标节点已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getPageStepList.mockReturnValue([
{
step: {
opType: 'update',
diff: [{ newSchema: { id: 'gone' }, oldSchema: { id: 'gone' } }],
},
},
]);
services.editorService.getNodeById.mockReturnValue(null);
const { onPageRevert } = useHistoryRevert(services);
await onPageRevert(0);
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(services.editorService.revertPageStep).not.toHaveBeenCalled();
});
test('页面 add 记录回滚时走普通二次确认,并执行 revertPageStep', async () => {
const services = createServices();
services.historyService.getPageStepList.mockReturnValue([
{
step: {
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
},
},
]);
const { onPageRevert } = useHistoryRevert(services);
await onPageRevert(0);
expect(confirmHistoryAction).toHaveBeenCalled();
expect(services.editorService.revertPageStep).toHaveBeenCalledWith(0);
});
test('数据源 update 记录对应目标已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getDataSourceStepList.mockReturnValue([
{
step: {
opType: 'update',
diff: [{ newSchema: { id: 'ds_1' }, oldSchema: { id: 'ds_1' } }],
},
},
]);
services.dataSourceService.getDataSourceById.mockReturnValue(null);
const { onDataSourceRevert } = useHistoryRevert(services);
await onDataSourceRevert('ds_1', 0);
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(services.dataSourceService.revert).not.toHaveBeenCalled();
});
});