From a9e9e65f9c50e47b22de8eab7184cebd87632bc6 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Wed, 3 Jun 2026 18:08:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=9B=9E=E6=BB=9A=E5=B7=AE=E5=BC=82?= =?UTF-8?q?=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为历史步骤自动写入 timestamp 并按当天/跨天格式化展示;回滚确认弹窗区分标题与说明,关闭时清理确认回调。 --- .../src/layouts/history-list/Bucket.vue | 5 ++ .../src/layouts/history-list/GroupRow.vue | 11 +++++ .../history-list/HistoryDiffDialog.vue | 12 ++++- .../layouts/history-list/HistoryListPanel.vue | 11 ++++- .../src/layouts/history-list/PageTab.vue | 12 ++++- .../src/layouts/history-list/composables.ts | 28 +++++++++++ packages/editor/src/services/history.ts | 3 ++ .../editor/src/theme/history-list-panel.scss | 21 +++++++++ packages/editor/src/type.ts | 8 ++++ .../layouts/history-list/GroupRow.spec.ts | 39 +++++++++++++++ .../history-list/HistoryDiffDialog.spec.ts | 30 +++++++++++- .../unit/layouts/history-list/PageTab.spec.ts | 15 ++++++ .../layouts/history-list/composables.spec.ts | 47 +++++++++++++++++++ .../tests/unit/services/history.spec.ts | 35 ++++++++++++++ 14 files changed, 272 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue index 82de021f..1062587f 100644 --- a/packages/editor/src/layouts/history-list/Bucket.vue +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -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'; diff --git a/packages/editor/src/layouts/history-list/GroupRow.vue b/packages/editor/src/layouts/history-list/GroupRow.vue index a021f2fa..0275f6d1 100644 --- a/packages/editor/src/layouts/history-list/GroupRow.vue +++ b/packages/editor/src/layouts/history-list/GroupRow.vue @@ -13,6 +13,8 @@ {{ opLabel(opType) }} {{ desc }} + {{ time }} + 合并 {{ stepCount }} 步 #{{ s.index + 1 }} {{ s.desc }} + {{ s.time }}
+
仅回滚有差异的字段
+
{{ targetText }}
@@ -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(() => { diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue index 1ad6e8f6..e243832b 100644 --- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -95,7 +95,12 @@ - + diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue index c7c316b7..32cb429c 100644 --- a/packages/editor/src/layouts/history-list/PageTab.vue +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -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'; diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts index f818e938..17e258ab 100644 --- a/packages/editor/src/layouts/history-list/composables.ts +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -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': diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index e750d96e..57d55a0e 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -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); diff --git a/packages/editor/src/theme/history-list-panel.scss b/packages/editor/src/theme/history-list-panel.scss index a4d7a3b0..27763e76 100644 --- a/packages/editor/src/theme/history-list-panel.scss +++ b/packages/editor/src/theme/history-list-panel.scss @@ -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; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 8997f1bd..7a031c21 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -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 diff --git a/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts b/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts index af7f612f..86e960fa 100644 --- a/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts @@ -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: { diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryDiffDialog.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryDiffDialog.spec.ts index bc79d734..690e1f78 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryDiffDialog.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryDiffDialog.spec.ts @@ -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()); diff --git a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts index 2aac5712..e532c9db 100644 --- a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts @@ -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', diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts index 51b0422a..7d53669b 100644 --- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -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; diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index 0a076157..4410829b 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -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,