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);
+ });
});