mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-01 05:28:14 +08:00
refactor(editor): 抽离历史记录回滚交互并开放复用入口
This commit is contained in:
parent
38065f013f
commit
9f2fa1a9c8
@ -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` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用二次确认弹窗:清空历史 / 无法差异对比的回滚等会改变状态的操作,先弹出确认框,
|
||||
* 用户点击「确定」返回 true,取消(confirm reject)时返回 false 并静默忽略。
|
||||
*/
|
||||
export const confirmHistoryAction = async (message: string): Promise<boolean> => {
|
||||
try {
|
||||
await tMagicMessageBox.confirm(message, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
60
packages/editor/src/layouts/history-list/useHistoryList.ts
Normal file
60
packages/editor/src/layouts/history-list/useHistoryList.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
400
packages/editor/src/layouts/history-list/useHistoryRevert.ts
Normal file
400
packages/editor/src/layouts/history-list/useHistoryRevert.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user