refactor(editor): 历史记录数据源/代码块 tab 复用通用 BucketTab

This commit is contained in:
roymondchen 2026-06-02 19:07:38 +08:00
parent 1cd69b33fe
commit 7a161cab00
19 changed files with 464 additions and 344 deletions

View File

@ -8,9 +8,9 @@
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="(group, gIdx) in groups"
:key="`${prefix}-${bucketId}-${gIdx}`"
:group-key="`${prefix}-${bucketId}-${gIdx}`"
v-for="group in groups"
:key="`${prefix}-${bucketId}-${group.steps[0]?.index}`"
:group-key="`${prefix}-${bucketId}-${group.steps[0]?.index}`"
:applied="group.applied"
:merged="group.steps.length > 1"
:op-type="group.opType"
@ -27,7 +27,8 @@
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`${prefix}-${bucketId}-${gIdx}`]"
:expanded="!!expanded[`${prefix}-${bucketId}-${group.steps[0]?.index}`]"
:goto-enabled="gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
@ -41,6 +42,7 @@
<InitialRow
v-if="showInitial !== false"
:is-current="isInitial"
:goto-enabled="gotoEnabled"
@goto-initial="$emit('goto-initial', bucketId)"
/>
</ul>
@ -87,9 +89,12 @@ const props = withDefaults(
isStepDiffable?: (_step: any) => boolean;
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto)。默认 true。 */
gotoEnabled?: boolean;
}>(),
{
showInitial: true,
gotoEnabled: true,
},
);

View File

@ -0,0 +1,77 @@
<template>
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${prefix}-${bucket.id}`"
:title="title"
:bucket-id="bucket.id"
:prefix="prefix"
:groups="bucket.groups"
:describe-group="describeGroup"
:describe-step="describeStep"
:is-step-diffable="isStepDiffable"
:expanded="expanded"
:goto-enabled="gotoEnabled"
@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)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
<script lang="ts" setup>
import { TMagicScrollbar } from '@tmagic/design';
import Bucket from './Bucket.vue';
defineOptions({
name: 'MEditorHistoryListBucketTab',
});
withDefaults(
defineProps<{
/** bucket 头部展示的标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块),与上层折叠状态 key 保持一致。 */
prefix: string;
/**
* 已按目标 id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: any[] }[];
/** 组级描述文案生成器,由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,由父组件按业务类型注入。 */
describeStep: (_step: any) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入。 */
isStepDiffable: (_step: any) => boolean;
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* tab 使用 `${prefix}-${id}-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新后已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto),透传给 Bucket。默认 true。 */
gotoEnabled?: boolean;
}>(),
{
gotoEnabled: true,
},
);
defineEmits<{
/** 透传子组件 Bucket 的 toggle 事件给上层 panel由其更新 expanded。 */
(_e: 'toggle', _key: string): void;
/** 透传 Bucket 的 goto 事件,携带目标 id 与目标 step 索引。 */
(_e: 'goto', _targetId: string | number, _index: number): void;
/** 透传 Bucket 的 goto-initial 事件,携带目标 id回到该目标未修改时的状态。 */
(_e: 'goto-initial', _targetId: string | number): void;
/** 透传 Bucket 的 diff-step 事件,携带目标 id 与 step 索引。 */
(_e: 'diff-step', _targetId: string | number, _index: number): void;
/** 透传 Bucket 的 revert-step 事件,携带目标 id 与 step 索引(类 git revert。 */
(_e: 'revert-step', _targetId: string | number, _index: number): void;
}>();
</script>

View File

@ -1,61 +0,0 @@
<template>
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`cb-${bucket.id}`"
title="代码块"
:bucket-id="bucket.id"
prefix="cb"
:groups="bucket.groups"
:describe-group="describeCodeBlockGroup"
:describe-step="describeCodeBlockStep"
:is-step-diffable="isCodeBlockStepDiffable"
: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)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
<script lang="ts" setup>
import { TMagicScrollbar } from '@tmagic/design';
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import { describeCodeBlockGroup, describeCodeBlockStep } from './composables';
defineOptions({
name: 'MEditorHistoryListCodeBlockTab',
});
defineProps<{
/**
* 已按 codeBlock.id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: CodeBlockHistoryGroup[] }[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `cb-${id}-${idx}` 作为 key。 */
expanded: Record<string, boolean>;
}>();
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;
/** 透传 Bucket 的 diff-step 事件,携带 codeBlock id 与 step 索引。 */
(_e: 'diff-step', _codeBlockId: string | number, _index: number): void;
/** 透传 Bucket 的 revert-step 事件,携带 codeBlock id 与 step 索引(类 git revert。 */
(_e: 'revert-step', _codeBlockId: string | number, _index: number): void;
}>();
/** 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
</script>

View File

@ -1,61 +0,0 @@
<template>
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`ds-${bucket.id}`"
title="数据源"
:bucket-id="bucket.id"
prefix="ds"
:groups="bucket.groups"
:describe-group="describeDataSourceGroup"
:describe-step="describeDataSourceStep"
:is-step-diffable="isDataSourceStepDiffable"
: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)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
<script lang="ts" setup>
import { TMagicScrollbar } from '@tmagic/design';
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import { describeDataSourceGroup, describeDataSourceStep } from './composables';
defineOptions({
name: 'MEditorHistoryListDataSourceTab',
});
defineProps<{
/**
* 已按 dataSource.id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: DataSourceHistoryGroup[] }[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `ds-${id}-${idx}` 作为 key。 */
expanded: Record<string, boolean>;
}>();
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;
/** 透传 Bucket 的 diff-step 事件,携带 dataSource id 与 step 索引。 */
(_e: 'diff-step', _dataSourceId: string | number, _index: number): void;
/** 透传 Bucket 的 revert-step 事件,携带 dataSource id 与 step 索引(类 git revert。 */
(_e: 'revert-step', _dataSourceId: string | number, _index: number): void;
}>();
/** 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
</script>

View File

@ -12,15 +12,9 @@
<span class="m-editor-history-list-item-index" :title="headIndexTitle">{{ headIndexLabel }}</span>
<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 && headDiffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(subSteps[0].index)"
>查看差异</span
>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
<span
v-if="!merged && headRevertable"
class="m-editor-history-list-item-revert"
@ -28,6 +22,20 @@
@click.stop="onRevertClick(subSteps[0].index)"
>回滚</span
>
<span
v-if="!merged && gotoEnabled && !isCurrent && subSteps.length"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onGotoClick(subSteps[0].index)"
>回到</span
>
<span
v-if="!merged && headDiffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(subSteps[0].index)"
>查看差异</span
>
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }"></span>
</div>
@ -35,20 +43,11 @@
<li
v-for="s in subStepsDisplay"
:key="s.index"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': gotoEnabled && !s.isCurrent }"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent }"
:title="subStepTitle(s)"
@click="onSubStepClick(s)"
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span v-if="s.isCurrent" class="m-editor-history-list-item-current">当前</span>
<span
v-if="s.diffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(s.index)"
>查看差异</span
>
<span
v-if="s.revertable"
class="m-editor-history-list-item-revert"
@ -56,6 +55,20 @@
@click.stop="onRevertClick(s.index)"
>回滚</span
>
<span
v-if="gotoEnabled && !s.isCurrent"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onGotoClick(s.index)"
>回到</span
>
<span
v-if="s.diffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(s.index)"
>查看差异</span
>
</li>
</ul>
</li>
@ -74,7 +87,7 @@ defineOptions({
const props = withDefaults(
defineProps<{
/** 唯一标识当前组的 key作为 toggle 事件的 payload 回传给上层。形如 `pg-${idx}` / `ds-${id}-${idx}` / `cb-${id}-${idx}`。 */
/** 唯一标识当前组的 key作为 toggle 事件的 payload 回传给上层。形如 `pg-${首步 index}` / `ds-${id}-${首步 index}` / `cb-${id}-${首步 index}`,以稳定的 step 索引标识分组。 */
groupKey: string;
/** 该组当前是否处于已应用状态false 表示已被 undo 撤销UI 会显示为灰态)。 */
applied: boolean;
@ -142,47 +155,34 @@ const emit = defineEmits<{
}>();
/**
* 单步组头部可点击 goto gotoEnabled合并组头部可点击切换展开
* 当前组isCurrent或禁用 goto 单步组头部不可点击
* 仅合并组头部可点击切换展开 / 收起
* 单步组的跳转改由头部的回退按钮触发整行不再可点击
*/
const isHeadClickable = computed(() => {
if (props.merged) return true;
return props.gotoEnabled && !props.isCurrent;
});
const isHeadClickable = computed(() => props.merged);
const headTitle = computed(() => {
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.isCurrent) return '当前所在记录';
if (!props.gotoEnabled) return '';
return '点击跳转到该记录';
return '';
});
/**
* 头部点击行为分流
* - 合并组仅切换展开 / 收起不触发 goto
* - 单步组跳转到该唯一步骤当前组忽略点击
* 头部点击行为仅合并组切换展开 / 收起单步组不再响应整行点击
*/
const onHeadClick = () => {
if (props.merged) {
emit('toggle', props.groupKey);
return;
}
if (props.isCurrent) return;
if (!props.gotoEnabled) return;
if (!props.subSteps.length) return;
emit('goto', props.subSteps[0].index);
};
const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
if (s.isCurrent) return;
const onGotoClick = (index: number) => {
if (!props.gotoEnabled) return;
emit('goto', s.index);
emit('goto', index);
};
const subStepTitle = (s: { isCurrent?: boolean }) => {
if (s.isCurrent) return '当前所在记录';
if (!props.gotoEnabled) return '';
return '点击跳转到该记录';
return '';
};
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */

View File

@ -59,7 +59,11 @@
</div>
<template #footer>
<TMagicButton size="small" @click="visible = false">关闭</TMagicButton>
<template v-if="onConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
</template>
</TMagicDialog>
</Teleport>
@ -74,13 +78,13 @@ import type { FormState } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
import type { CompareCategory, CompareFormLoadConfig } from '@editor/type';
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload } from '@editor/type';
defineOptions({
name: 'MEditorHistoryDiffDialog',
});
withDefaults(
const props = withDefaults(
defineProps<{
/**
* 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState
@ -94,32 +98,13 @@ withDefaults(
*/
loadConfig?: CompareFormLoadConfig;
width?: string;
onConfirm?: () => void;
}>(),
{
width: '900px',
},
);
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
category: CompareCategory;
/** 节点类型 / 数据源类型 */
type?: string;
/** 代码块场景下的数据源类型 */
dataSourceType?: string;
/** 该 step 修改前的值oldNode / oldSchema / oldContent */
lastValue: Record<string, any>;
/** 该 step 修改后的值newNode / newSchema / newContent */
value: Record<string, any>;
/** 当前编辑器中实际的最新值;不传或为 null 时禁用「与当前对比」 */
currentValue?: Record<string, any> | null;
/** 用于标题展示的目标名称 */
targetLabel?: string;
/** 用于标题展示的目标 id */
id?: string | number;
}
/**
* 差异对比模式
* - before该步骤修改前 vs 该步骤修改后默认行为体现这一步带来的变化
@ -184,6 +169,12 @@ const isSameAsCurrent = computed(() => {
return isEqual(payload.value.value, payload.value.currentValue);
});
const onConfirmClick = () => {
const cb = props.onConfirm;
visible.value = false;
cb?.();
};
const targetText = computed(() => {
if (!payload.value) return '';
const categoryText: Record<CompareCategory, string> = {

View File

@ -36,9 +36,14 @@
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
>
<DataSourceTab
<BucketTab
title="数据源"
prefix="ds"
:buckets="dataSourceGroupsByTarget"
:expanded="expanded"
:describe-group="describeDataSourceGroup"
:describe-step="describeDataSourceStep"
:is-step-diffable="isDataSourceStepDiffable"
@toggle="toggleGroup"
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
@ -52,9 +57,14 @@
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
>
<CodeBlockTab
<BucketTab
title="代码块"
prefix="cb"
:buckets="codeBlockGroupsByTarget"
:expanded="expanded"
:describe-group="describeCodeBlockGroup"
:describe-step="describeCodeBlockStep"
:is-step-diffable="isCodeBlockStepDiffable"
@toggle="toggleGroup"
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
@ -85,7 +95,7 @@
</template>
</TMagicPopover>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" :on-confirm="onConfirmRevert" />
</template>
<script lang="ts" setup>
@ -106,10 +116,11 @@
* 此外每条 step 上提供"查看差异"入口仅在前后值都存在的 update 步骤显示
* 点击后弹出 HistoryDiffDialog使用 CompareForm 组件以表单形式展示新旧值差异
*
* tab 的内容拆分为独立的 SFCPageTab / DataSourceTab / CodeBlockTab
* tab 的内容拆分为独立的 SFC页面用 PageTab数据源 / 代码块复用通用的 BucketTab
* 通过 title / prefix / describe* / isStepDiffable
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
@ -117,11 +128,16 @@ import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import type { HistoryListExtraTab } from '@editor/type';
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type';
import CodeBlockTab from './CodeBlockTab.vue';
import { useHistoryList } from './composables';
import DataSourceTab from './DataSourceTab.vue';
import BucketTab from './BucketTab.vue';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
useHistoryList,
} from './composables';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
import PageTab from './PageTab.vue';
@ -183,6 +199,12 @@ const {
codeBlockGroupsByTarget,
} = useHistoryList();
/** 数据源 step 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
/** 代码块 step 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
const indexToCursor = (index: number) => index + 1;
@ -214,40 +236,25 @@ const onCodeBlockGotoInitial = (id: string | number) => {
codeBlockService.goto(id, 0);
};
/**
* 回滚入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*/
const onPageRevert = (index: number) => {
editorService.revertPageStep(index);
};
const onDataSourceRevert = (id: string | number, index: number) => {
dataSourceService.revert(id, index);
};
const onCodeBlockRevert = (id: string | number, index: number) => {
codeBlockService.revert(id, index);
};
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
/**
* 页面 step 差异 update 单节点修改可对比传入旧/新节点
* 构造页面 step 的差异弹窗入参 update 单节点修改可对比传入旧/新节点
* 节点类型 `type` 优先取 newNode.type再回退 oldNode.type
* `currentValue` 取自 editorService 中该节点当前实际值用于支持与当前对比
* 无可对比内容如多节点 / add / remove时返回 null
*/
const onPageDiff = (index: number) => {
const buildPageDiffPayload = (index: number): DiffDialogPayload | null => {
const groups = historyService.getPageHistoryGroups();
for (const group of groups) {
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const item = entry.step.updatedItems?.[0];
if (!item?.oldNode || !item?.newNode) return;
if (!item?.oldNode || !item?.newNode) return null;
const type = (item.newNode.type as string) || (item.oldNode.type as string) || '';
const nodeId = item.newNode.id ?? item.oldNode.id;
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
diffDialogRef.value?.open({
return {
category: 'node',
type,
lastValue: item.oldNode as Record<string, any>,
@ -255,21 +262,35 @@ const onPageDiff = (index: number) => {
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
id: nodeId,
});
return;
};
}
return null;
};
const onDataSourceDiff = (id: string | number, index: number) => {
const groups = historyService.getDataSourceHistoryGroups();
/**
* 在指定分组列表中按 id / index 查找命中的 step命中后交由 build 构造差异弹窗入参
* 用于统一数据源代码块两类历史的查找逻辑
*/
const findGroupStep = <G extends { id: string | number; steps: { index: number; step: any }[] }>(
groups: G[],
id: string | number,
index: number,
build: (_step: G['steps'][number]['step']) => DiffDialogPayload | null,
): DiffDialogPayload | null => {
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const { oldSchema, newSchema } = entry.step;
if (!oldSchema || !newSchema) return;
return build(entry.step);
}
return null;
};
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, ({ oldSchema, newSchema }) => {
if (!oldSchema || !newSchema) return null;
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
diffDialogRef.value?.open({
return {
category: 'data-source',
type: newSchema.type || oldSchema.type || 'base',
lastValue: oldSchema as Record<string, any>,
@ -277,29 +298,74 @@ const onDataSourceDiff = (id: string | number, index: number) => {
currentValue: (currentSchema as Record<string, any>) || null,
targetLabel: newSchema.title || oldSchema.title || `${id}`,
id,
});
return;
}
};
};
});
const onCodeBlockDiff = (id: string | number, index: number) => {
const groups = historyService.getCodeBlockHistoryGroups();
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const { oldContent, newContent } = entry.step;
if (!oldContent || !newContent) return;
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, ({ oldContent, newContent }) => {
if (!oldContent || !newContent) return null;
const currentContent = codeBlockService.getCodeContentById(id);
diffDialogRef.value?.open({
return {
category: 'code-block',
lastValue: oldContent as Record<string, any>,
value: newContent as Record<string, any>,
currentValue: (currentContent as Record<string, any>) || null,
targetLabel: newContent.name || oldContent.name || `${id}`,
id,
});
return;
};
});
const onPageDiff = (index: number) => {
const payload = buildPageDiffPayload(index);
if (payload) diffDialogRef.value?.open(payload);
};
const onDataSourceDiff = (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
const onCodeBlockDiff = (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
const onConfirmRevert = shallowRef();
/**
* 回滚入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*
* 交互先弹出该步骤的差异弹窗供用户确认点击确定回滚后再真正执行回滚
* 对没有可对比内容的步骤 add / remove / 多节点更新则直接回滚
*/
const onPageRevert = (index: number) => {
const payload = buildPageDiffPayload(index);
onConfirmRevert.value = () => editorService.revertPageStep(index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
}
};
const onDataSourceRevert = (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
onConfirmRevert.value = () => dataSourceService.revert(id, index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
}
};
const onCodeBlockRevert = (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
onConfirmRevert.value = () => codeBlockService.revert(id, index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
}
};
</script>

View File

@ -3,12 +3,17 @@
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-index" title="历史步骤编号 #0未修改的初始状态">#0</span>
<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>
<span
v-if="gotoEnabled && !isCurrent"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onClick"
>回到</span
>
</li>
</template>
@ -24,10 +29,16 @@ defineOptions({
name: 'MEditorHistoryListInitialRow',
});
const props = defineProps<{
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
isCurrent: boolean;
}>();
const props = withDefaults(
defineProps<{
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
isCurrent: boolean;
gotoEnabled?: boolean;
}>(),
{
gotoEnabled: true,
},
);
const emit = defineEmits<{
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */

View File

@ -3,9 +3,9 @@
<TMagicScrollbar v-else max-height="360px">
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="(group, gIdx) in list"
:key="`pg-${gIdx}`"
:group-key="`pg-${gIdx}`"
v-for="group in list"
:key="`pg-${group.steps[0]?.index}`"
:group-key="`pg-${group.steps[0]?.index}`"
:applied="group.applied"
:merged="group.steps.length > 1"
:op-type="group.opType"
@ -22,7 +22,7 @@
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${gIdx}`]"
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@ -55,7 +55,11 @@ defineOptions({
const props = defineProps<{
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
list: PageHistoryGroup[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `pg-${idx}` 作为 key。 */
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* tab 使用 `pg-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新新增 / 撤销重做导致列表顺序变化已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
}>();

View File

@ -22,7 +22,10 @@ import type {
export const useHistoryList = () => {
const { historyService } = useServices();
/** 折叠状态key 形如 `pg-${groupIdx}` / `ds-${id}-${groupIdx}` / `cb-${id}-${groupIdx}`。 */
/**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}`
* index key
*/
const expanded = reactive<Record<string, boolean>>({});
const toggleGroup = (key: string) => {
expanded[key] = !expanded[key];

View File

@ -125,19 +125,19 @@
&.is-merged {
margin: 4px 0;
padding: 4px 8px 6px;
background-color: rgba(144, 105, 219, 0.06);
border: 1px solid rgba(144, 105, 219, 0.18);
border-left: 3px solid #9069db;
background-color: rgba(47, 84, 235, 0.06);
border: 1px solid rgba(47, 84, 235, 0.18);
border-left: 3px solid #2f54eb;
border-radius: 4px;
// 卡片本体已经有背景色hover 状态以更深的同色提示交互
&:hover {
background-color: rgba(144, 105, 219, 0.1);
background-color: rgba(47, 84, 235, 0.1);
}
.m-editor-history-list-group-head {
font-weight: 600;
color: #5b3fa5;
color: #1d39c4;
}
// 已撤销态整张卡片去色
@ -169,7 +169,7 @@
margin: 6px 0 0 6px;
padding: 0;
list-style: none;
border-left: 1px dashed rgba(144, 105, 219, 0.45);
border-left: 1px dashed rgba(47, 84, 235, 0.45);
li {
display: flex;
@ -185,7 +185,7 @@
cursor: pointer;
&:hover {
background-color: rgba(144, 105, 219, 0.1);
background-color: rgba(47, 84, 235, 0.1);
}
}
@ -240,7 +240,7 @@
}
&.op-update {
background-color: #409eff;
background-color: #e6a23c;
}
&.op-initial {
@ -279,7 +279,7 @@
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #9069db;
background-color: #2f54eb;
font-weight: 500;
letter-spacing: 0.2px;
}
@ -300,21 +300,39 @@
}
}
// 跳转按钮将历史游标移动到该 step替代原先点击整行跳转的交互
// 使用与组卡片一致的紫色色系查看差异回滚区分开
.m-editor-history-list-item-goto {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #606266;
background-color: rgba(96, 98, 102, 0.1);
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(96, 98, 102, 0.18);
}
}
// 回滚按钮 git revert把目标 step 反向应用一次作为新提交
// 使用与查看差异不同的色调橙黄用来区分"可逆操作""只读对比"
// 使用红色色调强调其为"破坏性/可逆操作"查看差异跳转区分开
.m-editor-history-list-item-revert {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #e6a23c;
background-color: rgba(230, 162, 60, 0.12);
color: #f56c6c;
background-color: rgba(245, 108, 108, 0.12);
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(230, 162, 60, 0.25);
background-color: rgba(245, 108, 108, 0.25);
}
}

View File

@ -1113,3 +1113,23 @@ export interface DslOpOptions extends HistoryOpOptions {
doNotSelect?: boolean;
doNotSwitchPage?: boolean;
}
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
category: CompareCategory;
/** 节点类型 / 数据源类型 */
type?: string;
/** 代码块场景下的数据源类型 */
dataSourceType?: string;
/** 该 step 修改前的值oldNode / oldSchema / oldContent */
lastValue: Record<string, any>;
/** 该 step 修改后的值newNode / newSchema / newContent */
value: Record<string, any>;
/** 当前编辑器中实际的最新值;不传或为 null 时禁用「与当前对比」 */
currentValue?: Record<string, any> | null;
/** 用于标题展示的目标名称 */
targetLabel?: string;
/** 用于标题展示的目标 id */
id?: string | number;
}

View File

@ -90,7 +90,7 @@ describe('Bucket.vue', () => {
expect(wrapper.emitted('goto')).toBeFalsy();
});
test('单步组头部点击 → goto 事件被透传到 Bucket并附带 bucketId', async () => {
test('单步组「回到」按钮点击 → goto 事件被透传到 Bucket并附带 bucketId', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
@ -102,13 +102,13 @@ describe('Bucket.vue', () => {
expanded: {},
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
const events = wrapper.emitted('goto');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['code_1', 0]);
});
test('合并组展开后点击子步 → goto 透传,附带子步 index', async () => {
test('合并组展开后点击子步「回到」按钮 → goto 透传,附带子步 index', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
@ -123,7 +123,7 @@ describe('Bucket.vue', () => {
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
expect(subItems).toHaveLength(2);
// 子步倒序渲染subItems[0] 对应 index=1
await subItems[0].trigger('click');
await subItems[0].find('.m-editor-history-list-item-goto').trigger('click');
const events = wrapper.emitted('goto');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['code_1', 1]);
@ -166,7 +166,7 @@ describe('Bucket.vue', () => {
// 已有 applied 组,初始项不应为当前
expect(initial.classes()).not.toContain('is-current');
await initial.trigger('click');
await initial.find('.m-editor-history-list-item-goto').trigger('click');
const events = wrapper.emitted('goto-initial');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['ds_1']);

View File

@ -7,8 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import CodeBlockTab from '@editor/layouts/history-list/CodeBlockTab.vue';
import type { CodeBlockHistoryGroup } from '@editor/type';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import { describeCodeBlockGroup, describeCodeBlockStep } from '@editor/layouts/history-list/composables';
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({
@ -25,17 +26,31 @@ const buildGroup = (
opType: 'add' | 'remove' | 'update',
steps: any[],
applied = true,
startIndex = 0,
): CodeBlockHistoryGroup => ({
kind: 'code-block',
id,
opType,
applied,
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
});
/** 代码块 tab 复用通用 BucketTab固定注入代码块的 title/prefix/describe/isStepDiffable。 */
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, {
props: {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent),
...props,
},
});
describe('CodeBlockTab.vue', () => {
test('buckets 为空时显示空态', () => {
const wrapper = mount(CodeBlockTab, { props: { buckets: [], expanded: {} } });
const wrapper = mountCodeBlockTab({ buckets: [], expanded: {} });
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
});
@ -48,7 +63,7 @@ describe('CodeBlockTab.vue', () => {
],
},
];
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
const wrapper = mountCodeBlockTab({ buckets, expanded: {} });
expect(wrapper.find('.m-editor-history-list-bucket-title').text()).toContain('代码块');
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
@ -75,29 +90,35 @@ describe('CodeBlockTab.vue', () => {
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' }],
},
]),
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' }],
},
],
true,
2,
),
],
},
];
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
const wrapper = mountCodeBlockTab({ buckets, expanded: {} });
const heads = wrapper.findAll('.m-editor-history-list-group-head');
await heads[0].trigger('click');
expect(wrapper.emitted('toggle')![0]).toEqual(['cb-code_1-0']);
await heads[1].trigger('click');
expect(wrapper.emitted('toggle')![1]).toEqual(['cb-code_1-1']);
expect(wrapper.emitted('toggle')![1]).toEqual(['cb-code_1-2']);
});
test('goto 透传:携带 codeBlock id 与最后一步 index', async () => {
@ -109,8 +130,8 @@ describe('CodeBlockTab.vue', () => {
],
},
];
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
const wrapper = mountCodeBlockTab({ buckets, expanded: {} });
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
const events = wrapper.emitted('goto');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['code_1', 0]);
@ -138,9 +159,7 @@ describe('CodeBlockTab.vue', () => {
],
},
];
const wrapper = mount(CodeBlockTab, {
props: { buckets, expanded: { 'cb-code_1-0': true } },
});
const wrapper = mountCodeBlockTab({ buckets, expanded: { 'cb-code_1-0': true } });
const items = wrapper.findAll('.m-editor-history-list-substeps li');
expect(items).toHaveLength(2);
// 子步倒序渲染最新在上params 在前content 在后

View File

@ -7,8 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import DataSourceTab from '@editor/layouts/history-list/DataSourceTab.vue';
import type { DataSourceHistoryGroup } from '@editor/type';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import { describeDataSourceGroup, describeDataSourceStep } from '@editor/layouts/history-list/composables';
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({
@ -25,17 +26,31 @@ const buildGroup = (
opType: 'add' | 'remove' | 'update',
steps: any[],
applied = true,
startIndex = 0,
): DataSourceHistoryGroup => ({
kind: 'data-source',
id,
opType,
applied,
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
});
/** 数据源 tab 复用通用 BucketTab固定注入数据源的 title/prefix/describe/isStepDiffable。 */
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, {
props: {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema),
...props,
},
});
describe('DataSourceTab.vue', () => {
test('buckets 为空时显示空态', () => {
const wrapper = mount(DataSourceTab, { props: { buckets: [], expanded: {} } });
const wrapper = mountDataSourceTab({ buckets: [], expanded: {} });
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
});
@ -52,7 +67,7 @@ describe('DataSourceTab.vue', () => {
],
},
];
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
const wrapper = mountDataSourceTab({ buckets, expanded: {} });
const titles = wrapper.findAll('.m-editor-history-list-bucket-title');
expect(titles).toHaveLength(2);
expect(titles[0].text()).toContain('数据源');
@ -86,27 +101,33 @@ describe('DataSourceTab.vue', () => {
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' }],
},
]),
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' }],
},
],
true,
2,
),
],
},
];
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
const wrapper = mountDataSourceTab({ buckets, expanded: {} });
const heads = wrapper.findAll('.m-editor-history-list-group-head');
await heads[1].trigger('click');
expect(wrapper.emitted('toggle')![0]).toEqual(['ds-ds_1-1']);
expect(wrapper.emitted('toggle')![0]).toEqual(['ds-ds_1-2']);
});
test('goto 透传:携带 dataSource id 与最后一步 index', async () => {
@ -116,8 +137,8 @@ describe('DataSourceTab.vue', () => {
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 wrapper = mountDataSourceTab({ buckets, expanded: {} });
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
const events = wrapper.emitted('goto');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['ds_1', 0]);
@ -145,9 +166,7 @@ describe('DataSourceTab.vue', () => {
],
},
];
const wrapper = mount(DataSourceTab, {
props: { buckets, expanded: { 'ds-ds_1-0': true } },
});
const wrapper = mountDataSourceTab({ buckets, expanded: { 'ds-ds_1-0': true } });
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
});
});

View File

@ -103,7 +103,7 @@ describe('GroupRow.vue', () => {
expect(wrapper.emitted('goto')).toBeFalsy();
});
test('点击单步组(非合并)头部触发 goto携带该唯一 step 的 index', async () => {
test('点击单步组(非合并)的「回到」按钮触发 goto携带该唯一 step 的 index', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
@ -111,7 +111,11 @@ describe('GroupRow.vue', () => {
subSteps: [{ index: 7, applied: true, desc: 'a' }],
},
});
// 点击头部本身不再触发 goto整行不可点击
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
expect(wrapper.emitted('goto')).toBeFalsy();
// 仅点击「回到」按钮才触发 goto
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')).toBeTruthy();
expect(wrapper.emitted('goto')![0]).toEqual([7]);
// 单步组没有展开概念,不应触发 toggle
@ -149,7 +153,7 @@ describe('GroupRow.vue', () => {
expect(wrapper.emitted('goto')).toBeFalsy();
});
test('点击子步触发 goto 携带该子步 index当前子步点击无效', async () => {
test('点击子步「回退」按钮触发 goto 携带该子步 index当前子步无回退按钮', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
@ -162,11 +166,14 @@ describe('GroupRow.vue', () => {
],
},
});
// 子步倒序渲染subItems[0] 为 index=1非当前可点击subItems[1] 为 index=0当前
// 子步倒序渲染subItems[0] 为 index=1非当前含跳转按钮subItems[1] 为 index=0当前无跳转按钮
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
await subItems[1].trigger('click');
expect(wrapper.emitted('goto')).toBeFalsy();
expect(subItems[1].find('.m-editor-history-list-item-goto').exists()).toBe(false);
// 点击子步行本身不再触发 goto
await subItems[0].trigger('click');
expect(wrapper.emitted('goto')).toBeFalsy();
// 仅点击「跳转」按钮才触发 goto
await subItems[0].find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')![0]).toEqual([1]);
});
});

View File

@ -187,11 +187,11 @@ describe('HistoryListPanel.vue', () => {
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');
await heads[1].find('.m-editor-history-list-item-goto').trigger('click');
expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1);
expect(editorService.gotoPageStep).toHaveBeenCalledWith(1);
// 当前组点击不触发 goto
// 当前组没有「回到」按钮,点击头部不触发 goto
await head.trigger('click');
expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1);
});
@ -213,7 +213,7 @@ describe('HistoryListPanel.vue', () => {
// 找到数据源 tab 那一组
const dsHead = heads.find((h) => h.text().includes('创建 DS'));
expect(dsHead).toBeTruthy();
await dsHead!.trigger('click');
await dsHead!.find('.m-editor-history-list-item-goto').trigger('click');
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1);
});
@ -232,7 +232,7 @@ describe('HistoryListPanel.vue', () => {
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');
await cbHead!.find('.m-editor-history-list-item-goto').trigger('click');
expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1);
});
@ -251,7 +251,7 @@ describe('HistoryListPanel.vue', () => {
const initials = wrapper.findAll('.m-editor-history-list-initial');
expect(initials.length).toBeGreaterThanOrEqual(1);
// 第一项(页面 tab应为页面 tab 的初始项page tab 在三个 tab 中最先渲染
await initials[0].trigger('click');
await initials[0].find('.m-editor-history-list-item-goto').trigger('click');
expect(editorService.gotoPageStep).toHaveBeenCalledWith(0);
});
@ -307,10 +307,10 @@ describe('HistoryListPanel.vue', () => {
// 顺序tab 渲染顺序是 page → data-source → code-block
// 因此 initials[0] 属于 ds_xinitials[1] 属于 code_x
await initials[0].trigger('click');
await initials[0].find('.m-editor-history-list-item-goto').trigger('click');
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_x', 0);
await initials[1].trigger('click');
await initials[1].find('.m-editor-history-list-item-goto').trigger('click');
expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0);
});
});

View File

@ -17,15 +17,15 @@ describe('InitialRow.vue', () => {
expect(wrapper.find('.m-editor-history-list-item-desc').text()).toBe('未修改的初始状态');
});
test('isCurrent=true 时附 is-current 类名并显示「当前」徽标', () => {
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);
expect(wrapper.find('.m-editor-history-list-item-goto').exists()).toBe(false);
});
test('非当前时点击触发 goto-initial 事件', async () => {
test('非当前时点击「回到」按钮触发 goto-initial 事件', async () => {
const wrapper = mount(InitialRow, { props: { isCurrent: false } });
await wrapper.find('.m-editor-history-list-initial').trigger('click');
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto-initial')).toBeTruthy();
expect(wrapper.emitted('goto-initial')).toHaveLength(1);
});

View File

@ -26,6 +26,7 @@ const buildPageGroup = (
applied = true,
targetName?: string,
targetId?: string,
startIndex = 0,
): PageHistoryGroup => ({
kind: 'page',
pageId: 'p1',
@ -33,7 +34,7 @@ const buildPageGroup = (
applied,
targetId,
targetName,
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
});
describe('PageTab.vue', () => {
@ -148,6 +149,7 @@ describe('PageTab.vue', () => {
true,
'按钮2',
'btn2',
2,
),
];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
@ -155,15 +157,15 @@ describe('PageTab.vue', () => {
await heads[1].trigger('click');
const events = wrapper.emitted('toggle');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['pg-1']);
expect(events![0]).toEqual(['pg-2']);
// 合并组头部不应触发 goto
expect(wrapper.emitted('goto')).toBeFalsy();
});
test('点击单步组头部透传 goto 事件,携带该 step 的 index', async () => {
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');
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')).toBeTruthy();
expect(wrapper.emitted('goto')![0]).toEqual([0]);
expect(wrapper.emitted('toggle')).toBeFalsy();
@ -203,10 +205,10 @@ describe('PageTab.vue', () => {
expect(initial.classes()).not.toContain('is-current');
});
test('点击非当前初始项透传 goto-initial 事件', async () => {
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');
await wrapper.find('.m-editor-history-list-initial .m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto-initial')).toBeTruthy();
expect(wrapper.emitted('goto-initial')).toHaveLength(1);
});