refactor(editor): 优化历史记录列表复用

This commit is contained in:
roymondchen 2026-06-08 20:09:10 +08:00
parent 614f12adf3
commit a965dfb06e
25 changed files with 1037 additions and 851 deletions

View File

@ -1,7 +1,7 @@
<template>
<div class="m-editor-history-list-bucket">
<div class="m-editor-history-list-bucket-title">
<span>{{ title }}</span>
<span>{{ config.title }}</span>
<code>{{ String(bucketId) }}</code>
<span class="m-editor-history-list-bucket-count">{{ groups.length }} 组</span>
</div>
@ -9,33 +9,10 @@
<ul class="m-editor-history-list-ul">
<GroupRow
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"
:desc="describeGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
saved: s.step.saved,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`${prefix}-${bucketId}-${group.steps[0]?.index}`]"
:goto-enabled="gotoEnabled"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
:goto-enabled="config.gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
@ -44,12 +21,12 @@
<!--
初始状态项:永远位于该 bucket 列表底部(同样按倒序展示,最底部 = 最早状态)。
当 bucket 内所有 group 都未 applied 时即为当前位置。
showInitial=false 时不展示(用于没有"撤销到初始状态"语义的自定义历史,如业务模块历史)。
config.showInitial=false 时不展示(用于没有"撤销到初始状态"语义的自定义历史,如业务模块历史)。
-->
<InitialRow
v-if="showInitial !== false"
v-if="config.showInitial !== false"
:is-current="isInitial"
:goto-enabled="gotoEnabled"
:goto-enabled="config.gotoEnabled"
@goto-initial="$emit('goto-initial', bucketId)"
/>
</ul>
@ -61,8 +38,8 @@ import { computed } from 'vue';
import type { BaseStepValue } from '@editor/type';
import type { HistoryBucketGroup } from './composables';
import { formatHistoryFullTime, formatHistoryTime, groupSource, groupTimestamp } from './composables';
import type { HistoryBucketConfig, HistoryBucketGroup, HistoryRowGroup } from './composables';
import { toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -70,39 +47,19 @@ defineOptions({
name: 'MEditorHistoryListBucket',
});
const props = withDefaults(
defineProps<{
/** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
title: string;
/** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
bucketId: string | number;
/**
* 子项 key 的命名空间前缀:内置 `ds` 表示数据源,`cb` 表示代码块;
* 业务方复用 Bucket 时可传入自定义前缀(如 `mod`)。与上层折叠状态 key 保持一致。
*/
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false 关闭。 */
showInitial?: boolean;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: HistoryBucketGroup<T>[];
/** 组级描述文案生成器,接收一个 group,返回展示文本。由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step,返回展示文本。用于合并组展开后的子步列表。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。由父组件按业务类型注入;不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto)。默认 true。 */
gotoEnabled?: boolean;
}>(),
{
showInitial: true,
gotoEnabled: true,
},
);
const props = defineProps<{
/**
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled)。
* 由父组件按业务类型注入,组件内部按需读取,避免逐项透传多个 props。
*/
config: HistoryBucketConfig<T>;
/** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
bucketId: string | number;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: HistoryBucketGroup<T>[];
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传子组件 GroupRow 的 toggle,由上层 panel 更新 expanded。 */
@ -120,6 +77,15 @@ defineEmits<{
(_e: 'revert-step', _bucketId: string | number, _index: number): void;
}>();
/**
* 子项 / 折叠状态 key:`${prefix}-${bucketId}-${组内首步 index}`。
* 以稳定的 step 索引(而非展示位置)标识分组,历史数据更新后已展开的分组状态仍能正确保持。
*/
const rowKey = (group: HistoryBucketGroup<T>) => `${props.config.prefix}-${props.bucketId}-${group.steps[0]?.index}`;
/** 把原始分组派生为 GroupRow 直接消费的视图模型。 */
const toRow = (group: HistoryBucketGroup<T>): HistoryRowGroup => toRowGroup(group, rowKey(group), props.config);
/** 该 bucket 是否处于初始状态(栈 cursor=0),等价于全部 group 都未 applied。 */
const isInitial = computed(() => props.groups.length > 0 && props.groups.every((g) => !g.applied));
</script>

View File

@ -2,22 +2,18 @@
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" :title="`清空${title}的历史记录`" @click="$emit('clear')">清空</span>
<span class="m-editor-history-list-clear" :title="`清空${config.title}的历史记录`" @click="$emit('clear')"
>清空</span
>
</div>
<TMagicScrollbar max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${prefix}-${bucket.id}`"
:title="title"
:key="`${config.prefix}-${bucket.id}`"
:config="config"
:bucket-id="bucket.id"
:prefix="prefix"
:groups="bucket.groups"
:describe-group="describeGroup"
:describe-step="describeStep"
:is-step-diffable="isStepDiffable"
:is-step-revertable="isStepRevertable"
: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)"
@ -34,44 +30,30 @@ import { TMagicScrollbar } from '@tmagic/design';
import type { BaseStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import type { HistoryBucketGroup } from './composables';
import type { HistoryBucketConfig, HistoryBucketGroup } from './composables';
defineOptions({
name: 'MEditorHistoryListBucketTab',
});
withDefaults(
defineProps<{
/** bucket 头部展示的标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块),与上层折叠状态 key 保持一致。 */
prefix: string;
/**
* 已按目标 id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
* 空数组时显示空态。
*/
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
/** 组级描述文案生成器,由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,由父组件按业务类型注入。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入。 */
isStepDiffable: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。由父组件按业务类型注入;不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
/**
* 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
* 本 tab 使用 `${prefix}-${id}-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
* 这样历史数据更新后已展开的分组状态仍能正确保持。
*/
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto),透传给 Bucket。默认 true。 */
gotoEnabled?: boolean;
}>(),
{
gotoEnabled: true,
},
);
defineProps<{
/**
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled),
* 由父组件按业务类型注入并整体透传给 Bucket,避免逐项透传多个 props。
*/
config: HistoryBucketConfig<T>;
/**
* 已按目标 id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
* 空数组时显示空态。
*/
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
/**
* 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
* key 形如 `${prefix}-${id}-${组内首步 index}`——以稳定的 step 索引而非展示位置标识分组,
* 这样历史数据更新后已展开的分组状态仍能正确保持。
*/
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */

View File

@ -1,7 +1,7 @@
<template>
<li
class="m-editor-history-list-item m-editor-history-list-group"
:class="{ 'is-undone': !applied, 'is-merged': merged, 'is-current': isCurrent }"
:class="{ 'is-undone': !group.applied, 'is-merged': merged, 'is-current': group.isCurrent }"
>
<div
class="m-editor-history-list-group-head"
@ -10,19 +10,24 @@
@click="onHeadClick"
>
<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 class="m-editor-history-list-item-op" :class="`op-${group.opType}`">{{ opLabel(group.opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ group.desc }}</span>
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="!merged && sourceLabel(source)"
v-if="!merged && sourceLabel(group.source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(source)}`"
>{{ sourceLabel(source) }}</span
:title="`操作途径:${sourceLabel(group.source)}`"
>{{ sourceLabel(group.source) }}</span
>
<span v-if="!merged && time" class="m-editor-history-list-item-time" :title="timeTitle || time">{{ time }}</span>
<span
v-if="!merged && group.time"
class="m-editor-history-list-item-time"
:title="group.timeTitle || group.time"
>{{ group.time }}</span
>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} 步</span>
@ -30,21 +35,21 @@
v-if="!merged && headRevertable"
class="m-editor-history-list-item-revert"
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
@click.stop="onRevertClick(subSteps[0].index)"
@click.stop="onRevertClick(group.subSteps[0].index)"
>回滚</span
>
<span
v-if="!merged && gotoEnabled && !isCurrent && subSteps.length"
v-if="!merged && gotoEnabled && !group.isCurrent && group.subSteps.length"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onGotoClick(subSteps[0].index)"
@click.stop="onGotoClick(group.subSteps[0].index)"
>回到</span
>
<span
v-if="!merged && headDiffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(subSteps[0].index)"
@click.stop="onDiffClick(group.subSteps[0].index)"
>查看差异</span
>
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }">▾</span>
@ -96,8 +101,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { HistoryOpSource, HistoryOpType } from '@editor/type';
import type { HistoryRowGroup, HistoryRowStep } from './composables';
import { opLabel, sourceLabel } from './composables';
defineOptions({
@ -106,46 +110,13 @@ defineOptions({
const props = withDefaults(
defineProps<{
/** 唯一标识当前组的 key,作为 toggle 事件的 payload 回传给上层。形如 `pg-${首步 index}` / `ds-${id}-${首步 index}` / `cb-${id}-${首步 index}`,以稳定的 step 索引标识分组。 */
groupKey: string;
/** 该组当前是否处于已应用状态(false 表示已被 undo 撤销,UI 会显示为灰态)。 */
applied: boolean;
/** 是否为合并组(即组内 step 数大于 1,由多次连续操作合并而来)。决定是否展示合并标记与可展开的子步列表。 */
merged: boolean;
/** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
opType: HistoryOpType;
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
desc: string;
/** 组的操作途径(一般取组内最近一步),用于头部展示「画布 / 树面板 / 配置面板…」标签。 */
source?: HistoryOpSource;
/** 组头部展示的时间文案(一般为组内最近一步的时间),为空时不渲染。 */
time?: string;
/** 组头部时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
timeTitle?: string;
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
stepCount: number;
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
subSteps: {
index: number;
applied: boolean;
desc: string;
isCurrent?: boolean;
/** 该子步是否为最近一次保存的记录,用于展示「已保存」标记。 */
saved?: boolean;
diffable?: boolean;
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
revertable?: boolean;
/** 该子步的操作途径,用于展示「画布 / 树面板 / 配置面板…」标签。 */
source?: HistoryOpSource;
/** 该子步的时间文案,为空时不渲染。 */
time?: string;
/** 该子步时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
timeTitle?: string;
}[];
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
/**
* 该组的视图模型(由 `toRowGroup` 统一派生):包含 key、应用状态、操作类型、描述、
* 来源 / 时间等头部信息以及子步列表。原先散落的十余个扁平 props 收敛于此单一对象。
*/
group: HistoryRowGroup;
/** 当前组是否处于展开状态。仅在合并组(子步数 > 1)时生效,控制子步列表是否渲染。 */
expanded: boolean;
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
isCurrent?: boolean;
/**
* 是否支持「跳转到该记录」(goto)。默认 true。
* 为 false 时:单步组头部与子步条目都不再可点击跳转、也不会触发 goto 事件,
@ -154,21 +125,20 @@ const props = withDefaults(
gotoEnabled?: boolean;
}>(),
{
isCurrent: false,
gotoEnabled: true,
},
);
const emit = defineEmits<{
/**
* 用户点击合并组头部时触发,携带 groupKey;上层用其切换 expanded 状态。
* 用户点击合并组头部时触发,携带 group.key;上层用其切换 expanded 状态。
* 对单步组(非合并)头部点击不会发该事件——因为单步组没有"展开"的概念。
*/
(_e: 'toggle', _key: string): void;
/**
* 用户希望跳转到该记录时触发,携带"目标 step 在所属栈中的索引"——上层据此计算目标 cursor (= index + 1)。
* 触发场景:
* - 单步组(merged=false)头部:取该唯一 step 的 index;
* - 单步组(非合并)头部:取该唯一 step 的 index;
* - 子步条目:取该子步的 index。
* 合并组头部不再触发 goto,避免与展开/收起冲突;用户应展开后点具体子步精准跳转。
* 当前所在的步骤(isCurrent)始终不会触发 goto。
@ -176,7 +146,7 @@ const emit = defineEmits<{
(_e: 'goto', _index: number): void;
/**
* 用户希望查看该 step 的修改差异(旧值 vs 新值)。
* 只在 step 满足"前后值都存在"(如 update / 数据源、代码块的 update)时由父级标记 `diffable=true`。
* 只在 step 满足"前后值都存在"(如 update / 数据源、代码块的 update)时由 `toRowGroup` 标记 `diffable=true`。
* payload 为该 step 在所属栈中的索引,由上层根据 index 取 step 内容并展示对比。
*/
(_e: 'diff-step', _index: number): void;
@ -187,15 +157,21 @@ const emit = defineEmits<{
(_e: 'revert-step', _index: number): void;
}>();
/** 子步数大于 1 即为合并组:决定是否展示合并标记与可展开的子步列表。 */
const merged = computed(() => props.group.subSteps.length > 1);
/** 组内 step 总数,仅在合并组时显示为 "合并 N 步"。 */
const stepCount = computed(() => props.group.subSteps.length);
/**
* 仅合并组头部可点击(切换展开 / 收起);
* 单步组的跳转改由头部的「回退」按钮触发,整行不再可点击。
* 单步组的跳转改由头部的「回到」按钮触发,整行不再可点击。
*/
const isHeadClickable = computed(() => props.merged);
const isHeadClickable = computed(() => merged.value);
const headTitle = computed(() => {
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.isCurrent) return '当前所在记录';
if (merged.value) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.group.isCurrent) return '当前所在记录';
return '';
});
@ -203,8 +179,8 @@ const headTitle = computed(() => {
* 头部点击行为:仅合并组切换展开 / 收起;单步组不再响应整行点击。
*/
const onHeadClick = () => {
if (props.merged) {
emit('toggle', props.groupKey);
if (merged.value) {
emit('toggle', props.group.key);
}
};
@ -224,43 +200,42 @@ const subStepTitle = (s: { isCurrent?: boolean }) => {
* - 合并组:组内任一子步为已保存即在头部提示(具体落在哪一步可展开查看)。
*/
const headSaved = computed(() =>
props.merged ? props.subSteps.some((s) => s.saved) : Boolean(props.subSteps[0]?.saved),
merged.value ? props.group.subSteps.some((s) => s.saved) : Boolean(props.group.subSteps[0]?.saved),
);
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
const headDiffable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.diffable));
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
const headRevertable = computed(() => !props.merged && Boolean(props.subSteps[0]?.revertable));
const headRevertable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.revertable));
/**
* 合并组展开后的子步渲染顺序:与外层分组列表保持一致——倒序展示(最新的子步在最上方)。
* 外层 page tab / bucket 都已对 groups 做了 reverse,子步沿用同样的视觉规则更直观。
* 注意:仅用于渲染,原 `subSteps` 保持时间正序,`headIndexLabel` 等基于首尾索引的展示语义不变。
*/
const subStepsDisplay = computed(() => props.subSteps.slice().reverse());
const subStepsDisplay = computed<HistoryRowStep[]>(() => props.group.subSteps.slice().reverse());
/**
* 头部索引展示:
* - 单步组(merged=false):显示该唯一 step 的编号,如 `#5`;
* - 单步组(非合并):显示该唯一 step 的编号,如 `#5`;
* - 合并组:显示组内 step 的编号范围,如 `#3-#7`(首尾相同则退化为 `#5`)。
*
* 这里展示的是 step.index + 1(与子步列表 `#{{ s.index + 1 }}` 保持一致),从 1 起编号更符合直觉。
*/
const headIndexLabel = computed(() => {
const list = props.subSteps;
const list = props.group.subSteps;
if (!list.length) return '';
const first = list[0].index + 1;
const last = list[list.length - 1].index + 1;
if (!props.merged || first === last) return `#${first}`;
if (!merged.value || first === last) return `#${first}`;
return `#${first}-#${last}`;
});
const headIndexTitle = computed(() => {
if (!props.merged) return `历史步骤编号 #${props.subSteps[0]?.index + 1}`;
return `合并了第 ${props.subSteps[0]?.index + 1} 至第 ${
props.subSteps[props.subSteps.length - 1]?.index + 1
} 共 ${props.subSteps.length} 条历史步骤`;
const list = props.group.subSteps;
if (!merged.value) return `历史步骤编号 #${list[0]?.index + 1}`;
return `合并了第 ${list[0]?.index + 1} 至第 ${list[list.length - 1]?.index + 1} 共 ${list.length} 条历史步骤`;
});
const onDiffClick = (index: number) => {

View File

@ -38,14 +38,9 @@
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
>
<BucketTab
title="数据源"
prefix="ds"
:config="dataSourceConfig"
:buckets="dataSourceGroupsByTarget"
:expanded="expanded"
:describe-group="describeDataSourceGroup"
:describe-step="describeDataSourceStep"
:is-step-diffable="isDataSourceStepDiffable"
:is-step-revertable="isDataSourceStepRevertable"
@toggle="toggleGroup"
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
@ -61,14 +56,9 @@
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
>
<BucketTab
title="代码块"
prefix="cb"
:config="codeBlockConfig"
:buckets="codeBlockGroupsByTarget"
:expanded="expanded"
:describe-group="describeCodeBlockGroup"
:describe-step="describeCodeBlockStep"
:is-step-diffable="isCodeBlockStepDiffable"
:is-step-revertable="isCodeBlockStepRevertable"
@toggle="toggleGroup"
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
@ -148,6 +138,7 @@ import { useServices } from '@editor/hooks/use-services';
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type';
import BucketTab from './BucketTab.vue';
import type { HistoryBucketConfig } from './composables';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
@ -219,10 +210,34 @@ const {
} = useHistoryList();
/** 数据源 step 仅 update(前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/**
* 数据源 / 代码块两类 bucket 历史的整体渲染配置:把 title / prefix 与各自的描述、
* 可差异、可回滚判定收敛为单一对象整体注入 BucketTab,组件内部按需读取。
*/
const dataSourceConfig: HistoryBucketConfig<DataSourceStepValue> = {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: isDataSourceStepDiffable,
isStepRevertable: isDataSourceStepRevertable,
};
const codeBlockConfig: HistoryBucketConfig<CodeBlockStepValue> = {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: isCodeBlockStepDiffable,
isStepRevertable: isCodeBlockStepRevertable,
};
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
const indexToCursor = (index: number) => index + 1;
@ -259,7 +274,7 @@ const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('di
/**
* 构造页面 step 的差异弹窗入参:仅 update 单节点修改可对比,传入旧/新节点。
* 节点类型 `type` 优先取 newNode.type,再回退 oldNode.type。
* 节点类型 `type` 优先取 newSchema.type,再回退 oldSchema.type。
* `currentValue` 取自 editorService 中该节点当前实际值,用于支持「与当前对比」。
* 无可对比内容(如多节点 / add / remove)时返回 null。
*/
@ -268,18 +283,18 @@ const buildPageDiffPayload = (index: number): DiffDialogPayload | null => {
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 null;
const type = (item.newNode.type as string) || (item.oldNode.type as string) || '';
const nodeId = item.newNode.id ?? item.oldNode.id;
const item = entry.step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return null;
const type = (item.newSchema.type as string) || (item.oldSchema.type as string) || '';
const nodeId = item.newSchema.id ?? item.oldSchema.id;
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
return {
category: 'node',
type,
lastValue: item.oldNode as Record<string, any>,
value: item.newNode as Record<string, any>,
lastValue: item.oldSchema as Record<string, any>,
value: item.newSchema as Record<string, any>,
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
targetLabel: (item.newSchema.name as string) || (item.oldSchema.name as string) || type,
id: nodeId,
};
}
@ -306,7 +321,9 @@ const findGroupStep = <G extends { id: string | number; steps: { index: number;
};
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, ({ oldSchema, newSchema }) => {
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, (step) => {
const oldSchema = step.diff?.[0]?.oldSchema;
const newSchema = step.diff?.[0]?.newSchema;
if (!oldSchema || !newSchema) return null;
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
return {
@ -321,7 +338,9 @@ const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDia
});
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, ({ oldContent, newContent }) => {
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, (step) => {
const oldContent = step.diff?.[0]?.oldSchema;
const newContent = step.diff?.[0]?.newSchema;
if (!oldContent || !newContent) return null;
const currentContent = codeBlockService.getCodeContentById(id);
return {
@ -358,33 +377,36 @@ const onConfirmRevert = shallowRef();
* 交互:先弹出该步骤的差异弹窗供用户确认,点击「确定回滚」后再真正执行回滚;
* 对没有可对比内容的步骤(如 add / remove / 多节点更新)则直接回滚。
*/
const onPageRevert = (index: number) => {
const onPageRevert = async (index: number) => {
const payload = buildPageDiffPayload(index);
onConfirmRevert.value = () => editorService.revertPageStep(index);
const revert = () => editorService.revertPageStep(index);
onConfirmRevert.value = revert;
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
} else if (await confirmRevert()) {
revert();
}
};
const onDataSourceRevert = (id: string | number, index: number) => {
const onDataSourceRevert = async (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
onConfirmRevert.value = () => dataSourceService.revert(id, index);
const revert = () => dataSourceService.revert(id, index);
onConfirmRevert.value = revert;
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
} else if (await confirmRevert()) {
revert();
}
};
const onCodeBlockRevert = (id: string | number, index: number) => {
const onCodeBlockRevert = async (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
onConfirmRevert.value = () => codeBlockService.revert(id, index);
const revert = () => codeBlockService.revert(id, index);
onConfirmRevert.value = revert;
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
} else if (await confirmRevert()) {
revert();
}
};
@ -392,6 +414,14 @@ const onDiffDialogClose = () => {
onConfirmRevert.value = undefined;
};
/**
* 「回滚」二次确认:新增 / 删除 / 多节点更新等无法做差异对比的步骤,
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
* 用户取消时返回 false,调用方据此中止回滚。
*/
const confirmRevert = (): Promise<boolean> =>
confirmClear('确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。');
/**
* 「清空历史记录」入口:先弹出二次确认,确认后清空对应类别的历史栈。
* 仅删除撤销/重做记录,不会改动当前 DSL / 数据源 / 代码块本身。

View File

@ -8,32 +8,9 @@
<ul class="m-editor-history-list-ul">
<GroupRow
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"
:desc="describePageGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
saved: s.step.saved,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied && isPageStepRevertable(s.step),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@ -56,15 +33,8 @@ import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup, StepValue } from '@editor/type';
import {
describePageGroup,
describePageStep,
formatHistoryFullTime,
formatHistoryTime,
groupSource,
groupTimestamp,
isPageStepRevertable,
} from './composables';
import type { HistoryRowDescriptor, HistoryRowGroup } from './composables';
import { describePageGroup, describePageStep, isPageStepRevertable, toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -101,16 +71,28 @@ defineEmits<{
/**
* 当前 step 是否可查看差异:
* - 仅 update 操作;
* - 单节点更新(updatedItems.length === 1),且 oldNode / newNode 都存在。
* - 单节点更新(diff.length === 1),且 oldSchema / newSchema 都存在。
* 多节点更新难以选定单一对比目标,统一不展示差异入口。
*/
const isPageStepDiffable = (step: StepValue): boolean => {
if (step.opType !== 'update') return false;
const items = step.updatedItems ?? [];
const items = step.diff ?? [];
if (items.length !== 1) return false;
return Boolean(items[0]?.oldNode && items[0]?.newNode);
return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
};
/** 页面历史的描述 / 可操作性判定集合,注入给统一的 `toRowGroup`。 */
const descriptor: HistoryRowDescriptor<StepValue> = {
describeGroup: describePageGroup,
describeStep: describePageStep,
isStepDiffable: isPageStepDiffable,
isStepRevertable: isPageStepRevertable,
};
const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`;
const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
/**
* 是否处于"初始状态"——即对应页面历史栈 cursor===0:
* 当 list 中所有 group 的 applied 都为 false 时即为该状态。

View File

@ -30,6 +30,86 @@ export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* 一组「描述 + 可操作性」的判定函数集合。页面 / 数据源 / 代码块及业务自定义历史
* 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/** 组级描述文案生成器,接收一个 group,返回展示文本。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step,返回展示文本(合并组展开后的子步列表用)。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
}
/**
* 通用 bucket(数据源 / 代码块 / 业务自定义历史)的整体渲染配置。
* 把原先散落在 Bucket / BucketTab 上的 title / prefix / describe* / isStep* / showInitial / gotoEnabled
* 收敛成一个对象作为单一 prop 传递,调用方一次配齐、组件内部按需读取。
*/
export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
showInitial?: boolean;
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
}
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */
export interface HistoryRowStep {
/** 该子步在所属栈中的稳定索引。 */
index: number;
/** 是否已应用(false 表示已被 undo,UI 灰态)。 */
applied: boolean;
/** 是否为当前所在步骤。 */
isCurrent?: boolean;
/** 是否为最近一次保存的记录。 */
saved?: boolean;
/** 子步描述文案。 */
desc: string;
/** 是否可查看差异。 */
diffable?: boolean;
/** 是否可回滚。 */
revertable?: boolean;
/** 操作途径。 */
source?: HistoryOpSource;
/** 时间文案。 */
time?: string;
/** 时间的完整 title 提示。 */
timeTitle?: string;
}
/**
* GroupRow 渲染所需的整组视图模型(由 {@link toRowGroup} 统一派生)。
* 把原先 GroupRow 上十多个扁平 props 收敛为单一对象,header 信息与子步列表一并携带。
*/
export interface HistoryRowGroup {
/** 分组的稳定 key,作为 toggle 事件 payload 与折叠状态的索引。 */
key: string;
/** 组内最后一步是否已应用。 */
applied: boolean;
/** 是否为当前所在分组。 */
isCurrent: boolean;
/** 操作类型,用于徽标颜色与文案。 */
opType: HistoryOpType;
/** 组整体描述文案。 */
desc: string;
/** 组的操作途径(取组内最近一步)。 */
source?: HistoryOpSource;
/** 组头部时间文案(取组内最近一步)。 */
time?: string;
/** 组头部时间的完整 title 提示。 */
timeTitle?: string;
/** 子步列表(时间正序);其长度即合并步数,length > 1 即为合并组。 */
subSteps: HistoryRowStep[];
}
/**
* 历史记录面板共享逻辑:
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
@ -150,7 +230,51 @@ export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
group.steps[group.steps.length - 1]?.step.source;
const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
/** {@link toRowGroup} 接受的最小分组结构,PageHistoryGroup 与 HistoryBucketGroup 均满足。 */
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
applied: boolean;
isCurrent?: boolean;
opType: HistoryOpType;
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* 把一个历史分组(页面 / bucket)派生为 GroupRow 直接消费的视图模型 {@link HistoryRowGroup}。
* 统一了原先 PageTab / Bucket 各自内联的 sub-steps 映射逻辑:描述、可差异、可回滚、时间、途径
* 全部在此一次性算好,组件层只负责渲染。
*/
export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
group: RowGroupInput<T>,
key: string,
descriptor: HistoryRowDescriptor<T>,
): HistoryRowGroup => {
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
const timestamp = groupTimestamp(group);
return {
key,
applied: group.applied,
isCurrent: Boolean(group.isCurrent),
opType: group.opType,
desc: describeGroup(group),
source: groupSource(group),
time: formatHistoryTime(timestamp),
timeTitle: formatHistoryFullTime(timestamp),
subSteps: group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
saved: s.step.saved,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
})),
};
};
const nameOf = (node?: { name?: string; id?: string | number; type?: string }) =>
node?.name || node?.type || `${node?.id ?? ''}`;
/**
@ -175,25 +299,25 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined
export const describePageStep = (step: StepValue) => {
if (step.historyDescription) return step.historyDescription;
const { opType } = step;
const items = step.diff ?? [];
if (opType === 'add') {
const count = step.nodes?.length ?? 0;
const node = step.nodes?.[0];
const count = items.length;
const node = items[0]?.newSchema;
return `新增 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
}
if (opType === 'remove') {
const count = step.removedItems?.length ?? 0;
const node = step.removedItems?.[0]?.node;
const count = items.length;
const node = items[0]?.oldSchema;
return `删除 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
}
const updated = step.updatedItems ?? [];
if (!updated.length) return '修改节点';
if (updated.length === 1) {
const { newNode, changeRecords } = updated[0];
if (!items.length) return '修改节点';
if (items.length === 1) {
const { newSchema, changeRecords } = items[0];
const propPath = changeRecords?.[0]?.propPath;
const target = labelWithId(nameOf(newNode), newNode?.id);
const target = labelWithId(nameOf(newSchema), newSchema?.id);
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
}
return `修改 ${updated.length} 个节点`;
return `修改 ${items.length} 个节点`;
};
/**
@ -208,7 +332,7 @@ export const describePageGroup = (group: PageHistoryGroup) => {
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.updatedItems?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const target = labelWithId(
@ -220,12 +344,11 @@ export const describePageGroup = (group: PageHistoryGroup) => {
export const describeDataSourceStep = (step: DataSourceStepValue) => {
if (step.historyDescription) return step.historyDescription;
if (step.oldSchema === null && step.newSchema)
return `创建 ${labelWithId(step.newSchema.title, step.newSchema.id ?? step.id)}`;
if (step.newSchema === null && step.oldSchema)
return `删除 ${labelWithId(step.oldSchema.title, step.oldSchema.id ?? step.id)}`;
const propPath = step.changeRecords?.[0]?.propPath;
const title = labelWithId(step.newSchema?.title || step.oldSchema?.title, step.id);
const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {};
if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`;
if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newSchema?.title || oldSchema?.title, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
@ -235,22 +358,23 @@ export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawTitle = group.steps[group.steps.length - 1].step.newSchema?.title || group.steps[0].step.oldSchema?.title;
const rawTitle =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title ||
group.steps[0].step.diff?.[0]?.oldSchema?.title;
const target = labelWithId(rawTitle, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
if (step.historyDescription) return step.historyDescription;
if (step.oldContent === null && step.newContent)
return `创建 ${labelWithId(step.newContent.name, step.newContent.id ?? step.id)}`;
if (step.newContent === null && step.oldContent)
return `删除 ${labelWithId(step.oldContent.name, step.oldContent.id ?? step.id)}`;
const propPath = step.changeRecords?.[0]?.propPath;
const title = labelWithId(step.newContent?.name || step.oldContent?.name, step.id);
const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {};
if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`;
if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newContent?.name || oldContent?.name, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
@ -260,10 +384,12 @@ export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawName = group.steps[group.steps.length - 1].step.newContent?.name || group.steps[0].step.oldContent?.name;
const rawName =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name ||
group.steps[0].step.diff?.[0]?.oldSchema?.name;
const target = labelWithId(rawName, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
@ -276,27 +402,29 @@ export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
*/
export const isPageStepRevertable = (step: StepValue): boolean => {
if (step.opType !== 'update') return true;
const items = step.updatedItems ?? [];
const items = step.diff ?? [];
if (!items.length) return false;
return items.every((item) => Boolean(item.changeRecords?.length));
};
/**
* 数据源 step 是否支持「回滚」:
* - 新增(oldSchema=null)/ 删除(newSchema=null):不依赖 changeRecords,始终可回滚;
* - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
* - 更新(前后 schema 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
*/
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
if (step.oldSchema === null || step.newSchema === null) return true;
return Boolean(step.changeRecords?.length);
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};
/**
* 代码块 step 是否支持「回滚」:
* - 新增(oldContent=null)/ 删除(newContent=null):不依赖 changeRecords,始终可回滚;
* - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
* - 更新(前后 content 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
*/
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
if (step.oldContent === null || step.newContent === null) return true;
return Boolean(step.changeRecords?.length);
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};

View File

@ -38,6 +38,7 @@ import type {
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep } from '@editor/utils/history';
import BaseService from './BaseService';
@ -48,18 +49,6 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
/**
* 「回滚」生成的新 step 简短描述。仅 service 层使用。
*/
const describeRevertCodeBlockStep = (step: CodeBlockStepValue): string => {
const { oldContent, newContent, changeRecords, id } = step;
if (oldContent === null && newContent) return `撤回新增 ${newContent.name || newContent.id || id}`;
if (oldContent && newContent === null) return `还原已删除的 ${oldContent.name || oldContent.id || id}`;
const name = newContent?.name || oldContent?.name || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${name} · ${propPath}` : `还原 ${name}`;
};
class CodeBlock extends BaseService {
private state = reactive<CodeState>({
codeDsl: null,
@ -458,8 +447,10 @@ class CodeBlock extends BaseService {
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
if (entry.step.oldContent && entry.step.newContent && !entry.step.changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`;
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.id, entry.step.diff?.[0], (s) => s.name)}`;
return await this.applyRevertStep(entry.step, description);
}
@ -567,21 +558,22 @@ class CodeBlock extends BaseService {
step: CodeBlockStepValue,
historyDescription: string,
): Promise<CodeBlockStepValue | null> {
const { id, oldContent, newContent, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (oldContent === null && newContent) {
if (!oldSchema && newSchema) {
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即写回
if (oldContent && newContent === null) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' });
if (oldSchema && !newSchema) {
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
if (!oldContent || !newContent) return null;
if (!oldSchema || !newSchema) return null;
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
if (changeRecords?.length) {
@ -594,10 +586,10 @@ class CodeBlock extends BaseService {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent));
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
setValueByKeyPath(record.propPath, value, patched);
}
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldSchema) : patched, true, {
changeRecords,
historyDescription,
historySource: 'rollback',
@ -605,7 +597,7 @@ class CodeBlock extends BaseService {
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' });
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
@ -624,31 +616,32 @@ class CodeBlock extends BaseService {
* @param reverse true=撤销,false=重做
*/
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
const { id, oldContent, newContent, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
if (oldContent === null && newContent) {
if (!oldSchema && newSchema) {
if (reverse) {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
} else {
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
this.setCodeDslByIdSync(id, cloneDeep(newSchema), true, { doNotPushHistory: true });
}
return;
}
if (oldContent && newContent === null) {
if (oldSchema && !newSchema) {
if (reverse) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { doNotPushHistory: true });
} else {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
}
return;
}
if (!oldContent || !newContent) return;
if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch;缺省退化为整内容替换
const sourceForValues = reverse ? oldContent : newContent;
const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) {
const current = this.getCodeContentById(id);

View File

@ -19,6 +19,7 @@ import type {
} from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep } from '@editor/utils/history';
import BaseService from './BaseService';
@ -54,19 +55,6 @@ const canUsePluginMethods = {
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
/**
* 「回滚」生成的新 step 简短描述。
* 仅在 service 层使用,避免依赖 UI 层 composables。
*/
const describeRevertDataSourceStep = (step: DataSourceStepValue): string => {
const { oldSchema, newSchema, changeRecords, id } = step;
if (oldSchema === null && newSchema) return `撤回新增 ${newSchema.title || newSchema.id || id}`;
if (oldSchema && newSchema === null) return `还原已删除的 ${oldSchema.title || oldSchema.id || id}`;
const title = newSchema?.title || oldSchema?.title || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${title} · ${propPath}` : `还原 ${title}`;
};
class DataSource extends BaseService {
private state = reactive<State>({
datasourceTypeList: [],
@ -342,8 +330,9 @@ class DataSource extends BaseService {
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
if (entry.step.oldSchema && entry.step.newSchema && !entry.step.changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`;
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.id, entry.step.diff?.[0], (s) => s.title)}`;
return this.applyRevertStep(entry.step, description);
}
@ -441,16 +430,17 @@ class DataSource extends BaseService {
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
*/
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
const { id, oldSchema, newSchema, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (oldSchema === null && newSchema) {
if (!oldSchema && newSchema) {
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即重新加回
if (oldSchema && newSchema === null) {
if (oldSchema && !newSchema) {
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
@ -498,10 +488,11 @@ class DataSource extends BaseService {
* @param reverse true=撤销,false=重做
*/
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id, oldSchema, newSchema, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
if (oldSchema === null && newSchema) {
if (!oldSchema && newSchema) {
if (reverse) {
this.remove(`${id}`, { doNotPushHistory: true });
} else {
@ -510,7 +501,7 @@ class DataSource extends BaseService {
return;
}
if (oldSchema && newSchema === null) {
if (oldSchema && !newSchema) {
if (reverse) {
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
} else {

View File

@ -47,6 +47,7 @@ import type {
HistoryOpSource,
HistoryOpType,
PastePosition,
StepDiffItem,
StepValue,
StoreState,
StoreStateKey,
@ -80,25 +81,26 @@ type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id
* 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
*/
const describeStepForRevert = (step: StepValue): string => {
const items = step.diff ?? [];
switch (step.opType) {
case 'add': {
const count = step.nodes?.length ?? 0;
const node = step.nodes?.[0];
const count = items.length;
const node = items[0]?.newSchema;
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `撤回新增 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
}
case 'remove': {
const count = step.removedItems?.length ?? 0;
const node = step.removedItems?.[0]?.node;
const count = items.length;
const node = items[0]?.oldSchema;
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `还原已删除的 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
}
case 'update':
default: {
const items = step.updatedItems ?? [];
if (items.length === 1) {
const { newNode, oldNode, changeRecords } = items[0];
const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
const { newSchema, oldSchema, changeRecords } = items[0];
const target =
newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || `${newSchema?.id ?? ''}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}
@ -107,6 +109,20 @@ const describeStepForRevert = (step: StepValue): string => {
}
};
/**
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
* 缺省(未传 / 空数组)才退化为整节点替换。
*/
const buildUpdateDiff = (
items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
): StepDiffItem<MNode>[] =>
items.map(({ oldNode, newNode, changeRecords }) => ({
oldSchema: oldNode,
newSchema: newNode,
...(changeRecords?.length ? { changeRecords } : {}),
}));
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
@ -487,17 +503,17 @@ class Editor extends BaseService {
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
if (!doNotPushHistory) {
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
this.pushOpHistory('add', {
extra: {
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
},
diff: newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
const idx = p ? getNodeIndex(n.id, p) : -1;
return {
newSchema: cloneDeep(toRaw(n)),
parentId,
index: typeof idx === 'number' ? idx : -1,
};
}),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
@ -610,7 +626,7 @@ class Editor extends BaseService {
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
const removedItems: StepDiffItem<MNode>[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) {
@ -621,7 +637,7 @@ class Editor extends BaseService {
}
const idx = getNodeIndex(curNode.id, parent);
removedItems.push({
node: cloneDeep(toRaw(curNode)),
oldSchema: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
@ -634,7 +650,7 @@ class Editor extends BaseService {
if (removedItems.length > 0 && pageForOp) {
if (!doNotPushHistory) {
this.pushOpHistory('remove', {
extra: { removedItems },
diff: removedItems,
pageData: pageForOp,
historyDescription,
source: historySource,
@ -760,15 +776,15 @@ class Editor extends BaseService {
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory('update', {
extra: {
updatedItems: updateData.map((d) => ({
// 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
diff: buildUpdateDiff(
updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
// 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
newNode: cloneDeep(d.newNode),
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
})),
},
),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
@ -1010,9 +1026,7 @@ class Editor extends BaseService {
'update',
{
extra: {
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
@ -1112,7 +1126,7 @@ class Editor extends BaseService {
}));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
this.pushOpHistory('update', {
extra: { updatedItems },
diff: buildUpdateDiff(updatedItems),
pageData: historyPage,
historyDescription,
source: historySource,
@ -1192,7 +1206,7 @@ class Editor extends BaseService {
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', {
extra: { updatedItems },
diff: buildUpdateDiff(updatedItems),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
@ -1335,7 +1349,7 @@ class Editor extends BaseService {
// 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
if (step.opType === 'update') {
const items = step.updatedItems ?? [];
const items = step.diff ?? [];
if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
}
@ -1354,9 +1368,9 @@ class Editor extends BaseService {
switch (step.opType) {
case 'add': {
// 原本是新增 → revert 即删除当时被加入的节点
const nodes = step.nodes ?? [];
for (const n of nodes) {
const existing = this.getNodeById(n.id, false);
for (const { newSchema } of step.diff ?? []) {
if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) {
await this.remove(existing, opts);
}
@ -1366,35 +1380,40 @@ class Editor extends BaseService {
case 'remove': {
// 原本是删除 → revert 即把节点按原父容器加回来。
// 按原 index 升序逐个插回,先小后大避免索引漂移。
const items = step.removedItems ?? [];
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const { node, parentId } of sorted) {
const items = step.diff ?? [];
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { oldSchema, parentId } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent) {
await this.add([cloneDeep(node)] as MNode[], parent, opts);
await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
}
}
break;
}
case 'update': {
// 原本是更新 → revert 即把 oldNode 的值写回;
// 原本是更新 → revert 即把 oldSchema 的值写回;
// 优先按 changeRecords 局部 patch(仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
const items = step.updatedItems ?? [];
const configs = items.map(({ oldNode, newNode, changeRecords }) => {
if (changeRecords?.length) {
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(oldNode);
const items = step.diff ?? [];
const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) {
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(oldNode);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
setValueByKeyPath(record.propPath, value, patch);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
setValueByKeyPath(record.propPath, value, patch);
return patch;
}
return patch;
}
return cloneDeep(oldNode);
});
return cloneDeep(oldNode);
});
if (configs.length) {
await this.update(configs, { historyDescription, historySource: 'rollback' });
}
@ -1527,12 +1546,12 @@ class Editor extends BaseService {
private pushOpHistory(
opType: HistoryOpType,
{
extra,
diff,
pageData,
historyDescription,
source,
}: {
extra: Partial<StepValue>;
diff: StepDiffItem<MNode>[];
pageData: { name: string; id: Id };
historyDescription?: string;
source?: HistoryOpSource;
@ -1545,7 +1564,7 @@ class Editor extends BaseService {
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra,
diff,
};
if (historyDescription) step.historyDescription = historyDescription;
if (source) step.source = source;
@ -1580,52 +1599,52 @@ class Editor extends BaseService {
switch (step.opType) {
case 'add': {
const nodes = step.nodes ?? [];
const items = step.diff ?? [];
if (reverse) {
// 撤销 add:把当时加入的节点删除
for (const n of nodes) {
const existing = this.getNodeById(n.id, false);
for (const { newSchema } of items) {
if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
}
} else {
// 重做 add:按记录的 indexMap 把节点重新插回父容器
const parent = this.getNodeById(step.parentId!, false) as MContainer | null;
if (parent) {
// 按目标 index 升序逐个插入,先小后大避免索引漂移
const sorted = [...nodes].sort((a, b) => (step.indexMap?.[a.id] ?? 0) - (step.indexMap?.[b.id] ?? 0));
for (const n of sorted) {
const idx = step.indexMap?.[n.id];
if (parent.items) {
if (typeof idx === 'number' && idx >= 0 && idx < parent.items.length) {
parent.items.splice(idx, 0, cloneDeep(n));
} else {
parent.items.push(cloneDeep(n));
}
await stage?.add({
config: cloneDeep(n),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
// 重做 add:按记录的 parentId / index 把节点重新插回父容器。
// 按目标 index 升序逐个插入,先小后大避免索引漂移
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { newSchema, parentId, index } of sorted) {
if (!newSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, cloneDeep(newSchema));
} else {
parent.items.push(cloneDeep(newSchema));
}
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
break;
}
case 'remove': {
const items = step.removedItems ?? [];
const items = step.diff ?? [];
if (reverse) {
// 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
parent.items.splice(index, 0, cloneDeep(node));
parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
await stage?.add({
config: cloneDeep(node),
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
parentId,
root: cloneDeep(root),
@ -1634,8 +1653,9 @@ class Editor extends BaseService {
}
} else {
// 重做 remove:再删一次
for (const { node } of items) {
const existing = this.getNodeById(node.id, false);
for (const { oldSchema } of items) {
if (!oldSchema) continue;
const existing = this.getNodeById(oldSchema.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
@ -1644,27 +1664,31 @@ class Editor extends BaseService {
break;
}
case 'update': {
const items = step.updatedItems ?? [];
const items = step.diff ?? [];
// 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
// 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
const configs = items.map(({ oldNode, newNode, changeRecords }) => {
if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
// 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(sourceForValues);
const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
// 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(sourceForValues);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patch);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patch);
return patch;
}
return patch;
}
return cloneDeep(reverse ? oldNode : newNode);
});
return cloneDeep(reverse ? oldNode : newNode);
});
if (configs.length) {
await this.update(configs, { doNotPushHistory: true });
}

View File

@ -25,11 +25,13 @@ import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
HistoryPersistOptions,
HistoryState,
PageHistoryGroup,
@ -52,20 +54,38 @@ const PERSIST_VERSION = 1;
class History extends BaseService {
/**
* 把单个代码块栈拆成若干 group:
* 把单个「按 id 分栈」的历史栈(代码块 / 数据源)拆成若干 group:
* - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并);
* - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。
*
* 代码块与数据源除 `kind` 外结构完全一致,统一由本方法处理;`kind` 决定返回的具体分组类型。
*/
private static mergeCodeBlockSteps(
codeBlockId: Id,
list: CodeBlockStepValue[],
private static mergeStackSteps<S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
kind: K,
id: Id,
list: S[],
cursor: number,
): CodeBlockHistoryGroup[] {
const groups: CodeBlockHistoryGroup[] = [];
let current: CodeBlockHistoryGroup | null = null;
): {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
}[] {
type Group = {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
};
const groups: Group[] = [];
let current: Group | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const opType = History.detectOpType(step.oldContent, step.newContent);
const { opType } = step;
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
@ -74,39 +94,8 @@ class History extends BaseService {
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind: 'code-block',
id: codeBlockId,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
}
private static mergeDataSourceSteps(
dataSourceId: Id,
list: DataSourceStepValue[],
cursor: number,
): DataSourceHistoryGroup[] {
const groups: DataSourceHistoryGroup[] = [];
let current: DataSourceHistoryGroup | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const opType = History.detectOpType(step.oldSchema, step.newSchema);
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
current.steps.push({ step, index, applied, isCurrent });
current.applied = applied;
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind: 'data-source',
id: dataSourceId,
kind,
id,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
@ -176,34 +165,34 @@ class History extends BaseService {
*/
private static detectPageTargetId(step: StepValue): Id | undefined {
if (step.opType !== 'update') return undefined;
const items = step.updatedItems;
const items = step.diff;
if (items?.length !== 1) return undefined;
return items[0].newNode?.id ?? items[0].oldNode?.id;
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
}
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
private static detectPageTargetName(step: StepValue): string | undefined {
const items = step.diff;
if (step.opType === 'update') {
const items = step.updatedItems;
if (items?.length === 1) {
const node = items[0].newNode || items[0].oldNode;
const node = items[0].newSchema || items[0].oldSchema;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (step.nodes?.length === 1) {
const n = step.nodes[0];
return (n.name as string) || (n.type as string) || `${n.id}`;
if (items?.length === 1) {
const n = items[0].newSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return step.nodes?.length ? `${step.nodes.length} 个节点` : undefined;
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (step.removedItems?.length === 1) {
const n = step.removedItems[0].node;
return (n.name as string) || (n.type as string) || `${n.id}`;
if (items?.length === 1) {
const n = items[0].oldSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return step.removedItems?.length ? `${step.removedItems.length} 个节点` : undefined;
return items?.length ? `${items.length} 个节点` : undefined;
}
return undefined;
}
@ -247,6 +236,16 @@ class History extends BaseService {
return result;
}
/**
* 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。
*/
private static getOrCreateStack<T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> {
if (!stacks[id]) {
stacks[id] = new UndoRedo<T>();
}
return stacks[id];
}
public state = reactive<HistoryState>({
pageSteps: {},
pageId: undefined,
@ -338,20 +337,15 @@ class History extends BaseService {
source?: HistoryOpSource;
},
): CodeBlockStepValue | null {
if (!codeBlockId) return null;
const step: CodeBlockStepValue = {
uuid: guid(),
id: codeBlockId,
oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
const step = this.createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
});
if (!step) return null;
History.getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
this.emit('code-block-history-change', codeBlockId, step);
return step;
}
@ -372,20 +366,15 @@ class History extends BaseService {
source?: HistoryOpSource;
},
): DataSourceStepValue | null {
if (!dataSourceId) return null;
const step: DataSourceStepValue = {
uuid: guid(),
id: dataSourceId,
oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
const step = this.createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
});
if (!step) return null;
History.getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
this.emit('data-source-history-change', dataSourceId, step);
return step;
}
@ -649,7 +638,7 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeCodeBlockSteps(id, list, cursor));
groups.push(...History.mergeStackSteps('code-block', id, list, cursor));
});
return groups;
}
@ -741,7 +730,7 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeDataSourceSteps(id, list, cursor));
groups.push(...History.mergeStackSteps('data-source', id, list, cursor));
});
return groups;
}
@ -777,23 +766,47 @@ class History extends BaseService {
}
/**
* 按 id 获取(或创建)指定代码块的 UndoRedo 栈。
* 构造一条代码块 / 数据源「按 id 分栈」的历史记录:两者除 payload 字段命名外完全一致。
*
* - `add`:oldValue = null;`remove`:newValue = null;`update`:两者都有,可带 changeRecords 做局部更新。
* - 内容会做 cloneDeep 防止后续被外部引用篡改;opType 依据 old/new 是否为 null 推断。
* - 仅负责构造 step 并返回,入栈与事件 emit 由各公共方法(pushCodeBlock / pushDataSource)自行处理。
* - 不直接驱动业务 service,调用方负责实际写回。
*/
private getCodeBlockUndoRedo(codeBlockId: Id): UndoRedo<CodeBlockStepValue> {
if (!this.state.codeBlockState[codeBlockId]) {
this.state.codeBlockState[codeBlockId] = new UndoRedo<CodeBlockStepValue>();
}
return this.state.codeBlockState[codeBlockId];
}
private createStackStep<T, S extends BaseStepValue<T> & { id: Id }>(
id: Id,
payload: {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
historyDescription?: string;
source?: HistoryOpSource;
},
): S | null {
if (!id) return null;
/**
* 按 id 获取(或创建)指定数据源的 UndoRedo 栈。
*/
private getDataSourceUndoRedo(dataSourceId: Id): UndoRedo<DataSourceStepValue> {
if (!this.state.dataSourceState[dataSourceId]) {
this.state.dataSourceState[dataSourceId] = new UndoRedo<DataSourceStepValue>();
}
return this.state.dataSourceState[dataSourceId];
const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null;
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = History.detectOpType(payload.oldValue, payload.newValue);
const step: BaseStepValue<T> & { id: Id } = {
uuid: guid(),
id,
opType,
diff: [
{
...(newSchema !== null ? { newSchema } : {}),
...(oldSchema !== null ? { oldSchema } : {}),
...(opType === 'update' && changeRecords ? { changeRecords } : {}),
},
],
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
return step as S;
}
}

View File

@ -721,17 +721,55 @@ export type HistoryOpSource =
| (string & {});
// #endregion HistoryOpSource
// #region StepDiffItem
/**
* 单条变更的 diff 描述,统一表达「页面节点 / 代码块 / 数据源」的变化内容,
* 被 {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} 的 `diff` 复用。
*
* 按 `opType` 区分携带的字段:
* - `add`:仅 `newSchema`(页面节点还带 `parentId` / `index`);
* - `remove`:仅 `oldSchema`(页面节点还带 `parentId` / `index`);
* - `update`:`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新。
*
* 泛型 `T` 为变化内容的快照类型:页面节点为 `MNode`,代码块为 `CodeBlockContent`,数据源为 `DataSourceSchema`。
*/
export interface StepDiffItem<T = unknown> {
/** 变更后的内容快照。`opType` 为 `add` / `update` 时有,`remove` 时无。 */
newSchema?: T;
/** 变更前的内容快照。`opType` 为 `remove` / `update` 时有,`add` 时无。 */
oldSchema?: T;
/** 父节点 id。仅页面节点有(数据源 / 代码块没有父节点)。 */
parentId?: Id;
/** 在父节点 items 数组中的索引。仅页面节点有(数据源 / 代码块无需排序)。 */
index?: number;
/**
* form 端 propPath/value 变更列表,仅 `opType` 为 `update` 时有;
* 撤销/重做时若有则按 propPath 局部更新,缺省才退化为整内容替换。
*/
changeRecords?: ChangeRecord[];
}
// #endregion StepDiffItem
// #region BaseStepValue
/**
* 历史记录条目公共字段,被 {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} 复用。
*
* 泛型 `T` 为 `diff` 中变化内容的快照类型(页面节点 `MNode` / 代码块 `CodeBlockContent` / 数据源 `DataSourceSchema`)。
*/
export interface BaseStepValue {
export interface BaseStepValue<T = unknown> {
/**
* 历史记录唯一标识(uuid)。入栈时自动写入(若调用方未指定),
* 用于精确定位 / 引用某一条历史记录(如 revert、埋点、跨端同步等)。
* 注意与各自的 `id`(关联的页面 / 代码块 / 数据源 id)区分。
*/
uuid: string;
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
opType: HistoryOpType;
/**
* 本次变更的内容(统一 diff 表达),每项见 {@link StepDiffItem}。
* 页面节点(add/remove 多节点、update 多节点)会有多项,代码块 / 数据源通常只有一项。
*/
diff: StepDiffItem<T>[];
/**
* 调用方可选传入的人类可读描述(如「调整按钮颜色」),用于历史面板展示。
* 不影响 undo/redo 行为;缺省时面板会根据节点 / propPath 自动生成描述。
@ -756,74 +794,42 @@ export interface BaseStepValue {
// #endregion BaseStepValue
// #region StepValue
export interface StepValue extends BaseStepValue {
export interface StepValue extends BaseStepValue<MNode> {
/** 页面信息 */
data: { name: string; id: Id };
opType: HistoryOpType;
/** 操作前选中的节点 ID,用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID,用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>;
/** opType 'add': 新增的节点 */
nodes?: MNode[];
/** opType 'add': 父节点 ID */
parentId?: Id;
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/**
* opType 'update': 变更前后的节点快照
*
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
* 缺省(未传 / 空数组)才退化为整节点替换。
*/
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
}
// #endregion StepValue
// #region CodeBlockStepValue
/**
* 代码块历史记录条目。按 codeBlock.id 分组保存到 historyState.codeBlockState。
* - 新增:oldContent = null,newContent = 新内容
* - 更新:oldContent / newContent 都为对应内容
* - 删除:newContent = null,oldContent = 删除前内容
* 变更内容统一由 `diff`(单项)表达,每项见 {@link StepDiffItem}:
* - 新增(opType 'add'):仅 `newSchema`(新内容);
* - 更新(opType 'update'):`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新;
* - 删除(opType 'remove'):仅 `oldSchema`(删除前内容)。
*/
export interface CodeBlockStepValue extends BaseStepValue {
export interface CodeBlockStepValue extends BaseStepValue<CodeBlockContent> {
/** 关联的代码块 id */
id: Id;
/** 变更前的代码块内容,新增时为 null */
oldContent: CodeBlockContent | null;
/** 变更后的代码块内容,删除时为 null */
newContent: CodeBlockContent | null;
/**
* form 端 propPath/value 列表。撤销/重做时若有则按 propPath 局部更新;
* 缺省才退化为整内容替换。新增/删除场景通常无 changeRecords。
*/
changeRecords?: ChangeRecord[];
}
// #endregion CodeBlockStepValue
// #region DataSourceStepValue
/**
* 数据源历史记录条目。按 dataSource.id 分组保存到 historyState.dataSourceState。
* - 新增:oldSchema = null,newSchema = 新 schema
* - 更新:oldSchema / newSchema 都为对应 schema
* - 删除:newSchema = null,oldSchema = 删除前 schema
* 变更内容统一由 `diff`(单项)表达,每项见 {@link StepDiffItem}:
* - 新增(opType 'add'):仅 `newSchema`(新 schema);
* - 更新(opType 'update'):`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新;
* - 删除(opType 'remove'):仅 `oldSchema`(删除前 schema)。
*/
export interface DataSourceStepValue extends BaseStepValue {
export interface DataSourceStepValue extends BaseStepValue<DataSourceSchema> {
/** 关联的数据源 id */
id: Id;
/** 变更前的数据源 schema,新增时为 null */
oldSchema: DataSourceSchema | null;
/** 变更后的数据源 schema,删除时为 null */
newSchema: DataSourceSchema | null;
/**
* form 端 propPath/value 列表。撤销/重做时若有则按 propPath 局部更新;
* 缺省才退化为整 schema 替换。新增/删除场景通常无 changeRecords。
*/
changeRecords?: ChangeRecord[];
}
// #endregion DataSourceStepValue

View File

@ -0,0 +1,43 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Id } from '@tmagic/core';
import type { StepDiffItem } from '@editor/type';
/**
* 「回滚」生成的新 step 简短描述。代码块 / 数据源共用。
* 二者逻辑一致,仅展示名取值字段不同(代码块取 `name`,数据源取 `title`),
* 因此通过 `getLabel` 注入取值方式。
*
* @param id 关联的代码块 / 数据源 id
* @param diff 单条变更 diff(缺省视为空)
* @param getLabel 从快照取展示名
*/
export const describeRevertStep = <T extends object>(
id: Id,
{ oldSchema, newSchema, changeRecords }: StepDiffItem<T> = {},
getLabel: (schema: T) => string | undefined,
): string => {
const labelOf = (schema: T) => getLabel(schema) || (schema as { id?: Id }).id;
if (!oldSchema && newSchema) return `撤回新增 ${labelOf(newSchema) || id}`;
if (oldSchema && !newSchema) return `还原已删除的 ${labelOf(oldSchema) || id}`;
const label = (newSchema && getLabel(newSchema)) || (oldSchema && getLabel(oldSchema)) || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${label} · ${propPath}` : `还原 ${label}`;
};

View File

@ -28,5 +28,6 @@ export * from './scroll-viewer';
export * from './tree';
export * from './undo-redo';
export * from './indexed-db';
export * from './history';
export * from './const';
export { default as loadMonaco } from './monaco-editor';

View File

@ -7,8 +7,9 @@ import { describe, expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import Bucket from '@editor/layouts/history-list/Bucket.vue';
import type { HistoryBucketConfig } from '@editor/layouts/history-list/composables';
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true) => ({
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true): any => ({
applied,
opType,
steps: Array.from({ length: stepCount }, (_, i) => ({
@ -18,16 +19,22 @@ const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, appl
})),
});
/** 把 title/prefix/describe* 收敛成单一 config,贴近真实调用方式。 */
const buildConfig = (overrides: Partial<HistoryBucketConfig<any>> = {}): HistoryBucketConfig<any> => ({
title: '数据源',
prefix: 'ds',
describeGroup: () => 'desc',
describeStep: () => 'sub-desc',
...overrides,
});
describe('Bucket.vue', () => {
test('渲染 bucket 头部信息与组数', () => {
const wrapper = mount(Bucket, {
props: {
title: '数据源',
config: buildConfig(),
bucketId: 'ds_1',
prefix: 'ds',
groups: [buildGroup('update', 1), buildGroup('add', 1)],
describeGroup: () => 'desc',
describeStep: () => 'sub-desc',
expanded: {},
},
});
@ -44,12 +51,9 @@ describe('Bucket.vue', () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup, describeStep }),
bucketId: 'code_1',
prefix: 'cb',
groups,
describeGroup,
describeStep,
expanded: { 'cb-code_1-0': true },
},
});
@ -73,12 +77,9 @@ describe('Bucket.vue', () => {
test('合并组头部点击 → toggle 事件被透传到 Bucket', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'code_1',
prefix: 'cb',
groups: [buildGroup('update', 2)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});
@ -93,12 +94,9 @@ describe('Bucket.vue', () => {
test('单步组「回到」按钮点击 → goto 事件被透传到 Bucket,并附带 bucketId', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'code_1',
prefix: 'cb',
groups: [buildGroup('update', 1)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});
@ -111,12 +109,9 @@ describe('Bucket.vue', () => {
test('合并组展开后点击子步「回到」按钮 → goto 透传,附带子步 index', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'code_1',
prefix: 'cb',
groups: [buildGroup('update', 2)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: { 'cb-code_1-0': true },
},
});
@ -132,12 +127,9 @@ describe('Bucket.vue', () => {
test('groupKey 命名空间使用 prefix + bucketId + 索引', () => {
const wrapper = mount(Bucket, {
props: {
title: '数据源',
config: buildConfig({ describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 42,
prefix: 'ds',
groups: [buildGroup('update', 2), buildGroup('add', 1)],
describeGroup: () => 'g',
describeStep: () => 's',
// 给第二组打开展开状态
expanded: { 'ds-42-1': true },
},
@ -152,12 +144,9 @@ describe('Bucket.vue', () => {
test('groups 非空时底部追加初始项;点击透传 goto-initial 携带 bucketId', async () => {
const wrapper = mount(Bucket, {
props: {
title: '数据源',
config: buildConfig({ describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'ds_1',
prefix: 'ds',
groups: [buildGroup('add', 1)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});
@ -175,12 +164,9 @@ describe('Bucket.vue', () => {
test('该 bucket 全部组都已撤销时初始项标记为当前', () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'cb_1',
prefix: 'cb',
groups: [buildGroup('add', 1, false), buildGroup('update', 2, false)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});

View File

@ -21,6 +21,19 @@ vi.mock('@tmagic/design', () => ({
}),
}));
/** 把以 oldContent/newContent/changeRecords 描述的 fixture 归一成统一 diff 形态的 step。 */
const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
id: s.id,
opType,
diff: [
{
...(s.newContent != null ? { newSchema: s.newContent } : {}),
...(s.oldContent != null ? { oldSchema: s.oldContent } : {}),
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
},
],
});
const buildGroup = (
id: string,
opType: 'add' | 'remove' | 'update',
@ -32,18 +45,20 @@ const buildGroup = (
id,
opType,
applied,
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
});
/** 代码块 tab 复用通用 BucketTab,固定注入代码块的 title/prefix/describe/isStepDiffable。 */
/** 代码块 tab 复用通用 BucketTab,固定注入代码块的 config(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),
config: {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
},
...props,
},
});

View File

@ -21,6 +21,19 @@ vi.mock('@tmagic/design', () => ({
}),
}));
/** 把以 oldSchema/newSchema/changeRecords 描述的 fixture 归一成统一 diff 形态的 step。 */
const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
id: s.id,
opType,
diff: [
{
...(s.newSchema != null ? { newSchema: s.newSchema } : {}),
...(s.oldSchema != null ? { oldSchema: s.oldSchema } : {}),
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
},
],
});
const buildGroup = (
id: string,
opType: 'add' | 'remove' | 'update',
@ -32,18 +45,20 @@ const buildGroup = (
id,
opType,
applied,
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
});
/** 数据源 tab 复用通用 BucketTab,固定注入数据源的 title/prefix/describe/isStepDiffable。 */
/** 数据源 tab 复用通用 BucketTab,固定注入数据源的 config(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),
config: {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
},
...props,
},
});

View File

@ -6,22 +6,30 @@
import { describe, expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import type { HistoryRowGroup, HistoryRowStep } from '@editor/layouts/history-list/composables';
import GroupRow from '@editor/layouts/history-list/GroupRow.vue';
const baseProps = {
groupKey: 'pg-0',
/** 构造 GroupRow 的视图模型(merged / stepCount 由 subSteps 长度派生)。 */
const makeGroup = (overrides: Partial<HistoryRowGroup> = {}): HistoryRowGroup => ({
key: 'pg-0',
applied: true,
merged: false,
opType: 'update' as const,
isCurrent: false,
opType: 'update',
desc: '修改 按钮',
stepCount: 1,
subSteps: [] as { index: number; applied: boolean; desc: string }[],
expanded: false,
};
subSteps: [],
...overrides,
});
/** 构造单个子步,缺省值贴近真实派生结果。 */
const makeStep = (overrides: Partial<HistoryRowStep> & Pick<HistoryRowStep, 'index'>): HistoryRowStep => ({
applied: true,
desc: '',
...overrides,
});
describe('GroupRow.vue', () => {
test('渲染描述与操作类型徽标(update→修改)', () => {
const wrapper = mount(GroupRow, { props: baseProps });
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮');
const op = wrapper.find('.m-editor-history-list-item-op');
expect(op.text()).toBe('修改');
@ -29,36 +37,41 @@ describe('GroupRow.vue', () => {
});
test('add / remove 操作徽标使用对应类名与文案', () => {
const w1 = mount(GroupRow, { props: { ...baseProps, opType: 'add' } });
const w1 = mount(GroupRow, { props: { group: makeGroup({ opType: 'add' }), expanded: false } });
expect(w1.find('.m-editor-history-list-item-op').text()).toBe('新增');
expect(w1.find('.m-editor-history-list-item-op').classes()).toContain('op-add');
const w2 = mount(GroupRow, { props: { ...baseProps, opType: 'remove' } });
const w2 = mount(GroupRow, { props: { group: makeGroup({ opType: 'remove' }), expanded: false } });
expect(w2.find('.m-editor-history-list-item-op').text()).toBe('删除');
expect(w2.find('.m-editor-history-list-item-op').classes()).toContain('op-remove');
});
test('applied=false 时附加 is-undone 类名', () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, applied: false } });
const wrapper = mount(GroupRow, { props: { group: makeGroup({ applied: false }), expanded: false } });
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
});
test('merged=true 时显示「合并 N 步」并附 is-merged 类名', () => {
test('merged(子步数>1)时显示「合并 N 步」并附 is-merged 类名', () => {
const wrapper = mount(GroupRow, {
props: { ...baseProps, merged: true, stepCount: 3 },
props: {
group: makeGroup({
subSteps: [makeStep({ index: 0 }), makeStep({ index: 1 }), makeStep({ index: 2 })],
}),
expanded: false,
},
});
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-merged');
expect(wrapper.find('.m-editor-history-list-item-merge').text()).toBe('合并 3 步');
});
test('未合并时不渲染合并标记', () => {
const wrapper = mount(GroupRow, { props: baseProps });
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-merge').exists()).toBe(false);
});
test('传入 time 时头部渲染时间,title 取 timeTitle', () => {
const wrapper = mount(GroupRow, {
props: { ...baseProps, time: '12:00:00', timeTitle: '2026-06-03 12:00:00' },
props: { group: makeGroup({ time: '12:00:00', timeTitle: '2026-06-03 12:00:00' }), expanded: false },
});
const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true);
@ -67,26 +80,25 @@ describe('GroupRow.vue', () => {
});
test('未传 time 时头部不渲染时间元素', () => {
const wrapper = mount(GroupRow, { props: baseProps });
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
});
test('timeTitle 缺省时 title 回退为 time 本身', () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, time: '08:30:00' } });
const wrapper = mount(GroupRow, { props: { group: makeGroup({ time: '08:30:00' }), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-time').attributes('title')).toBe('08:30:00');
});
test('展开的子步各自渲染自己的时间', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({
subSteps: [
makeStep({ index: 0, desc: '修改 颜色', time: '10:00:00', timeTitle: '2026-06-03 10:00:00' }),
makeStep({ index: 1, desc: '修改 字号', time: '10:01:00', timeTitle: '2026-06-03 10:01:00' }),
],
}),
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: '修改 颜色', time: '10:00:00', timeTitle: '2026-06-03 10:00:00' },
{ index: 1, applied: true, desc: '修改 字号', time: '10:01:00', timeTitle: '2026-06-03 10:01:00' },
],
},
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
@ -95,17 +107,16 @@ describe('GroupRow.vue', () => {
expect(items[1].find('.m-editor-history-list-item-time').text()).toBe('10:00:00');
});
test('merged=true 且 expanded=true 时渲染子步列表', () => {
test('merged 且 expanded=true 时渲染子步列表', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({
subSteps: [
makeStep({ index: 0, applied: true, desc: '修改 颜色' }),
makeStep({ index: 1, applied: false, desc: '修改 字号' }),
],
}),
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: '修改 颜色' },
{ index: 1, applied: false, desc: '修改 字号' },
],
},
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
@ -119,21 +130,23 @@ describe('GroupRow.vue', () => {
expect(items[1].text()).toContain('修改 颜色');
});
test('merged=true 但 expanded=false 时不渲染子步列表', () => {
test('merged 但 expanded=false 时不渲染子步列表', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({ subSteps: [makeStep({ index: 0, desc: 'x' }), makeStep({ index: 1, desc: 'y' })] }),
expanded: false,
subSteps: [{ index: 0, applied: true, desc: 'x' }],
},
});
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
});
test('点击合并组头部触发 toggle 事件并携带 groupKey', async () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, merged: true, stepCount: 2 } });
test('点击合并组头部触发 toggle 事件并携带 group.key', async () => {
const wrapper = mount(GroupRow, {
props: {
group: makeGroup({ subSteps: [makeStep({ index: 0 }), makeStep({ index: 1 })] }),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
const events = wrapper.emitted('toggle');
expect(events).toBeTruthy();
@ -145,9 +158,8 @@ describe('GroupRow.vue', () => {
test('点击单步组(非合并)的「回到」按钮触发 goto,携带该唯一 step 的 index', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: false,
subSteps: [{ index: 7, applied: true, desc: 'a' }],
group: makeGroup({ subSteps: [makeStep({ index: 7, applied: true, desc: 'a' })] }),
expanded: false,
},
});
// 点击头部本身不再触发 goto(整行不可点击)
@ -164,10 +176,8 @@ describe('GroupRow.vue', () => {
test('当前单步组(isCurrent=true)点击头部不触发 goto', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: false,
isCurrent: true,
subSteps: [{ index: 0, applied: true, desc: 'x' }],
group: makeGroup({ isCurrent: true, subSteps: [makeStep({ index: 0, desc: 'x' })] }),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
@ -177,14 +187,11 @@ describe('GroupRow.vue', () => {
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 },
],
group: makeGroup({
isCurrent: true,
subSteps: [makeStep({ index: 0, desc: 'a' }), makeStep({ index: 1, desc: 'b', isCurrent: true })],
}),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
@ -192,17 +199,16 @@ describe('GroupRow.vue', () => {
expect(wrapper.emitted('goto')).toBeFalsy();
});
test('点击子步「回退」按钮触发 goto 携带该子步 index;当前子步无回退按钮', async () => {
test('点击子步「回到」按钮触发 goto 携带该子步 index;当前子步无回到按钮', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({
subSteps: [
makeStep({ index: 0, applied: true, desc: 'a', isCurrent: true }),
makeStep({ index: 1, applied: false, desc: 'b' }),
],
}),
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: 'a', isCurrent: true },
{ index: 1, applied: false, desc: 'b' },
],
},
});
// 子步倒序渲染:subItems[0] 为 index=1(非当前,含跳转按钮),subItems[1] 为 index=0(当前,无跳转按钮)

View File

@ -105,7 +105,7 @@ describe('HistoryListPanel.vue', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.pushDataSource('ds_1', {
@ -136,10 +136,10 @@ describe('HistoryListPanel.vue', () => {
const mkUpdate = (path: string) => ({
opType: 'update',
modifiedNodeIds: new Map(),
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn', name: '按钮' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn', name: '按钮' },
changeRecords: [{ propPath: path }],
},
],
@ -169,12 +169,12 @@ describe('HistoryListPanel.vue', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n2', name: 'B' }],
diff: [{ newSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
} as any);
@ -240,7 +240,7 @@ describe('HistoryListPanel.vue', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);

View File

@ -46,16 +46,16 @@ describe('PageTab.vue', () => {
test('list 非空:每个 group 渲染一行', () => {
const list = [
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]),
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }]),
buildPageGroup(
'update',
[
{
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'style.color' }],
},
],
@ -78,7 +78,9 @@ describe('PageTab.vue', () => {
});
test('step 含 timestamp 时渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }], timestamp: Date.now() }])];
const list = [
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }], timestamp: Date.now() }]),
];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true);
@ -87,7 +89,7 @@ describe('PageTab.vue', () => {
});
test('step 无 timestamp 时不渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
});
@ -98,20 +100,20 @@ describe('PageTab.vue', () => {
[
{
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'a' }],
},
],
},
{
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'b' }],
},
],
@ -138,11 +140,11 @@ describe('PageTab.vue', () => {
[
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
},
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
},
],
true,
@ -154,11 +156,11 @@ describe('PageTab.vue', () => {
[
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
},
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
},
],
true,
@ -178,7 +180,7 @@ describe('PageTab.vue', () => {
});
test('点击单步组「回到」按钮透传 goto 事件,携带该 step 的 index', async () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')).toBeTruthy();
@ -187,7 +189,7 @@ describe('PageTab.vue', () => {
});
test('已撤销组(applied=false)附 is-undone 类名', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
});
@ -198,13 +200,13 @@ describe('PageTab.vue', () => {
expect(empty.find('.m-editor-history-list-initial').exists()).toBe(false);
// 非空 list:底部应有一条初始项
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { 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 list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { 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');
@ -212,8 +214,8 @@ describe('PageTab.vue', () => {
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),
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true),
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n2', name: 'B' } }] }], false),
];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const initial = wrapper.find('.m-editor-history-list-initial');
@ -221,7 +223,7 @@ describe('PageTab.vue', () => {
});
test('点击非当前初始项的「回到」按钮透传 goto-initial 事件', async () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true)];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
await wrapper.find('.m-editor-history-list-initial .m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto-initial')).toBeTruthy();

View File

@ -109,7 +109,7 @@ describe('describePageStep', () => {
test('add 单个节点:含名称与 id', () => {
const step = {
opType: 'add',
nodes: [{ id: 'btn_1', type: 'button', name: '主按钮' }],
diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1))');
});
@ -117,7 +117,7 @@ describe('describePageStep', () => {
test('add 节点无 name 但有 type:使用 type 作为名称', () => {
const step = {
opType: 'add',
nodes: [{ id: 'n1', type: 'text' }],
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1))');
});
@ -125,7 +125,7 @@ describe('describePageStep', () => {
test('add 节点 name 与 id 相同:仅显示 id', () => {
const step = {
opType: 'add',
nodes: [{ id: 'n1', name: 'n1' }],
diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(n1)');
});
@ -133,7 +133,7 @@ describe('describePageStep', () => {
test('add 多个节点:仅给出数量', () => {
const step = {
opType: 'add',
nodes: [{ id: 'a' }, { id: 'b' }],
diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 2 个节点');
});
@ -146,7 +146,7 @@ describe('describePageStep', () => {
test('remove 单个节点:含名称与 id', () => {
const step = {
opType: 'remove',
removedItems: [{ node: { id: 'btn_1', name: '主按钮' } }],
diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1))');
});
@ -154,7 +154,7 @@ describe('describePageStep', () => {
test('remove 多个节点', () => {
const step = {
opType: 'remove',
removedItems: [{ node: { id: 'a' } }, { node: { id: 'b' } }],
diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 2 个节点');
});
@ -162,10 +162,10 @@ describe('describePageStep', () => {
test('update 单节点:附 propPath 与 id', () => {
const step = {
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1', name: '按钮' },
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: 'style.color' }],
},
],
@ -176,7 +176,7 @@ describe('describePageStep', () => {
test('update 单节点无 propPath:仅展示节点', () => {
const step = {
opType: 'update',
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)');
});
@ -184,15 +184,15 @@ describe('describePageStep', () => {
test('update 多节点:返回数量', () => {
const step = {
opType: 'update',
updatedItems: [
{ newNode: { id: 'a' }, oldNode: { id: 'a' } },
{ newNode: { id: 'b' }, oldNode: { id: 'b' } },
diff: [
{ newSchema: { id: 'a' }, oldSchema: { id: 'a' } },
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 2 个节点');
});
test('update updatedItems 缺省:兜底为「修改节点」', () => {
test('update diff 缺省:兜底为「修改节点」', () => {
const step = { opType: 'update' } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改节点');
});
@ -219,7 +219,7 @@ describe('describePageGroup', () => {
test('单步 group 复用 describePageStep', () => {
const step = {
opType: 'update',
updatedItems: [{ newNode: { id: 'a', name: 'A' }, oldNode: { id: 'a' } }],
diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
} as unknown as StepValue;
const group: PageHistoryGroup = {
kind: 'page',
@ -237,10 +237,10 @@ describe('describePageGroup', () => {
const mkStep = (path: string) =>
({
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1', name: '按钮' },
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: path }],
},
],
@ -262,10 +262,10 @@ describe('describePageGroup', () => {
const mkStep = (path: string) =>
({
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1' },
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1' },
changeRecords: [{ propPath: path }],
},
],
@ -294,7 +294,7 @@ describe('describePageGroup', () => {
const mkStep = () =>
({
opType: 'update',
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
}) as unknown as StepValue;
const group: PageHistoryGroup = {
@ -317,8 +317,8 @@ describe('describePageGroup', () => {
targetId: 'btn_1',
applied: true,
steps: [
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 0),
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 1),
buildPageEntry({ opType: 'update', diff: [] } as any, 0),
buildPageEntry({ opType: 'update', diff: [] } as any, 1),
],
};
// targetName 为 undefined,labelWithId 看 label === id 时只展示 id
@ -328,61 +328,72 @@ describe('describePageGroup', () => {
describe('describeDataSourceStep', () => {
test('historyDescription 优先', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: null,
newSchema: null,
opType: 'update',
diff: [{}],
historyDescription: '自定义',
};
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('自定义');
});
test('新增(oldSchema=null):展示 title 与 id', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: null,
newSchema: { id: 'ds_1', title: '用户列表' } as any,
};
opType: 'add',
diff: [{ newSchema: { id: 'ds_1', title: '用户列表' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
});
test('删除(newSchema=null):展示 title 与 id', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
newSchema: null,
};
opType: 'remove',
diff: [{ oldSchema: { id: 'ds_1', title: '用户列表' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
});
test('修改:展示 propPath', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
newSchema: { id: 'ds_1', title: '用户列表' } as any,
changeRecords: [{ propPath: 'fields.0.name' } as any],
};
opType: 'update',
diff: [
{
oldSchema: { id: 'ds_1', title: '用户列表' },
newSchema: { id: 'ds_1', title: '用户列表' },
changeRecords: [{ propPath: 'fields.0.name' }],
},
],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
});
test('修改无 title 时仅展示 id', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: { id: 'ds_1' } as any,
newSchema: { id: 'ds_1' } as any,
};
opType: 'update',
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('修改 ds_1');
});
});
describe('describeDataSourceGroup', () => {
test('多步组:聚合 propPath 与目标 id', () => {
const mkStep = (path: string): DataSourceStepValue => ({
id: 'ds_1',
oldSchema: { id: 'ds_1', title: 'T' } as any,
newSchema: { id: 'ds_1', title: 'T' } as any,
changeRecords: [{ propPath: path } as any],
});
const mkStep = (path: string) =>
({
id: 'ds_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'ds_1', title: 'T' },
newSchema: { id: 'ds_1', title: 'T' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as DataSourceStepValue;
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
@ -404,7 +415,11 @@ describe('describeDataSourceGroup', () => {
applied: true,
steps: [
{
step: { id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'T' } as any },
step: {
id: 'ds_1',
opType: 'add',
diff: [{ newSchema: { id: 'ds_1', title: 'T' } }],
} as unknown as DataSourceStepValue,
index: 0,
applied: true,
},
@ -423,10 +438,10 @@ describe('describeDataSourceGroup', () => {
{
step: {
id: 'ds_1',
oldSchema: null,
newSchema: null,
opType: 'update',
diff: [{}],
historyDescription: '我的描述',
},
} as unknown as DataSourceStepValue,
index: 0,
applied: true,
},
@ -438,52 +453,63 @@ describe('describeDataSourceGroup', () => {
describe('describeCodeBlockStep', () => {
test('新增', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: null,
newContent: { id: 'code_1', name: 'onClick' } as any,
};
opType: 'add',
diff: [{ newSchema: { id: 'code_1', name: 'onClick' } }],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
});
test('删除', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: { id: 'code_1', name: 'onClick' } as any,
newContent: null,
};
opType: 'remove',
diff: [{ oldSchema: { id: 'code_1', name: 'onClick' } }],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
});
test('修改 + propPath', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: { id: 'code_1', name: 'onClick' } as any,
newContent: { id: 'code_1', name: 'onClick' } as any,
changeRecords: [{ propPath: 'content' } as any],
};
opType: 'update',
diff: [
{
oldSchema: { id: 'code_1', name: 'onClick' },
newSchema: { id: 'code_1', name: 'onClick' },
changeRecords: [{ propPath: 'content' }],
},
],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
});
test('historyDescription 优先', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: null,
newContent: null,
opType: 'update',
diff: [{}],
historyDescription: '自定义说明',
};
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('自定义说明');
});
});
describe('describeCodeBlockGroup', () => {
test('多步组:聚合 propPath', () => {
const mkStep = (path: string): CodeBlockStepValue => ({
id: 'code_1',
oldContent: { id: 'code_1', name: 'fn' } as any,
newContent: { id: 'code_1', name: 'fn' } as any,
changeRecords: [{ propPath: path } as any],
});
const mkStep = (path: string) =>
({
id: 'code_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'code_1', name: 'fn' },
newSchema: { id: 'code_1', name: 'fn' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as CodeBlockStepValue;
const group: CodeBlockHistoryGroup = {
kind: 'code-block',
id: 'code_1',
@ -505,7 +531,11 @@ describe('describeCodeBlockGroup', () => {
applied: false,
steps: [
{
step: { id: 'code_1', oldContent: { id: 'code_1', name: 'fn' } as any, newContent: null },
step: {
id: 'code_1',
opType: 'remove',
diff: [{ oldSchema: { id: 'code_1', name: 'fn' } }],
} as unknown as CodeBlockStepValue,
index: 0,
applied: false,
},
@ -550,12 +580,12 @@ describe('useHistoryList', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push({
opType: 'remove',
removedItems: [{ node: { id: 'n2', name: 'B' } }],
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
} as any);
@ -613,15 +643,15 @@ describe('useHistoryList', () => {
describe('isPageStepRevertable', () => {
test('add / remove 始终可回滚', () => {
expect(isPageStepRevertable({ opType: 'add', nodes: [{ id: 'n1' }] } as any)).toBe(true);
expect(isPageStepRevertable({ opType: 'remove', removedItems: [{ node: { id: 'n1' } }] } as any)).toBe(true);
expect(isPageStepRevertable({ opType: 'add', diff: [{ newSchema: { id: 'n1' } }] } as any)).toBe(true);
expect(isPageStepRevertable({ opType: 'remove', diff: [{ oldSchema: { id: 'n1' } }] } as any)).toBe(true);
});
test('update 每项都有 changeRecords 才可回滚', () => {
expect(
isPageStepRevertable({
opType: 'update',
updatedItems: [{ oldNode: { id: 'n1' }, newNode: { id: 'n1' }, changeRecords: [{ propPath: 'style.color' }] }],
diff: [{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' }, changeRecords: [{ propPath: 'style.color' }] }],
} as any),
).toBe(true);
});
@ -630,7 +660,7 @@ describe('isPageStepRevertable', () => {
expect(
isPageStepRevertable({
opType: 'update',
updatedItems: [{ oldNode: { id: 'n1' }, newNode: { id: 'n1' } }],
diff: [{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' } }],
} as any),
).toBe(false);
});
@ -639,53 +669,51 @@ describe('isPageStepRevertable', () => {
expect(
isPageStepRevertable({
opType: 'update',
updatedItems: [
{ oldNode: { id: 'n1' }, newNode: { id: 'n1' }, changeRecords: [{ propPath: 'a' }] },
{ oldNode: { id: 'n2' }, newNode: { id: 'n2' } },
diff: [
{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' }, changeRecords: [{ propPath: 'a' }] },
{ oldSchema: { id: 'n2' }, newSchema: { id: 'n2' } },
],
} as any),
).toBe(false);
});
test('update 无 updatedItems 不可回滚', () => {
test('update 无 diff 不可回滚', () => {
expect(isPageStepRevertable({ opType: 'update' } as any)).toBe(false);
});
});
describe('isDataSourceStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isDataSourceStepRevertable({ oldSchema: null, newSchema: { id: 'ds_1' } } as any)).toBe(true);
expect(isDataSourceStepRevertable({ oldSchema: { id: 'ds_1' }, newSchema: null } as any)).toBe(true);
expect(isDataSourceStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
expect(isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect(
isDataSourceStepRevertable({
oldSchema: { id: 'ds_1' },
newSchema: { id: 'ds_1' },
changeRecords: [{ propPath: 'title' }],
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }],
} as any),
).toBe(true);
expect(isDataSourceStepRevertable({ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } } as any)).toBe(false);
expect(
isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
).toBe(false);
});
});
describe('isCodeBlockStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isCodeBlockStepRevertable({ oldContent: null, newContent: { id: 'code_1' } } as any)).toBe(true);
expect(isCodeBlockStepRevertable({ oldContent: { id: 'code_1' }, newContent: null } as any)).toBe(true);
expect(isCodeBlockStepRevertable({ diff: [{ newSchema: { id: 'code_1' } }] } as any)).toBe(true);
expect(isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' } }] } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect(
isCodeBlockStepRevertable({
oldContent: { id: 'code_1' },
newContent: { id: 'code_1' },
changeRecords: [{ propPath: 'content' }],
diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' }, changeRecords: [{ propPath: 'content' }] }],
} as any),
).toBe(true);
expect(isCodeBlockStepRevertable({ oldContent: { id: 'code_1' }, newContent: { id: 'code_1' } } as any)).toBe(
false,
);
expect(
isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' } }] } as any),
).toBe(false);
});
});

View File

@ -176,8 +176,8 @@ describe('CodeBlockService - 历史记录接入', () => {
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
const step = historyService.undoCodeBlock('new_code');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A' }));
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A' }));
});
test('setCodeDslByIdSync - 更新时入历史(oldContent / newContent 都非空)', async () => {
@ -185,8 +185,8 @@ describe('CodeBlockService - 历史记录接入', () => {
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A2' }));
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A2' }));
});
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
@ -200,8 +200,8 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.deleteCodeDslByIds(['a']);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toBeNull();
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
});
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
@ -218,7 +218,7 @@ describe('CodeBlockService - 历史记录接入', () => {
});
const step = historyService.undoCodeBlock('a');
expect(step?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
});
test('setCodeDslByIdSync - 不传 changeRecords 时 step.changeRecords 为 undefined', async () => {
@ -227,7 +227,7 @@ describe('CodeBlockService - 历史记录接入', () => {
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.changeRecords).toBeUndefined();
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
});
});

View File

@ -135,8 +135,8 @@ describe('DataSource service - 历史记录接入', () => {
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
expect(historyService.canUndoDataSource(ds.id!)).toBe(true);
const step = historyService.undoDataSource(ds.id!);
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('a');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('a');
});
test('update - 入历史,oldSchema 是旧值,newSchema 是新值', () => {
@ -146,8 +146,8 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema?.title).toBe('b');
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
expect(step?.diff?.[0]?.newSchema?.title).toBe('b');
});
test('remove - 入历史(newSchema=null)', () => {
@ -156,8 +156,8 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.remove(created.id!);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema).toBeNull();
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
});
test('remove - 不存在的 id 不入历史', () => {
@ -174,7 +174,7 @@ describe('DataSource service - 历史记录接入', () => {
});
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
});
test('update - 不传 changeRecords 时 step.changeRecords 为 undefined', () => {
@ -183,7 +183,7 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toBeUndefined();
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
});
});

View File

@ -779,7 +779,7 @@ describe('revertPageStepById', () => {
expect(typeof uuid).toBe('string');
const addedStep = historyService.getPageStepList().find((e) => e.step.uuid === uuid)!.step;
const addedId = addedStep.nodes![0].id;
const addedId = addedStep.diff[0].newSchema!.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
const reverted = await editorService.revertPageStepById(uuid!);

View File

@ -206,8 +206,8 @@ describe('history service - IndexedDB 持久化', () => {
await history.restoreFromIndexedDB();
const current = (history.state.codeBlockState as any).code_1.getCurrentElement();
expect(typeof current.newContent.code).toBe('function');
expect(current.newContent.code()).toBe(42);
expect(typeof current.diff[0].newSchema.code).toBe('function');
expect(current.diff[0].newSchema.code()).toBe(42);
});
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {

View File

@ -143,8 +143,8 @@ describe('history service - codeBlock', () => {
expect(step).not.toBeNull();
expect(step?.id).toBe('code_1');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual({ name: 'A', content: 'x' });
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' }));
@ -179,11 +179,11 @@ describe('history service - codeBlock', () => {
expect(history.canUndoCodeBlock('code_1')).toBe(true);
const undone = history.undoCodeBlock('code_1');
expect(undone?.newContent).toEqual({ name: 'B' });
expect(undone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
expect(history.canRedoCodeBlock('code_1')).toBe(true);
const redone = history.redoCodeBlock('code_1');
expect(redone?.newContent).toEqual({ name: 'B' });
expect(redone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
});
test('undoCodeBlock 对不存在 id 返回 null', () => {
@ -229,8 +229,8 @@ describe('history service - dataSource', () => {
expect(step).not.toBeNull();
expect(step?.id).toBe('ds_1');
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('A');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('A');
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
expect(history.canUndoDataSource('ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' }));
@ -267,10 +267,10 @@ describe('history service - dataSource', () => {
});
const undone = history.undoDataSource('ds_1');
expect(undone?.newSchema?.title).toBe('B');
expect(undone?.diff?.[0]?.newSchema?.title).toBe('B');
const redone = history.redoDataSource('ds_1');
expect(redone?.newSchema?.title).toBe('B');
expect(redone?.diff?.[0]?.newSchema?.title).toBe('B');
});
test('undoDataSource 对不存在 id 返回 null', () => {