feat(editor): 历史记录面板支持点击跳转与回到初始状态

- 单步组头部点击跳转到该步骤;合并组头部点击展开/收起,子步行点击跳转到具体步骤
- 列表底部新增「初始」记录项,可一键回到所有修改之前的状态
- editorService/dataSourceService/codeBlockService 新增 goto API;historyService 暴露 cursor 读取器
This commit is contained in:
roymondchen 2026-05-28 18:52:11 +08:00
parent 0446202ba6
commit 62a2ee6693
19 changed files with 832 additions and 27 deletions

View File

@ -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)"
/>
<!--
初始状态项永远位于该 bucket 列表底部同样按倒序展示最底部 = 最早状态
bucket 内所有 group 都未 applied 时即为当前位置
-->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial', bucketId)" />
</ul>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
defineOptions({
name: 'MEditorHistoryListBucket',
});
defineProps<{
const props = defineProps<{
/** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
title: string;
/** 当前 bucket 对应的目标 iddataSource.id 或 codeBlock.id同时用于组装子项的 key。 */
@ -66,5 +75,15 @@ defineProps<{
defineEmits<{
/** 透传子组件 GroupRow 的 toggle由上层 panel 更新 expanded。 */
(_e: 'toggle', _key: string): void;
/**
* 透传子组件 GroupRow goto并附带当前 bucket 对应的 iddataSourceId / codeBlockId
* 上层据此调用对应 service.goto(id, targetCursor)
*/
(_e: 'goto', _bucketId: string | number, _index: number): void;
/** 用户点击初始项希望该 bucket 回到未修改状态;携带 bucketId 用于上层路由到正确的 service。 */
(_e: 'goto-initial', _bucketId: string | number): void;
}>();
/** 该 bucket 是否处于初始状态(栈 cursor=0等价于全部 group 都未 applied。 */
const isInitial = computed(() => props.groups.length > 0 && props.groups.every((g) => !g.applied));
</script>

View File

@ -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)"
/>
</TMagicScrollbar>
</template>
@ -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;
}>();
</script>

View File

@ -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)"
/>
</TMagicScrollbar>
</template>
@ -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;
}>();
</script>

View File

@ -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 }"
>
<div class="m-editor-history-list-group-head" @click="$emit('toggle', groupKey)">
<div
class="m-editor-history-list-group-head"
:class="{ 'is-clickable': isHeadClickable }"
:title="headTitle"
@click="onHeadClick"
>
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }"></span>
</div>
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
<li v-for="s in subSteps" :key="s.index" :class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent }">
<li
v-for="s in subSteps"
:key="s.index"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': !s.isCurrent }"
:title="s.isCurrent ? '当前所在记录' : '点击跳转到该记录'"
@click="onSubStepClick(s)"
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span>{{ s.desc }}</span>
<span v-if="s.isCurrent" class="m-editor-history-list-item-current">当前</span>
@ -21,6 +33,8 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import { opLabel } from './composables';
@ -29,7 +43,7 @@ defineOptions({
name: 'MEditorHistoryListGroupRow',
});
defineProps<{
const props = defineProps<{
/** 唯一标识当前组的 key作为 toggle 事件的 payload 回传给上层。形如 `pg-${idx}` / `ds-${id}-${idx}` / `cb-${id}-${idx}`。 */
groupKey: string;
/** 该组当前是否处于已应用状态false 表示已被 undo 撤销UI 会显示为灰态)。 */
@ -50,8 +64,52 @@ defineProps<{
isCurrent?: boolean;
}>();
defineEmits<{
/** 用户点击组头部时触发,携带 groupKey由上层维护折叠状态。 */
const emit = defineEmits<{
/**
* 用户点击合并组头部时触发携带 groupKey上层用其切换 expanded 状态
* 对单步组非合并头部点击不会发该事件因为单步组没有"展开"的概念
*/
(_e: 'toggle', _key: string): void;
/**
* 用户希望跳转到该记录时触发携带"目标 step 在所属栈中的索引"上层据此计算目标 cursor (= index + 1)
* 触发场景
* - 单步组merged=false头部取该唯一 step index
* - 子步条目取该子步的 index
* 合并组头部不再触发 goto避免与展开/收起冲突用户应展开后点具体子步精准跳转
* 当前所在的步骤isCurrent始终不会触发 goto
*/
(_e: 'goto', _index: number): void;
}>();
/** 单步组:头部可点击 goto合并组头部可点击切换展开。当前组isCurrent的单步组头部不可点击。 */
const isHeadClickable = computed(() => {
if (props.merged) return true;
return !props.isCurrent;
});
const headTitle = computed(() => {
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.isCurrent) return '当前所在记录';
return '点击跳转到该记录';
});
/**
* 头部点击行为分流
* - 合并组仅切换展开 / 收起不触发 goto
* - 单步组跳转到该唯一步骤当前组忽略点击
*/
const onHeadClick = () => {
if (props.merged) {
emit('toggle', props.groupKey);
return;
}
if (props.isCurrent) return;
if (!props.subSteps.length) return;
emit('goto', props.subSteps[0].index);
};
const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
if (s.isCurrent) return;
emit('goto', s.index);
};
</script>

View File

@ -6,21 +6,39 @@
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}"
>
<PageTab :list="pageGroupsDisplay" :expanded="expanded" @toggle="toggleGroup" />
<PageTab
:list="pageGroupsDisplay"
:expanded="expanded"
@toggle="toggleGroup"
@goto="onPageGoto"
@goto-initial="onPageGotoInitial"
/>
</component>
<component
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
>
<DataSourceTab :buckets="dataSourceGroupsByTarget" :expanded="expanded" @toggle="toggleGroup" />
<DataSourceTab
:buckets="dataSourceGroupsByTarget"
:expanded="expanded"
@toggle="toggleGroup"
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
/>
</component>
<component
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
>
<CodeBlockTab :buckets="codeBlockGroupsByTarget" :expanded="expanded" @toggle="toggleGroup" />
<CodeBlockTab
:buckets="codeBlockGroupsByTarget"
:expanded="expanded"
@toggle="toggleGroup"
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
/>
</component>
</TMagicTabs>
</div>
@ -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 的内容拆分为独立的 SFCPageTab / 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);
};
</script>

View File

@ -0,0 +1,40 @@
<template>
<li
class="m-editor-history-list-item m-editor-history-list-initial"
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
@click="onClick"
>
<span class="m-editor-history-list-item-op op-initial">初始</span>
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
</li>
</template>
<script lang="ts" setup>
/**
* 初始状态记录行渲染于历史列表底部作为整个栈的"零点"
* - 点击该行会把对应栈撤销到 cursor === 0即没有任何已应用步骤等同于回到所有修改之前
* - 当对应栈本身已处于 cursor === 0 isCurrent=true用户已在初始状态点击不再触发动作
*
* 该行不是真实 step仅作为 UI 入口上层负责把"点击"翻译为 `service.goto*(0)`
*/
defineOptions({
name: 'MEditorHistoryListInitialRow',
});
const props = defineProps<{
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
isCurrent: boolean;
}>();
const emit = defineEmits<{
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
(_e: 'goto-initial'): void;
}>();
const onClick = () => {
if (props.isCurrent) return;
emit('goto-initial');
};
</script>

View File

@ -22,24 +22,33 @@
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${gIdx}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
作为"未修改"零点当所有 group 都未 applied 时它即为当前位置
-->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup } from '@editor/type';
import { describePageGroup, describePageStep } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
defineOptions({
name: 'MEditorHistoryListPageTab',
});
defineProps<{
const props = defineProps<{
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
list: PageHistoryGroup[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `pg-${idx}` 作为 key。 */
@ -49,5 +58,16 @@ defineProps<{
defineEmits<{
/** 透传 GroupRow 的 toggle 事件给上层 panel由其更新 expanded。 */
(_e: 'toggle', _key: string): void;
/** 透传 GroupRow 的 goto 事件,携带目标 step 在栈中的索引。 */
(_e: 'goto', _index: number): void;
/** 用户点击初始项希望回到未修改的状态cursor=0。 */
(_e: 'goto-initial'): void;
}>();
/**
* 是否处于"初始状态"即对应页面历史栈 cursor===0
* list 中所有 group applied 都为 false 时即为该状态
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底本计算可以不考虑
*/
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
</script>

View File

@ -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<number> {
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

View File

@ -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()}`;
}

View File

@ -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<number> {
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;

View File

@ -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
*/

View File

@ -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 {

View File

@ -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');
});
});

View File

@ -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 = [
{

View File

@ -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 = [
{

View File

@ -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]);
});
});

View File

@ -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 = 0cursor 应为 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_xinitials[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);
});
});

View File

@ -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();
});
});

View File

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