mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
feat(editor): 历史记录面板支持单步回滚(类 git revert)
将目标历史步骤的修改作为一次新操作反向应用,不破坏原有栈结构, page/dataSource/codeBlock 三类 service 均提供 revert 能力; 面板新增关闭按钮、步骤编号展示与合并组卡片样式优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f0c66427b8
commit
b02aa75ddc
@ -34,7 +34,7 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close>
|
<TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close>
|
||||||
<div style="display: flex; margin-bottom: 10px">
|
<div style="display: flex; margin-bottom: 10px">
|
||||||
<div style="flex: 1"><TMagicTag size="small" type="info">修改前</TMagicTag></div>
|
<div style="flex: 1"><TMagicTag size="small" type="danger">修改前</TMagicTag></div>
|
||||||
<div style="flex: 1"><TMagicTag size="small" type="success">修改后</TMagicTag></div>
|
<div style="flex: 1"><TMagicTag size="small" type="success">修改后</TMagicTag></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -139,6 +139,7 @@ const wrapperStyle = computed(() => {
|
|||||||
*/
|
*/
|
||||||
const isEmptyCodeSelectValue = (v: any): boolean => {
|
const isEmptyCodeSelectValue = (v: any): boolean => {
|
||||||
if (v === '' || v === undefined || v === null) return true;
|
if (v === '' || v === undefined || v === null) return true;
|
||||||
|
if (Array.isArray(v) && v.length === 0) return true;
|
||||||
return typeof v === 'object' && v.hookType === HookType.CODE && Array.isArray(v.hookData) && v.hookData.length === 0;
|
return typeof v === 'object' && v.hookType === HookType.CODE && Array.isArray(v.hookData) && v.hookData.length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
isCurrent: s.isCurrent,
|
isCurrent: s.isCurrent,
|
||||||
desc: describeStep(s.step),
|
desc: describeStep(s.step),
|
||||||
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
|
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
|
||||||
|
revertable: s.applied,
|
||||||
}))
|
}))
|
||||||
"
|
"
|
||||||
:is-current="group.isCurrent"
|
:is-current="group.isCurrent"
|
||||||
@ -30,6 +31,7 @@
|
|||||||
@toggle="(key: string) => $emit('toggle', key)"
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
@goto="(index: number) => $emit('goto', bucketId, index)"
|
@goto="(index: number) => $emit('goto', bucketId, index)"
|
||||||
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
|
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
|
||||||
|
@revert-step="(index: number) => $emit('revert-step', bucketId, index)"
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
初始状态项:永远位于该 bucket 列表底部(同样按倒序展示,最底部 = 最早状态)。
|
初始状态项:永远位于该 bucket 列表底部(同样按倒序展示,最底部 = 最早状态)。
|
||||||
@ -88,6 +90,8 @@ defineEmits<{
|
|||||||
(_e: 'goto-initial', _bucketId: string | number): void;
|
(_e: 'goto-initial', _bucketId: string | number): void;
|
||||||
/** 用户点击"查看差异",携带 bucketId 与 step 索引。 */
|
/** 用户点击"查看差异",携带 bucketId 与 step 索引。 */
|
||||||
(_e: 'diff-step', _bucketId: string | number, _index: number): void;
|
(_e: 'diff-step', _bucketId: string | number, _index: number): void;
|
||||||
|
/** 用户点击"回滚"按钮,携带 bucketId 与 step 索引,类 git revert。 */
|
||||||
|
(_e: 'revert-step', _bucketId: string | number, _index: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** 该 bucket 是否处于初始状态(栈 cursor=0),等价于全部 group 都未 applied。 */
|
/** 该 bucket 是否处于初始状态(栈 cursor=0),等价于全部 group 都未 applied。 */
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
|
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
|
||||||
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
|
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
|
||||||
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
|
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
|
||||||
|
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
|
||||||
/>
|
/>
|
||||||
</TMagicScrollbar>
|
</TMagicScrollbar>
|
||||||
</template>
|
</template>
|
||||||
@ -51,6 +52,8 @@ defineEmits<{
|
|||||||
(_e: 'goto-initial', _codeBlockId: string | number): void;
|
(_e: 'goto-initial', _codeBlockId: string | number): void;
|
||||||
/** 透传 Bucket 的 diff-step 事件,携带 codeBlock id 与 step 索引。 */
|
/** 透传 Bucket 的 diff-step 事件,携带 codeBlock id 与 step 索引。 */
|
||||||
(_e: 'diff-step', _codeBlockId: string | number, _index: number): void;
|
(_e: 'diff-step', _codeBlockId: string | number, _index: number): void;
|
||||||
|
/** 透传 Bucket 的 revert-step 事件,携带 codeBlock id 与 step 索引(类 git revert)。 */
|
||||||
|
(_e: 'revert-step', _codeBlockId: string | number, _index: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** 仅 update(前后 content 都存在)时可查看差异。 */
|
/** 仅 update(前后 content 都存在)时可查看差异。 */
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
|
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
|
||||||
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
|
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
|
||||||
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
|
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
|
||||||
|
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
|
||||||
/>
|
/>
|
||||||
</TMagicScrollbar>
|
</TMagicScrollbar>
|
||||||
</template>
|
</template>
|
||||||
@ -51,6 +52,8 @@ defineEmits<{
|
|||||||
(_e: 'goto-initial', _dataSourceId: string | number): void;
|
(_e: 'goto-initial', _dataSourceId: string | number): void;
|
||||||
/** 透传 Bucket 的 diff-step 事件,携带 dataSource id 与 step 索引。 */
|
/** 透传 Bucket 的 diff-step 事件,携带 dataSource id 与 step 索引。 */
|
||||||
(_e: 'diff-step', _dataSourceId: string | number, _index: number): void;
|
(_e: 'diff-step', _dataSourceId: string | number, _index: number): void;
|
||||||
|
/** 透传 Bucket 的 revert-step 事件,携带 dataSource id 与 step 索引(类 git revert)。 */
|
||||||
|
(_e: 'revert-step', _dataSourceId: string | number, _index: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** 仅 update(前后 schema 都存在)时可查看差异。 */
|
/** 仅 update(前后 schema 都存在)时可查看差异。 */
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
:title="headTitle"
|
:title="headTitle"
|
||||||
@click="onHeadClick"
|
@click="onHeadClick"
|
||||||
>
|
>
|
||||||
|
<span class="m-editor-history-list-item-index" :title="headIndexTitle">{{ headIndexLabel }}</span>
|
||||||
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
|
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
|
||||||
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||||
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||||
@ -20,12 +21,19 @@
|
|||||||
>查看差异</span
|
>查看差异</span
|
||||||
>
|
>
|
||||||
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} 步</span>
|
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} 步</span>
|
||||||
|
<span
|
||||||
|
v-if="!merged && headRevertable"
|
||||||
|
class="m-editor-history-list-item-revert"
|
||||||
|
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||||
|
@click.stop="onRevertClick(subSteps[0].index)"
|
||||||
|
>回滚</span
|
||||||
|
>
|
||||||
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }">▾</span>
|
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }">▾</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
|
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
|
||||||
<li
|
<li
|
||||||
v-for="s in subSteps"
|
v-for="s in subStepsDisplay"
|
||||||
:key="s.index"
|
:key="s.index"
|
||||||
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': !s.isCurrent }"
|
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': !s.isCurrent }"
|
||||||
:title="s.isCurrent ? '当前所在记录' : '点击跳转到该记录'"
|
:title="s.isCurrent ? '当前所在记录' : '点击跳转到该记录'"
|
||||||
@ -41,6 +49,13 @@
|
|||||||
@click.stop="onDiffClick(s.index)"
|
@click.stop="onDiffClick(s.index)"
|
||||||
>查看差异</span
|
>查看差异</span
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="s.revertable"
|
||||||
|
class="m-editor-history-list-item-revert"
|
||||||
|
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||||
|
@click.stop="onRevertClick(s.index)"
|
||||||
|
>回滚</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@ -71,7 +86,15 @@ const props = defineProps<{
|
|||||||
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
|
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
|
||||||
stepCount: number;
|
stepCount: number;
|
||||||
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
|
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
|
||||||
subSteps: { index: number; applied: boolean; desc: string; isCurrent?: boolean; diffable?: boolean }[];
|
subSteps: {
|
||||||
|
index: number;
|
||||||
|
applied: boolean;
|
||||||
|
desc: string;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
diffable?: boolean;
|
||||||
|
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
|
||||||
|
revertable?: boolean;
|
||||||
|
}[];
|
||||||
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
|
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
|
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
|
||||||
@ -99,6 +122,11 @@ const emit = defineEmits<{
|
|||||||
* payload 为该 step 在所属栈中的索引,由上层根据 index 取 step 内容并展示对比。
|
* payload 为该 step 在所属栈中的索引,由上层根据 index 取 step 内容并展示对比。
|
||||||
*/
|
*/
|
||||||
(_e: 'diff-step', _index: number): void;
|
(_e: 'diff-step', _index: number): void;
|
||||||
|
/**
|
||||||
|
* 用户希望「回滚」该 step——把它的修改作为一次新操作反向应用(类 git revert)。
|
||||||
|
* payload 为该 step 在所属栈中的索引。仅在单步组头部(headRevertable)或合并组的可回滚子步上触发。
|
||||||
|
*/
|
||||||
|
(_e: 'revert-step', _index: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** 单步组:头部可点击 goto;合并组:头部可点击切换展开。当前组(isCurrent)的单步组头部不可点击。 */
|
/** 单步组:头部可点击 goto;合并组:头部可点击切换展开。当前组(isCurrent)的单步组头部不可点击。 */
|
||||||
@ -136,7 +164,44 @@ const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
|
|||||||
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
|
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
|
||||||
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
|
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
|
||||||
|
|
||||||
|
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
|
||||||
|
const headRevertable = computed(() => !props.merged && Boolean(props.subSteps[0]?.revertable));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并组展开后的子步渲染顺序:与外层分组列表保持一致——倒序展示(最新的子步在最上方)。
|
||||||
|
* 外层 page tab / bucket 都已对 groups 做了 reverse,子步沿用同样的视觉规则更直观。
|
||||||
|
* 注意:仅用于渲染,原 `subSteps` 保持时间正序,`headIndexLabel` 等基于首尾索引的展示语义不变。
|
||||||
|
*/
|
||||||
|
const subStepsDisplay = computed(() => props.subSteps.slice().reverse());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部索引展示:
|
||||||
|
* - 单步组(merged=false):显示该唯一 step 的编号,如 `#5`;
|
||||||
|
* - 合并组:显示组内 step 的编号范围,如 `#3-#7`(首尾相同则退化为 `#5`)。
|
||||||
|
*
|
||||||
|
* 这里展示的是 step.index + 1(与子步列表 `#{{ s.index + 1 }}` 保持一致),从 1 起编号更符合直觉。
|
||||||
|
*/
|
||||||
|
const headIndexLabel = computed(() => {
|
||||||
|
const list = props.subSteps;
|
||||||
|
if (!list.length) return '';
|
||||||
|
const first = list[0].index + 1;
|
||||||
|
const last = list[list.length - 1].index + 1;
|
||||||
|
if (!props.merged || first === last) return `#${first}`;
|
||||||
|
return `#${first}-#${last}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headIndexTitle = computed(() => {
|
||||||
|
if (!props.merged) return `历史步骤编号 #${props.subSteps[0]?.index + 1}`;
|
||||||
|
return `合并了第 ${props.subSteps[0]?.index + 1} 至第 ${
|
||||||
|
props.subSteps[props.subSteps.length - 1]?.index + 1
|
||||||
|
} 共 ${props.subSteps.length} 条历史步骤`;
|
||||||
|
});
|
||||||
|
|
||||||
const onDiffClick = (index: number) => {
|
const onDiffClick = (index: number) => {
|
||||||
emit('diff-step', index);
|
emit('diff-step', index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onRevertClick = (index: number) => {
|
||||||
|
emit('revert-step', index);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-editor-history-diff-dialog-legend">
|
<div class="m-editor-history-diff-dialog-legend">
|
||||||
<TMagicTag size="small" type="info">{{ leftLabel }}</TMagicTag>
|
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
|
||||||
<span class="m-editor-history-diff-dialog-arrow">→</span>
|
<span class="m-editor-history-diff-dialog-arrow">→</span>
|
||||||
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
|
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
|
||||||
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
|
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
|
||||||
|
|||||||
@ -1,6 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<TMagicPopover popper-class="m-editor-history-list-popover" placement="bottom" trigger="click" :width="660">
|
<TMagicPopover
|
||||||
|
popper-class="m-editor-history-list-popover"
|
||||||
|
placement="bottom"
|
||||||
|
trigger="click"
|
||||||
|
:visible="visible"
|
||||||
|
:width="660"
|
||||||
|
>
|
||||||
<div class="m-editor-history-list">
|
<div class="m-editor-history-list">
|
||||||
|
<TMagicTooltip effect="dark" placement="top" content="关闭">
|
||||||
|
<TMagicButton class="m-editor-history-list-close" size="small" link @click="visible = false">
|
||||||
|
<template #icon>
|
||||||
|
<MIcon :icon="CloseIcon"></MIcon>
|
||||||
|
</template>
|
||||||
|
</TMagicButton>
|
||||||
|
</TMagicTooltip>
|
||||||
|
|
||||||
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
|
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
|
||||||
<component
|
<component
|
||||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
@ -13,6 +27,7 @@
|
|||||||
@goto="onPageGoto"
|
@goto="onPageGoto"
|
||||||
@goto-initial="onPageGotoInitial"
|
@goto-initial="onPageGotoInitial"
|
||||||
@diff-step="onPageDiff"
|
@diff-step="onPageDiff"
|
||||||
|
@revert-step="onPageRevert"
|
||||||
/>
|
/>
|
||||||
</component>
|
</component>
|
||||||
|
|
||||||
@ -27,6 +42,7 @@
|
|||||||
@goto="onDataSourceGoto"
|
@goto="onDataSourceGoto"
|
||||||
@goto-initial="onDataSourceGotoInitial"
|
@goto-initial="onDataSourceGotoInitial"
|
||||||
@diff-step="onDataSourceDiff"
|
@diff-step="onDataSourceDiff"
|
||||||
|
@revert-step="onDataSourceRevert"
|
||||||
/>
|
/>
|
||||||
</component>
|
</component>
|
||||||
|
|
||||||
@ -41,6 +57,7 @@
|
|||||||
@goto="onCodeBlockGoto"
|
@goto="onCodeBlockGoto"
|
||||||
@goto-initial="onCodeBlockGotoInitial"
|
@goto-initial="onCodeBlockGotoInitial"
|
||||||
@diff-step="onCodeBlockDiff"
|
@diff-step="onCodeBlockDiff"
|
||||||
|
@revert-step="onCodeBlockRevert"
|
||||||
/>
|
/>
|
||||||
</component>
|
</component>
|
||||||
</TMagicTabs>
|
</TMagicTabs>
|
||||||
@ -48,7 +65,7 @@
|
|||||||
|
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<TMagicTooltip effect="dark" placement="bottom" content="历史记录">
|
<TMagicTooltip effect="dark" placement="bottom" content="历史记录">
|
||||||
<TMagicButton size="small" link>
|
<TMagicButton size="small" link @click="visible = !visible">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<MIcon :icon="ClockIcon"></MIcon>
|
<MIcon :icon="ClockIcon"></MIcon>
|
||||||
</template>
|
</template>
|
||||||
@ -82,7 +99,7 @@
|
|||||||
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
||||||
*/
|
*/
|
||||||
import { inject, markRaw, ref, useTemplateRef } from 'vue';
|
import { inject, markRaw, ref, useTemplateRef } from 'vue';
|
||||||
import { Clock } from '@element-plus/icons-vue';
|
import { Clock, Close } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
||||||
import type { FormState } from '@tmagic/form';
|
import type { FormState } from '@tmagic/form';
|
||||||
@ -101,8 +118,12 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ClockIcon = markRaw(Clock);
|
const ClockIcon = markRaw(Clock);
|
||||||
|
const CloseIcon = markRaw(Close);
|
||||||
const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
|
const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
|
||||||
|
|
||||||
|
/** 面板显隐受控:reference 图标点击切换,右上角关闭按钮置为 false。 */
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
const tabPaneComponent = getDesignConfig('components')?.tabPane;
|
const tabPaneComponent = getDesignConfig('components')?.tabPane;
|
||||||
|
|
||||||
const { editorService, dataSourceService, codeBlockService, historyService } = useServices();
|
const { editorService, dataSourceService, codeBlockService, historyService } = useServices();
|
||||||
@ -159,6 +180,22 @@ const onCodeBlockGotoInitial = (id: string | number) => {
|
|||||||
codeBlockService.goto(id, 0);
|
codeBlockService.goto(id, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」入口:把目标历史步骤的修改作为一次新操作反向应用(类 git revert),
|
||||||
|
* 不破坏原有栈结构。各 service 内部完成反向 + 入栈,并自带描述用于面板展示。
|
||||||
|
*/
|
||||||
|
const onPageRevert = (index: number) => {
|
||||||
|
editorService.revertPageStep(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceRevert = (id: string | number, index: number) => {
|
||||||
|
dataSourceService.revert(id, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockRevert = (id: string | number, index: number) => {
|
||||||
|
codeBlockService.revert(id, index);
|
||||||
|
};
|
||||||
|
|
||||||
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
|
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
|
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
|
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0(未修改的初始状态)">#0</span>
|
||||||
<span class="m-editor-history-list-item-op op-initial">初始</span>
|
<span class="m-editor-history-list-item-op op-initial">初始</span>
|
||||||
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
|
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
|
||||||
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
isCurrent: s.isCurrent,
|
isCurrent: s.isCurrent,
|
||||||
desc: describePageStep(s.step),
|
desc: describePageStep(s.step),
|
||||||
diffable: isPageStepDiffable(s.step),
|
diffable: isPageStepDiffable(s.step),
|
||||||
|
revertable: s.applied,
|
||||||
}))
|
}))
|
||||||
"
|
"
|
||||||
:is-current="group.isCurrent"
|
:is-current="group.isCurrent"
|
||||||
@ -25,6 +26,7 @@
|
|||||||
@toggle="(key: string) => $emit('toggle', key)"
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
@goto="(index: number) => $emit('goto', index)"
|
@goto="(index: number) => $emit('goto', index)"
|
||||||
@diff-step="(index: number) => $emit('diff-step', index)"
|
@diff-step="(index: number) => $emit('diff-step', index)"
|
||||||
|
@revert-step="(index: number) => $emit('revert-step', index)"
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
初始状态项:永远位于列表底部(页面 tab 倒序展示,最底部=最早),
|
初始状态项:永远位于列表底部(页面 tab 倒序展示,最底部=最早),
|
||||||
@ -66,6 +68,8 @@ defineEmits<{
|
|||||||
(_e: 'goto-initial'): void;
|
(_e: 'goto-initial'): void;
|
||||||
/** 用户点击"查看差异"按钮,携带目标 step 在栈中的索引。 */
|
/** 用户点击"查看差异"按钮,携带目标 step 在栈中的索引。 */
|
||||||
(_e: 'diff-step', _index: number): void;
|
(_e: 'diff-step', _index: number): void;
|
||||||
|
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
|
||||||
|
(_e: 'revert-step', _index: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -48,6 +48,18 @@ const canUsePluginMethods = {
|
|||||||
|
|
||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」生成的新 step 简短描述。仅 service 层使用。
|
||||||
|
*/
|
||||||
|
const describeRevertCodeBlockStep = (step: CodeBlockStepValue): string => {
|
||||||
|
const { oldContent, newContent, changeRecords, id } = step;
|
||||||
|
if (oldContent === null && newContent) return `撤回新增 ${newContent.name || newContent.id || id}`;
|
||||||
|
if (oldContent && newContent === null) return `还原已删除的 ${oldContent.name || oldContent.id || id}`;
|
||||||
|
const name = newContent?.name || oldContent?.name || `${id}`;
|
||||||
|
const propPath = changeRecords?.[0]?.propPath;
|
||||||
|
return propPath ? `还原 ${name} · ${propPath}` : `还原 ${name}`;
|
||||||
|
};
|
||||||
|
|
||||||
class CodeBlock extends BaseService {
|
class CodeBlock extends BaseService {
|
||||||
private state = reactive<CodeState>({
|
private state = reactive<CodeState>({
|
||||||
codeDsl: null,
|
codeDsl: null,
|
||||||
@ -347,6 +359,24 @@ class CodeBlock extends BaseService {
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」指定代码块历史步骤(类 git revert 语义):
|
||||||
|
* - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
|
||||||
|
* - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
|
||||||
|
* - 仅对已应用的步骤生效。
|
||||||
|
*
|
||||||
|
* @param id 代码块 id
|
||||||
|
* @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
|
||||||
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
|
*/
|
||||||
|
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
|
||||||
|
const list = historyService.getCodeBlockStepList(id);
|
||||||
|
const entry = list[index];
|
||||||
|
if (!entry?.applied) return null;
|
||||||
|
const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`;
|
||||||
|
return await this.applyRevertStep(entry.step, description);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成代码块唯一id
|
* 生成代码块唯一id
|
||||||
* @returns {Id} 代码块唯一id
|
* @returns {Id} 代码块唯一id
|
||||||
@ -429,6 +459,55 @@ class CodeBlock extends BaseService {
|
|||||||
super.usePlugin(options);
|
super.usePlugin(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反向应用一个 step 并以新 step 入栈。逻辑与 applyHistoryStep(reverse=true) 同构,
|
||||||
|
* 差异仅在于通过公开的 setCodeDslByIdSync / deleteCodeDslByIds 触发 push。
|
||||||
|
*/
|
||||||
|
private async applyRevertStep(
|
||||||
|
step: CodeBlockStepValue,
|
||||||
|
historyDescription: string,
|
||||||
|
): Promise<CodeBlockStepValue | null> {
|
||||||
|
const { id, oldContent, newContent, changeRecords } = step;
|
||||||
|
|
||||||
|
// 原本是新增 → revert 即删除
|
||||||
|
if (oldContent === null && newContent) {
|
||||||
|
await this.deleteCodeDslByIds([id], { historyDescription });
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原本是删除 → revert 即写回
|
||||||
|
if (oldContent && newContent === null) {
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldContent || !newContent) return null;
|
||||||
|
|
||||||
|
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const current = this.getCodeContentById(id);
|
||||||
|
if (!current) return null;
|
||||||
|
const patched = cloneDeep(current) as CodeBlockContent;
|
||||||
|
let fallbackToFullReplace = false;
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
fallbackToFullReplace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent));
|
||||||
|
setValueByKeyPath(record.propPath, value, patched);
|
||||||
|
}
|
||||||
|
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
|
||||||
|
changeRecords,
|
||||||
|
historyDescription,
|
||||||
|
});
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一条历史 step 应用到当前代码块服务上。
|
* 把一条历史 step 应用到当前代码块服务上。
|
||||||
*
|
*
|
||||||
|
|||||||
@ -54,6 +54,19 @@ const canUsePluginMethods = {
|
|||||||
|
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」生成的新 step 简短描述。
|
||||||
|
* 仅在 service 层使用,避免依赖 UI 层 composables。
|
||||||
|
*/
|
||||||
|
const describeRevertDataSourceStep = (step: DataSourceStepValue): string => {
|
||||||
|
const { oldSchema, newSchema, changeRecords, id } = step;
|
||||||
|
if (oldSchema === null && newSchema) return `撤回新增 ${newSchema.title || newSchema.id || id}`;
|
||||||
|
if (oldSchema && newSchema === null) return `还原已删除的 ${oldSchema.title || oldSchema.id || id}`;
|
||||||
|
const title = newSchema?.title || oldSchema?.title || `${id}`;
|
||||||
|
const propPath = changeRecords?.[0]?.propPath;
|
||||||
|
return propPath ? `还原 ${title} · ${propPath}` : `还原 ${title}`;
|
||||||
|
};
|
||||||
|
|
||||||
class DataSource extends BaseService {
|
class DataSource extends BaseService {
|
||||||
private state = reactive<State>({
|
private state = reactive<State>({
|
||||||
datasourceTypeList: [],
|
datasourceTypeList: [],
|
||||||
@ -251,6 +264,24 @@ class DataSource extends BaseService {
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」指定数据源历史步骤(类 git revert 语义):
|
||||||
|
* - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
|
||||||
|
* - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
|
||||||
|
* - 仅对已应用的步骤生效——未应用步骤本身就不存在于当前 schema 中,反向无意义。
|
||||||
|
*
|
||||||
|
* @param id 数据源 id
|
||||||
|
* @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
|
||||||
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
|
*/
|
||||||
|
public revert(id: Id, index: number): DataSourceStepValue | null {
|
||||||
|
const list = historyService.getDataSourceStepList(id);
|
||||||
|
const entry = list[index];
|
||||||
|
if (!entry?.applied) return null;
|
||||||
|
const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`;
|
||||||
|
return this.applyRevertStep(entry.step, description);
|
||||||
|
}
|
||||||
|
|
||||||
public createId(): string {
|
public createId(): string {
|
||||||
return `ds_${guid()}`;
|
return `ds_${guid()}`;
|
||||||
}
|
}
|
||||||
@ -326,6 +357,52 @@ class DataSource extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反向应用一个 step 并以新 step 入栈(不带 doNotPushHistory)。逻辑与 applyHistoryStep(reverse=true)
|
||||||
|
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
|
||||||
|
*/
|
||||||
|
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
|
||||||
|
const { id, oldSchema, newSchema, changeRecords } = step;
|
||||||
|
|
||||||
|
// 原本是新增 → revert 即删除
|
||||||
|
if (oldSchema === null && newSchema) {
|
||||||
|
this.remove(`${id}`, { historyDescription });
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原本是删除 → revert 即重新加回
|
||||||
|
if (oldSchema && newSchema === null) {
|
||||||
|
this.add(cloneDeep(oldSchema), { historyDescription });
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldSchema || !newSchema) return null;
|
||||||
|
|
||||||
|
// 原本是更新 → 把 oldSchema 写回;优先按 changeRecords 局部 patch
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const current = this.getDataSourceById(`${id}`);
|
||||||
|
if (!current) return null;
|
||||||
|
const patched = cloneDeep(current) as DataSourceSchema;
|
||||||
|
let fallbackToFullReplace = false;
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
fallbackToFullReplace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
|
||||||
|
setValueByKeyPath(record.propPath, value, patched);
|
||||||
|
}
|
||||||
|
this.update(fallbackToFullReplace ? cloneDeep(oldSchema) : patched, {
|
||||||
|
changeRecords,
|
||||||
|
historyDescription,
|
||||||
|
});
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(cloneDeep(oldSchema), { historyDescription });
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一条历史 step 应用到当前数据源服务上。
|
* 把一条历史 step 应用到当前数据源服务上。
|
||||||
*
|
*
|
||||||
|
|||||||
@ -66,6 +66,38 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
|
|||||||
|
|
||||||
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给「回滚」生成的新 step 用的简短描述生成器。
|
||||||
|
* 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
|
||||||
|
*/
|
||||||
|
const describeStepForRevert = (step: StepValue): string => {
|
||||||
|
switch (step.opType) {
|
||||||
|
case 'add': {
|
||||||
|
const count = step.nodes?.length ?? 0;
|
||||||
|
const node = step.nodes?.[0];
|
||||||
|
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
|
||||||
|
return `撤回新增 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
|
||||||
|
}
|
||||||
|
case 'remove': {
|
||||||
|
const count = step.removedItems?.length ?? 0;
|
||||||
|
const node = step.removedItems?.[0]?.node;
|
||||||
|
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
|
||||||
|
return `还原已删除的 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
|
||||||
|
}
|
||||||
|
case 'update':
|
||||||
|
default: {
|
||||||
|
const items = step.updatedItems ?? [];
|
||||||
|
if (items.length === 1) {
|
||||||
|
const { newNode, oldNode, changeRecords } = items[0];
|
||||||
|
const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
|
||||||
|
const propPath = changeRecords?.[0]?.propPath;
|
||||||
|
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
|
||||||
|
}
|
||||||
|
return `还原 ${items.length} 个节点的修改`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
class Editor extends BaseService {
|
class Editor extends BaseService {
|
||||||
public state: StoreState = reactive({
|
public state: StoreState = reactive({
|
||||||
root: null,
|
root: null,
|
||||||
@ -1127,6 +1159,106 @@ class Editor extends BaseService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」指定页面历史步骤(类 git revert 语义):
|
||||||
|
* - 不动原始历史栈结构(不移动 cursor、不丢弃任何步骤);
|
||||||
|
* - 取出 `index` 对应的 step,**反向应用**一次(add→remove / remove→add / update→旧值);
|
||||||
|
* - 把这次反向应用作为一条**新步骤**追加到栈顶,可被普通 undo / redo。
|
||||||
|
*
|
||||||
|
* 与 `gotoPageStep`(类 git reset)的区别在于此操作**不丢弃**目标之后的历史。
|
||||||
|
* 与 `applyHistoryOp(reverse=true)` 的区别在于:本方法**不带** `doNotPushHistory`,
|
||||||
|
* 反向应用会以一条新 step 入栈;并且不实施 step 中保存的选区与 modifiedNodeIds 状态,
|
||||||
|
* 选区由用户当前位置决定,符合"新提交"语义。
|
||||||
|
*
|
||||||
|
* 仅对处于「已应用」状态的步骤生效——未应用的步骤本身就不存在于当前 DSL 中,反向无意义。
|
||||||
|
*
|
||||||
|
* @param index 目标 step 在所属页面栈中的索引(0 为最早),通常由历史面板传入
|
||||||
|
* @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
|
||||||
|
*/
|
||||||
|
public async revertPageStep(index: number): Promise<StepValue | null> {
|
||||||
|
const list = historyService.getPageStepList();
|
||||||
|
const entry = list[index];
|
||||||
|
if (!entry?.applied) return null;
|
||||||
|
|
||||||
|
const { step } = entry;
|
||||||
|
const root = this.get('root');
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
|
||||||
|
let revertedStep: StepValue | null = null;
|
||||||
|
const captureRevert = (s: StepValue) => {
|
||||||
|
revertedStep = s;
|
||||||
|
};
|
||||||
|
historyService.once('change', captureRevert);
|
||||||
|
|
||||||
|
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
|
||||||
|
// revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
|
||||||
|
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (step.opType) {
|
||||||
|
case 'add': {
|
||||||
|
// 原本是新增 → revert 即删除当时被加入的节点
|
||||||
|
const nodes = step.nodes ?? [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
const existing = this.getNodeById(n.id, false);
|
||||||
|
if (existing) {
|
||||||
|
await this.remove(existing, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove': {
|
||||||
|
// 原本是删除 → revert 即把节点按原父容器加回来。
|
||||||
|
// 按原 index 升序逐个插回,先小后大避免索引漂移。
|
||||||
|
const items = step.removedItems ?? [];
|
||||||
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||||
|
for (const { node, parentId } of sorted) {
|
||||||
|
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||||
|
if (parent) {
|
||||||
|
await this.add([cloneDeep(node)] as MNode[], parent, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'update': {
|
||||||
|
// 原本是更新 → revert 即把 oldNode 的值写回;
|
||||||
|
// 优先按 changeRecords 局部 patch(仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
|
||||||
|
const items = step.updatedItems ?? [];
|
||||||
|
const configs = items.map(({ oldNode, newNode, changeRecords }) => {
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const patch: MNode = { id: newNode.id, type: newNode.type };
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
// 没有 propPath 视为整节点替换
|
||||||
|
return cloneDeep(oldNode);
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
|
||||||
|
setValueByKeyPath(record.propPath, value, patch);
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
return cloneDeep(oldNode);
|
||||||
|
});
|
||||||
|
if (configs.length) {
|
||||||
|
await this.update(configs, { historyDescription });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
historyService.off('change', captureRevert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
|
||||||
|
const page = toRaw(this.get('page'));
|
||||||
|
if (page) {
|
||||||
|
this.emit('history-change', page as MPage | MPageFragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return revertedStep;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转当前页面历史栈到指定游标位置。
|
* 跳转当前页面历史栈到指定游标位置。
|
||||||
*
|
*
|
||||||
|
|||||||
@ -478,6 +478,28 @@ class History extends BaseService {
|
|||||||
return this.state.dataSourceState[dataSourceId]?.getCursor() ?? 0;
|
return this.state.dataSourceState[dataSourceId]?.getCursor() ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出指定代码块历史栈的平铺步骤列表(含 applied 标记)。供 revert 等按 index 索引步骤使用。
|
||||||
|
*/
|
||||||
|
public getCodeBlockStepList(codeBlockId: Id): { step: CodeBlockStepValue; index: number; applied: boolean }[] {
|
||||||
|
const undoRedo = this.state.codeBlockState[codeBlockId];
|
||||||
|
if (!undoRedo) return [];
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
return list.map((step, index) => ({ step, index, applied: index < cursor }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出指定数据源历史栈的平铺步骤列表(含 applied 标记)。供 revert 等按 index 索引步骤使用。
|
||||||
|
*/
|
||||||
|
public getDataSourceStepList(dataSourceId: Id): { step: DataSourceStepValue; index: number; applied: boolean }[] {
|
||||||
|
const undoRedo = this.state.dataSourceState[dataSourceId];
|
||||||
|
if (!undoRedo) return [];
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
return list.map((step, index) => ({ step, index, applied: index < cursor }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取出全部数据源的历史栈,按 dataSourceId 分组。同上。
|
* 取出全部数据源的历史栈,按 dataSourceId 分组。同上。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,9 +2,29 @@
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
|
||||||
.m-editor-history-list {
|
.m-editor-history-list {
|
||||||
|
position: relative;
|
||||||
padding: 4px 8px 8px;
|
padding: 4px 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭按钮悬浮在 tab 标题同一行(绝对定位,不占布局空间)。
|
||||||
|
// top 对齐容器顶部内边距,height 跟 el-tabs/t-tabs 头部默认高度一致,
|
||||||
|
// 再用 flex 居中让图标与 tab 标题视觉对齐。
|
||||||
|
.m-editor-history-list-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.m-editor-history-list-tabs {
|
.m-editor-history-list-tabs {
|
||||||
.el-tabs__header,
|
.el-tabs__header,
|
||||||
.t-tabs__header {
|
.t-tabs__header {
|
||||||
@ -36,7 +56,8 @@
|
|||||||
color: #303133;
|
color: #303133;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
&:hover {
|
// 合并组(卡片)自己定义了 hover/active 视觉,跳过通用单步 hover 避免色彩叠加
|
||||||
|
&:not(.m-editor-history-list-group.is-merged):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,7 +69,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-current {
|
// 当前所在的「单步」记录:左侧蓝条 + 浅蓝底;
|
||||||
|
// 合并组的当前态由 `.is-merged.is-current` 单独覆盖(border-left + 卡片背景),
|
||||||
|
// 故这里仅作用于「非合并组」的单步条目,避免与卡片样式互相干扰。
|
||||||
|
&.is-current:not(.m-editor-history-list-group.is-merged) {
|
||||||
background-color: rgba(64, 158, 255, 0.1);
|
background-color: rgba(64, 158, 255, 0.1);
|
||||||
box-shadow: inset 2px 0 0 #409eff;
|
box-shadow: inset 2px 0 0 #409eff;
|
||||||
|
|
||||||
@ -94,22 +118,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-merged .m-editor-history-list-group-head {
|
// 合并组(≥2 个连续同节点的修改被聚合)需要与单步条目明显区分:
|
||||||
font-weight: 500;
|
// - 左侧 3px 紫色色条暗示「这是一组」;
|
||||||
|
// - 浅紫背景 + 边框把整个组视觉化为一张「卡片」;
|
||||||
|
// - 头部加 padding,避免和单步条目混在一起难以辨认。
|
||||||
|
&.is-merged {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
background-color: rgba(144, 105, 219, 0.06);
|
||||||
|
border: 1px solid rgba(144, 105, 219, 0.18);
|
||||||
|
border-left: 3px solid #9069db;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
// 卡片本体已经有背景色,hover 状态以更深的同色提示交互
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(144, 105, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5b3fa5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已撤销态:整张卡片去色
|
||||||
|
&.is-undone {
|
||||||
|
background-color: rgba(192, 196, 204, 0.08);
|
||||||
|
border-color: rgba(192, 196, 204, 0.4);
|
||||||
|
border-left-color: #c0c4cc;
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前组:卡片左条变蓝,与单步「当前」高亮一致
|
||||||
|
&.is-current {
|
||||||
|
background-color: rgba(64, 158, 255, 0.08);
|
||||||
|
border-color: rgba(64, 158, 255, 0.3);
|
||||||
|
border-left-color: #409eff;
|
||||||
|
box-shadow: none; // 覆盖 .is-current 公共的 inset 阴影
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-history-list-substeps {
|
.m-editor-history-list-substeps {
|
||||||
margin: 4px 0 0 18px;
|
margin: 6px 0 0 6px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
border-left: 1px dashed #dcdfe6;
|
border-left: 1px dashed rgba(144, 105, 219, 0.45);
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 2px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@ -119,7 +185,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
background-color: rgba(144, 105, 219, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +218,8 @@
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 400; // 防止被合并组头部的粗体继承
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-history-list-item-op {
|
.m-editor-history-list-item-op {
|
||||||
@ -203,14 +271,17 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「合并 N 步」徽标:紫色实心胶囊,与合并组卡片色系一致,醒目区分单步条目。
|
||||||
.m-editor-history-list-item-merge {
|
.m-editor-history-list-item-merge {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0 6px;
|
padding: 0 8px;
|
||||||
border-radius: 2px;
|
border-radius: 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
color: #e6a23c;
|
color: #fff;
|
||||||
background-color: rgba(230, 162, 60, 0.12);
|
background-color: #9069db;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-history-list-item-diff {
|
.m-editor-history-list-item-diff {
|
||||||
@ -229,6 +300,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「回滚」按钮:类 git revert,把目标 step 反向应用一次作为新提交。
|
||||||
|
// 使用与「查看差异」不同的色调(橙黄),用来区分"可逆操作"与"只读对比"。
|
||||||
|
.m-editor-history-list-item-revert {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #e6a23c;
|
||||||
|
background-color: rgba(230, 162, 60, 0.12);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(230, 162, 60, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.m-editor-history-list-substep-desc {
|
.m-editor-history-list-substep-desc {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -70,8 +70,8 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const factory = async () => {
|
const factory = async () => {
|
||||||
const { default: HistoryListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
|
const { default: historyListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
|
||||||
return mount(HistoryListPanel, { attachTo: document.body });
|
return mount(historyListPanel, { attachTo: document.body });
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('HistoryListPanel.vue', () => {
|
describe('HistoryListPanel.vue', () => {
|
||||||
|
|||||||
@ -469,13 +469,13 @@ describe('useHistoryList', () => {
|
|||||||
// useHistoryList 内部用了 useServices,需要 mount 在一个 host 组件里 provide services
|
// useHistoryList 内部用了 useServices,需要 mount 在一个 host 组件里 provide services
|
||||||
const mountWithHost = () => {
|
const mountWithHost = () => {
|
||||||
let api!: ReturnType<typeof useHistoryList>;
|
let api!: ReturnType<typeof useHistoryList>;
|
||||||
const Host = defineComponent({
|
const host = defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
api = useHistoryList();
|
api = useHistoryList();
|
||||||
return () => h('div');
|
return () => h('div');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const wrapper = mount(Host, {
|
const wrapper = mount(host, {
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
services: { historyService },
|
services: { historyService },
|
||||||
|
|||||||
@ -78,7 +78,10 @@
|
|||||||
<TMagicFormItem
|
<TMagicFormItem
|
||||||
v-if="isSelfDiffField"
|
v-if="isSelfDiffField"
|
||||||
v-bind="formItemProps"
|
v-bind="formItemProps"
|
||||||
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-diff': true, 'self-diff': true }"
|
:class="{
|
||||||
|
'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text,
|
||||||
|
'self-diff': true,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
|
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
|
||||||
@ -121,7 +124,7 @@
|
|||||||
<!-- 上次内容 -->
|
<!-- 上次内容 -->
|
||||||
<TMagicFormItem
|
<TMagicFormItem
|
||||||
v-bind="formItemProps"
|
v-bind="formItemProps"
|
||||||
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-diff': true }"
|
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-before-diff': true }"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
|
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
|
||||||
@ -161,7 +164,7 @@
|
|||||||
<TMagicFormItem
|
<TMagicFormItem
|
||||||
v-bind="formItemProps"
|
v-bind="formItemProps"
|
||||||
:style="config.tip ? 'flex: 1' : ''"
|
:style="config.tip ? 'flex: 1' : ''"
|
||||||
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-diff': true }"
|
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-after-diff': true }"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
|
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
|
||||||
|
|||||||
@ -79,7 +79,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref, watchEffect } from 'vue';
|
import { computed, inject, ref, watch, watchEffect } from 'vue';
|
||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
import { getDesignConfig, TMagicBadge } from '@tmagic/design';
|
import { getDesignConfig, TMagicBadge } from '@tmagic/design';
|
||||||
@ -175,6 +175,11 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// model 或 lastValues 变化时,重置差异数
|
||||||
|
watch([() => props.model, () => props.lastValues], () => {
|
||||||
|
diffCount.value = {};
|
||||||
|
});
|
||||||
|
|
||||||
const tabItems = (tab: TabPaneConfig) => (props.config.dynamic ? props.config.items : tab.items);
|
const tabItems = (tab: TabPaneConfig) => (props.config.dynamic ? props.config.items : tab.items);
|
||||||
|
|
||||||
const tabClickHandler = (tab: any) => {
|
const tabClickHandler = (tab: any) => {
|
||||||
|
|||||||
@ -15,7 +15,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tmagic-design-form-item {
|
.tmagic-design-form-item {
|
||||||
&.show-diff {
|
&.show-after-diff {
|
||||||
|
background: rgb(225, 243, 216);
|
||||||
|
}
|
||||||
|
&.show-before-diff {
|
||||||
background: #f7dadd;
|
background: #f7dadd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user