feat(editor): 历史记录面板支持单步回滚(类 git revert)

将目标历史步骤的修改作为一次新操作反向应用,不破坏原有栈结构,
page/dataSource/codeBlock 三类 service 均提供 revert 能力;
面板新增关闭按钮、步骤编号展示与合并组卡片样式优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-29 14:19:44 +08:00
parent f0c66427b8
commit b02aa75ddc
20 changed files with 556 additions and 28 deletions

View File

@ -34,7 +34,7 @@
<Teleport to="body">
<TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close>
<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>

View File

@ -139,6 +139,7 @@ const wrapperStyle = computed(() => {
*/
const isEmptyCodeSelectValue = (v: any): boolean => {
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;
};

View File

@ -23,6 +23,7 @@
isCurrent: s.isCurrent,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied,
}))
"
:is-current="group.isCurrent"
@ -30,6 +31,7 @@
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
@revert-step="(index: number) => $emit('revert-step', bucketId, index)"
/>
<!--
初始状态项永远位于该 bucket 列表底部同样按倒序展示最底部 = 最早状态
@ -88,6 +90,8 @@ defineEmits<{
(_e: 'goto-initial', _bucketId: string | number): void;
/** 用户点击"查看差异",携带 bucketId 与 step 索引。 */
(_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。 */

View File

@ -16,6 +16,7 @@
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
@ -51,6 +52,8 @@ defineEmits<{
(_e: 'goto-initial', _codeBlockId: string | number): void;
/** 透传 Bucket 的 diff-step 事件,携带 codeBlock id 与 step 索引。 */
(_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 都存在)时可查看差异。 */

View File

@ -16,6 +16,7 @@
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
@ -51,6 +52,8 @@ defineEmits<{
(_e: 'goto-initial', _dataSourceId: string | number): void;
/** 透传 Bucket 的 diff-step 事件,携带 dataSource id 与 step 索引。 */
(_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 都存在)时可查看差异。 */

View File

@ -9,6 +9,7 @@
:title="headTitle"
@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-desc">{{ desc }}</span>
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
@ -20,12 +21,19 @@
>查看差异</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>
</div>
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
<li
v-for="s in subSteps"
v-for="s in subStepsDisplay"
:key="s.index"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': !s.isCurrent }"
:title="s.isCurrent ? '当前所在记录' : '点击跳转到该记录'"
@ -41,6 +49,13 @@
@click.stop="onDiffClick(s.index)"
>查看差异</span
>
<span
v-if="s.revertable"
class="m-editor-history-list-item-revert"
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
@click.stop="onRevertClick(s.index)"
>回滚</span
>
</li>
</ul>
</li>
@ -71,7 +86,15 @@ const props = defineProps<{
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
stepCount: number;
/** 子步列表,用于在展开状态下逐条展示每个 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 时生效,控制子步列表是否渲染。 */
expanded: boolean;
/** 是否为当前所在的分组包含栈中最近一次已应用步骤的那一组UI 高亮展示。 */
@ -99,6 +122,11 @@ const emit = defineEmits<{
* payload 为该 step 在所属栈中的索引由上层根据 index step 内容并展示对比
*/
(_e: 'diff-step', _index: number): void;
/**
* 用户希望回滚 step把它的修改作为一次新操作反向应用 git revert
* payload 为该 step 在所属栈中的索引仅在单步组头部headRevertable或合并组的可回滚子步上触发
*/
(_e: 'revert-step', _index: number): void;
}>();
/** 单步组:头部可点击 goto合并组头部可点击切换展开。当前组isCurrent的单步组头部不可点击。 */
@ -136,7 +164,44 @@ const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
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) => {
emit('diff-step', index);
};
const onRevertClick = (index: number) => {
emit('revert-step', index);
};
</script>

View File

@ -18,7 +18,7 @@
</div>
<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>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">

View File

@ -1,6 +1,20 @@
<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">
<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">
<component
:is="tabPaneComponent?.component || 'el-tab-pane'"
@ -13,6 +27,7 @@
@goto="onPageGoto"
@goto-initial="onPageGotoInitial"
@diff-step="onPageDiff"
@revert-step="onPageRevert"
/>
</component>
@ -27,6 +42,7 @@
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
@diff-step="onDataSourceDiff"
@revert-step="onDataSourceRevert"
/>
</component>
@ -41,6 +57,7 @@
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
@diff-step="onCodeBlockDiff"
@revert-step="onCodeBlockRevert"
/>
</component>
</TMagicTabs>
@ -48,7 +65,7 @@
<template #reference>
<TMagicTooltip effect="dark" placement="bottom" content="历史记录">
<TMagicButton size="small" link>
<TMagicButton size="small" link @click="visible = !visible">
<template #icon>
<MIcon :icon="ClockIcon"></MIcon>
</template>
@ -82,7 +99,7 @@
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
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 type { FormState } from '@tmagic/form';
@ -101,8 +118,12 @@ defineOptions({
});
const ClockIcon = markRaw(Clock);
const CloseIcon = markRaw(Close);
const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
/** 面板显隐受控reference 图标点击切换,右上角关闭按钮置为 false。 */
const visible = ref(false);
const tabPaneComponent = getDesignConfig('components')?.tabPane;
const { editorService, dataSourceService, codeBlockService, historyService } = useServices();
@ -159,6 +180,22 @@ const onCodeBlockGotoInitial = (id: string | number) => {
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');
/**

View File

@ -5,6 +5,7 @@
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
@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-desc">未修改的初始状态</span>
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>

View File

@ -18,6 +18,7 @@
isCurrent: s.isCurrent,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied,
}))
"
:is-current="group.isCurrent"
@ -25,6 +26,7 @@
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
@ -66,6 +68,8 @@ defineEmits<{
(_e: 'goto-initial'): void;
/** 用户点击"查看差异"按钮,携带目标 step 在栈中的索引。 */
(_e: 'diff-step', _index: number): void;
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
(_e: 'revert-step', _index: number): void;
}>();
/**

View File

@ -48,6 +48,18 @@ const canUsePluginMethods = {
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 {
private state = reactive<CodeState>({
codeDsl: null,
@ -347,6 +359,24 @@ class CodeBlock extends BaseService {
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
* @returns {Id} id
@ -429,6 +459,55 @@ class CodeBlock extends BaseService {
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
*

View File

@ -54,6 +54,19 @@ const canUsePluginMethods = {
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 {
private state = reactive<State>({
datasourceTypeList: [],
@ -251,6 +264,24 @@ class DataSource extends BaseService {
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 {
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
*

View File

@ -66,6 +66,38 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
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 {
public state: StoreState = reactive({
root: null,
@ -1127,6 +1159,106 @@ class Editor extends BaseService {
return value;
}
/**
* git revert
* - cursor
* - `index` step****addremove / removeadd / 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;
}
/**
*
*

View File

@ -478,6 +478,28 @@ class History extends BaseService {
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
*/

View File

@ -2,9 +2,29 @@
padding: 0 !important;
.m-editor-history-list {
position: relative;
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 {
.el-tabs__header,
.t-tabs__header {
@ -36,7 +56,8 @@
color: #303133;
cursor: default;
&:hover {
// 合并组卡片自己定义了 hover/active 视觉跳过通用单步 hover 避免色彩叠加
&:not(.m-editor-history-list-group.is-merged):hover {
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);
box-shadow: inset 2px 0 0 #409eff;
@ -94,22 +118,64 @@
}
}
&.is-merged .m-editor-history-list-group-head {
font-weight: 500;
// 合并组2 个连续同节点的修改被聚合需要与单步条目明显区分
// - 左侧 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 {
margin: 4px 0 0 18px;
margin: 6px 0 0 6px;
padding: 0;
list-style: none;
border-left: 1px dashed #dcdfe6;
border-left: 1px dashed rgba(144, 105, 219, 0.45);
li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
padding: 3px 8px;
font-size: 11px;
color: #606266;
cursor: default;
@ -119,7 +185,7 @@
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
background-color: rgba(144, 105, 219, 0.1);
}
}
@ -152,6 +218,8 @@
color: #909399;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
font-weight: 400; // 防止被合并组头部的粗体继承
white-space: nowrap;
}
.m-editor-history-list-item-op {
@ -203,14 +271,17 @@
white-space: nowrap;
}
// 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目
.m-editor-history-list-item-merge {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
padding: 0 8px;
border-radius: 8px;
font-size: 10px;
line-height: 16px;
color: #e6a23c;
background-color: rgba(230, 162, 60, 0.12);
color: #fff;
background-color: #9069db;
font-weight: 500;
letter-spacing: 0.2px;
}
.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 {
flex: 1 1 auto;
overflow: hidden;

View File

@ -70,8 +70,8 @@ afterEach(() => {
});
const factory = async () => {
const { default: HistoryListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
return mount(HistoryListPanel, { attachTo: document.body });
const { default: historyListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
return mount(historyListPanel, { attachTo: document.body });
};
describe('HistoryListPanel.vue', () => {

View File

@ -469,13 +469,13 @@ describe('useHistoryList', () => {
// useHistoryList 内部用了 useServices需要 mount 在一个 host 组件里 provide services
const mountWithHost = () => {
let api!: ReturnType<typeof useHistoryList>;
const Host = defineComponent({
const host = defineComponent({
setup() {
api = useHistoryList();
return () => h('div');
},
});
const wrapper = mount(Host, {
const wrapper = mount(host, {
global: {
provide: {
services: { historyService },

View File

@ -78,7 +78,10 @@
<TMagicFormItem
v-if="isSelfDiffField"
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>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
@ -121,7 +124,7 @@
<!-- 上次内容 -->
<TMagicFormItem
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>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
@ -161,7 +164,7 @@
<TMagicFormItem
v-bind="formItemProps"
: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>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">

View File

@ -79,7 +79,7 @@
</template>
<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 { 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 tabClickHandler = (tab: any) => {

View File

@ -15,7 +15,10 @@
}
.tmagic-design-form-item {
&.show-diff {
&.show-after-diff {
background: rgb(225, 243, 216);
}
&.show-before-diff {
background: #f7dadd;
}
}