mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
feat(editor): 新增历史记录列表面板
- 新增 history-list 模块(面板、Tab、Bucket、GroupRow 与 composables) - NavMenu 接入历史记录面板入口 - history/editor/codeBlock/dataSource service 配合面板能力调整 - utils/undo-redo 适配新面板 - 扩展 type.ts 相关类型定义 - 新增 history-list-panel.scss 并在 theme.scss 引入 - 补充 history-list 模块完整单元测试 - playground 同步小幅调整
This commit is contained in:
parent
285434ef3e
commit
0446202ba6
@ -19,6 +19,7 @@ import { NodeType } from '@tmagic/core';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import { ColumnLayout, MenuBarData, MenuButton, MenuComponent, MenuItem } from '@editor/type';
|
||||
|
||||
import HistoryListPanel from './history-list/HistoryListPanel.vue';
|
||||
import NavMenuColumn from './NavMenuColumn.vue';
|
||||
|
||||
defineOptions({
|
||||
@ -103,6 +104,14 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
||||
handler: () => editorService.redo(),
|
||||
});
|
||||
break;
|
||||
case 'history-list':
|
||||
// 历史记录面板:以 component 形式挂入,自带 popover;点击 nav 上的图标弹出。
|
||||
config.push({
|
||||
type: 'component',
|
||||
className: 'history-list',
|
||||
component: markRaw(HistoryListPanel),
|
||||
});
|
||||
break;
|
||||
case 'zoom-in':
|
||||
config.push({
|
||||
type: 'button',
|
||||
|
||||
70
packages/editor/src/layouts/history-list/Bucket.vue
Normal file
70
packages/editor/src/layouts/history-list/Bucket.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="m-editor-history-list-bucket">
|
||||
<div class="m-editor-history-list-bucket-title">
|
||||
<span>{{ title }}</span>
|
||||
<code>{{ String(bucketId) }}</code>
|
||||
<span class="m-editor-history-list-bucket-count">{{ groups.length }} 组</span>
|
||||
</div>
|
||||
|
||||
<ul class="m-editor-history-list-ul">
|
||||
<GroupRow
|
||||
v-for="(group, gIdx) in groups"
|
||||
:key="`${prefix}-${bucketId}-${gIdx}`"
|
||||
:group-key="`${prefix}-${bucketId}-${gIdx}`"
|
||||
:applied="group.applied"
|
||||
:merged="group.steps.length > 1"
|
||||
:op-type="group.opType"
|
||||
:desc="describeGroup(group)"
|
||||
:step-count="group.steps.length"
|
||||
:sub-steps="
|
||||
group.steps.map((s: any) => ({
|
||||
index: s.index,
|
||||
applied: s.applied,
|
||||
isCurrent: s.isCurrent,
|
||||
desc: describeStep(s.step),
|
||||
}))
|
||||
"
|
||||
:is-current="group.isCurrent"
|
||||
:expanded="!!expanded[`${prefix}-${bucketId}-${gIdx}`]"
|
||||
@toggle="(key: string) => $emit('toggle', key)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { HistoryOpType } from '@editor/type';
|
||||
|
||||
import GroupRow from './GroupRow.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListBucket',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
|
||||
title: string;
|
||||
/** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
|
||||
bucketId: string | number;
|
||||
/** 子项 key 的命名空间前缀:`ds` 表示数据源,`cb` 表示代码块。与上层折叠状态 key 保持一致。 */
|
||||
prefix: 'ds' | 'cb';
|
||||
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
|
||||
groups: {
|
||||
applied: boolean;
|
||||
isCurrent?: boolean;
|
||||
opType: HistoryOpType;
|
||||
steps: { index: number; applied: boolean; isCurrent?: boolean; step: any }[];
|
||||
}[];
|
||||
/** 组级描述文案生成器,接收一个 group,返回展示文本。由父组件按业务类型注入。 */
|
||||
describeGroup: (_group: any) => string;
|
||||
/** 单步描述文案生成器,接收一个 step,返回展示文本。用于合并组展开后的子步列表。 */
|
||||
describeStep: (_step: any) => string;
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 透传子组件 GroupRow 的 toggle,由上层 panel 更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
}>();
|
||||
</script>
|
||||
45
packages/editor/src/layouts/history-list/CodeBlockTab.vue
Normal file
45
packages/editor/src/layouts/history-list/CodeBlockTab.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<TMagicScrollbar v-else max-height="360px">
|
||||
<Bucket
|
||||
v-for="bucket in buckets"
|
||||
:key="`cb-${bucket.id}`"
|
||||
title="代码块"
|
||||
:bucket-id="bucket.id"
|
||||
prefix="cb"
|
||||
:groups="bucket.groups"
|
||||
:describe-group="describeCodeBlockGroup"
|
||||
:describe-step="describeCodeBlockStep"
|
||||
:expanded="expanded"
|
||||
@toggle="(key: string) => $emit('toggle', key)"
|
||||
/>
|
||||
</TMagicScrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { CodeBlockHistoryGroup } from '@editor/type';
|
||||
|
||||
import Bucket from './Bucket.vue';
|
||||
import { describeCodeBlockGroup, describeCodeBlockStep } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListCodeBlockTab',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* 已按 codeBlock.id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
|
||||
* 空数组时显示空态。
|
||||
*/
|
||||
buckets: { id: string | number; groups: CodeBlockHistoryGroup[] }[];
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `cb-${id}-${idx}` 作为 key。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
}>();
|
||||
</script>
|
||||
45
packages/editor/src/layouts/history-list/DataSourceTab.vue
Normal file
45
packages/editor/src/layouts/history-list/DataSourceTab.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<TMagicScrollbar v-else max-height="360px">
|
||||
<Bucket
|
||||
v-for="bucket in buckets"
|
||||
:key="`ds-${bucket.id}`"
|
||||
title="数据源"
|
||||
:bucket-id="bucket.id"
|
||||
prefix="ds"
|
||||
:groups="bucket.groups"
|
||||
:describe-group="describeDataSourceGroup"
|
||||
:describe-step="describeDataSourceStep"
|
||||
:expanded="expanded"
|
||||
@toggle="(key: string) => $emit('toggle', key)"
|
||||
/>
|
||||
</TMagicScrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { DataSourceHistoryGroup } from '@editor/type';
|
||||
|
||||
import Bucket from './Bucket.vue';
|
||||
import { describeDataSourceGroup, describeDataSourceStep } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListDataSourceTab',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* 已按 dataSource.id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
|
||||
* 空数组时显示空态。
|
||||
*/
|
||||
buckets: { id: string | number; groups: DataSourceHistoryGroup[] }[];
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `ds-${id}-${idx}` 作为 key。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
}>();
|
||||
</script>
|
||||
57
packages/editor/src/layouts/history-list/GroupRow.vue
Normal file
57
packages/editor/src/layouts/history-list/GroupRow.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<li
|
||||
class="m-editor-history-list-item m-editor-history-list-group"
|
||||
:class="{ 'is-undone': !applied, 'is-merged': merged, 'is-current': isCurrent }"
|
||||
>
|
||||
<div class="m-editor-history-list-group-head" @click="$emit('toggle', groupKey)">
|
||||
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
|
||||
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} 步</span>
|
||||
</div>
|
||||
|
||||
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
|
||||
<li v-for="s in subSteps" :key="s.index" :class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent }">
|
||||
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
|
||||
<span>{{ s.desc }}</span>
|
||||
<span v-if="s.isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { HistoryOpType } from '@editor/type';
|
||||
|
||||
import { opLabel } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListGroupRow',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/** 唯一标识当前组的 key,作为 toggle 事件的 payload 回传给上层。形如 `pg-${idx}` / `ds-${id}-${idx}` / `cb-${id}-${idx}`。 */
|
||||
groupKey: string;
|
||||
/** 该组当前是否处于已应用状态(false 表示已被 undo 撤销,UI 会显示为灰态)。 */
|
||||
applied: boolean;
|
||||
/** 是否为合并组(即组内 step 数大于 1,由多次连续操作合并而来)。决定是否展示合并标记与可展开的子步列表。 */
|
||||
merged: boolean;
|
||||
/** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
|
||||
opType: HistoryOpType;
|
||||
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
|
||||
desc: string;
|
||||
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
|
||||
stepCount: number;
|
||||
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
|
||||
subSteps: { index: number; applied: boolean; desc: string; isCurrent?: boolean }[];
|
||||
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
|
||||
expanded: boolean;
|
||||
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
|
||||
isCurrent?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 用户点击组头部时触发,携带 groupKey,由上层维护折叠状态。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
}>();
|
||||
</script>
|
||||
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<TMagicPopover popper-class="m-editor-history-list-popover" placement="bottom" trigger="click" :width="660">
|
||||
<div class="m-editor-history-list">
|
||||
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
|
||||
<component
|
||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}"
|
||||
>
|
||||
<PageTab :list="pageGroupsDisplay" :expanded="expanded" @toggle="toggleGroup" />
|
||||
</component>
|
||||
|
||||
<component
|
||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
|
||||
>
|
||||
<DataSourceTab :buckets="dataSourceGroupsByTarget" :expanded="expanded" @toggle="toggleGroup" />
|
||||
</component>
|
||||
|
||||
<component
|
||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
|
||||
>
|
||||
<CodeBlockTab :buckets="codeBlockGroupsByTarget" :expanded="expanded" @toggle="toggleGroup" />
|
||||
</component>
|
||||
</TMagicTabs>
|
||||
</div>
|
||||
|
||||
<template #reference>
|
||||
<TMagicTooltip effect="dark" placement="bottom" content="历史记录">
|
||||
<TMagicButton size="small" link>
|
||||
<template #icon>
|
||||
<MIcon :icon="ClockIcon"></MIcon>
|
||||
</template>
|
||||
</TMagicButton>
|
||||
</TMagicTooltip>
|
||||
</template>
|
||||
</TMagicPopover>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 历史记录面板:在顶部 NavMenu 上点击图标打开 popover,分三个 tab:
|
||||
* - 页面:当前活动页面的历史栈,连续修改同一节点的多步会被合并成一组
|
||||
* - 数据源:以 dataSource.id 分组,每组内部相邻的连续 update 自动合并
|
||||
* - 代码块:同上,按 codeBlock.id 分组并合并相邻 update
|
||||
*
|
||||
* 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示。
|
||||
*
|
||||
* 各 tab 的内容拆分为独立的 SFC(PageTab / DataSourceTab / CodeBlockTab),
|
||||
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
||||
*/
|
||||
import { markRaw, ref } from 'vue';
|
||||
import { Clock } from '@element-plus/icons-vue';
|
||||
|
||||
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
||||
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
|
||||
import CodeBlockTab from './CodeBlockTab.vue';
|
||||
import { useHistoryList } from './composables';
|
||||
import DataSourceTab from './DataSourceTab.vue';
|
||||
import PageTab from './PageTab.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListPanel',
|
||||
});
|
||||
|
||||
const ClockIcon = markRaw(Clock);
|
||||
const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
|
||||
|
||||
const tabPaneComponent = getDesignConfig('components')?.tabPane;
|
||||
|
||||
const {
|
||||
expanded,
|
||||
toggleGroup,
|
||||
pageGroups,
|
||||
dataSourceGroups,
|
||||
codeBlockGroups,
|
||||
pageGroupsDisplay,
|
||||
dataSourceGroupsByTarget,
|
||||
codeBlockGroupsByTarget,
|
||||
} = useHistoryList();
|
||||
</script>
|
||||
53
packages/editor/src/layouts/history-list/PageTab.vue
Normal file
53
packages/editor/src/layouts/history-list/PageTab.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<TMagicScrollbar v-else max-height="360px">
|
||||
<ul class="m-editor-history-list-ul">
|
||||
<GroupRow
|
||||
v-for="(group, gIdx) in list"
|
||||
:key="`pg-${gIdx}`"
|
||||
:group-key="`pg-${gIdx}`"
|
||||
:applied="group.applied"
|
||||
:merged="group.steps.length > 1"
|
||||
:op-type="group.opType"
|
||||
:desc="describePageGroup(group)"
|
||||
:step-count="group.steps.length"
|
||||
:sub-steps="
|
||||
group.steps.map((s) => ({
|
||||
index: s.index,
|
||||
applied: s.applied,
|
||||
isCurrent: s.isCurrent,
|
||||
desc: describePageStep(s.step),
|
||||
}))
|
||||
"
|
||||
:is-current="group.isCurrent"
|
||||
:expanded="!!expanded[`pg-${gIdx}`]"
|
||||
@toggle="(key: string) => $emit('toggle', key)"
|
||||
/>
|
||||
</ul>
|
||||
</TMagicScrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { PageHistoryGroup } from '@editor/type';
|
||||
|
||||
import { describePageGroup, describePageStep } from './composables';
|
||||
import GroupRow from './GroupRow.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListPageTab',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
||||
list: PageHistoryGroup[];
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `pg-${idx}` 作为 key。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 透传 GroupRow 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
}>();
|
||||
</script>
|
||||
195
packages/editor/src/layouts/history-list/composables.ts
Normal file
195
packages/editor/src/layouts/history-list/composables.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import type {
|
||||
CodeBlockHistoryGroup,
|
||||
CodeBlockStepValue,
|
||||
DataSourceHistoryGroup,
|
||||
DataSourceStepValue,
|
||||
HistoryOpType,
|
||||
PageHistoryGroup,
|
||||
StepValue,
|
||||
} from '@editor/type';
|
||||
|
||||
/**
|
||||
* 历史记录面板共享逻辑:
|
||||
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
||||
* - 提供折叠状态管理;
|
||||
* - 提供操作描述文案生成器。
|
||||
*
|
||||
* 所有数据基于 historyService 的 reactive state 派生,自动跟随历史变化刷新。
|
||||
*/
|
||||
export const useHistoryList = () => {
|
||||
const { historyService } = useServices();
|
||||
|
||||
/** 折叠状态:key 形如 `pg-${groupIdx}` / `ds-${id}-${groupIdx}` / `cb-${id}-${groupIdx}`。 */
|
||||
const expanded = reactive<Record<string, boolean>>({});
|
||||
const toggleGroup = (key: string) => {
|
||||
expanded[key] = !expanded[key];
|
||||
};
|
||||
|
||||
const pageGroups = computed(() => historyService.getPageHistoryGroups());
|
||||
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
|
||||
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
|
||||
|
||||
/** 页面 tab 倒序展示(最新一组在最上面)。 */
|
||||
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
|
||||
|
||||
/**
|
||||
* 把按时间正序的 group 列表,再按 id 聚拢成 bucket(同 id 的所有分组放一起)。
|
||||
* 每个 bucket 内部仍然按时间倒序展示(最近的操作最先看到)。
|
||||
*/
|
||||
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
|
||||
const map = new Map<string | number, G[]>();
|
||||
groups.forEach((g) => {
|
||||
const list = map.get(g.id) ?? [];
|
||||
list.push(g);
|
||||
map.set(g.id, list);
|
||||
});
|
||||
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
|
||||
};
|
||||
|
||||
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
|
||||
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
|
||||
|
||||
return {
|
||||
expanded,
|
||||
toggleGroup,
|
||||
pageGroups,
|
||||
dataSourceGroups,
|
||||
codeBlockGroups,
|
||||
pageGroupsDisplay,
|
||||
dataSourceGroupsByTarget,
|
||||
codeBlockGroupsByTarget,
|
||||
};
|
||||
};
|
||||
|
||||
export const opLabel = (op: HistoryOpType) => {
|
||||
switch (op) {
|
||||
case 'add':
|
||||
return '新增';
|
||||
case 'remove':
|
||||
return '删除';
|
||||
case 'update':
|
||||
default:
|
||||
return '修改';
|
||||
}
|
||||
};
|
||||
|
||||
const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
|
||||
node?.name || node?.type || `${node?.id ?? ''}`;
|
||||
|
||||
/**
|
||||
* 默认描述里展示「名称 (id: xxx)」,便于区分同名实体。
|
||||
* - 当未传入 id,或 label 本身就是 id 字符串(即没有 name/type/title 可用)时,仅展示 label,避免出现「123 (id: 123)」。
|
||||
*/
|
||||
const labelWithId = (label: string | number | undefined, id: string | number | undefined): string => {
|
||||
const labelStr = label === undefined || label === null ? '' : `${label}`;
|
||||
if (id === undefined || id === null || id === '') return labelStr;
|
||||
if (labelStr === '' || labelStr === `${id}`) return `${id}`;
|
||||
return `${labelStr} (id: ${id})`;
|
||||
};
|
||||
|
||||
/** 从一组可选 historyDescription 中取最后一条非空值;都为空时返回 undefined。 */
|
||||
const pickLastDescription = (descs: (string | undefined)[]): string | undefined => {
|
||||
for (let i = descs.length - 1; i >= 0; i--) {
|
||||
if (descs[i]) return descs[i];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const describePageStep = (step: StepValue) => {
|
||||
if (step.historyDescription) return step.historyDescription;
|
||||
const { opType } = step;
|
||||
if (opType === 'add') {
|
||||
const count = step.nodes?.length ?? 0;
|
||||
const node = step.nodes?.[0];
|
||||
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;
|
||||
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];
|
||||
const propPath = changeRecords?.[0]?.propPath;
|
||||
const target = labelWithId(nameOf(newNode), newNode?.id);
|
||||
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
|
||||
}
|
||||
return `修改 ${updated.length} 个节点`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并组的展示文案:
|
||||
* - 若组内任一步显式提供了 historyDescription:取最后一条非空 historyDescription(最近一次的描述更准确);
|
||||
* - 单步组:复用 describePageStep;
|
||||
* - 多步组(连续修改同一节点):展示节点名 + 涉及的前几个 propPath。
|
||||
*/
|
||||
export const describePageGroup = (group: PageHistoryGroup) => {
|
||||
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||
if (lastDesc) return lastDesc;
|
||||
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));
|
||||
});
|
||||
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
||||
const target = labelWithId(
|
||||
group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'),
|
||||
group.targetId,
|
||||
);
|
||||
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
||||
};
|
||||
|
||||
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);
|
||||
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
||||
};
|
||||
|
||||
export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
|
||||
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||
if (lastDesc) return lastDesc;
|
||||
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));
|
||||
});
|
||||
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 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);
|
||||
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
||||
};
|
||||
|
||||
export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
|
||||
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||
if (lastDesc) return lastDesc;
|
||||
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));
|
||||
});
|
||||
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 target = labelWithId(rawName, group.id);
|
||||
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
||||
};
|
||||
@ -22,13 +22,19 @@ import type { Writable } from 'type-fest';
|
||||
|
||||
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
|
||||
import { Target, Watcher } from '@tmagic/core';
|
||||
import type { ChangeRecord, TableColumnConfig } from '@tmagic/form';
|
||||
import type { TableColumnConfig } from '@tmagic/form';
|
||||
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
import editorService from '@editor/services/editor';
|
||||
import historyService from '@editor/services/history';
|
||||
import storageService, { Protocol } from '@editor/services/storage';
|
||||
import type { AsyncHookPlugin, CodeBlockStepValue, CodeState } from '@editor/type';
|
||||
import type {
|
||||
AsyncHookPlugin,
|
||||
CodeBlockStepValue,
|
||||
CodeState,
|
||||
HistoryOpOptions,
|
||||
HistoryOpOptionsWithChangeRecords,
|
||||
} from '@editor/type';
|
||||
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
||||
import { getEditorConfig } from '@editor/utils/config';
|
||||
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||
@ -102,7 +108,7 @@ class CodeBlock extends BaseService {
|
||||
public async setCodeDslById(
|
||||
id: Id,
|
||||
codeConfig: Partial<CodeBlockContent>,
|
||||
{ changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
|
||||
{ changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {},
|
||||
): Promise<void> {
|
||||
this.setCodeDslByIdSync(id, codeConfig, true, { changeRecords, doNotPushHistory });
|
||||
}
|
||||
@ -116,13 +122,14 @@ class CodeBlock extends BaseService {
|
||||
* @param options 可选配置
|
||||
* @param options.changeRecords form 端 propPath/value 列表,用于历史记录的精细化撤销/重做
|
||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||
* @returns {void}
|
||||
*/
|
||||
public setCodeDslByIdSync(
|
||||
id: Id,
|
||||
codeConfig: Partial<CodeBlockContent>,
|
||||
force = true,
|
||||
{ changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
|
||||
{ changeRecords, doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
|
||||
): void {
|
||||
const codeDsl = this.getCodeDsl();
|
||||
|
||||
@ -153,7 +160,7 @@ class CodeBlock extends BaseService {
|
||||
const newContent = cloneDeep(codeDsl[id]);
|
||||
|
||||
if (!doNotPushHistory) {
|
||||
historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords });
|
||||
historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords, historyDescription });
|
||||
}
|
||||
|
||||
this.emit('addOrUpdate', id, codeDsl[id]);
|
||||
@ -249,7 +256,7 @@ class CodeBlock extends BaseService {
|
||||
*/
|
||||
public async deleteCodeDslByIds(
|
||||
codeIds: Id[],
|
||||
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {},
|
||||
{ doNotPushHistory = false, historyDescription }: HistoryOpOptions = {},
|
||||
): Promise<void> {
|
||||
const currentDsl = await this.getCodeDsl();
|
||||
|
||||
@ -262,7 +269,7 @@ class CodeBlock extends BaseService {
|
||||
delete currentDsl[id];
|
||||
|
||||
if (oldContent && !doNotPushHistory) {
|
||||
historyService.pushCodeBlock(id, { oldContent, newContent: null });
|
||||
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription });
|
||||
}
|
||||
|
||||
this.emit('remove', id);
|
||||
|
||||
@ -4,13 +4,19 @@ import type { Writable } from 'type-fest';
|
||||
|
||||
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
|
||||
import { Target, Watcher } from '@tmagic/core';
|
||||
import type { ChangeRecord, FormConfig } from '@tmagic/form';
|
||||
import type { FormConfig } from '@tmagic/form';
|
||||
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||
|
||||
import editorService from '@editor/services/editor';
|
||||
import historyService from '@editor/services/history';
|
||||
import storageService, { Protocol } from '@editor/services/storage';
|
||||
import type { DataSourceStepValue, DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
|
||||
import type {
|
||||
DataSourceStepValue,
|
||||
DatasourceTypeOption,
|
||||
HistoryOpOptions,
|
||||
HistoryOpOptionsWithChangeRecords,
|
||||
SyncHookPlugin,
|
||||
} from '@editor/type';
|
||||
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
||||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||||
|
||||
@ -108,8 +114,9 @@ class DataSource extends BaseService {
|
||||
* @param config 数据源配置
|
||||
* @param options 可选配置
|
||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||
*/
|
||||
public add(config: DataSourceSchema, { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}) {
|
||||
public add(config: DataSourceSchema, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
|
||||
const newConfig = {
|
||||
...config,
|
||||
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
||||
@ -118,7 +125,7 @@ class DataSource extends BaseService {
|
||||
this.get('dataSources').push(newConfig);
|
||||
|
||||
if (!doNotPushHistory) {
|
||||
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig });
|
||||
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig, historyDescription });
|
||||
}
|
||||
|
||||
this.emit('add', newConfig);
|
||||
@ -132,13 +139,11 @@ class DataSource extends BaseService {
|
||||
* @param data 额外数据
|
||||
* @param data.changeRecords form 端变更记录
|
||||
* @param data.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||
*/
|
||||
public update(
|
||||
config: DataSourceSchema,
|
||||
{
|
||||
changeRecords = [],
|
||||
doNotPushHistory = false,
|
||||
}: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
|
||||
{ changeRecords = [], doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
|
||||
) {
|
||||
const dataSources = this.get('dataSources');
|
||||
|
||||
@ -154,6 +159,7 @@ class DataSource extends BaseService {
|
||||
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
|
||||
newSchema: newConfig,
|
||||
changeRecords,
|
||||
historyDescription,
|
||||
});
|
||||
}
|
||||
|
||||
@ -170,15 +176,16 @@ class DataSource extends BaseService {
|
||||
* @param id 数据源 id
|
||||
* @param options 可选配置
|
||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||
*/
|
||||
public remove(id: string, { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}) {
|
||||
public remove(id: string, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
|
||||
const dataSources = this.get('dataSources');
|
||||
const index = dataSources.findIndex((ds) => ds.id === id);
|
||||
const oldConfig = index !== -1 ? dataSources[index] : null;
|
||||
dataSources.splice(index, 1);
|
||||
|
||||
if (oldConfig && !doNotPushHistory) {
|
||||
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null });
|
||||
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null, historyDescription });
|
||||
}
|
||||
|
||||
this.emit('remove', id);
|
||||
|
||||
@ -374,7 +374,7 @@ class Editor extends BaseService {
|
||||
public async add(
|
||||
addNode: AddMNode | MNode[],
|
||||
parent?: MContainer | null,
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
@ -447,6 +447,7 @@ class Editor extends BaseService {
|
||||
),
|
||||
},
|
||||
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
historyDescription,
|
||||
);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
@ -544,7 +545,7 @@ class Editor extends BaseService {
|
||||
*/
|
||||
public async remove(
|
||||
nodeOrNodeList: MNode | MNode[],
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
|
||||
): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
@ -573,7 +574,7 @@ class Editor extends BaseService {
|
||||
|
||||
if (removedItems.length > 0 && pageForOp) {
|
||||
if (!doNotPushHistory) {
|
||||
this.pushOpHistory('remove', { removedItems }, pageForOp);
|
||||
this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
}
|
||||
@ -661,6 +662,7 @@ class Editor extends BaseService {
|
||||
* @param data.changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 changeRecordList)
|
||||
* @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords
|
||||
* @param data.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为)
|
||||
* @returns 更新后的节点配置
|
||||
*/
|
||||
public async update(
|
||||
@ -669,11 +671,12 @@ class Editor extends BaseService {
|
||||
changeRecords?: ChangeRecord[];
|
||||
changeRecordList?: ChangeRecord[][];
|
||||
doNotPushHistory?: boolean;
|
||||
historyDescription?: string;
|
||||
} = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const { doNotPushHistory = false, changeRecordList, changeRecords } = data;
|
||||
const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data;
|
||||
|
||||
const nodes = Array.isArray(config) ? config : [config];
|
||||
|
||||
@ -703,6 +706,7 @@ class Editor extends BaseService {
|
||||
})),
|
||||
},
|
||||
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
historyDescription,
|
||||
);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
@ -1186,7 +1190,12 @@ class Editor extends BaseService {
|
||||
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
||||
}
|
||||
|
||||
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
|
||||
private pushOpHistory(
|
||||
opType: HistoryOpType,
|
||||
extra: Partial<StepValue>,
|
||||
pageData: { name: string; id: Id },
|
||||
historyDescription?: string,
|
||||
) {
|
||||
const step: StepValue = {
|
||||
data: pageData,
|
||||
opType,
|
||||
@ -1195,6 +1204,7 @@ class Editor extends BaseService {
|
||||
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
|
||||
...extra,
|
||||
};
|
||||
if (historyDescription) step.historyDescription = historyDescription;
|
||||
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
||||
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
||||
historyService.push(step, pageData.id);
|
||||
|
||||
@ -22,12 +22,178 @@ import { cloneDeep } from 'lodash-es';
|
||||
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
|
||||
import type { ChangeRecord } from '@tmagic/form';
|
||||
|
||||
import type { CodeBlockStepValue, DataSourceStepValue, HistoryState, StepValue } from '@editor/type';
|
||||
import type {
|
||||
CodeBlockHistoryGroup,
|
||||
CodeBlockStepValue,
|
||||
DataSourceHistoryGroup,
|
||||
DataSourceStepValue,
|
||||
HistoryState,
|
||||
PageHistoryGroup,
|
||||
PageHistoryStepEntry,
|
||||
StepValue,
|
||||
} from '@editor/type';
|
||||
import { UndoRedo } from '@editor/utils/undo-redo';
|
||||
|
||||
import BaseService from './BaseService';
|
||||
|
||||
class History extends BaseService {
|
||||
/**
|
||||
* 把单个代码块栈拆成若干 group:
|
||||
* - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并);
|
||||
* - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。
|
||||
*/
|
||||
private static mergeCodeBlockSteps(
|
||||
codeBlockId: Id,
|
||||
list: CodeBlockStepValue[],
|
||||
cursor: number,
|
||||
): CodeBlockHistoryGroup[] {
|
||||
const groups: CodeBlockHistoryGroup[] = [];
|
||||
let current: CodeBlockHistoryGroup | null = null;
|
||||
const currentIndex = cursor - 1;
|
||||
list.forEach((step, index) => {
|
||||
const opType = History.detectOpType(step.oldContent, step.newContent);
|
||||
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: '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,
|
||||
opType,
|
||||
steps: [{ step, index, applied, isCurrent }],
|
||||
applied,
|
||||
isCurrent,
|
||||
};
|
||||
groups.push(current);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 old/new 是否为 null 推断 opType(与 push 时的约定一致)。
|
||||
*/
|
||||
private static detectOpType(oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' {
|
||||
if (oldVal === null && newVal !== null) return 'add';
|
||||
if (oldVal !== null && newVal === null) return 'remove';
|
||||
return 'update';
|
||||
}
|
||||
|
||||
/**
|
||||
* 把页面栈拆成若干 group:
|
||||
* - 单节点的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group;
|
||||
* - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单节点修改组);
|
||||
* - 多节点 'update'(如批量改属性)也独立成组(无明确单一目标,避免误合并)。
|
||||
*/
|
||||
private static mergePageSteps(pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] {
|
||||
const groups: PageHistoryGroup[] = [];
|
||||
let current: PageHistoryGroup | null = null;
|
||||
const currentIndex = cursor - 1;
|
||||
list.forEach((step, index) => {
|
||||
const applied = index < cursor;
|
||||
const isCurrent = index === currentIndex;
|
||||
const targetId = History.detectPageTargetId(step);
|
||||
const targetName = History.detectPageTargetName(step);
|
||||
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
|
||||
|
||||
// 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。
|
||||
const mergeable = step.opType === 'update' && targetId !== undefined;
|
||||
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
|
||||
current.steps.push(entry);
|
||||
current.applied = applied;
|
||||
if (isCurrent) current.isCurrent = true;
|
||||
// 保持目标名为最近一次的(节点重命名时也能反映)
|
||||
if (targetName) current.targetName = targetName;
|
||||
} else {
|
||||
current = {
|
||||
kind: 'page',
|
||||
pageId,
|
||||
opType: step.opType,
|
||||
targetId: mergeable ? targetId : undefined,
|
||||
targetName,
|
||||
steps: [entry],
|
||||
applied,
|
||||
isCurrent,
|
||||
};
|
||||
groups.push(current);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 StepValue 中的"目标节点 id"用于合并:
|
||||
* - 单节点 update:取唯一一项 updatedItems 的节点 id;
|
||||
* - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。
|
||||
*/
|
||||
private static detectPageTargetId(step: StepValue): Id | undefined {
|
||||
if (step.opType !== 'update') return undefined;
|
||||
const items = step.updatedItems;
|
||||
if (items?.length !== 1) return undefined;
|
||||
return items[0].newNode?.id ?? items[0].oldNode?.id;
|
||||
}
|
||||
|
||||
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
|
||||
private static detectPageTargetName(step: StepValue): string | undefined {
|
||||
if (step.opType === 'update') {
|
||||
const items = step.updatedItems;
|
||||
if (items?.length === 1) {
|
||||
const node = items[0].newNode || items[0].oldNode;
|
||||
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}`;
|
||||
}
|
||||
return step.nodes?.length ? `${step.nodes.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}`;
|
||||
}
|
||||
return step.removedItems?.length ? `${step.removedItems.length} 个节点` : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public state = reactive<HistoryState>({
|
||||
pageSteps: {},
|
||||
pageId: undefined,
|
||||
@ -111,6 +277,8 @@ class History extends BaseService {
|
||||
oldContent: CodeBlockContent | null;
|
||||
newContent: CodeBlockContent | null;
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
|
||||
historyDescription?: string;
|
||||
},
|
||||
): CodeBlockStepValue | null {
|
||||
if (!codeBlockId) return null;
|
||||
@ -120,6 +288,7 @@ class History extends BaseService {
|
||||
oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
|
||||
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
|
||||
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
|
||||
historyDescription: payload.historyDescription,
|
||||
};
|
||||
|
||||
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
|
||||
@ -137,6 +306,8 @@ class History extends BaseService {
|
||||
oldSchema: DataSourceSchema | null;
|
||||
newSchema: DataSourceSchema | null;
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 可选的人类可读描述,仅用于历史面板展示。 */
|
||||
historyDescription?: string;
|
||||
},
|
||||
): DataSourceStepValue | null {
|
||||
if (!dataSourceId) return null;
|
||||
@ -146,6 +317,7 @@ class History extends BaseService {
|
||||
oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
|
||||
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
|
||||
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
|
||||
historyDescription: payload.historyDescription,
|
||||
};
|
||||
|
||||
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
|
||||
@ -231,6 +403,76 @@ class History extends BaseService {
|
||||
this.removeAllPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出当前活动页的历史步骤平铺列表(包含已应用 + 已撤销)。
|
||||
* 列表按时间正序,最早一步在最前面。
|
||||
* 通常 UI 应使用 `getPageHistoryGroups` 取已合并分组的版本;本方法仅为兼容/调试保留。
|
||||
*/
|
||||
public getPageStepList(pageId?: Id): PageHistoryStepEntry[] {
|
||||
const targetPageId = pageId ?? this.state.pageId;
|
||||
if (!targetPageId) return [];
|
||||
const undoRedo = this.state.pageSteps[targetPageId];
|
||||
if (!undoRedo) return [];
|
||||
const list = undoRedo.getElementList();
|
||||
const cursor = undoRedo.getCursor();
|
||||
return list.map((step, index) => ({
|
||||
step,
|
||||
index,
|
||||
applied: index < cursor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出当前活动页的历史栈,按"目标节点"做相邻合并:
|
||||
* - 连续修改同一节点(单节点 update)的多步合并为一个 group,组内可展开查看每步;
|
||||
* - add / remove / 多节点 update 始终独立成组。
|
||||
* 用于历史面板的"页面"tab 展示。
|
||||
*/
|
||||
public getPageHistoryGroups(pageId?: Id): PageHistoryGroup[] {
|
||||
const targetPageId = pageId ?? this.state.pageId;
|
||||
if (!targetPageId) return [];
|
||||
const undoRedo = this.state.pageSteps[targetPageId];
|
||||
if (!undoRedo) return [];
|
||||
const list = undoRedo.getElementList();
|
||||
if (!list.length) return [];
|
||||
const cursor = undoRedo.getCursor();
|
||||
return History.mergePageSteps(targetPageId, list, cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出全部代码块的历史栈,按 codeBlockId 分组。
|
||||
* 同一栈内相邻、同 opType 且作用于同一 id 的多步会被合并为一个 group:
|
||||
* - 这正是"代码块/数据源各自按 id 分栈"的天然表现,再叠加"连续修改同目标的相邻步骤合并展示"。
|
||||
* - 合并后 group 暴露子步骤数组,UI 可展开查看每一步的 changeRecords。
|
||||
* - applied 字段:组内最后一步是否处于已应用段。
|
||||
*/
|
||||
public getCodeBlockHistoryGroups(): CodeBlockHistoryGroup[] {
|
||||
const groups: CodeBlockHistoryGroup[] = [];
|
||||
Object.entries(this.state.codeBlockState).forEach(([id, undoRedo]) => {
|
||||
if (!undoRedo) return;
|
||||
const list = undoRedo.getElementList();
|
||||
if (!list.length) return;
|
||||
const cursor = undoRedo.getCursor();
|
||||
groups.push(...History.mergeCodeBlockSteps(id, list, cursor));
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出全部数据源的历史栈,按 dataSourceId 分组。同上。
|
||||
*/
|
||||
public getDataSourceHistoryGroups(): DataSourceHistoryGroup[] {
|
||||
const groups: DataSourceHistoryGroup[] = [];
|
||||
Object.entries(this.state.dataSourceState).forEach(([id, undoRedo]) => {
|
||||
if (!undoRedo) return;
|
||||
const list = undoRedo.getElementList();
|
||||
if (!list.length) return;
|
||||
const cursor = undoRedo.getCursor();
|
||||
groups.push(...History.mergeDataSourceSteps(id, list, cursor));
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出指定页面的栈;不传 pageId 时按当前活动页取。
|
||||
*
|
||||
|
||||
203
packages/editor/src/theme/history-list-panel.scss
Normal file
203
packages/editor/src/theme/history-list-panel.scss
Normal file
@ -0,0 +1,203 @@
|
||||
.m-editor-history-list-popover {
|
||||
padding: 0 !important;
|
||||
|
||||
.m-editor-history-list {
|
||||
padding: 4px 8px 8px;
|
||||
}
|
||||
|
||||
.m-editor-history-list-tabs {
|
||||
.el-tabs__header,
|
||||
.t-tabs__header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m-editor-history-list-ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m-editor-history-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #303133;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.is-undone {
|
||||
color: #c0c4cc;
|
||||
|
||||
.m-editor-history-list-item-op {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
box-shadow: inset 2px 0 0 #409eff;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.16);
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-desc {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
|
||||
.m-editor-history-list-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.is-merged .m-editor-history-list-group-head {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-substeps {
|
||||
margin: 4px 0 0 18px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-left: 1px dashed #dcdfe6;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: #606266;
|
||||
|
||||
&.is-undone {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
background-color: rgba(64, 158, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-current {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
background-color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-index {
|
||||
flex: 0 0 auto;
|
||||
color: #909399;
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-op {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
color: #fff;
|
||||
background-color: #909399;
|
||||
|
||||
&.op-add {
|
||||
background-color: #67c23a;
|
||||
}
|
||||
|
||||
&.op-remove {
|
||||
background-color: #f56c6c;
|
||||
}
|
||||
|
||||
&.op-update {
|
||||
background-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-desc {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-merge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #e6a23c;
|
||||
background-color: rgba(230, 162, 60, 0.12);
|
||||
}
|
||||
|
||||
.m-editor-history-list-bucket {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-bucket-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1 1 auto;
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
color: #409eff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.m-editor-history-list-bucket-count {
|
||||
flex: 0 0 auto;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
@use "./search-input.scss";
|
||||
@use "./nav-menu.scss";
|
||||
@use "./history-list-panel.scss";
|
||||
@use "./framework.scss";
|
||||
@use "./sidebar.scss";
|
||||
@use "./layer-panel.scss";
|
||||
|
||||
@ -417,6 +417,7 @@ export interface MenuComponent {
|
||||
* 'rule': 显示隐藏标尺
|
||||
* 'scale-to-original': 缩放到实际大小
|
||||
* 'scale-to-fit': 缩放以适应
|
||||
* 'history-list': 历史记录面板(按 页面 / 数据源 / 代码块 三个 tab 展示,相邻同目标修改自动合并)
|
||||
*/
|
||||
// #region MenuItem
|
||||
export type MenuItem =
|
||||
@ -431,6 +432,7 @@ export type MenuItem =
|
||||
| 'rule'
|
||||
| 'scale-to-original'
|
||||
| 'scale-to-fit'
|
||||
| 'history-list'
|
||||
| MenuButton
|
||||
| MenuComponent
|
||||
| string;
|
||||
@ -644,6 +646,11 @@ export interface StepValue {
|
||||
* 缺省(未传 / 空数组)才退化为整节点替换。
|
||||
*/
|
||||
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
|
||||
/**
|
||||
* 调用方可选传入的人类可读描述(如「调整按钮颜色」),用于历史面板展示。
|
||||
* 不影响 undo/redo 行为;缺省时面板会根据节点 / propPath 自动生成描述。
|
||||
*/
|
||||
historyDescription?: string;
|
||||
}
|
||||
// #endregion StepValue
|
||||
|
||||
@ -666,6 +673,8 @@ export interface CodeBlockStepValue {
|
||||
* 缺省才退化为整内容替换。新增/删除场景通常无 changeRecords。
|
||||
*/
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
|
||||
historyDescription?: string;
|
||||
}
|
||||
// #endregion CodeBlockStepValue
|
||||
|
||||
@ -688,6 +697,8 @@ export interface DataSourceStepValue {
|
||||
* 缺省才退化为整 schema 替换。新增/删除场景通常无 changeRecords。
|
||||
*/
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
|
||||
historyDescription?: string;
|
||||
}
|
||||
// #endregion DataSourceStepValue
|
||||
|
||||
@ -708,6 +719,81 @@ export interface HistoryState {
|
||||
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
|
||||
}
|
||||
|
||||
// #region HistoryListEntry
|
||||
/**
|
||||
* 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。
|
||||
*/
|
||||
export interface PageHistoryStepEntry {
|
||||
/** 步骤内容 */
|
||||
step: StepValue;
|
||||
/** 在所属栈中的索引(0 为最早) */
|
||||
index: number;
|
||||
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
|
||||
applied: boolean;
|
||||
/** 是否为当前所在的步骤(栈中最近一次已应用的那一步,即 index === cursor - 1)。 */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面历史面板分组。
|
||||
* - 连续修改同一目标节点(updatedItems[0].oldNode.id 一致)的 'update' 步骤合并成一组;
|
||||
* - 多节点更新 / add / remove 始终独立成组(无法明确归属单一目标)。
|
||||
* - targetId 为 undefined 表示"无明确目标"(如 add/remove/多节点 update),不参与合并。
|
||||
*/
|
||||
export interface PageHistoryGroup {
|
||||
kind: 'page';
|
||||
/** 所属页面 id */
|
||||
pageId: Id;
|
||||
/** 该分组的操作类型 */
|
||||
opType: HistoryOpType;
|
||||
/**
|
||||
* 合并的目标节点 id;只有"单节点 update"才有值,并按此 id 与相邻同 id 的 update 合并。
|
||||
* undefined 表示该分组不可被合并(add / remove / 多节点 update)。
|
||||
*/
|
||||
targetId?: Id;
|
||||
/** 目标节点的可读名(取最后一步的 newNode.name/type/id) */
|
||||
targetName?: string;
|
||||
/** 组内所有步骤,按时间正序 */
|
||||
steps: PageHistoryStepEntry[];
|
||||
/** 组内最后一步是否已应用 */
|
||||
applied: boolean;
|
||||
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块历史面板分组。
|
||||
* - 同一 codeBlockId 的栈内,相邻的 'update' 操作会合并成一个 group;
|
||||
* - 'add' / 'remove' 始终独立成组(语义上是一次性事件)。
|
||||
*/
|
||||
export interface CodeBlockHistoryGroup {
|
||||
kind: 'code-block';
|
||||
/** 关联的 codeBlock id */
|
||||
id: Id;
|
||||
/** 该分组的操作类型 */
|
||||
opType: HistoryOpType;
|
||||
/** 组内所有步骤,按时间正序 */
|
||||
steps: { step: CodeBlockStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
|
||||
/** 组内最后一步是否已应用,用于整组的状态展示 */
|
||||
applied: boolean;
|
||||
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源历史面板分组,结构同 CodeBlockHistoryGroup。
|
||||
*/
|
||||
export interface DataSourceHistoryGroup {
|
||||
kind: 'data-source';
|
||||
id: Id;
|
||||
opType: HistoryOpType;
|
||||
steps: { step: DataSourceStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
|
||||
applied: boolean;
|
||||
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
// #endregion HistoryListEntry
|
||||
|
||||
export enum KeyBindingCommand {
|
||||
/** 复制 */
|
||||
COPY_NODE = 'tmagic-system-copy-node',
|
||||
@ -943,14 +1029,30 @@ export const canUsePluginMethods = {
|
||||
|
||||
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||
|
||||
/**
|
||||
* 历史记录写入相关的通用配置(codeBlock / dataSource / editor 共用)
|
||||
* - doNotPushHistory: 操作完成后是否不要将本次操作压入历史栈(撤销/重做记录),默认 false
|
||||
* - historyDescription: 入栈时附带的人类可读描述,用于历史面板展示;不影响 undo/redo 行为,缺省时面板会自动生成描述
|
||||
*/
|
||||
export interface HistoryOpOptions {
|
||||
doNotPushHistory?: boolean;
|
||||
historyDescription?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 HistoryOpOptions 基础上携带 form 端 propPath/value 变更记录,
|
||||
* 用于历史记录的精细化撤销/重做(按 propPath 局部 patch)。
|
||||
*/
|
||||
export interface HistoryOpOptionsWithChangeRecords extends HistoryOpOptions {
|
||||
changeRecords?: ChangeRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DSL 修改类操作的通用配置
|
||||
* - doNotSelect: 操作后是否不要自动触发选中(不调用 this.select / this.multiSelect / stage.select / stage.multiSelect)
|
||||
* - doNotSwitchPage: 操作若会引发当前页面切换(如新增 / 删除 / 跨页移动),是否跳过这次切换
|
||||
* - doNotPushHistory: 操作完成后是否不要将本次操作压入历史栈(撤销/重做记录)
|
||||
*/
|
||||
export type DslOpOptions = {
|
||||
export interface DslOpOptions extends HistoryOpOptions {
|
||||
doNotSelect?: boolean;
|
||||
doNotSwitchPage?: boolean;
|
||||
doNotPushHistory?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@ -75,5 +75,28 @@ export class UndoRedo<T = any> {
|
||||
}
|
||||
return cloneDeep(this.elementList[this.listCursor - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回栈内全部元素的浅克隆数组(按时间顺序,索引 0 为最早一步)。
|
||||
* 仅用于历史面板等只读展示场景,不应直接修改返回值。
|
||||
*/
|
||||
public getElementList(): T[] {
|
||||
return this.elementList.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前游标位置:表示已应用的步骤数量。
|
||||
* - cursor === 0 表示全部已撤销
|
||||
* - cursor === length 表示已重做到末尾
|
||||
* 历史面板用于区分"已应用 / 已撤销"两段。
|
||||
*/
|
||||
public getCursor(): number {
|
||||
return this.listCursor;
|
||||
}
|
||||
|
||||
/** 栈内总步数。 */
|
||||
public getLength(): number {
|
||||
return this.elementList.length;
|
||||
}
|
||||
}
|
||||
// #endregion UndoRedo
|
||||
|
||||
109
packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts
Normal file
109
packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Bucket from '@editor/layouts/history-list/Bucket.vue';
|
||||
|
||||
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true) => ({
|
||||
applied,
|
||||
opType,
|
||||
steps: Array.from({ length: stepCount }, (_, i) => ({
|
||||
index: i,
|
||||
applied,
|
||||
step: { mark: `s-${i}` },
|
||||
})),
|
||||
});
|
||||
|
||||
describe('Bucket.vue', () => {
|
||||
test('渲染 bucket 头部信息与组数', () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
title: '数据源',
|
||||
bucketId: 'ds_1',
|
||||
prefix: 'ds',
|
||||
groups: [buildGroup('update', 1), buildGroup('add', 1)],
|
||||
describeGroup: () => 'desc',
|
||||
describeStep: () => 'sub-desc',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
const head = wrapper.find('.m-editor-history-list-bucket-title');
|
||||
expect(head.text()).toContain('数据源');
|
||||
expect(head.find('code').text()).toBe('ds_1');
|
||||
expect(head.find('.m-editor-history-list-bucket-count').text()).toBe('2 组');
|
||||
});
|
||||
|
||||
test('为每个 group 渲染一个 GroupRow 并通过描述生成器生成文案', () => {
|
||||
const groups = [buildGroup('update', 2), buildGroup('remove', 1)];
|
||||
const describeGroup = (g: any) => `group-${g.opType}-${g.steps.length}`;
|
||||
const describeStep = (s: any) => `step-${s.mark}`;
|
||||
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
title: '代码块',
|
||||
bucketId: 'code_1',
|
||||
prefix: 'cb',
|
||||
groups,
|
||||
describeGroup,
|
||||
describeStep,
|
||||
expanded: { 'cb-code_1-0': true },
|
||||
},
|
||||
});
|
||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||
expect(rows).toHaveLength(2);
|
||||
// 第一组(merged,2 步)的 desc 来自 describeGroup
|
||||
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('group-update-2');
|
||||
expect(rows[0].find('.m-editor-history-list-item-merge').exists()).toBe(true);
|
||||
// 第一组展开后渲染的子步描述来自 describeStep
|
||||
const subItems = rows[0].findAll('.m-editor-history-list-substeps li');
|
||||
expect(subItems).toHaveLength(2);
|
||||
expect(subItems[0].text()).toContain('step-s-0');
|
||||
expect(subItems[1].text()).toContain('step-s-1');
|
||||
|
||||
// 第二组只有 1 步:未合并
|
||||
expect(rows[1].find('.m-editor-history-list-item-merge').exists()).toBe(false);
|
||||
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('group-remove-1');
|
||||
});
|
||||
|
||||
test('GroupRow toggle 事件被透传到 Bucket', async () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
title: '代码块',
|
||||
bucketId: 'code_1',
|
||||
prefix: 'cb',
|
||||
groups: [buildGroup('update', 1)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
const events = wrapper.emitted('toggle');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['cb-code_1-0']);
|
||||
});
|
||||
|
||||
test('groupKey 命名空间使用 prefix + bucketId + 索引', () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
title: '数据源',
|
||||
bucketId: 42,
|
||||
prefix: 'ds',
|
||||
groups: [buildGroup('update', 2), buildGroup('add', 1)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
// 给第二组打开展开状态
|
||||
expanded: { 'ds-42-1': true },
|
||||
},
|
||||
});
|
||||
// 第二组只有 1 步,不应渲染 substeps(即使 expanded 为 true)
|
||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||
expect(rows[1].find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
// 第一组未展开,也不应有 substeps
|
||||
expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import CodeBlockTab from '@editor/layouts/history-list/CodeBlockTab.vue';
|
||||
import type { CodeBlockHistoryGroup } from '@editor/type';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'FakeScrollbar',
|
||||
props: ['maxHeight'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const buildGroup = (
|
||||
id: string,
|
||||
opType: 'add' | 'remove' | 'update',
|
||||
steps: any[],
|
||||
applied = true,
|
||||
): CodeBlockHistoryGroup => ({
|
||||
kind: 'code-block',
|
||||
id,
|
||||
opType,
|
||||
applied,
|
||||
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
|
||||
});
|
||||
|
||||
describe('CodeBlockTab.vue', () => {
|
||||
test('buckets 为空时显示空态', () => {
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets: [], expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('每个 bucket 渲染一组(标题为「代码块」+ id)', () => {
|
||||
const buckets = [
|
||||
{
|
||||
id: 'code_1',
|
||||
groups: [
|
||||
buildGroup('code_1', 'add', [{ id: 'code_1', oldContent: null, newContent: { id: 'code_1', name: 'fn' } }]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-bucket-title').text()).toContain('代码块');
|
||||
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
|
||||
|
||||
const desc = wrapper.find('.m-editor-history-list-item-desc').text();
|
||||
expect(desc).toBe('创建 fn (id: code_1)');
|
||||
});
|
||||
|
||||
test('toggle 透传:key 形如 cb-${id}-${idx}', async () => {
|
||||
const buckets = [
|
||||
{
|
||||
id: 'code_1',
|
||||
groups: [
|
||||
buildGroup('code_1', 'add', [{ id: 'code_1', oldContent: null, newContent: { id: 'code_1', name: 'fn' } }]),
|
||||
buildGroup('code_1', 'remove', [
|
||||
{ id: 'code_1', oldContent: { id: 'code_1', name: 'fn' }, newContent: null },
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
await heads[0].trigger('click');
|
||||
expect(wrapper.emitted('toggle')![0]).toEqual(['cb-code_1-0']);
|
||||
await heads[1].trigger('click');
|
||||
expect(wrapper.emitted('toggle')![1]).toEqual(['cb-code_1-1']);
|
||||
});
|
||||
|
||||
test('合并组在 expanded 时展开子步', () => {
|
||||
const buckets = [
|
||||
{
|
||||
id: 'code_1',
|
||||
groups: [
|
||||
buildGroup('code_1', 'update', [
|
||||
{
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'fn' },
|
||||
newContent: { id: 'code_1', name: 'fn' },
|
||||
changeRecords: [{ propPath: 'content' }],
|
||||
},
|
||||
{
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'fn' },
|
||||
newContent: { id: 'code_1', name: 'fn' },
|
||||
changeRecords: [{ propPath: 'params' }],
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(CodeBlockTab, {
|
||||
props: { buckets, expanded: { 'cb-code_1-0': true } },
|
||||
});
|
||||
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].text()).toContain('修改 fn (id: code_1) · content');
|
||||
expect(items[1].text()).toContain('修改 fn (id: code_1) · params');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import DataSourceTab from '@editor/layouts/history-list/DataSourceTab.vue';
|
||||
import type { DataSourceHistoryGroup } from '@editor/type';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'FakeScrollbar',
|
||||
props: ['maxHeight'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const buildGroup = (
|
||||
id: string,
|
||||
opType: 'add' | 'remove' | 'update',
|
||||
steps: any[],
|
||||
applied = true,
|
||||
): DataSourceHistoryGroup => ({
|
||||
kind: 'data-source',
|
||||
id,
|
||||
opType,
|
||||
applied,
|
||||
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
|
||||
});
|
||||
|
||||
describe('DataSourceTab.vue', () => {
|
||||
test('buckets 为空时显示空态', () => {
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets: [], expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('每个 bucket 渲染一组(标题为「数据源」+ id)', () => {
|
||||
const buckets = [
|
||||
{
|
||||
id: 'ds_1',
|
||||
groups: [buildGroup('ds_1', 'add', [{ id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'A' } }])],
|
||||
},
|
||||
{
|
||||
id: 'ds_2',
|
||||
groups: [
|
||||
buildGroup('ds_2', 'remove', [{ id: 'ds_2', oldSchema: { id: 'ds_2', title: 'B' }, newSchema: null }]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
|
||||
const titles = wrapper.findAll('.m-editor-history-list-bucket-title');
|
||||
expect(titles).toHaveLength(2);
|
||||
expect(titles[0].text()).toContain('数据源');
|
||||
expect(titles[0].find('code').text()).toBe('ds_1');
|
||||
expect(titles[1].find('code').text()).toBe('ds_2');
|
||||
|
||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
|
||||
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('创建 A (id: ds_1)');
|
||||
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('删除');
|
||||
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('删除 B (id: ds_2)');
|
||||
});
|
||||
|
||||
test('toggle 透传:key 形如 ds-${id}-${idx}', async () => {
|
||||
const buckets = [
|
||||
{
|
||||
id: 'ds_1',
|
||||
groups: [
|
||||
buildGroup('ds_1', 'add', [{ id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'A' } }]),
|
||||
buildGroup('ds_1', 'update', [
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A' },
|
||||
newSchema: { id: 'ds_1', title: 'A2' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
await heads[1].trigger('click');
|
||||
expect(wrapper.emitted('toggle')![0]).toEqual(['ds-ds_1-1']);
|
||||
});
|
||||
|
||||
test('expanded 中对应 key 打开时展示子步', () => {
|
||||
const buckets = [
|
||||
{
|
||||
id: 'ds_1',
|
||||
groups: [
|
||||
buildGroup('ds_1', 'update', [
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A' },
|
||||
newSchema: { id: 'ds_1', title: 'A' },
|
||||
changeRecords: [{ propPath: 'a' }],
|
||||
},
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A' },
|
||||
newSchema: { id: 'ds_1', title: 'A' },
|
||||
changeRecords: [{ propPath: 'b' }],
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mount(DataSourceTab, {
|
||||
props: { buckets, expanded: { 'ds-ds_1-0': true } },
|
||||
});
|
||||
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
102
packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts
Normal file
102
packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import GroupRow from '@editor/layouts/history-list/GroupRow.vue';
|
||||
|
||||
const baseProps = {
|
||||
groupKey: 'pg-0',
|
||||
applied: true,
|
||||
merged: false,
|
||||
opType: 'update' as const,
|
||||
desc: '修改 按钮',
|
||||
stepCount: 1,
|
||||
subSteps: [] as { index: number; applied: boolean; desc: string }[],
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
describe('GroupRow.vue', () => {
|
||||
test('渲染描述与操作类型徽标(update→修改)', () => {
|
||||
const wrapper = mount(GroupRow, { props: baseProps });
|
||||
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('修改');
|
||||
expect(op.classes()).toContain('op-update');
|
||||
});
|
||||
|
||||
test('add / remove 操作徽标使用对应类名与文案', () => {
|
||||
const w1 = mount(GroupRow, { props: { ...baseProps, opType: 'add' } });
|
||||
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' } });
|
||||
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 } });
|
||||
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
|
||||
});
|
||||
|
||||
test('merged=true 时显示「合并 N 步」并附 is-merged 类名', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: { ...baseProps, merged: true, stepCount: 3 },
|
||||
});
|
||||
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 });
|
||||
expect(wrapper.find('.m-editor-history-list-item-merge').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('merged=true 且 expanded=true 时渲染子步列表', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
...baseProps,
|
||||
merged: true,
|
||||
stepCount: 2,
|
||||
expanded: true,
|
||||
subSteps: [
|
||||
{ index: 0, applied: true, desc: '修改 颜色' },
|
||||
{ index: 1, applied: false, desc: '修改 字号' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].text()).toContain('#1');
|
||||
expect(items[0].text()).toContain('修改 颜色');
|
||||
expect(items[1].text()).toContain('#2');
|
||||
expect(items[1].text()).toContain('修改 字号');
|
||||
// 第二个子步未应用
|
||||
expect(items[1].classes()).toContain('is-undone');
|
||||
});
|
||||
|
||||
test('merged=true 但 expanded=false 时不渲染子步列表', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
...baseProps,
|
||||
merged: true,
|
||||
stepCount: 2,
|
||||
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 });
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
const events = wrapper.emitted('toggle');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['pg-0']);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import historyService from '@editor/services/history';
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ historyService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
getDesignConfig: vi.fn(() => undefined),
|
||||
TMagicButton: defineComponent({
|
||||
name: 'FakeButton',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('button', { class: 'fake-btn' }, [slots.icon?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
TMagicPopover: defineComponent({
|
||||
name: 'FakePopover',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
TMagicTabs: defineComponent({
|
||||
name: 'FakeTabs',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tabs' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicTooltip: defineComponent({
|
||||
name: 'FakeTooltip',
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'FakeScrollbar',
|
||||
props: ['maxHeight'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/Icon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeIcon',
|
||||
props: ['icon'],
|
||||
setup() {
|
||||
return () => h('i', { class: 'fake-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
historyService.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const factory = async () => {
|
||||
const { default: HistoryListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
|
||||
return mount(HistoryListPanel, { attachTo: document.body });
|
||||
};
|
||||
|
||||
describe('HistoryListPanel.vue', () => {
|
||||
test('挂载渲染:tab 数量为 0 时三个 tab 标签都显示 (0)', async () => {
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-popover').exists()).toBe(true);
|
||||
// 由于 fake tab-pane 的回退是 el-tab-pane(无组件),label 显示在 tab 容器里
|
||||
// 三个 tab 的 default slot 都会被渲染(fake tabs 仅是包裹层),可以看到三个空态
|
||||
const empties = wrapper.findAll('.m-editor-history-list-empty');
|
||||
expect(empties).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('页面/数据源/代码块 数据齐全时各 tab 渲染对应内容', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
nodes: [{ id: 'n1', name: 'A' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||
});
|
||||
historyService.pushCodeBlock('code_1', {
|
||||
oldContent: null,
|
||||
newContent: { id: 'code_1', name: 'CB' } as any,
|
||||
});
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||
// 三个 tab 各一条记录
|
||||
expect(rows.length).toBe(3);
|
||||
|
||||
const descs = rows.map((r) => r.find('.m-editor-history-list-item-desc').text());
|
||||
expect(descs.some((t) => t.includes('新增 1 个节点'))).toBe(true);
|
||||
expect(descs.some((t) => t === '创建 DS (id: ds_1)')).toBe(true);
|
||||
expect(descs.some((t) => t === '创建 CB (id: code_1)')).toBe(true);
|
||||
});
|
||||
|
||||
test('点击合并组头部能切换 expanded 状态', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
// 推两个修改同一节点的步骤,会合并为一个 group
|
||||
const mkUpdate = (path: string) => ({
|
||||
opType: 'update',
|
||||
modifiedNodeIds: new Map(),
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn', name: '按钮' },
|
||||
changeRecords: [{ propPath: path }],
|
||||
},
|
||||
],
|
||||
});
|
||||
historyService.push(mkUpdate('a') as any);
|
||||
historyService.push(mkUpdate('b') as any);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
const head = wrapper.find('.m-editor-history-list-group-head');
|
||||
expect(head.exists()).toBe(true);
|
||||
// 默认未展开
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
// 点击展开
|
||||
await head.trigger('click');
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
|
||||
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
|
||||
// 再点击折叠
|
||||
await head.trigger('click');
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
135
packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts
Normal file
135
packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import PageTab from '@editor/layouts/history-list/PageTab.vue';
|
||||
import type { PageHistoryGroup } from '@editor/type';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
name: 'FakeScrollbar',
|
||||
props: ['maxHeight'],
|
||||
setup(_p, { slots }) {
|
||||
return () => h('div', { class: 'fake-scrollbar' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const buildPageGroup = (
|
||||
opType: 'add' | 'remove' | 'update',
|
||||
steps: any[],
|
||||
applied = true,
|
||||
targetName?: string,
|
||||
targetId?: string,
|
||||
): PageHistoryGroup => ({
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType,
|
||||
applied,
|
||||
targetId,
|
||||
targetName,
|
||||
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
|
||||
});
|
||||
|
||||
describe('PageTab.vue', () => {
|
||||
test('list 为空时显示空态文案', () => {
|
||||
const wrapper = mount(PageTab, { props: { list: [], expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-history-list-empty').text()).toBe('暂无操作记录');
|
||||
});
|
||||
|
||||
test('list 非空:每个 group 渲染一行', () => {
|
||||
const list = [
|
||||
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]),
|
||||
buildPageGroup(
|
||||
'update',
|
||||
[
|
||||
{
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn' },
|
||||
changeRecords: [{ propPath: 'style.color' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
'按钮',
|
||||
'btn',
|
||||
),
|
||||
];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||
expect(rows).toHaveLength(2);
|
||||
// 第一组 add
|
||||
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
|
||||
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toContain('新增 1 个节点');
|
||||
// 第二组 update
|
||||
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('修改');
|
||||
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮 (id: btn) · style.color');
|
||||
});
|
||||
|
||||
test('expanded 控制合并组的展开状态(key=pg-${idx})', async () => {
|
||||
const mergedGroup = buildPageGroup(
|
||||
'update',
|
||||
[
|
||||
{
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn' },
|
||||
changeRecords: [{ propPath: 'a' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn' },
|
||||
changeRecords: [{ propPath: 'b' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
'按钮',
|
||||
'btn',
|
||||
);
|
||||
|
||||
const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: { 'pg-0': true } } });
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
|
||||
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
|
||||
|
||||
await wrapper.setProps({ list: [mergedGroup], expanded: {} });
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('点击 group 头部触发 toggle 事件,携带 pg-${idx} key', async () => {
|
||||
const list = [
|
||||
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]),
|
||||
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n2', name: 'B' }] }]),
|
||||
];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
await heads[1].trigger('click');
|
||||
const events = wrapper.emitted('toggle');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['pg-1']);
|
||||
});
|
||||
|
||||
test('已撤销组(applied=false)附 is-undone 类名', () => {
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,562 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import {
|
||||
describeCodeBlockGroup,
|
||||
describeCodeBlockStep,
|
||||
describeDataSourceGroup,
|
||||
describeDataSourceStep,
|
||||
describePageGroup,
|
||||
describePageStep,
|
||||
opLabel,
|
||||
useHistoryList,
|
||||
} from '@editor/layouts/history-list/composables';
|
||||
import historyService from '@editor/services/history';
|
||||
import type {
|
||||
CodeBlockHistoryGroup,
|
||||
CodeBlockStepValue,
|
||||
DataSourceHistoryGroup,
|
||||
DataSourceStepValue,
|
||||
PageHistoryGroup,
|
||||
PageHistoryStepEntry,
|
||||
StepValue,
|
||||
} from '@editor/type';
|
||||
|
||||
afterEach(() => {
|
||||
historyService.reset();
|
||||
});
|
||||
|
||||
const buildPageEntry = (step: StepValue, index = 0, applied = true): PageHistoryStepEntry => ({
|
||||
step,
|
||||
index,
|
||||
applied,
|
||||
});
|
||||
|
||||
describe('opLabel', () => {
|
||||
test('add / remove / update 分别返回中文标签', () => {
|
||||
expect(opLabel('add')).toBe('新增');
|
||||
expect(opLabel('remove')).toBe('删除');
|
||||
expect(opLabel('update')).toBe('修改');
|
||||
});
|
||||
|
||||
test('未知操作类型回退到「修改」', () => {
|
||||
expect(opLabel('unknown' as any)).toBe('修改');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describePageStep', () => {
|
||||
test('显式 historyDescription 优先于自动生成', () => {
|
||||
const step = { opType: 'update', historyDescription: '调整按钮颜色' } as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('调整按钮颜色');
|
||||
});
|
||||
|
||||
test('add 单个节点:含名称与 id', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
nodes: [{ id: 'btn_1', type: 'button', name: '主按钮' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1))');
|
||||
});
|
||||
|
||||
test('add 节点无 name 但有 type:使用 type 作为名称', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
nodes: [{ id: 'n1', type: 'text' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1))');
|
||||
});
|
||||
|
||||
test('add 节点 name 与 id 相同:仅显示 id', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
nodes: [{ id: 'n1', name: 'n1' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 1 个节点(n1)');
|
||||
});
|
||||
|
||||
test('add 多个节点:仅给出数量', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
nodes: [{ id: 'a' }, { id: 'b' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 2 个节点');
|
||||
});
|
||||
|
||||
test('add 无 nodes:count 为 0 且不附名称', () => {
|
||||
const step = { opType: 'add' } as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 0 个节点');
|
||||
});
|
||||
|
||||
test('remove 单个节点:含名称与 id', () => {
|
||||
const step = {
|
||||
opType: 'remove',
|
||||
removedItems: [{ node: { id: 'btn_1', name: '主按钮' } }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1))');
|
||||
});
|
||||
|
||||
test('remove 多个节点', () => {
|
||||
const step = {
|
||||
opType: 'remove',
|
||||
removedItems: [{ node: { id: 'a' } }, { node: { id: 'b' } }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('删除 2 个节点');
|
||||
});
|
||||
|
||||
test('update 单节点:附 propPath 与 id', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn_1', name: '按钮' },
|
||||
oldNode: { id: 'btn_1', name: '按钮' },
|
||||
changeRecords: [{ propPath: 'style.color' }],
|
||||
},
|
||||
],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1) · style.color');
|
||||
});
|
||||
|
||||
test('update 单节点无 propPath:仅展示节点', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)');
|
||||
});
|
||||
|
||||
test('update 多节点:返回数量', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{ newNode: { id: 'a' }, oldNode: { id: 'a' } },
|
||||
{ newNode: { id: 'b' }, oldNode: { id: 'b' } },
|
||||
],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改 2 个节点');
|
||||
});
|
||||
|
||||
test('update updatedItems 缺省:兜底为「修改节点」', () => {
|
||||
const step = { opType: 'update' } as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改节点');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describePageGroup', () => {
|
||||
test('historyDescription 取最后一条非空的描述', () => {
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType: 'update',
|
||||
targetId: 'btn_1',
|
||||
targetName: '按钮',
|
||||
applied: true,
|
||||
steps: [
|
||||
buildPageEntry({ opType: 'update', historyDescription: '旧描述' } as any),
|
||||
buildPageEntry({ opType: 'update', historyDescription: undefined } as any, 1),
|
||||
buildPageEntry({ opType: 'update', historyDescription: '新描述' } as any, 2),
|
||||
],
|
||||
};
|
||||
expect(describePageGroup(group)).toBe('新描述');
|
||||
});
|
||||
|
||||
test('单步 group 复用 describePageStep', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
updatedItems: [{ newNode: { id: 'a', name: 'A' }, oldNode: { id: 'a' } }],
|
||||
} as unknown as StepValue;
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType: 'update',
|
||||
targetId: 'a',
|
||||
targetName: 'A',
|
||||
applied: true,
|
||||
steps: [buildPageEntry(step)],
|
||||
};
|
||||
expect(describePageGroup(group)).toBe('修改 A (id: a)');
|
||||
});
|
||||
|
||||
test('多步合并组:聚合 propPath 列表', () => {
|
||||
const mkStep = (path: string) =>
|
||||
({
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn_1', name: '按钮' },
|
||||
oldNode: { id: 'btn_1', name: '按钮' },
|
||||
changeRecords: [{ propPath: path }],
|
||||
},
|
||||
],
|
||||
}) as unknown as StepValue;
|
||||
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType: 'update',
|
||||
targetId: 'btn_1',
|
||||
targetName: '按钮',
|
||||
applied: true,
|
||||
steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)],
|
||||
};
|
||||
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1) · style.color, style.fontSize');
|
||||
});
|
||||
|
||||
test('多步合并组:超过 3 个 propPath 时截断并加省略号', () => {
|
||||
const mkStep = (path: string) =>
|
||||
({
|
||||
opType: 'update',
|
||||
updatedItems: [
|
||||
{
|
||||
newNode: { id: 'btn_1', name: '按钮' },
|
||||
oldNode: { id: 'btn_1' },
|
||||
changeRecords: [{ propPath: path }],
|
||||
},
|
||||
],
|
||||
}) as unknown as StepValue;
|
||||
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType: 'update',
|
||||
targetId: 'btn_1',
|
||||
targetName: '按钮',
|
||||
applied: true,
|
||||
steps: [
|
||||
buildPageEntry(mkStep('a'), 0),
|
||||
buildPageEntry(mkStep('b'), 1),
|
||||
buildPageEntry(mkStep('c'), 2),
|
||||
buildPageEntry(mkStep('d'), 3),
|
||||
],
|
||||
};
|
||||
const desc = describePageGroup(group);
|
||||
expect(desc).toContain('修改 按钮 (id: btn_1) · a, b, c');
|
||||
expect(desc.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
test('多步合并组无 propPath 时仅展示目标', () => {
|
||||
const mkStep = () =>
|
||||
({
|
||||
opType: 'update',
|
||||
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
|
||||
}) as unknown as StepValue;
|
||||
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType: 'update',
|
||||
targetId: 'btn_1',
|
||||
targetName: '按钮',
|
||||
applied: true,
|
||||
steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)],
|
||||
};
|
||||
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1)');
|
||||
});
|
||||
|
||||
test('多步组 targetName 缺省时使用 targetId 兜底', () => {
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
opType: 'update',
|
||||
targetId: 'btn_1',
|
||||
applied: true,
|
||||
steps: [
|
||||
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 0),
|
||||
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 1),
|
||||
],
|
||||
};
|
||||
// targetName 为 undefined,labelWithId 看 label === id 时只展示 id
|
||||
expect(describePageGroup(group)).toBe('修改 btn_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeDataSourceStep', () => {
|
||||
test('historyDescription 优先', () => {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
oldSchema: null,
|
||||
newSchema: null,
|
||||
historyDescription: '自定义',
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('自定义');
|
||||
});
|
||||
|
||||
test('新增(oldSchema=null):展示 title 与 id', () => {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
|
||||
});
|
||||
|
||||
test('删除(newSchema=null):展示 title 与 id', () => {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
newSchema: null,
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
|
||||
});
|
||||
|
||||
test('修改:展示 propPath', () => {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
newSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
changeRecords: [{ propPath: 'fields.0.name' } as any],
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
|
||||
});
|
||||
|
||||
test('修改无 title 时仅展示 id', () => {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1' } as any,
|
||||
newSchema: { id: 'ds_1' } as any,
|
||||
};
|
||||
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 group: DataSourceHistoryGroup = {
|
||||
kind: 'data-source',
|
||||
id: 'ds_1',
|
||||
opType: 'update',
|
||||
applied: true,
|
||||
steps: [
|
||||
{ step: mkStep('a'), index: 0, applied: true },
|
||||
{ step: mkStep('b'), index: 1, applied: true },
|
||||
],
|
||||
};
|
||||
expect(describeDataSourceGroup(group)).toBe('修改 T (id: ds_1) · a, b');
|
||||
});
|
||||
|
||||
test('单步组:复用 describeDataSourceStep', () => {
|
||||
const group: DataSourceHistoryGroup = {
|
||||
kind: 'data-source',
|
||||
id: 'ds_1',
|
||||
opType: 'add',
|
||||
applied: true,
|
||||
steps: [
|
||||
{
|
||||
step: { id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'T' } as any },
|
||||
index: 0,
|
||||
applied: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(describeDataSourceGroup(group)).toBe('创建 T (id: ds_1)');
|
||||
});
|
||||
|
||||
test('historyDescription 优先', () => {
|
||||
const group: DataSourceHistoryGroup = {
|
||||
kind: 'data-source',
|
||||
id: 'ds_1',
|
||||
opType: 'update',
|
||||
applied: true,
|
||||
steps: [
|
||||
{
|
||||
step: {
|
||||
id: 'ds_1',
|
||||
oldSchema: null,
|
||||
newSchema: null,
|
||||
historyDescription: '我的描述',
|
||||
},
|
||||
index: 0,
|
||||
applied: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(describeDataSourceGroup(group)).toBe('我的描述');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCodeBlockStep', () => {
|
||||
test('新增', () => {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
oldContent: null,
|
||||
newContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
|
||||
});
|
||||
|
||||
test('删除', () => {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
newContent: null,
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
|
||||
});
|
||||
|
||||
test('修改 + propPath', () => {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
newContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
changeRecords: [{ propPath: 'content' } as any],
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
|
||||
});
|
||||
|
||||
test('historyDescription 优先', () => {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
oldContent: null,
|
||||
newContent: null,
|
||||
historyDescription: '自定义说明',
|
||||
};
|
||||
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 group: CodeBlockHistoryGroup = {
|
||||
kind: 'code-block',
|
||||
id: 'code_1',
|
||||
opType: 'update',
|
||||
applied: true,
|
||||
steps: [
|
||||
{ step: mkStep('content'), index: 0, applied: true },
|
||||
{ step: mkStep('params'), index: 1, applied: true },
|
||||
],
|
||||
};
|
||||
expect(describeCodeBlockGroup(group)).toBe('修改 fn (id: code_1) · content, params');
|
||||
});
|
||||
|
||||
test('单步组:复用 step 描述', () => {
|
||||
const group: CodeBlockHistoryGroup = {
|
||||
kind: 'code-block',
|
||||
id: 'code_1',
|
||||
opType: 'remove',
|
||||
applied: false,
|
||||
steps: [
|
||||
{
|
||||
step: { id: 'code_1', oldContent: { id: 'code_1', name: 'fn' } as any, newContent: null },
|
||||
index: 0,
|
||||
applied: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(describeCodeBlockGroup(group)).toBe('删除 fn (id: code_1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHistoryList', () => {
|
||||
// useHistoryList 内部用了 useServices,需要 mount 在一个 host 组件里 provide services
|
||||
const mountWithHost = () => {
|
||||
let api!: ReturnType<typeof useHistoryList>;
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
api = useHistoryList();
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const wrapper = mount(Host, {
|
||||
global: {
|
||||
provide: {
|
||||
services: { historyService },
|
||||
},
|
||||
},
|
||||
});
|
||||
return { api, wrapper };
|
||||
};
|
||||
|
||||
test('toggleGroup 切换 expanded[key]', () => {
|
||||
const { api } = mountWithHost();
|
||||
expect(api.expanded.foo).toBeFalsy();
|
||||
api.toggleGroup('foo');
|
||||
expect(api.expanded.foo).toBe(true);
|
||||
api.toggleGroup('foo');
|
||||
expect(api.expanded.foo).toBe(false);
|
||||
});
|
||||
|
||||
test('pageGroupsDisplay:按时间倒序', () => {
|
||||
const { api } = mountWithHost();
|
||||
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
nodes: [{ id: 'n1', name: 'A' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
historyService.push({
|
||||
opType: 'remove',
|
||||
removedItems: [{ node: { id: 'n2', name: 'B' } }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
|
||||
expect(api.pageGroups.value).toHaveLength(2);
|
||||
// 正序:最早的 add 在前;倒序展示:最新的 remove 在前
|
||||
expect(api.pageGroups.value[0].opType).toBe('add');
|
||||
expect(api.pageGroupsDisplay.value[0].opType).toBe('remove');
|
||||
});
|
||||
|
||||
test('dataSourceGroupsByTarget:按 id 聚拢,每 bucket 内倒序', () => {
|
||||
const { api } = mountWithHost();
|
||||
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_1', title: 'A' } as any,
|
||||
});
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: { id: 'ds_1', title: 'A' } as any,
|
||||
newSchema: { id: 'ds_1', title: 'A2' } as any,
|
||||
});
|
||||
historyService.pushDataSource('ds_2', {
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_2', title: 'B' } as any,
|
||||
});
|
||||
|
||||
const buckets = api.dataSourceGroupsByTarget.value;
|
||||
expect(buckets).toHaveLength(2);
|
||||
const bucket1 = buckets.find((b) => b.id === 'ds_1');
|
||||
const bucket2 = buckets.find((b) => b.id === 'ds_2');
|
||||
expect(bucket1?.groups).toHaveLength(2);
|
||||
expect(bucket2?.groups).toHaveLength(1);
|
||||
|
||||
// bucket 内倒序:最近的 update 排第一
|
||||
expect(bucket1?.groups[0].opType).toBe('update');
|
||||
expect(bucket1?.groups[1].opType).toBe('add');
|
||||
});
|
||||
|
||||
test('codeBlockGroupsByTarget:按 id 聚拢', () => {
|
||||
const { api } = mountWithHost();
|
||||
|
||||
historyService.pushCodeBlock('code_1', {
|
||||
oldContent: null,
|
||||
newContent: { id: 'code_1', name: 'fn' } as any,
|
||||
});
|
||||
historyService.pushCodeBlock('code_2', {
|
||||
oldContent: null,
|
||||
newContent: { id: 'code_2', name: 'fn2' } as any,
|
||||
});
|
||||
|
||||
const buckets = api.codeBlockGroupsByTarget.value;
|
||||
expect(buckets).toHaveLength(2);
|
||||
expect(buckets.map((b) => b.id).sort()).toEqual(['code_1', 'code_2']);
|
||||
});
|
||||
});
|
||||
@ -27,7 +27,7 @@ export const useEditorMenu = (value: Ref<MApp>, save: () => void) => {
|
||||
component: AdapterSelect,
|
||||
},
|
||||
],
|
||||
center: ['delete', 'undo', 'redo', 'guides', 'rule', 'zoom'],
|
||||
center: ['delete', 'undo', 'redo', 'history-list', 'guides', 'rule', 'zoom'],
|
||||
right: [
|
||||
{
|
||||
type: 'button',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user