diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue index 0a703a27..38c60aa5 100644 --- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -135,6 +135,7 @@ import type { FormState } from '@tmagic/form'; import MIcon from '@editor/components/Icon.vue'; import { useServices } from '@editor/hooks/use-services'; import type { + BaseStepValue, CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, @@ -143,15 +144,7 @@ import type { } from '@editor/type'; import BucketTab from './BucketTab.vue'; -import { - describeCodeBlockGroup, - describeCodeBlockStep, - describeDataSourceGroup, - describeDataSourceStep, - isCodeBlockStepRevertable, - isDataSourceStepRevertable, - useHistoryList, -} from './composables'; +import { describeStep, isSingleDiffStepRevertable, useHistoryList } from './composables'; import HistoryDiffDialog from './HistoryDiffDialog.vue'; import PageTab from './PageTab.vue'; @@ -223,34 +216,28 @@ const { */ const pageMarker = computed(() => historyService.getPageMarker()); -/** 数据源 step 仅 update(前后 schema 都存在)时可查看差异。 */ -const isDataSourceStepDiffable = (step: DataSourceStepValue) => - Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema); - /** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */ -const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => - Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema); +const isStepDiffable = (step: BaseStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema); /** * 数据源 / 代码块两类 bucket 历史的整体渲染配置:把 title / prefix 与各自的描述、 * 可差异、可回滚判定收敛为单一对象整体注入 BucketTab,组件内部按需读取。 */ +// 数据源/代码块不做相邻合并,每组恒为单步,省略 describeGroup,由 toRowGroup 回退到 describeStep。 const dataSourceConfig: HistoryBucketConfig = { title: '数据源', prefix: 'ds', - describeGroup: describeDataSourceGroup, - describeStep: describeDataSourceStep, - isStepDiffable: isDataSourceStepDiffable, - isStepRevertable: isDataSourceStepRevertable, + describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'), + isStepDiffable, + isStepRevertable: isSingleDiffStepRevertable, }; const codeBlockConfig: HistoryBucketConfig = { title: '代码块', prefix: 'cb', - describeGroup: describeCodeBlockGroup, - describeStep: describeCodeBlockStep, - isStepDiffable: isCodeBlockStepDiffable, - isStepRevertable: isCodeBlockStepRevertable, + describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'), + isStepDiffable, + isStepRevertable: isSingleDiffStepRevertable, }; /** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */ diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts index 318e3ba1..ebc0e07b 100644 --- a/packages/editor/src/layouts/history-list/composables.ts +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -5,10 +5,6 @@ import { datetimeFormatter } from '@tmagic/form'; import { useServices } from '@editor/hooks/use-services'; import type { BaseStepValue, - CodeBlockHistoryGroup, - CodeBlockStepValue, - DataSourceHistoryGroup, - DataSourceStepValue, HistoryOpSource, HistoryOpType, HistoryRowDescriptor, @@ -227,12 +223,14 @@ export const toRowGroup = ( ): HistoryRowGroup => { const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor; const timestamp = groupTimestamp(group); + // 无 describeGroup 时回退到组内最后一步的 describeStep:数据源/代码块不做相邻合并,每组恒为单步,二者等价。 + const lastStep = group.steps[group.steps.length - 1]?.step; return { key, applied: group.applied, isCurrent: Boolean(group.isCurrent), opType: group.opType, - desc: describeGroup(group), + desc: describeGroup ? describeGroup(group) : describeStep(lastStep), source: groupSource(group), time: formatHistoryTime(timestamp), timeTitle: formatHistoryFullTime(timestamp), @@ -273,30 +271,43 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined return undefined; }; -export const describePageStep = (step: StepValue) => { +/** + * 页面 / 数据源 / 代码块三类历史共用的单步描述核心。 + * 各类型只在「取展示名」与「实体单位名」上有差异,通过参数注入,文案模板完全一致: + * - 新增 / 删除:单实体展示「label」,多实体(仅页面可能出现)退化为「N 个X」; + * - 修改:展示「label · propPath」,无 diff 时兜底「X」,多实体退化为「N 个X」。 + * 操作类型(新增 / 删除 / 修改)已由列表行的 op 徽标单独展示,故描述文案不再重复动词。 + * 展示 id 统一取 schema.id;调用方显式传入的 historyDescription 永远优先。 + */ +export const describeStep = ( + step: BaseStepValue, + getLabel: (_schema?: T) => string | number | undefined, + unit: string, +): string => { if (step.historyDescription) return step.historyDescription; - const { opType } = step; const items = step.diff ?? []; - if (opType === 'add') { - const count = items.length; + const label = (schema?: T) => labelWithId(getLabel(schema), (schema as { id?: string | number } | undefined)?.id); + + if (step.opType === 'add') { const node = items[0]?.newSchema; - return `新增 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`; + return items.length === 1 && node ? label(node) : `${items.length} 个${unit}`; } - if (opType === 'remove') { - const count = items.length; + if (step.opType === 'remove') { const node = items[0]?.oldSchema; - return `删除 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`; + return items.length === 1 && node ? label(node) : `${items.length} 个${unit}`; } - if (!items.length) return '修改节点'; + if (!items.length) return unit; if (items.length === 1) { - const { newSchema, changeRecords } = items[0]; - const propPath = changeRecords?.[0]?.propPath; - const target = labelWithId(nameOf(newSchema), newSchema?.id); - return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`; + const { newSchema, oldSchema, changeRecords } = items[0]; + const propPath = changeRecords?.map((changeRecord) => changeRecord.propPath).join(','); + const target = label(newSchema ?? oldSchema); + return propPath ? `${target} · ${propPath}` : target; } - return `修改 ${items.length} 个节点`; + return `${items.length} 个${unit}`; }; +export const describePageStep = (step: StepValue): string => describeStep(step, (node) => nameOf(node), '节点'); + /** * 合并组的展示文案: * - 若组内任一步显式提供了 historyDescription:取最后一条非空 historyDescription(最近一次的描述更准确); @@ -307,68 +318,8 @@ 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(); - group.steps.forEach((s) => { - s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath)); - }); - const pathList = Array.from(paths).slice(0, 3).join(', '); - const target = labelWithId( - 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; - const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {}; - if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`; - if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`; - const propPath = changeRecords?.[0]?.propPath; - const title = labelWithId(newSchema?.title || oldSchema?.title, step.id); - return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`; -}; - -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(); - group.steps.forEach((s) => { - s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath)); - }); - const pathList = Array.from(paths).slice(0, 3).join(', '); - const rawTitle = - group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title || - group.steps[0].step.diff?.[0]?.oldSchema?.title; - const target = labelWithId(rawTitle, group.id); - return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`; -}; - -export const describeCodeBlockStep = (step: CodeBlockStepValue) => { - if (step.historyDescription) return step.historyDescription; - const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {}; - if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`; - if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`; - const propPath = changeRecords?.[0]?.propPath; - const title = labelWithId(newContent?.name || oldContent?.name, step.id); - return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`; -}; - -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(); - group.steps.forEach((s) => { - s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath)); - }); - const pathList = Array.from(paths).slice(0, 3).join(', '); - const rawName = - group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name || - group.steps[0].step.diff?.[0]?.oldSchema?.name; - const target = labelWithId(rawName, group.id); - return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`; + return labelWithId(group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'), group.targetId); }; /** @@ -385,22 +336,11 @@ export const isPageStepRevertable = (step: StepValue): boolean => { }; /** - * 数据源 step 是否支持「回滚」: + * 单 diff 项历史(数据源 / 代码块)是否支持「回滚」: * - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚; - * - 更新(前后 schema 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。 + * - 更新(前后内容都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。 */ -export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => { - const item = step.diff?.[0]; - if (!item?.oldSchema || !item?.newSchema) return true; - return Boolean(item.changeRecords?.length); -}; - -/** - * 代码块 step 是否支持「回滚」: - * - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚; - * - 更新(前后 content 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。 - */ -export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => { +export const isSingleDiffStepRevertable = (step: BaseStepValue): boolean => { const item = step.diff?.[0]; if (!item?.oldSchema || !item?.newSchema) return true; return Boolean(item.changeRecords?.length); diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index abb465ff..1d2f3e25 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -23,9 +23,7 @@ import type { ChangeRecord } from '@tmagic/form'; import { guid } from '@tmagic/utils'; import type { - CodeBlockHistoryGroup, CodeBlockStepValue, - DataSourceHistoryGroup, DataSourceStepValue, HistoryOpSource, HistoryPersistOptions, @@ -33,6 +31,7 @@ import type { PageHistoryGroup, PageHistoryStepEntry, PersistedHistoryState, + StackHistoryGroup, StepValue, } from '@editor/type'; import { getEditorConfig } from '@editor/utils/config'; @@ -527,8 +526,8 @@ class History extends BaseService { * 取出全部代码块的历史栈,按 codeBlockId 分桶展示。 * 同一栈内每条操作记录独立成组,不做相邻 update 合并。 */ - public getCodeBlockHistoryGroups(): CodeBlockHistoryGroup[] { - const groups: CodeBlockHistoryGroup[] = []; + public getCodeBlockHistoryGroups(): StackHistoryGroup[] { + const groups: StackHistoryGroup[] = []; Object.entries(this.state.codeBlockState).forEach(([id, undoRedo]) => { if (!undoRedo) return; const list = undoRedo.getElementList(); @@ -619,8 +618,8 @@ class History extends BaseService { /** * 取出全部数据源的历史栈,按 dataSourceId 分桶展示。同上,每条操作独立成组。 */ - public getDataSourceHistoryGroups(): DataSourceHistoryGroup[] { - const groups: DataSourceHistoryGroup[] = []; + public getDataSourceHistoryGroups(): StackHistoryGroup[] { + const groups: StackHistoryGroup[] = []; Object.entries(this.state.dataSourceState).forEach(([id, undoRedo]) => { if (!undoRedo) return; const list = undoRedo.getElementList(); diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 5750b5a6..8be2d4cd 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -962,32 +962,26 @@ export interface PageHistoryGroup { } /** - * 代码块历史面板分组。 - * - 同一 codeBlockId 的栈内,相邻的 'update' 操作会合并成一个 group; - * - 'add' / 'remove' 始终独立成组(语义上是一次性事件)。 + * 数据源 / 代码块历史面板分组(按 id 分栈展示)。 + * 二者结构完全一致,仅 `kind` 与 step 类型不同,统一由该泛型描述: + * - 数据源:`StackHistoryGroup`; + * - 代码块:`StackHistoryGroup`。 + * + * 每条操作记录独立成组,不做相邻合并(与页面历史 {@link PageHistoryGroup} 不同),故 `steps` 恒为单元素。 */ -export interface CodeBlockHistoryGroup { - kind: 'code-block'; - /** 关联的 codeBlock id */ +export interface StackHistoryGroup< + T extends BaseStepValue = BaseStepValue, + K extends 'code-block' | 'data-source' = 'code-block' | 'data-source', +> { + /** 区分代码块 / 数据源。 */ + kind: K; + /** 关联的代码块 / 数据源 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 }[]; + /** 组内所有步骤,按时间正序(不做相邻合并,恒为单元素)。 */ + steps: { step: T; index: number; applied: boolean; isCurrent?: boolean }[]; + /** 组内最后一步是否已应用,用于整组的状态展示。 */ applied: boolean; /** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */ isCurrent?: boolean; @@ -1296,8 +1290,11 @@ export interface DiffDialogPayload { * 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。 */ export interface HistoryRowDescriptor { - /** 组级描述文案生成器,接收一个 group,返回展示文本。 */ - describeGroup: (_group: any) => string; + /** + * 组级描述文案生成器,接收一个 group,返回展示文本。 + * 不传时回退到对组内最后一步调用 {@link describeStep}(适用于不做相邻合并、每组恒为单步的历史,如数据源/代码块)。 + */ + describeGroup?: (_group: any) => string; /** 单步描述文案生成器,接收一个 step,返回展示文本(合并组展开后的子步列表用)。 */ describeStep: (_step: T) => string; /** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */ diff --git a/packages/editor/src/utils/history.ts b/packages/editor/src/utils/history.ts index fe01ba4b..7efc2e46 100644 --- a/packages/editor/src/utils/history.ts +++ b/packages/editor/src/utils/history.ts @@ -26,9 +26,9 @@ import { guid } from '@tmagic/utils'; import type { BaseStepValue, HistoryOpSource, - HistoryOpType, PageHistoryGroup, PageHistoryStepEntry, + StackHistoryGroup, StepDiffItem, StepValue, } from '@editor/type'; @@ -131,14 +131,7 @@ export const mergeStackSteps = { +): StackHistoryGroup[] => { const currentIndex = cursor - 1; return list.map((step, index) => { const applied = index < cursor; diff --git a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts index f5d65b9e..a18c180e 100644 --- a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts @@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue'; import { mount } from '@vue/test-utils'; import BucketTab from '@editor/layouts/history-list/BucketTab.vue'; -import { describeCodeBlockGroup, describeCodeBlockStep } from '@editor/layouts/history-list/composables'; -import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type'; +import { describeStep } from '@editor/layouts/history-list/composables'; +import type { CodeBlockStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type'; vi.mock('@tmagic/design', () => ({ TMagicScrollbar: defineComponent({ @@ -40,7 +40,7 @@ const buildGroup = ( steps: any[], applied = true, startIndex = 0, -): CodeBlockHistoryGroup => ({ +): StackHistoryGroup => ({ kind: 'code-block', id, opType, @@ -49,16 +49,17 @@ const buildGroup = ( }); /** 代码块 tab 复用通用 BucketTab,固定注入代码块的 config(title/prefix/describe/isStepDiffable)。 */ +const codeBlockConfig: HistoryBucketConfig = { + title: '代码块', + prefix: 'cb', + describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'), + isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema), +}; + const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record }) => mount(BucketTab, { props: { - config: { - title: '代码块', - prefix: 'cb', - describeGroup: describeCodeBlockGroup, - describeStep: describeCodeBlockStep, - isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema), - }, + config: codeBlockConfig, ...props, }, }); @@ -83,7 +84,7 @@ describe('CodeBlockTab.vue', () => { 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)'); + expect(desc).toBe('fn (id: code_1)'); }); test('toggle 透传:key 形如 cb-${id}-${idx}', async () => { @@ -178,7 +179,7 @@ describe('CodeBlockTab.vue', () => { const items = wrapper.findAll('.m-editor-history-list-substeps li'); expect(items).toHaveLength(2); // 子步倒序渲染(最新在上):params 在前,content 在后 - expect(items[0].text()).toContain('修改 fn (id: code_1) · params'); - expect(items[1].text()).toContain('修改 fn (id: code_1) · content'); + expect(items[0].text()).toContain('fn (id: code_1) · params'); + expect(items[1].text()).toContain('fn (id: code_1) · content'); }); }); diff --git a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts index a0a43a95..287e50aa 100644 --- a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts @@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue'; import { mount } from '@vue/test-utils'; import BucketTab from '@editor/layouts/history-list/BucketTab.vue'; -import { describeDataSourceGroup, describeDataSourceStep } from '@editor/layouts/history-list/composables'; -import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type'; +import { describeStep } from '@editor/layouts/history-list/composables'; +import type { DataSourceStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type'; vi.mock('@tmagic/design', () => ({ TMagicScrollbar: defineComponent({ @@ -40,7 +40,7 @@ const buildGroup = ( steps: any[], applied = true, startIndex = 0, -): DataSourceHistoryGroup => ({ +): StackHistoryGroup => ({ kind: 'data-source', id, opType, @@ -49,16 +49,17 @@ const buildGroup = ( }); /** 数据源 tab 复用通用 BucketTab,固定注入数据源的 config(title/prefix/describe/isStepDiffable)。 */ +const dataSourceConfig: HistoryBucketConfig = { + title: '数据源', + prefix: 'ds', + describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'), + isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema), +}; + const mountDataSourceTab = (props: { buckets: any[]; expanded: Record }) => mount(BucketTab, { props: { - config: { - title: '数据源', - prefix: 'ds', - describeGroup: describeDataSourceGroup, - describeStep: describeDataSourceStep, - isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema), - }, + config: dataSourceConfig, ...props, }, }); @@ -92,9 +93,9 @@ describe('DataSourceTab.vue', () => { 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[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)'); + expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('B (id: ds_2)'); }); test('toggle 透传:key 形如 ds-${id}-${idx}', async () => { diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts index aaf08a6d..16c2aa21 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -158,9 +158,9 @@ describe('HistoryListPanel.vue', () => { 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); + expect(descs.some((t) => t === 'A (id: n1)')).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 状态(不触发 goto)', async () => { @@ -288,7 +288,7 @@ describe('HistoryListPanel.vue', () => { const heads = wrapper.findAll('.m-editor-history-list-group-head'); // 找到数据源 tab 那一组 - const dsHead = heads.find((h) => h.text().includes('创建 DS')); + const dsHead = heads.find((h) => h.text().includes('DS (id: ds_1)')); expect(dsHead).toBeTruthy(); await dsHead!.find('.m-editor-history-list-item-goto').trigger('click'); expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1); @@ -307,7 +307,7 @@ describe('HistoryListPanel.vue', () => { await nextTick(); const heads = wrapper.findAll('.m-editor-history-list-group-head'); - const cbHead = heads.find((h) => h.text().includes('创建 CB')); + const cbHead = heads.find((h) => h.text().includes('CB (id: code_1)')); expect(cbHead).toBeTruthy(); await cbHead!.find('.m-editor-history-list-item-goto').trigger('click'); expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1); diff --git a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts index 688d2836..6ab117f0 100644 --- a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts @@ -71,10 +71,10 @@ describe('PageTab.vue', () => { 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 个节点'); + expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: n1)'); // 第二组 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'); + expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('按钮 (id: btn) · style.color'); }); test('step 含 timestamp 时渲染时间元素', () => { diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts index 62a56079..74d065bb 100644 --- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -8,31 +8,18 @@ import { defineComponent, h } from 'vue'; import { mount } from '@vue/test-utils'; import { - describeCodeBlockGroup, - describeCodeBlockStep, - describeDataSourceGroup, - describeDataSourceStep, describePageGroup, describePageStep, formatHistoryFullTime, formatHistoryTime, groupTimestamp, - isCodeBlockStepRevertable, - isDataSourceStepRevertable, isPageStepRevertable, + isSingleDiffStepRevertable, 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'; +import type { PageHistoryGroup, PageHistoryStepEntry, StepValue } from '@editor/type'; afterEach(() => { historyService.reset(); @@ -111,7 +98,7 @@ describe('describePageStep', () => { opType: 'add', diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1))'); + expect(describePageStep(step)).toBe('主按钮 (id: btn_1)'); }); test('add 节点无 name 但有 type:使用 type 作为名称', () => { @@ -119,7 +106,7 @@ describe('describePageStep', () => { opType: 'add', diff: [{ newSchema: { id: 'n1', type: 'text' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1))'); + expect(describePageStep(step)).toBe('text (id: n1)'); }); test('add 节点 name 与 id 相同:仅显示 id', () => { @@ -127,7 +114,7 @@ describe('describePageStep', () => { opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'n1' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('新增 1 个节点(n1)'); + expect(describePageStep(step)).toBe('n1'); }); test('add 多个节点:仅给出数量', () => { @@ -135,12 +122,12 @@ describe('describePageStep', () => { opType: 'add', diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('新增 2 个节点'); + expect(describePageStep(step)).toBe('2 个节点'); }); test('add 无 nodes:count 为 0 且不附名称', () => { const step = { opType: 'add' } as unknown as StepValue; - expect(describePageStep(step)).toBe('新增 0 个节点'); + expect(describePageStep(step)).toBe('0 个节点'); }); test('remove 单个节点:含名称与 id', () => { @@ -148,7 +135,7 @@ describe('describePageStep', () => { opType: 'remove', diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1))'); + expect(describePageStep(step)).toBe('主按钮 (id: btn_1)'); }); test('remove 多个节点', () => { @@ -156,7 +143,7 @@ describe('describePageStep', () => { opType: 'remove', diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('删除 2 个节点'); + expect(describePageStep(step)).toBe('2 个节点'); }); test('update 单节点:附 propPath 与 id', () => { @@ -170,7 +157,7 @@ describe('describePageStep', () => { }, ], } as unknown as StepValue; - expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1) · style.color'); + expect(describePageStep(step)).toBe('按钮 (id: btn_1) · style.color'); }); test('update 单节点无 propPath:仅展示节点', () => { @@ -178,7 +165,7 @@ describe('describePageStep', () => { opType: 'update', diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }], } as unknown as StepValue; - expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)'); + expect(describePageStep(step)).toBe('按钮 (id: btn_1)'); }); test('update 多节点:返回数量', () => { @@ -189,12 +176,12 @@ describe('describePageStep', () => { { newSchema: { id: 'b' }, oldSchema: { id: 'b' } }, ], } as unknown as StepValue; - expect(describePageStep(step)).toBe('修改 2 个节点'); + expect(describePageStep(step)).toBe('2 个节点'); }); - test('update diff 缺省:兜底为「修改节点」', () => { + test('update diff 缺省:兜底为「节点」', () => { const step = { opType: 'update' } as unknown as StepValue; - expect(describePageStep(step)).toBe('修改节点'); + expect(describePageStep(step)).toBe('节点'); }); }); @@ -230,10 +217,10 @@ describe('describePageGroup', () => { applied: true, steps: [buildPageEntry(step)], }; - expect(describePageGroup(group)).toBe('修改 A (id: a)'); + expect(describePageGroup(group)).toBe('A (id: a)'); }); - test('多步合并组:聚合 propPath 列表', () => { + test('多步合并组:展示目标名称与 id', () => { const mkStep = (path: string) => ({ opType: 'update', @@ -255,10 +242,10 @@ describe('describePageGroup', () => { applied: true, steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)], }; - expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1) · style.color, style.fontSize'); + expect(describePageGroup(group)).toBe('按钮 (id: btn_1)'); }); - test('多步合并组:超过 3 个 propPath 时截断并加省略号', () => { + test('多步合并组:多步时仍仅展示目标', () => { const mkStep = (path: string) => ({ opType: 'update', @@ -285,9 +272,7 @@ describe('describePageGroup', () => { buildPageEntry(mkStep('d'), 3), ], }; - const desc = describePageGroup(group); - expect(desc).toContain('修改 按钮 (id: btn_1) · a, b, c'); - expect(desc.endsWith('…')).toBe(true); + expect(describePageGroup(group)).toBe('按钮 (id: btn_1)'); }); test('多步合并组无 propPath 时仅展示目标', () => { @@ -306,7 +291,7 @@ describe('describePageGroup', () => { applied: true, steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)], }; - expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1)'); + expect(describePageGroup(group)).toBe('按钮 (id: btn_1)'); }); test('多步组 targetName 缺省时使用 targetId 兜底', () => { @@ -322,226 +307,7 @@ describe('describePageGroup', () => { ], }; // targetName 为 undefined,labelWithId 看 label === id 时只展示 id - expect(describePageGroup(group)).toBe('修改 btn_1'); - }); -}); - -describe('describeDataSourceStep', () => { - test('historyDescription 优先', () => { - const step = { - id: 'ds_1', - opType: 'update', - diff: [{}], - historyDescription: '自定义', - } as unknown as DataSourceStepValue; - expect(describeDataSourceStep(step)).toBe('自定义'); - }); - - test('新增(oldSchema=null):展示 title 与 id', () => { - const step = { - id: 'ds_1', - opType: 'add', - diff: [{ newSchema: { id: 'ds_1', title: '用户列表' } }], - } as unknown as DataSourceStepValue; - expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)'); - }); - - test('删除(newSchema=null):展示 title 与 id', () => { - const step = { - id: 'ds_1', - opType: 'remove', - diff: [{ oldSchema: { id: 'ds_1', title: '用户列表' } }], - } as unknown as DataSourceStepValue; - expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)'); - }); - - test('修改:展示 propPath', () => { - const step = { - id: 'ds_1', - opType: 'update', - diff: [ - { - oldSchema: { id: 'ds_1', title: '用户列表' }, - newSchema: { id: 'ds_1', title: '用户列表' }, - changeRecords: [{ propPath: 'fields.0.name' }], - }, - ], - } as unknown as DataSourceStepValue; - expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name'); - }); - - test('修改无 title 时仅展示 id', () => { - const step = { - id: 'ds_1', - opType: 'update', - diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }], - } as unknown as DataSourceStepValue; - expect(describeDataSourceStep(step)).toBe('修改 ds_1'); - }); -}); - -describe('describeDataSourceGroup', () => { - test('多步组:聚合 propPath 与目标 id', () => { - const mkStep = (path: string) => - ({ - id: 'ds_1', - opType: 'update', - diff: [ - { - oldSchema: { id: 'ds_1', title: 'T' }, - newSchema: { id: 'ds_1', title: 'T' }, - changeRecords: [{ propPath: path }], - }, - ], - }) as unknown as DataSourceStepValue; - const group: DataSourceHistoryGroup = { - kind: 'data-source', - id: 'ds_1', - 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', - opType: 'add', - diff: [{ newSchema: { id: 'ds_1', title: 'T' } }], - } as unknown as DataSourceStepValue, - 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', - opType: 'update', - diff: [{}], - historyDescription: '我的描述', - } as unknown as DataSourceStepValue, - index: 0, - applied: true, - }, - ], - }; - expect(describeDataSourceGroup(group)).toBe('我的描述'); - }); -}); - -describe('describeCodeBlockStep', () => { - test('新增', () => { - const step = { - id: 'code_1', - opType: 'add', - diff: [{ newSchema: { id: 'code_1', name: 'onClick' } }], - } as unknown as CodeBlockStepValue; - expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)'); - }); - - test('删除', () => { - const step = { - id: 'code_1', - opType: 'remove', - diff: [{ oldSchema: { id: 'code_1', name: 'onClick' } }], - } as unknown as CodeBlockStepValue; - expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)'); - }); - - test('修改 + propPath', () => { - const step = { - id: 'code_1', - opType: 'update', - diff: [ - { - oldSchema: { id: 'code_1', name: 'onClick' }, - newSchema: { id: 'code_1', name: 'onClick' }, - changeRecords: [{ propPath: 'content' }], - }, - ], - } as unknown as CodeBlockStepValue; - expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content'); - }); - - test('historyDescription 优先', () => { - const step = { - id: 'code_1', - opType: 'update', - diff: [{}], - historyDescription: '自定义说明', - } as unknown as CodeBlockStepValue; - expect(describeCodeBlockStep(step)).toBe('自定义说明'); - }); -}); - -describe('describeCodeBlockGroup', () => { - test('多步组:聚合 propPath', () => { - const mkStep = (path: string) => - ({ - id: 'code_1', - opType: 'update', - diff: [ - { - oldSchema: { id: 'code_1', name: 'fn' }, - newSchema: { id: 'code_1', name: 'fn' }, - changeRecords: [{ propPath: path }], - }, - ], - }) as unknown as CodeBlockStepValue; - const group: CodeBlockHistoryGroup = { - kind: 'code-block', - id: 'code_1', - 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', - opType: 'remove', - diff: [{ oldSchema: { id: 'code_1', name: 'fn' } }], - } as unknown as CodeBlockStepValue, - index: 0, - applied: false, - }, - ], - }; - expect(describeCodeBlockGroup(group)).toBe('删除 fn (id: code_1)'); + expect(describePageGroup(group)).toBe('btn_1'); }); }); @@ -684,38 +450,20 @@ describe('isPageStepRevertable', () => { }); }); -describe('isDataSourceStepRevertable', () => { +describe('isSingleDiffStepRevertable', () => { test('新增 / 删除 始终可回滚', () => { - expect(isDataSourceStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true); - expect(isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true); + expect(isSingleDiffStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true); + expect(isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true); }); test('更新有 changeRecords 才可回滚', () => { expect( - isDataSourceStepRevertable({ + isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }], } as any), ).toBe(true); expect( - isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any), - ).toBe(false); - }); -}); - -describe('isCodeBlockStepRevertable', () => { - test('新增 / 删除 始终可回滚', () => { - expect(isCodeBlockStepRevertable({ diff: [{ newSchema: { id: 'code_1' } }] } as any)).toBe(true); - expect(isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' } }] } as any)).toBe(true); - }); - - test('更新有 changeRecords 才可回滚', () => { - expect( - isCodeBlockStepRevertable({ - diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' }, changeRecords: [{ propPath: 'content' }] }], - } as any), - ).toBe(true); - expect( - isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' } }] } as any), + isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any), ).toBe(false); }); });