mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
feat(editor): 历史记录面板支持点击跳转与回到初始状态
- 单步组头部点击跳转到该步骤;合并组头部点击展开/收起,子步行点击跳转到具体步骤 - 列表底部新增「初始」记录项,可一键回到所有修改之前的状态 - editorService/dataSourceService/codeBlockService 新增 goto API;historyService 暴露 cursor 读取器
This commit is contained in:
parent
0446202ba6
commit
62a2ee6693
@ -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 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
|
||||
@ -66,5 +75,15 @@ defineProps<{
|
||||
defineEmits<{
|
||||
/** 透传子组件 GroupRow 的 toggle,由上层 panel 更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
/**
|
||||
* 透传子组件 GroupRow 的 goto,并附带当前 bucket 对应的 id(dataSourceId / 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 的内容拆分为独立的 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);
|
||||
};
|
||||
</script>
|
||||
|
||||
40
packages/editor/src/layouts/history-list/InitialRow.vue
Normal file
40
packages/editor/src/layouts/history-list/InitialRow.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 分组。同上。
|
||||
*/
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user