From 62a2ee66931caed51f86bf170c3bce96c7e40dea Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 28 May 2026 18:52:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=9D=A2=E6=9D=BF=E6=94=AF=E6=8C=81=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E4=B8=8E=E5=9B=9E=E5=88=B0=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单步组头部点击跳转到该步骤;合并组头部点击展开/收起,子步行点击跳转到具体步骤 - 列表底部新增「初始」记录项,可一键回到所有修改之前的状态 - editorService/dataSourceService/codeBlockService 新增 goto API;historyService 暴露 cursor 读取器 --- .../src/layouts/history-list/Bucket.vue | 21 ++- .../src/layouts/history-list/CodeBlockTab.vue | 6 + .../layouts/history-list/DataSourceTab.vue | 6 + .../src/layouts/history-list/GroupRow.vue | 68 ++++++++- .../layouts/history-list/HistoryListPanel.vue | 66 ++++++++- .../src/layouts/history-list/InitialRow.vue | 40 ++++++ .../src/layouts/history-list/PageTab.vue | 22 ++- packages/editor/src/services/codeBlock.ts | 23 ++++ packages/editor/src/services/dataSource.ts | 21 +++ packages/editor/src/services/editor.ts | 26 ++++ packages/editor/src/services/history.ts | 20 +++ .../editor/src/theme/history-list-panel.scss | 51 ++++++- .../unit/layouts/history-list/Bucket.spec.ts | 84 +++++++++++- .../layouts/history-list/CodeBlockTab.spec.ts | 46 ++++++- .../history-list/DataSourceTab.spec.ts | 36 ++++- .../layouts/history-list/GroupRow.spec.ts | 72 +++++++++- .../history-list/HistoryListPanel.spec.ts | 129 +++++++++++++++++- .../layouts/history-list/InitialRow.spec.ts | 38 ++++++ .../unit/layouts/history-list/PageTab.spec.ts | 84 +++++++++++- 19 files changed, 832 insertions(+), 27 deletions(-) create mode 100644 packages/editor/src/layouts/history-list/InitialRow.vue create mode 100644 packages/editor/tests/unit/layouts/history-list/InitialRow.spec.ts diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue index 8d04fc65..4051b01c 100644 --- a/packages/editor/src/layouts/history-list/Bucket.vue +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -27,21 +27,30 @@ :is-current="group.isCurrent" :expanded="!!expanded[`${prefix}-${bucketId}-${gIdx}`]" @toggle="(key: string) => $emit('toggle', key)" + @goto="(index: number) => $emit('goto', bucketId, index)" /> + + diff --git a/packages/editor/src/layouts/history-list/CodeBlockTab.vue b/packages/editor/src/layouts/history-list/CodeBlockTab.vue index 38c1cd79..29861989 100644 --- a/packages/editor/src/layouts/history-list/CodeBlockTab.vue +++ b/packages/editor/src/layouts/history-list/CodeBlockTab.vue @@ -12,6 +12,8 @@ :describe-step="describeCodeBlockStep" :expanded="expanded" @toggle="(key: string) => $emit('toggle', key)" + @goto="(id: string | number, index: number) => $emit('goto', id, index)" + @goto-initial="(id: string | number) => $emit('goto-initial', id)" /> @@ -41,5 +43,9 @@ defineProps<{ defineEmits<{ /** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */ (_e: 'toggle', _key: string): void; + /** 透传 Bucket 的 goto 事件,携带 codeBlock id 与目标 step 索引。 */ + (_e: 'goto', _codeBlockId: string | number, _index: number): void; + /** 透传 Bucket 的 goto-initial 事件,携带 codeBlock id(回到该代码块未修改时的状态)。 */ + (_e: 'goto-initial', _codeBlockId: string | number): void; }>(); diff --git a/packages/editor/src/layouts/history-list/DataSourceTab.vue b/packages/editor/src/layouts/history-list/DataSourceTab.vue index c2c1c5b7..3579a551 100644 --- a/packages/editor/src/layouts/history-list/DataSourceTab.vue +++ b/packages/editor/src/layouts/history-list/DataSourceTab.vue @@ -12,6 +12,8 @@ :describe-step="describeDataSourceStep" :expanded="expanded" @toggle="(key: string) => $emit('toggle', key)" + @goto="(id: string | number, index: number) => $emit('goto', id, index)" + @goto-initial="(id: string | number) => $emit('goto-initial', id)" /> @@ -41,5 +43,9 @@ defineProps<{ defineEmits<{ /** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */ (_e: 'toggle', _key: string): void; + /** 透传 Bucket 的 goto 事件,携带 dataSource id 与目标 step 索引。 */ + (_e: 'goto', _dataSourceId: string | number, _index: number): void; + /** 透传 Bucket 的 goto-initial 事件,携带 dataSource id(回到该数据源未修改时的状态)。 */ + (_e: 'goto-initial', _dataSourceId: string | number): void; }>(); diff --git a/packages/editor/src/layouts/history-list/GroupRow.vue b/packages/editor/src/layouts/history-list/GroupRow.vue index 701189bb..8c54ace1 100644 --- a/packages/editor/src/layouts/history-list/GroupRow.vue +++ b/packages/editor/src/layouts/history-list/GroupRow.vue @@ -3,15 +3,27 @@ class="m-editor-history-list-item m-editor-history-list-group" :class="{ 'is-undone': !applied, 'is-merged': merged, 'is-current': isCurrent }" > -
+
{{ opLabel(opType) }} {{ desc }} 当前 合并 {{ stepCount }} 步 +
    -
  • +
  • #{{ s.index + 1 }} {{ s.desc }} 当前 @@ -21,6 +33,8 @@ diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue index 0ff852ba..bd2e96fd 100644 --- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -6,21 +6,39 @@ :is="tabPaneComponent?.component || 'el-tab-pane'" v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}" > - + - + - +
@@ -44,7 +62,13 @@ * - 数据源:以 dataSource.id 分组,每组内部相邻的连续 update 自动合并 * - 代码块:同上,按 codeBlock.id 分组并合并相邻 update * - * 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示。 + * 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示, + * 同时支持点击任意一条记录跳转至该状态: + * - 页面 tab:调用 editorService.gotoPageStep(targetCursor) + * - 数据源 tab:调用 dataSourceService.goto(id, targetCursor) + * - 代码块 tab:调用 codeBlockService.goto(id, targetCursor) + * + * 这里的 targetCursor = 用户点击的 step.index + 1,即"应用至此步完成的状态"。 * * 各 tab 的内容拆分为独立的 SFC(PageTab / DataSourceTab / CodeBlockTab), * 共享的描述生成与折叠状态在 composables.ts 中维护。 @@ -55,6 +79,7 @@ import { Clock } from '@element-plus/icons-vue'; import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design'; import MIcon from '@editor/components/Icon.vue'; +import { useServices } from '@editor/hooks/use-services'; import CodeBlockTab from './CodeBlockTab.vue'; import { useHistoryList } from './composables'; @@ -70,6 +95,8 @@ const activeTab = ref<'page' | 'data-source' | 'code-block'>('page'); const tabPaneComponent = getDesignConfig('components')?.tabPane; +const { editorService, dataSourceService, codeBlockService } = useServices(); + const { expanded, toggleGroup, @@ -80,4 +107,35 @@ const { dataSourceGroupsByTarget, codeBlockGroupsByTarget, } = useHistoryList(); + +/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */ +const indexToCursor = (index: number) => index + 1; + +const onPageGoto = (index: number) => { + editorService.gotoPageStep(indexToCursor(index)); +}; + +const onDataSourceGoto = (id: string | number, index: number) => { + dataSourceService.goto(id, indexToCursor(index)); +}; + +const onCodeBlockGoto = (id: string | number, index: number) => { + codeBlockService.goto(id, indexToCursor(index)); +}; + +/** + * "回到初始状态" = 把对应栈 cursor 移到 0(全部已撤销)。 + * 复用 service.goto*(0) 即可,所有真实 step 的反向应用由 service 层的 undo 链路完成。 + */ +const onPageGotoInitial = () => { + editorService.gotoPageStep(0); +}; + +const onDataSourceGotoInitial = (id: string | number) => { + dataSourceService.goto(id, 0); +}; + +const onCodeBlockGotoInitial = (id: string | number) => { + codeBlockService.goto(id, 0); +}; diff --git a/packages/editor/src/layouts/history-list/InitialRow.vue b/packages/editor/src/layouts/history-list/InitialRow.vue new file mode 100644 index 00000000..014bd5c6 --- /dev/null +++ b/packages/editor/src/layouts/history-list/InitialRow.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue index 27b2487d..a62c5671 100644 --- a/packages/editor/src/layouts/history-list/PageTab.vue +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -22,24 +22,33 @@ :is-current="group.isCurrent" :expanded="!!expanded[`pg-${gIdx}`]" @toggle="(key: string) => $emit('toggle', key)" + @goto="(index: number) => $emit('goto', index)" /> + + diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index b8c7cfeb..24055080 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -324,6 +324,29 @@ class CodeBlock extends BaseService { return historyService.canRedoCodeBlock(id); } + /** + * 跳转指定代码块的历史栈到目标游标。语义同 editor.gotoPageStep。 + * + * @param id 代码块 id + * @param targetCursor 目标游标位置(已应用步骤数量) + * @returns 实际移动到的最终游标位置 + */ + public async goto(id: Id, targetCursor: number): Promise { + let cursor = historyService.getCodeBlockCursor(id); + const target = Math.max(0, targetCursor); + while (cursor > target) { + const step = await this.undo(id); + if (!step) break; + cursor -= 1; + } + while (cursor < target) { + const step = await this.redo(id); + if (!step) break; + cursor += 1; + } + return cursor; + } + /** * 生成代码块唯一id * @returns {Id} 代码块唯一id diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index f0382607..b6ede6fd 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -230,6 +230,27 @@ class DataSource extends BaseService { return historyService.canRedoDataSource(id); } + /** + * 跳转指定数据源的历史栈到目标游标。语义同 editor.gotoPageStep。 + * + * @param id 数据源 id + * @param targetCursor 目标游标位置(已应用步骤数量) + * @returns 实际移动到的最终游标位置 + */ + public goto(id: Id, targetCursor: number): number { + let cursor = historyService.getDataSourceCursor(id); + const target = Math.max(0, targetCursor); + while (cursor > target) { + if (!this.undo(id)) break; + cursor -= 1; + } + while (cursor < target) { + if (!this.redo(id)) break; + cursor += 1; + } + return cursor; + } + public createId(): string { return `ds_${guid()}`; } diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 9fddc99f..39ee411b 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -1127,6 +1127,32 @@ class Editor extends BaseService { return value; } + /** + * 跳转当前页面历史栈到指定游标位置。 + * + * `targetCursor` 与 `UndoRedo.getCursor()` 同义:表示"已应用步骤数量", + * 取值范围 `[0, length]`。当目标 < 当前游标时循环 undo,否则循环 redo。 + * 通常由历史面板传入「点击的 step.index + 1」作为目标。 + * + * @returns 实际移动到的最终游标位置 + */ + public async gotoPageStep(targetCursor: number): Promise { + let cursor = historyService.getPageCursor(); + const { length } = historyService.getPageStepList(); + const target = Math.max(0, Math.min(targetCursor, length)); + while (cursor > target) { + const step = await this.undo(); + if (!step) break; + cursor -= 1; + } + while (cursor < target) { + const step = await this.redo(); + if (!step) break; + cursor += 1; + } + return cursor; + } + public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) { const node = toRaw(this.get('node')); if (!node || isPage(node)) return; diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index b5abd330..6fe27766 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -458,6 +458,26 @@ class History extends BaseService { return groups; } + /** + * 读取指定页面历史栈的当前游标(已应用步骤数量)。不传则取当前活动页。 + * 没有对应栈时返回 0。 + */ + public getPageCursor(pageId?: Id): number { + const targetPageId = pageId ?? this.state.pageId; + if (!targetPageId) return 0; + return this.state.pageSteps[targetPageId]?.getCursor() ?? 0; + } + + /** 读取指定代码块历史栈的当前游标。 */ + public getCodeBlockCursor(codeBlockId: Id): number { + return this.state.codeBlockState[codeBlockId]?.getCursor() ?? 0; + } + + /** 读取指定数据源历史栈的当前游标。 */ + public getDataSourceCursor(dataSourceId: Id): number { + return this.state.dataSourceState[dataSourceId]?.getCursor() ?? 0; + } + /** * 取出全部数据源的历史栈,按 dataSourceId 分组。同上。 */ diff --git a/packages/editor/src/theme/history-list-panel.scss b/packages/editor/src/theme/history-list-panel.scss index 0aa6f66a..9ba3e51b 100644 --- a/packages/editor/src/theme/history-list-panel.scss +++ b/packages/editor/src/theme/history-list-panel.scss @@ -72,7 +72,26 @@ display: flex; align-items: center; gap: 6px; - cursor: pointer; + cursor: default; + + &.is-clickable { + cursor: pointer; + } + } + + .m-editor-history-list-group-toggle { + flex: 0 0 auto; + width: 16px; + text-align: center; + color: #909399; + font-size: 12px; + user-select: none; + transition: transform 0.15s ease; + pointer-events: none; + + &.is-expanded { + transform: rotate(180deg); + } } &.is-merged .m-editor-history-list-group-head { @@ -93,6 +112,16 @@ padding: 2px 8px; font-size: 11px; color: #606266; + cursor: default; + border-radius: 3px; + + &.is-clickable { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } &.is-undone { color: #c0c4cc; @@ -145,6 +174,26 @@ &.op-update { background-color: #409eff; } + + &.op-initial { + background-color: #909399; + } + } + + .m-editor-history-list-initial { + cursor: default; + color: #606266; + border-top: 1px dashed #dcdfe6; + margin-top: 4px; + padding-top: 8px; + + &.is-clickable { + cursor: pointer; + } + + .m-editor-history-list-item-desc { + font-style: italic; + } } .m-editor-history-list-item-desc { diff --git a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts index 32dea4c0..62629ace 100644 --- a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts @@ -69,7 +69,27 @@ describe('Bucket.vue', () => { expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('group-remove-1'); }); - test('GroupRow toggle 事件被透传到 Bucket', async () => { + test('合并组头部点击 → toggle 事件被透传到 Bucket', async () => { + const wrapper = mount(Bucket, { + props: { + title: '代码块', + bucketId: 'code_1', + prefix: 'cb', + groups: [buildGroup('update', 2)], + describeGroup: () => 'g', + describeStep: () => 's', + expanded: {}, + }, + }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + const events = wrapper.emitted('toggle'); + expect(events).toBeTruthy(); + expect(events![0]).toEqual(['cb-code_1-0']); + // 合并组头部不应触发 goto + expect(wrapper.emitted('goto')).toBeFalsy(); + }); + + test('单步组头部点击 → goto 事件被透传到 Bucket,并附带 bucketId', async () => { const wrapper = mount(Bucket, { props: { title: '代码块', @@ -82,9 +102,29 @@ describe('Bucket.vue', () => { }, }); await wrapper.find('.m-editor-history-list-group-head').trigger('click'); - const events = wrapper.emitted('toggle'); + const events = wrapper.emitted('goto'); expect(events).toBeTruthy(); - expect(events![0]).toEqual(['cb-code_1-0']); + expect(events![0]).toEqual(['code_1', 0]); + }); + + test('合并组展开后点击子步 → goto 透传,附带子步 index', async () => { + const wrapper = mount(Bucket, { + props: { + title: '代码块', + bucketId: 'code_1', + prefix: 'cb', + groups: [buildGroup('update', 2)], + describeGroup: () => 'g', + describeStep: () => 's', + expanded: { 'cb-code_1-0': true }, + }, + }); + const subItems = wrapper.findAll('.m-editor-history-list-substeps li'); + expect(subItems).toHaveLength(2); + await subItems[1].trigger('click'); + const events = wrapper.emitted('goto'); + expect(events).toBeTruthy(); + expect(events![0]).toEqual(['code_1', 1]); }); test('groupKey 命名空间使用 prefix + bucketId + 索引', () => { @@ -106,4 +146,42 @@ describe('Bucket.vue', () => { // 第一组未展开,也不应有 substeps expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(false); }); + + test('groups 非空时底部追加初始项;点击透传 goto-initial 携带 bucketId', async () => { + const wrapper = mount(Bucket, { + props: { + title: '数据源', + bucketId: 'ds_1', + prefix: 'ds', + groups: [buildGroup('add', 1)], + describeGroup: () => 'g', + describeStep: () => 's', + expanded: {}, + }, + }); + const initial = wrapper.find('.m-editor-history-list-initial'); + expect(initial.exists()).toBe(true); + // 已有 applied 组,初始项不应为当前 + expect(initial.classes()).not.toContain('is-current'); + + await initial.trigger('click'); + const events = wrapper.emitted('goto-initial'); + expect(events).toBeTruthy(); + expect(events![0]).toEqual(['ds_1']); + }); + + test('该 bucket 全部组都已撤销时初始项标记为当前', () => { + const wrapper = mount(Bucket, { + props: { + title: '代码块', + bucketId: 'cb_1', + prefix: 'cb', + groups: [buildGroup('add', 1, false), buildGroup('update', 2, false)], + describeGroup: () => 'g', + describeStep: () => 's', + expanded: {}, + }, + }); + expect(wrapper.find('.m-editor-history-list-initial').classes()).toContain('is-current'); + }); }); diff --git a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts index 3bf2c5fa..b71fb35b 100644 --- a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts @@ -61,9 +61,33 @@ describe('CodeBlockTab.vue', () => { { id: 'code_1', groups: [ - buildGroup('code_1', 'add', [{ id: 'code_1', oldContent: null, newContent: { id: 'code_1', name: 'fn' } }]), - buildGroup('code_1', 'remove', [ - { id: 'code_1', oldContent: { id: 'code_1', name: 'fn' }, newContent: null }, + buildGroup('code_1', 'update', [ + { + id: 'code_1', + oldContent: { id: 'code_1', name: 'fn' }, + newContent: { id: 'code_1', name: 'fn' }, + changeRecords: [{ propPath: 'a' }], + }, + { + id: 'code_1', + oldContent: { id: 'code_1', name: 'fn' }, + newContent: { id: 'code_1', name: 'fn' }, + changeRecords: [{ propPath: 'b' }], + }, + ]), + buildGroup('code_1', 'update', [ + { + id: 'code_1', + oldContent: { id: 'code_1', name: 'fn' }, + newContent: { id: 'code_1', name: 'fn' }, + changeRecords: [{ propPath: 'c' }], + }, + { + id: 'code_1', + oldContent: { id: 'code_1', name: 'fn' }, + newContent: { id: 'code_1', name: 'fn' }, + changeRecords: [{ propPath: 'd' }], + }, ]), ], }, @@ -76,6 +100,22 @@ describe('CodeBlockTab.vue', () => { expect(wrapper.emitted('toggle')![1]).toEqual(['cb-code_1-1']); }); + test('goto 透传:携带 codeBlock id 与最后一步 index', async () => { + const buckets = [ + { + id: 'code_1', + groups: [ + buildGroup('code_1', 'add', [{ id: 'code_1', oldContent: null, newContent: { id: 'code_1', name: 'fn' } }]), + ], + }, + ]; + const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + const events = wrapper.emitted('goto'); + expect(events).toBeTruthy(); + expect(events![0]).toEqual(['code_1', 0]); + }); + test('合并组在 expanded 时展开子步', () => { const buckets = [ { diff --git a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts index 25e38b84..f41ab176 100644 --- a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts @@ -72,12 +72,32 @@ describe('DataSourceTab.vue', () => { { id: 'ds_1', groups: [ - buildGroup('ds_1', 'add', [{ id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'A' } }]), + buildGroup('ds_1', 'update', [ + { + id: 'ds_1', + oldSchema: { id: 'ds_1', title: 'A' }, + newSchema: { id: 'ds_1', title: 'A' }, + changeRecords: [{ propPath: 'a' }], + }, + { + id: 'ds_1', + oldSchema: { id: 'ds_1', title: 'A' }, + newSchema: { id: 'ds_1', title: 'A' }, + changeRecords: [{ propPath: 'b' }], + }, + ]), buildGroup('ds_1', 'update', [ { id: 'ds_1', oldSchema: { id: 'ds_1', title: 'A' }, newSchema: { id: 'ds_1', title: 'A2' }, + changeRecords: [{ propPath: 'c' }], + }, + { + id: 'ds_1', + oldSchema: { id: 'ds_1', title: 'A2' }, + newSchema: { id: 'ds_1', title: 'A3' }, + changeRecords: [{ propPath: 'd' }], }, ]), ], @@ -89,6 +109,20 @@ describe('DataSourceTab.vue', () => { expect(wrapper.emitted('toggle')![0]).toEqual(['ds-ds_1-1']); }); + test('goto 透传:携带 dataSource id 与最后一步 index', async () => { + const buckets = [ + { + id: 'ds_1', + groups: [buildGroup('ds_1', 'add', [{ id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'A' } }])], + }, + ]; + const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + const events = wrapper.emitted('goto'); + expect(events).toBeTruthy(); + expect(events![0]).toEqual(['ds_1', 0]); + }); + test('expanded 中对应 key 打开时展示子步', () => { const buckets = [ { 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 c92d196a..e57a349a 100644 --- a/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts @@ -92,11 +92,79 @@ describe('GroupRow.vue', () => { expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); }); - test('点击头部触发 toggle 事件并携带 groupKey', async () => { - const wrapper = mount(GroupRow, { props: baseProps }); + test('点击合并组头部触发 toggle 事件并携带 groupKey', async () => { + const wrapper = mount(GroupRow, { props: { ...baseProps, merged: true, stepCount: 2 } }); await wrapper.find('.m-editor-history-list-group-head').trigger('click'); const events = wrapper.emitted('toggle'); expect(events).toBeTruthy(); expect(events![0]).toEqual(['pg-0']); + // 合并组头部不应触发 goto,避免与展开/收起冲突 + expect(wrapper.emitted('goto')).toBeFalsy(); + }); + + test('点击单步组(非合并)头部触发 goto,携带该唯一 step 的 index', async () => { + const wrapper = mount(GroupRow, { + props: { + ...baseProps, + merged: false, + subSteps: [{ index: 7, applied: true, desc: 'a' }], + }, + }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + expect(wrapper.emitted('goto')).toBeTruthy(); + expect(wrapper.emitted('goto')![0]).toEqual([7]); + // 单步组没有展开概念,不应触发 toggle + expect(wrapper.emitted('toggle')).toBeFalsy(); + }); + + test('当前单步组(isCurrent=true)点击头部不触发 goto', async () => { + const wrapper = mount(GroupRow, { + props: { + ...baseProps, + merged: false, + isCurrent: true, + subSteps: [{ index: 0, applied: true, desc: 'x' }], + }, + }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + expect(wrapper.emitted('goto')).toBeFalsy(); + }); + + test('当前合并组(isCurrent=true)点击头部仍能 toggle', async () => { + const wrapper = mount(GroupRow, { + props: { + ...baseProps, + merged: true, + stepCount: 2, + isCurrent: true, + subSteps: [ + { index: 0, applied: true, desc: 'a' }, + { index: 1, applied: true, desc: 'b', isCurrent: true }, + ], + }, + }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + expect(wrapper.emitted('toggle')).toBeTruthy(); + expect(wrapper.emitted('goto')).toBeFalsy(); + }); + + test('点击子步触发 goto 携带该子步 index;当前子步点击无效', async () => { + const wrapper = mount(GroupRow, { + props: { + ...baseProps, + merged: true, + stepCount: 2, + expanded: true, + subSteps: [ + { index: 0, applied: true, desc: 'a', isCurrent: true }, + { index: 1, applied: false, desc: 'b' }, + ], + }, + }); + const subItems = wrapper.findAll('.m-editor-history-list-substeps li'); + await subItems[0].trigger('click'); + expect(wrapper.emitted('goto')).toBeFalsy(); + await subItems[1].trigger('click'); + expect(wrapper.emitted('goto')![0]).toEqual([1]); }); }); diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts index 1b2d398c..f376386c 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -9,8 +9,12 @@ import { mount } from '@vue/test-utils'; import historyService from '@editor/services/history'; +const editorService = { gotoPageStep: vi.fn(async () => 0) }; +const dataSourceService = { goto: vi.fn(() => 0) }; +const codeBlockService = { goto: vi.fn(async () => 0) }; + vi.mock('@editor/hooks/use-services', () => ({ - useServices: () => ({ historyService }), + useServices: () => ({ historyService, editorService, dataSourceService, codeBlockService }), })); vi.mock('@tmagic/design', () => ({ @@ -110,7 +114,7 @@ describe('HistoryListPanel.vue', () => { expect(descs.some((t) => t === '创建 CB (id: code_1)')).toBe(true); }); - test('点击合并组头部能切换 expanded 状态', async () => { + test('点击合并组头部能切换 expanded 状态(不触发 goto)', async () => { historyService.changePage({ id: 'p1' } as any); // 推两个修改同一节点的步骤,会合并为一个 group const mkUpdate = (path: string) => ({ @@ -138,8 +142,127 @@ describe('HistoryListPanel.vue', () => { await head.trigger('click'); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true); expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2); + // 合并组头部点击不应触发 goto + expect(editorService.gotoPageStep).not.toHaveBeenCalled(); // 再点击折叠 - await head.trigger('click'); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); }); + + test('点击页面 group 头部调用 editorService.gotoPageStep', async () => { + historyService.changePage({ id: 'p1' } as any); + historyService.push({ + opType: 'add', + nodes: [{ id: 'n1', name: 'A' }], + modifiedNodeIds: new Map(), + } as any); + historyService.push({ + opType: 'add', + nodes: [{ id: 'n2', name: 'B' }], + modifiedNodeIds: new Map(), + } as any); + + const wrapper = await factory(); + await nextTick(); + + // 第一组(页面 tab,倒序:最新一组在前,对应 step.index = 1) + const head = wrapper.find('.m-editor-history-list-group-head'); + // 当前组(最新一组)属于 isCurrent=true,点击不会触发 goto;改点第二组 + const heads = wrapper.findAll('.m-editor-history-list-group-head'); + expect(heads.length).toBeGreaterThanOrEqual(2); + // 第二行(pg-1)对应原始 step.index = 0;cursor 应为 0+1 = 1 + await heads[1].trigger('click'); + expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1); + expect(editorService.gotoPageStep).toHaveBeenCalledWith(1); + + // 当前组点击不触发 goto + await head.trigger('click'); + expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1); + }); + + test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => { + historyService.pushDataSource('ds_1', { + oldSchema: null, + newSchema: { id: 'ds_1', title: 'DS' } as any, + }); + + const wrapper = await factory(); + await nextTick(); + + // 当前 ds 组(isCurrent)点击不触发 goto;为了能触发,先撤销该步使其变为非当前 + historyService.undoDataSource('ds_1'); + await nextTick(); + + const heads = wrapper.findAll('.m-editor-history-list-group-head'); + // 找到数据源 tab 那一组 + const dsHead = heads.find((h) => h.text().includes('创建 DS')); + expect(dsHead).toBeTruthy(); + await dsHead!.trigger('click'); + expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1); + }); + + test('点击代码块组头部调用 codeBlockService.goto(id, cursor)', async () => { + historyService.pushCodeBlock('code_1', { + oldContent: null, + newContent: { id: 'code_1', name: 'CB' } as any, + }); + + const wrapper = await factory(); + await nextTick(); + + historyService.undoCodeBlock('code_1'); + await nextTick(); + + const heads = wrapper.findAll('.m-editor-history-list-group-head'); + const cbHead = heads.find((h) => h.text().includes('创建 CB')); + expect(cbHead).toBeTruthy(); + await cbHead!.trigger('click'); + expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1); + }); + + test('点击页面初始项调用 editorService.gotoPageStep(0)', async () => { + historyService.changePage({ id: 'p1' } as any); + historyService.push({ + opType: 'add', + nodes: [{ id: 'n1', name: 'A' }], + modifiedNodeIds: new Map(), + } as any); + + const wrapper = await factory(); + await nextTick(); + + // 页面 tab 列表底部应有初始项 + const initials = wrapper.findAll('.m-editor-history-list-initial'); + expect(initials.length).toBeGreaterThanOrEqual(1); + // 第一项(页面 tab)应为页面 tab 的初始项;page tab 在三个 tab 中最先渲染 + await initials[0].trigger('click'); + expect(editorService.gotoPageStep).toHaveBeenCalledWith(0); + }); + + test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => { + historyService.pushDataSource('ds_x', { + oldSchema: null, + newSchema: { id: 'ds_x', title: 'DS' } as any, + }); + historyService.pushCodeBlock('code_x', { + oldContent: null, + newContent: { id: 'code_x', name: 'CB' } as any, + }); + + const wrapper = await factory(); + await nextTick(); + + // 三个 tab 都内容齐全:page tab 因没有 page push 是空态,没有初始项; + // ds tab 与 cb tab 各 1 个 bucket → 各 1 条初始项 + const initials = wrapper.findAll('.m-editor-history-list-initial'); + expect(initials).toHaveLength(2); + + // 顺序:tab 渲染顺序是 page → data-source → code-block + // 因此 initials[0] 属于 ds_x,initials[1] 属于 code_x + await initials[0].trigger('click'); + expect(dataSourceService.goto).toHaveBeenCalledWith('ds_x', 0); + + await initials[1].trigger('click'); + expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0); + }); }); diff --git a/packages/editor/tests/unit/layouts/history-list/InitialRow.spec.ts b/packages/editor/tests/unit/layouts/history-list/InitialRow.spec.ts new file mode 100644 index 00000000..d2700766 --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/InitialRow.spec.ts @@ -0,0 +1,38 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { mount } from '@vue/test-utils'; + +import InitialRow from '@editor/layouts/history-list/InitialRow.vue'; + +describe('InitialRow.vue', () => { + test('渲染初始项的徽标与描述文案', () => { + const wrapper = mount(InitialRow, { props: { isCurrent: false } }); + expect(wrapper.find('.m-editor-history-list-initial').exists()).toBe(true); + expect(wrapper.find('.m-editor-history-list-item-op').text()).toBe('初始'); + expect(wrapper.find('.m-editor-history-list-item-op').classes()).toContain('op-initial'); + expect(wrapper.find('.m-editor-history-list-item-desc').text()).toBe('未修改的初始状态'); + }); + + test('isCurrent=true 时附 is-current 类名并显示「当前」徽标', () => { + const wrapper = mount(InitialRow, { props: { isCurrent: true } }); + expect(wrapper.find('.m-editor-history-list-initial').classes()).toContain('is-current'); + expect(wrapper.find('.m-editor-history-list-item-current').exists()).toBe(true); + }); + + test('非当前时点击触发 goto-initial 事件', async () => { + const wrapper = mount(InitialRow, { props: { isCurrent: false } }); + await wrapper.find('.m-editor-history-list-initial').trigger('click'); + expect(wrapper.emitted('goto-initial')).toBeTruthy(); + expect(wrapper.emitted('goto-initial')).toHaveLength(1); + }); + + test('当前状态点击不触发 goto-initial 事件', async () => { + const wrapper = mount(InitialRow, { props: { isCurrent: true } }); + await wrapper.find('.m-editor-history-list-initial').trigger('click'); + expect(wrapper.emitted('goto-initial')).toBeFalsy(); + }); +}); 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 4de5bb6e..182112c6 100644 --- a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts @@ -114,10 +114,41 @@ describe('PageTab.vue', () => { expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); }); - test('点击 group 头部触发 toggle 事件,携带 pg-${idx} key', async () => { + test('点击合并组头部透传 toggle 事件,携带 pg-${idx} key', async () => { const list = [ - buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]), - buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n2', name: 'B' }] }]), + // 构造合并组(≥2 步) + buildPageGroup( + 'update', + [ + { + opType: 'update', + updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }], + }, + { + opType: 'update', + updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }], + }, + ], + true, + '按钮', + 'btn', + ), + buildPageGroup( + 'update', + [ + { + opType: 'update', + updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }], + }, + { + opType: 'update', + updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }], + }, + ], + true, + '按钮2', + 'btn2', + ), ]; const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const heads = wrapper.findAll('.m-editor-history-list-group-head'); @@ -125,6 +156,17 @@ describe('PageTab.vue', () => { const events = wrapper.emitted('toggle'); expect(events).toBeTruthy(); expect(events![0]).toEqual(['pg-1']); + // 合并组头部不应触发 goto + expect(wrapper.emitted('goto')).toBeFalsy(); + }); + + test('点击单步组头部透传 goto 事件,携带该 step 的 index', async () => { + const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])]; + const wrapper = mount(PageTab, { props: { list, expanded: {} } }); + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + expect(wrapper.emitted('goto')).toBeTruthy(); + expect(wrapper.emitted('goto')![0]).toEqual([0]); + expect(wrapper.emitted('toggle')).toBeFalsy(); }); test('已撤销组(applied=false)附 is-undone 类名', () => { @@ -132,4 +174,40 @@ describe('PageTab.vue', () => { const wrapper = mount(PageTab, { props: { list, expanded: {} } }); expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone'); }); + + test('list 非空时在底部追加「初始状态」项;list 为空时不渲染', () => { + // 空 list:走空态分支,不应有初始项 + const empty = mount(PageTab, { props: { list: [], expanded: {} } }); + expect(empty.find('.m-editor-history-list-initial').exists()).toBe(false); + + // 非空 list:底部应有一条初始项 + 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-initial').exists()).toBe(true); + }); + + test('全部 group 都未 applied 时初始项标记为当前', () => { + const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)]; + const wrapper = mount(PageTab, { props: { list, expanded: {} } }); + const initial = wrapper.find('.m-editor-history-list-initial'); + expect(initial.classes()).toContain('is-current'); + }); + + test('存在已 applied 的 group 时初始项不为当前', () => { + const list = [ + buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true), + buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n2', name: 'B' }] }], false), + ]; + const wrapper = mount(PageTab, { props: { list, expanded: {} } }); + const initial = wrapper.find('.m-editor-history-list-initial'); + expect(initial.classes()).not.toContain('is-current'); + }); + + test('点击非当前的初始项透传 goto-initial 事件', async () => { + const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true)]; + const wrapper = mount(PageTab, { props: { list, expanded: {} } }); + await wrapper.find('.m-editor-history-list-initial').trigger('click'); + expect(wrapper.emitted('goto-initial')).toBeTruthy(); + expect(wrapper.emitted('goto-initial')).toHaveLength(1); + }); });