feat(editor): 历史记录列表展示时间并优化回滚差异弹窗

为历史步骤自动写入 timestamp 并按当天/跨天格式化展示;回滚确认弹窗区分标题与说明,关闭时清理确认回调。
This commit is contained in:
roymondchen 2026-06-03 18:08:02 +08:00
parent 42162f2e4a
commit a9e9e65f9c
14 changed files with 272 additions and 5 deletions

View File

@ -15,6 +15,8 @@
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describeGroup(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s: any) => ({
@ -24,6 +26,8 @@
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
@ -54,6 +58,7 @@ import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import { formatHistoryFullTime, formatHistoryTime, groupTimestamp } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';

View File

@ -13,6 +13,8 @@
<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="time" class="m-editor-history-list-item-time" :title="timeTitle || time">{{ time }}</span>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
<span
@ -48,6 +50,7 @@
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
<span
v-if="s.revertable"
class="m-editor-history-list-item-revert"
@ -97,6 +100,10 @@ const props = withDefaults(
opType: HistoryOpType;
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
desc: string;
/** 组头部展示的时间文案(一般为组内最近一步的时间),为空时不渲染。 */
time?: string;
/** 组头部时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
timeTitle?: string;
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
stepCount: number;
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
@ -108,6 +115,10 @@ const props = withDefaults(
diffable?: boolean;
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
revertable?: boolean;
/** 该子步的时间文案,为空时不渲染。 */
time?: string;
/** 该子步时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
timeTitle?: string;
}[];
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
expanded: boolean;

View File

@ -3,7 +3,7 @@
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
title="查看修改差异"
:title="dialogTitle"
top="5vh"
destroy-on-close
append-to-body
@ -11,6 +11,8 @@
@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>
<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">
@ -44,6 +46,7 @@
:last-value="leftValue"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
height="70vh"
/>
@ -100,6 +103,7 @@ const props = withDefaults(
loadConfig?: CompareFormLoadConfig;
width?: string;
onConfirm?: () => void;
selfDiffFieldTypes?: string[];
}>(),
{
width: '900px',
@ -147,6 +151,8 @@ const codeDiffOptions = {
},
};
const dialogTitle = computed(() => (props.onConfirm ? '确认回滚' : '查看修改差异'));
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
/** 左侧(旧/参照)值 */
@ -174,8 +180,10 @@ const isSameAsCurrent = computed(() => {
const onConfirmClick = () => {
const cb = props.onConfirm;
visible.value = false;
cb?.();
visible.value = false;
};
const targetText = computed(() => {

View File

@ -95,7 +95,12 @@
</template>
</TMagicPopover>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" :on-confirm="onConfirmRevert" />
<HistoryDiffDialog
ref="diffDialog"
:extend-state="extendFormState"
:on-confirm="onConfirmRevert"
@close="onDiffDialogClose"
/>
</template>
<script lang="ts" setup>
@ -368,4 +373,8 @@ const onCodeBlockRevert = (id: string | number, index: number) => {
onConfirmRevert.value();
}
};
const onDiffDialogClose = () => {
onConfirmRevert.value = undefined;
};
</script>

View File

@ -10,6 +10,8 @@
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describePageGroup(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
@ -19,6 +21,8 @@
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
@ -44,7 +48,13 @@ import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup, StepValue } from '@editor/type';
import { describePageGroup, describePageStep } from './composables';
import {
describePageGroup,
describePageStep,
formatHistoryFullTime,
formatHistoryTime,
groupTimestamp,
} from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';

View File

@ -1,5 +1,7 @@
import { computed, reactive } from 'vue';
import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type {
CodeBlockHistoryGroup,
@ -67,6 +69,32 @@ export const useHistoryList = () => {
};
};
/**
*
* - `HH:mm:ss`
* - `MM-DD HH:mm:ss`
* / UI
*/
export const formatHistoryTime = (timestamp?: number): string => {
if (!timestamp) return '';
const isToday =
datetimeFormatter(new Date(timestamp), '', 'YYYY-MM-DD') ===
(datetimeFormatter(new Date(), '', 'YYYY-MM-DD') as string);
return `${
isToday
? datetimeFormatter(new Date(timestamp), '', 'HH:mm:ss')
: datetimeFormatter(new Date(timestamp), '', 'MM-DD HH:mm:ss')
}`;
};
/** 完整时间(含年份与秒),用于 title 悬浮提示。无时间戳时返回空串。 */
export const formatHistoryFullTime = (timestamp?: number): string =>
timestamp ? `${datetimeFormatter(new Date(timestamp), '', 'YYYY-MM-DD HH:mm:ss')}` : '';
/** 取一组历史步骤里最后一步(最近一次)的时间戳,用于组头部展示。 */
export const groupTimestamp = (group: { steps: { step: { timestamp?: number } }[] }): number | undefined =>
group.steps[group.steps.length - 1]?.step.timestamp;
export const opLabel = (op: HistoryOpType) => {
switch (op) {
case 'add':

View File

@ -254,6 +254,7 @@ class History extends BaseService {
public push(state: StepValue, pageId?: Id): StepValue | null {
const undoRedo = this.getUndoRedo(pageId);
if (!undoRedo) return null;
if (state.timestamp === undefined) state.timestamp = Date.now();
undoRedo.pushElement(state);
// 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
if (pageId === undefined || `${pageId}` === `${this.state.pageId}`) {
@ -289,6 +290,7 @@ class History extends BaseService {
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
historyDescription: payload.historyDescription,
timestamp: Date.now(),
};
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
@ -318,6 +320,7 @@ class History extends BaseService {
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
historyDescription: payload.historyDescription,
timestamp: Date.now(),
};
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);

View File

@ -222,6 +222,16 @@
white-space: nowrap;
}
// 操作时间弱化展示紧贴在描述之后各操作按钮之前
.m-editor-history-list-item-time {
flex: 0 0 auto;
color: #a8abb2;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
font-weight: 400; // 防止被合并组头部的粗体继承
white-space: nowrap;
}
.m-editor-history-list-item-op {
flex: 0 0 auto;
padding: 0 6px;
@ -387,6 +397,17 @@
flex-direction: column;
}
.m-editor-history-diff-dialog-notice {
margin-bottom: 8px;
padding: 8px 12px;
background-color: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 4px;
color: #e6a23c;
font-size: 13px;
line-height: 1.5;
}
.m-editor-history-diff-dialog-header {
display: flex;
align-items: center;

View File

@ -708,6 +708,10 @@ export interface StepValue {
* undo/redo / propPath
*/
historyDescription?: string;
/**
* historyService.push
*/
timestamp?: number;
}
// #endregion StepValue
@ -732,6 +736,8 @@ export interface CodeBlockStepValue {
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion CodeBlockStepValue
@ -756,6 +762,8 @@ export interface DataSourceStepValue {
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion DataSourceStepValue

View File

@ -56,6 +56,45 @@ describe('GroupRow.vue', () => {
expect(wrapper.find('.m-editor-history-list-item-merge').exists()).toBe(false);
});
test('传入 time 时头部渲染时间title 取 timeTitle', () => {
const wrapper = mount(GroupRow, {
props: { ...baseProps, time: '12:00:00', timeTitle: '2026-06-03 12:00:00' },
});
const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true);
expect(time.text()).toBe('12:00:00');
expect(time.attributes('title')).toBe('2026-06-03 12:00:00');
});
test('未传 time 时头部不渲染时间元素', () => {
const wrapper = mount(GroupRow, { props: baseProps });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
});
test('timeTitle 缺省时 title 回退为 time 本身', () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, time: '08:30:00' } });
expect(wrapper.find('.m-editor-history-list-item-time').attributes('title')).toBe('08:30:00');
});
test('展开的子步各自渲染自己的时间', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: '修改 颜色', time: '10:00:00', timeTitle: '2026-06-03 10:00:00' },
{ index: 1, applied: true, desc: '修改 字号', time: '10:01:00', timeTitle: '2026-06-03 10:01:00' },
],
},
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
// 子步倒序渲染index=1 在前
expect(items[0].find('.m-editor-history-list-item-time').text()).toBe('10:01:00');
expect(items[1].find('.m-editor-history-list-item-time').text()).toBe('10:00:00');
});
test('merged=true 且 expanded=true 时渲染子步列表', () => {
const wrapper = mount(GroupRow, {
props: {

View File

@ -13,7 +13,7 @@ vi.mock('@tmagic/design', () => ({
// 受控对话框modelValue 为真时才渲染 body / footer 插槽
TMagicDialog: defineComponent({
name: 'TMagicDialog',
props: ['modelValue'],
props: ['modelValue', 'title'],
setup(props, { slots }) {
return () =>
props.modelValue ? h('div', { class: 'fake-dialog' }, [slots.default?.(), slots.footer?.()]) : null;
@ -207,6 +207,34 @@ describe('HistoryDiffDialog.vue', () => {
expect(form.props('lastValue')).toEqual({ text: 'old' });
});
test('无 onConfirm 时标题为「查看修改差异」', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
expect(wrapper.findComponent({ name: 'TMagicDialog' }).props('title')).toBe('查看修改差异');
});
test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => {
const wrapper = mount(HistoryDiffDialog, {
global: { stubs: { teleport: true } },
props: { onConfirm: vi.fn() },
});
(wrapper.vm as any).open(basePayload());
await nextTick();
expect(wrapper.findComponent({ name: 'TMagicDialog' }).props('title')).toBe('确认回滚');
expect(wrapper.find('.m-editor-history-diff-dialog-notice').text()).toBe('仅回滚有差异的字段');
});
test('无 onConfirm 时不展示回滚说明', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
expect(wrapper.find('.m-editor-history-diff-dialog-notice').exists()).toBe(false);
});
test('close() 隐藏对话框并清空 payload', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());

View File

@ -77,6 +77,21 @@ describe('PageTab.vue', () => {
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮 (id: btn) · style.color');
});
test('step 含 timestamp 时渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }], timestamp: Date.now() }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true);
// 当天记录展示 HH:mm:ss
expect(time.text()).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
test('step 无 timestamp 时不渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
});
test('expanded 控制合并组的展开状态key=pg-${idx}', async () => {
const mergedGroup = buildPageGroup(
'update',

View File

@ -14,6 +14,9 @@ import {
describeDataSourceStep,
describePageGroup,
describePageStep,
formatHistoryFullTime,
formatHistoryTime,
groupTimestamp,
opLabel,
useHistoryList,
} from '@editor/layouts/history-list/composables';
@ -50,6 +53,50 @@ describe('opLabel', () => {
});
});
describe('formatHistoryFullTime', () => {
test('无时间戳返回空串', () => {
expect(formatHistoryFullTime()).toBe('');
expect(formatHistoryFullTime(0)).toBe('');
});
test('格式化为北京时间的完整 YYYY-MM-DD HH:mm:ss不随本地时区漂移', () => {
// 2026-01-02 03:04:05 UTC → 北京时间 (UTC+8) 2026-01-02 11:04:05
const ts = Date.UTC(2026, 0, 2, 3, 4, 5);
expect(formatHistoryFullTime(ts)).toBe('2026-01-02 11:04:05');
});
});
describe('formatHistoryTime', () => {
test('无时间戳返回空串', () => {
expect(formatHistoryTime()).toBe('');
expect(formatHistoryTime(0)).toBe('');
});
test('当天记录只显示 HH:mm:ss', () => {
expect(formatHistoryTime(Date.now())).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
test('跨天记录显示 MM-DD HH:mm:ss', () => {
// 取一个明显不是今天的旧时间戳
const ts = Date.UTC(2020, 5, 15, 1, 2, 3);
expect(formatHistoryTime(ts)).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
});
describe('groupTimestamp', () => {
test('取组内最后一步的时间戳', () => {
const group = {
steps: [{ step: { timestamp: 100 } }, { step: { timestamp: 200 } }, { step: { timestamp: 300 } }],
};
expect(groupTimestamp(group)).toBe(300);
});
test('末步无时间戳时返回 undefined', () => {
expect(groupTimestamp({ steps: [{ step: {} }] })).toBeUndefined();
expect(groupTimestamp({ steps: [] })).toBeUndefined();
});
});
describe('describePageStep', () => {
test('显式 historyDescription 优先于自动生成', () => {
const step = { opType: 'update', historyDescription: '调整按钮颜色' } as unknown as StepValue;

View File

@ -84,6 +84,25 @@ describe('history service', () => {
expect((history.state.pageSteps as any).p1.canUndo()).toBe(true);
expect(history.state.canUndo).toBe(true);
});
test('push 未带 timestamp 时自动写入入栈时间', () => {
history.changePage({ id: 'p1' } as any);
const before = Date.now();
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('push 已带 timestamp 时保留调用方指定的值', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
timestamp: 123456,
} as any);
expect(step?.timestamp).toBe(123456);
});
});
describe('history service - codeBlock', () => {
@ -111,6 +130,14 @@ describe('history service - codeBlock', () => {
expect(history.pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
});
test('pushCodeBlock 自动写入入栈时间戳', () => {
const before = Date.now();
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_1', {
@ -183,6 +210,14 @@ describe('history service - dataSource', () => {
expect(history.pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
});
test('pushDataSource 自动写入入栈时间戳', () => {
const before = Date.now();
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
history.pushDataSource('ds_1', {
oldSchema: null,