diff --git a/packages/editor/src/layouts/NavMenu.vue b/packages/editor/src/layouts/NavMenu.vue index fe0e8eae..c7369a6a 100644 --- a/packages/editor/src/layouts/NavMenu.vue +++ b/packages/editor/src/layouts/NavMenu.vue @@ -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', diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue new file mode 100644 index 00000000..8d04fc65 --- /dev/null +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -0,0 +1,70 @@ + + + + {{ title }} + {{ String(bucketId) }} + {{ groups.length }} 组 + + + + $emit('toggle', key)" + /> + + + + + diff --git a/packages/editor/src/layouts/history-list/CodeBlockTab.vue b/packages/editor/src/layouts/history-list/CodeBlockTab.vue new file mode 100644 index 00000000..38c1cd79 --- /dev/null +++ b/packages/editor/src/layouts/history-list/CodeBlockTab.vue @@ -0,0 +1,45 @@ + + 暂无操作记录 + + $emit('toggle', key)" + /> + + + + diff --git a/packages/editor/src/layouts/history-list/DataSourceTab.vue b/packages/editor/src/layouts/history-list/DataSourceTab.vue new file mode 100644 index 00000000..c2c1c5b7 --- /dev/null +++ b/packages/editor/src/layouts/history-list/DataSourceTab.vue @@ -0,0 +1,45 @@ + + 暂无操作记录 + + $emit('toggle', key)" + /> + + + + diff --git a/packages/editor/src/layouts/history-list/GroupRow.vue b/packages/editor/src/layouts/history-list/GroupRow.vue new file mode 100644 index 00000000..701189bb --- /dev/null +++ b/packages/editor/src/layouts/history-list/GroupRow.vue @@ -0,0 +1,57 @@ + + + + {{ opLabel(opType) }} + {{ desc }} + 当前 + 合并 {{ stepCount }} 步 + + + + + #{{ s.index + 1 }} + {{ s.desc }} + 当前 + + + + + + diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue new file mode 100644 index 00000000..0ff852ba --- /dev/null +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue new file mode 100644 index 00000000..27b2487d --- /dev/null +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -0,0 +1,53 @@ + + 暂无操作记录 + + + $emit('toggle', key)" + /> + + + + + diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts new file mode 100644 index 00000000..dcbc98ec --- /dev/null +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -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>({}); + 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 = (groups: G[]) => { + const map = new Map(); + 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(); + 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(); + 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(); + 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}`; +}; diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 07abe4af..b8c7cfeb 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -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, - { changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {}, + { changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {}, ): Promise { 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, 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 { 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); diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 1b56871a..f0382607 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -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); diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 7c6c7a6b..9fddc99f 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { 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 { 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 { 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, pageData: { name: string; id: Id }) { + private pushOpHistory( + opType: HistoryOpType, + extra: Partial, + 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); diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 373b51b2..b5abd330 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -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({ 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 时按当前活动页取。 * diff --git a/packages/editor/src/theme/history-list-panel.scss b/packages/editor/src/theme/history-list-panel.scss new file mode 100644 index 00000000..0aa6f66a --- /dev/null +++ b/packages/editor/src/theme/history-list-panel.scss @@ -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; + } +} diff --git a/packages/editor/src/theme/theme.scss b/packages/editor/src/theme/theme.scss index cc2dd0b7..a4741f80 100644 --- a/packages/editor/src/theme/theme.scss +++ b/packages/editor/src/theme/theme.scss @@ -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"; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index e07c8721..97b52fa3 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -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>; } +// #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; -}; +} diff --git a/packages/editor/src/utils/undo-redo.ts b/packages/editor/src/utils/undo-redo.ts index ee9e2a5a..0539880c 100644 --- a/packages/editor/src/utils/undo-redo.ts +++ b/packages/editor/src/utils/undo-redo.ts @@ -75,5 +75,28 @@ export class UndoRedo { } 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 diff --git a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts new file mode 100644 index 00000000..32dea4c0 --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts new file mode 100644 index 00000000..3bf2c5fa --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts new file mode 100644 index 00000000..25e38b84 --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts b/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts new file mode 100644 index 00000000..c92d196a --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/GroupRow.spec.ts @@ -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']); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts new file mode 100644 index 00000000..1b2d398c --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -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); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts new file mode 100644 index 00000000..4de5bb6e --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts @@ -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'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts new file mode 100644 index 00000000..1245dfa0 --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -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; + 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']); + }); +}); diff --git a/playground/src/pages/composables/use-editor-menu.ts b/playground/src/pages/composables/use-editor-menu.ts index 6c732600..4df09303 100644 --- a/playground/src/pages/composables/use-editor-menu.ts +++ b/playground/src/pages/composables/use-editor-menu.ts @@ -27,7 +27,7 @@ export const useEditorMenu = (value: Ref, save: () => void) => { component: AdapterSelect, }, ], - center: ['delete', 'undo', 'redo', 'guides', 'rule', 'zoom'], + center: ['delete', 'undo', 'redo', 'history-list', 'guides', 'rule', 'zoom'], right: [ { type: 'button',
{{ String(bucketId) }}