fix(editor): 优化历史回滚确认流程

This commit is contained in:
roymondchen 2026-06-09 11:03:57 +08:00
parent a965dfb06e
commit 48519b0155
3 changed files with 268 additions and 188 deletions

View File

@ -1,76 +1,74 @@
<template>
<Teleport to="body">
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
:title="dialogTitle"
top="5vh"
destroy-on-close
append-to-body
:width="width"
@close="onClose"
>
<div v-if="payload" class="m-editor-history-diff-dialog-body">
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
:title="dialogTitle"
top="5vh"
destroy-on-close
append-to-body
:width="width"
@close="onClose"
>
<div v-if="payload && visible" class="m-editor-history-diff-dialog-body">
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<div class="m-editor-history-diff-dialog-controls">
<TMagicRadioGroup v-model="viewMode" size="small" class="m-editor-history-diff-dialog-view">
<TMagicRadioButton value="form">表单对比</TMagicRadioButton>
<TMagicRadioButton value="code">源码对比</TMagicRadioButton>
</TMagicRadioGroup>
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<div class="m-editor-history-diff-dialog-controls">
<TMagicRadioGroup v-model="viewMode" size="small" class="m-editor-history-diff-dialog-view">
<TMagicRadioButton value="form">表单对比</TMagicRadioButton>
<TMagicRadioButton value="code">源码对比</TMagicRadioButton>
</TMagicRadioGroup>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup>
</div>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup>
</div>
<div class="m-editor-history-diff-dialog-legend">
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
<span class="m-editor-history-diff-dialog-arrow"></span>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
当前值与该步修改后一致无差异
</span>
</div>
<CompareForm
v-if="viewMode === 'form'"
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
height="70vh"
/>
<CodeEditor
v-else
type="diff"
language="json"
:init-values="leftValue"
:modified-values="rightValue"
:options="codeDiffOptions"
disabled-full-screen
height="70vh"
/>
</div>
<template #footer>
<template v-if="onConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
<div class="m-editor-history-diff-dialog-legend">
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
<span class="m-editor-history-diff-dialog-arrow"></span>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
当前值与该步修改后一致无差异
</span>
</div>
<CompareForm
v-if="viewMode === 'form'"
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
height="70vh"
/>
<CodeEditor
v-else
type="diff"
language="json"
:init-values="leftValue"
:modified-values="rightValue"
:options="codeDiffOptions"
disabled-full-screen
height="70vh"
/>
</div>
<template #footer>
<template v-if="isConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template>
</TMagicDialog>
</Teleport>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
</template>
</TMagicDialog>
</template>
<script lang="ts" setup>
@ -102,6 +100,7 @@ const props = withDefaults(
*/
loadConfig?: CompareFormLoadConfig;
width?: string;
isConfirm?: boolean;
onConfirm?: () => void;
selfDiffFieldTypes?: string[];
}>(),
@ -178,10 +177,15 @@ const isSameAsCurrent = computed(() => {
return isEqual(payload.value.value, payload.value.currentValue);
});
const onConfirmClick = () => {
const cb = props.onConfirm;
/** confirm() 的 resolve仅在「等待用户确认回滚」期间存在 */
let confirmResolve: ((_value: boolean) => void) | null = null;
cb?.();
const onConfirmClick = () => {
props.onConfirm?.();
// resolve(true) visible=false resolve(false)
confirmResolve?.(true);
confirmResolve = null;
visible.value = false;
};
@ -210,6 +214,24 @@ const open = (p: DiffDialogPayload) => {
visible.value = true;
};
/**
* Promise 形式打开确认回滚弹窗
* - 用户点击确定回滚 resolve(true)
* - 取消 / 关闭 / Esc 等其他方式关闭弹窗时 resolve(false)
*
* 同一时刻只允许一个待确认流程重复调用会先 resolve(false) 掉上一个
*/
const confirm = (p: DiffDialogPayload): Promise<boolean> => {
// Promise
confirmResolve?.(false);
confirmResolve = null;
return new Promise<boolean>((resolve) => {
confirmResolve = resolve;
open(p);
});
};
const close = () => {
visible.value = false;
};
@ -218,6 +240,9 @@ const close = () => {
watch(visible, (v) => {
if (!v) {
payload.value = null;
// / Esc / resolve(false)
confirmResolve?.(false);
confirmResolve = null;
}
});
@ -227,6 +252,7 @@ const onClose = () => {
defineExpose({
open,
confirm,
close,
});
</script>

View File

@ -90,12 +90,8 @@
</template>
</TMagicPopover>
<HistoryDiffDialog
ref="diffDialog"
:extend-state="extendFormState"
:on-confirm="onConfirmRevert"
@close="onDiffDialogClose"
/>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
<HistoryDiffDialog ref="confirmDialog" :is-confirm="true" :extend-state="extendFormState" />
</template>
<script lang="ts" setup>
@ -120,12 +116,13 @@
* 通过 title / prefix / describe* / isStepDiffable
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import {
getDesignConfig,
TMagicButton,
tMagicMessage,
tMagicMessageBox,
TMagicPopover,
TMagicTabs,
@ -271,87 +268,90 @@ const onCodeBlockGotoInitial = (id: string | number) => {
};
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
const confirmDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('confirmDialog');
/**
* 构造页面 step 的差异弹窗入参 update 单节点修改可对比传入旧/新节点
* 节点类型 `type` 优先取 newSchema.type再回退 oldSchema.type
* `currentValue` 取自 editorService 中该节点当前实际值用于支持与当前对比
* 无可对比内容如多节点 / add / remove时返回 null
* 三类历史页面 / 数据源 / 代码块差异弹窗入参的构造差异收敛为一份配置
* 分组来源当前值读取类型 / 展示名提取不同定位 step校验前后值组装 payload 的流程共用
*/
const buildPageDiffPayload = (index: number): DiffDialogPayload | null => {
const groups = historyService.getPageHistoryGroups();
for (const group of groups) {
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const item = entry.step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return null;
const type = (item.newSchema.type as string) || (item.oldSchema.type as string) || '';
const nodeId = item.newSchema.id ?? item.oldSchema.id;
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
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: 'node',
type,
lastValue: item.oldSchema as Record<string, any>,
value: item.newSchema as Record<string, any>,
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newSchema.name as string) || (item.oldSchema.name as string) || type,
id: nodeId,
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;
};
/**
* 在指定分组列表中按 id / index 查找命中的 step命中后交由 build 构造差异弹窗入参
* 用于统一数据源代码块两类历史的查找逻辑
*/
const findGroupStep = <G extends { id: string | number; steps: { index: number; step: any }[] }>(
groups: G[],
id: string | number,
index: number,
build: (_step: G['steps'][number]['step']) => DiffDialogPayload | null,
): DiffDialogPayload | null => {
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
return build(entry.step);
}
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 =>
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, (step) => {
const oldSchema = step.diff?.[0]?.oldSchema;
const newSchema = step.diff?.[0]?.newSchema;
if (!oldSchema || !newSchema) return null;
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
return {
buildDiffPayload(
{
category: 'data-source',
type: newSchema.type || oldSchema.type || 'base',
lastValue: oldSchema as Record<string, any>,
value: newSchema as Record<string, any>,
currentValue: (currentSchema as Record<string, any>) || null,
targetLabel: newSchema.title || oldSchema.title || `${id}`,
id,
};
});
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 =>
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, (step) => {
const oldContent = step.diff?.[0]?.oldSchema;
const newContent = step.diff?.[0]?.newSchema;
if (!oldContent || !newContent) return null;
const currentContent = codeBlockService.getCodeContentById(id);
return {
buildDiffPayload(
{
category: 'code-block',
lastValue: oldContent as Record<string, any>,
value: newContent as Record<string, any>,
currentValue: (currentContent as Record<string, any>) || null,
targetLabel: newContent.name || oldContent.name || `${id}`,
id,
};
});
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);
@ -368,50 +368,92 @@ const onCodeBlockDiff = (id: string | number, index: number) => {
if (payload) diffDialogRef.value?.open(payload);
};
const onConfirmRevert = shallowRef();
/**
* 回滚入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 回滚统一入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*
* 交互先弹出该步骤的差异弹窗供用户确认点击确定回滚后再真正执行回滚
* 对没有可对比内容的步骤 add / remove / 多节点更新则直接回滚
* 交互
* - 可差异对比的步骤单节点 / 单实体 update弹出差异弹窗供用户确认确定回滚再执行
* - 无法对比的步骤add / remove / 多节点更新payload null弹出普通二次确认框确认后执行
*
* 页面 / 数据源 / 代码块三类回滚仅差异入参构造实际 revert 调用不同
* 由调用方分别传入 payload revert公共的弹窗 / 确认流程在此收敛
*/
const onPageRevert = async (index: number) => {
const payload = buildPageDiffPayload(index);
const revert = () => editorService.revertPageStep(index);
onConfirmRevert.value = revert;
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else if (await confirmRevert()) {
revert();
const runRevert = (payload: DiffDialogPayload | null): Promise<boolean> => {
if (payload && confirmDialogRef.value) {
return confirmDialogRef.value.confirm(payload);
}
return confirmRevert();
};
const onDataSourceRevert = async (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
const revert = () => dataSourceService.revert(id, index);
onConfirmRevert.value = revert;
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else if (await confirmRevert()) {
revert();
/**
* 回滚前置校验若该历史步骤回滚所依赖的目标数据已被删除则无法回滚
* - 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;
};
const onCodeBlockRevert = async (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
const revert = () => codeBlockService.revert(id, index);
onConfirmRevert.value = revert;
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else if (await confirmRevert()) {
revert();
}
/** 数据源 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}`));
};
const onDiffDialogClose = () => {
onConfirmRevert.value = undefined;
/** 代码块 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,
);
};
/**
@ -420,14 +462,15 @@ const onDiffDialogClose = () => {
* 用户取消时返回 false调用方据此中止回滚
*/
const confirmRevert = (): Promise<boolean> =>
confirmClear('确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。');
confirmDialog(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
/**
* 清空历史记录入口先弹出二次确认确认后清空对应类别的历史栈
* 仅删除撤销/重做记录不会改动当前 DSL / 数据源 / 代码块本身
* 用户取消confirm reject时静默忽略
* 通用二次确认弹窗清空历史 / 无法差异对比的回滚等会改变状态的操作先弹出确认框
* 用户点击确定返回 true取消confirm reject时返回 false 并静默忽略
*/
const confirmClear = async (message: string): Promise<boolean> => {
const confirmDialog = async (message: string): Promise<boolean> => {
try {
await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
@ -457,7 +500,7 @@ const syncIndexedDB = async () => {
const onPageClear = async () => {
if (
await confirmClear('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
await confirmDialog('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearPage();
await syncIndexedDB();
@ -465,14 +508,18 @@ const onPageClear = async () => {
};
const onDataSourceClear = async () => {
if (await confirmClear('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) {
if (
await confirmDialog('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearDataSource();
await syncIndexedDB();
}
};
const onCodeBlockClear = async () => {
if (await confirmClear('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) {
if (
await confirmDialog('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearCodeBlock();
await syncIndexedDB();
}

View File

@ -82,25 +82,32 @@ type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id
*/
const describeStepForRevert = (step: StepValue): string => {
const items = step.diff ?? [];
// 在可读名后拼接组件 id便于在历史面板中精确定位被回滚的组件id 缺失时退化为仅展示名称。
const withId = (node: MNode | undefined, label: string): string => {
const id = node?.id;
if (id === undefined || id === null || `${id}` === '') return label;
return label ? `${label}id: ${id}` : `id: ${id}`;
};
switch (step.opType) {
case 'add': {
const count = items.length;
const node = items[0]?.newSchema;
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `撤回新增 ${count} 个节点${count === 1 && label ? `${label}` : ''}`;
const label = node?.name || node?.type || '';
return `撤回新增 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'remove': {
const count = items.length;
const node = items[0]?.oldSchema;
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `还原已删除的 ${count} 个节点${count === 1 && label ? `${label}` : ''}`;
const label = node?.name || node?.type || '';
return `还原已删除的 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'update':
default: {
if (items.length === 1) {
const { newSchema, oldSchema, changeRecords } = items[0];
const target =
newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || `${newSchema?.id ?? ''}`;
const node = newSchema || oldSchema;
const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || '';
const target = withId(node, label);
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}