From 10b70c36bbace6af48bf6fa63f2df0704c6861af Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 4 Jun 2026 16:48:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E7=A6=81=E6=AD=A2=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E5=8F=98=E6=9B=B4=E8=AE=B0=E5=BD=95=E7=9A=84=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/layouts/history-list/Bucket.vue | 4 +- .../src/layouts/history-list/BucketTab.vue | 3 + .../history-list/HistoryDiffDialog.vue | 3 +- .../layouts/history-list/HistoryListPanel.vue | 4 + .../src/layouts/history-list/PageTab.vue | 3 +- .../src/layouts/history-list/composables.ts | 33 ++++++++ packages/editor/src/services/codeBlock.ts | 2 + packages/editor/src/services/dataSource.ts | 2 + packages/editor/src/services/editor.ts | 6 ++ .../editor/src/theme/history-list-panel.scss | 2 +- packages/editor/src/theme/props-panel.scss | 6 +- packages/editor/src/type.ts | 2 +- .../layouts/history-list/composables.spec.ts | 82 +++++++++++++++++++ 13 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue index 0f543b55..4ce7b286 100644 --- a/packages/editor/src/layouts/history-list/Bucket.vue +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -26,7 +26,7 @@ isCurrent: s.isCurrent, desc: describeStep(s.step), diffable: isStepDiffable ? isStepDiffable(s.step) : false, - revertable: s.applied, + revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true), source: s.step.source, time: formatHistoryTime(s.step.timestamp), timeTitle: formatHistoryFullTime(s.step.timestamp), @@ -94,6 +94,8 @@ const props = withDefaults( describeStep: (_step: any) => string; /** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */ isStepDiffable?: (_step: any) => boolean; + /** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。由父组件按业务类型注入;不传则已应用即可回滚。 */ + isStepRevertable?: (_step: any) => boolean; /** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */ expanded: Record; /** 是否支持「跳转到该记录」(goto)。默认 true。 */ diff --git a/packages/editor/src/layouts/history-list/BucketTab.vue b/packages/editor/src/layouts/history-list/BucketTab.vue index 5b7932a9..04c42c4f 100644 --- a/packages/editor/src/layouts/history-list/BucketTab.vue +++ b/packages/editor/src/layouts/history-list/BucketTab.vue @@ -11,6 +11,7 @@ :describe-group="describeGroup" :describe-step="describeStep" :is-step-diffable="isStepDiffable" + :is-step-revertable="isStepRevertable" :expanded="expanded" :goto-enabled="gotoEnabled" @toggle="(key: string) => $emit('toggle', key)" @@ -48,6 +49,8 @@ withDefaults( describeStep: (_step: any) => string; /** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入。 */ isStepDiffable: (_step: any) => boolean; + /** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。由父组件按业务类型注入;不传则已应用即可回滚。 */ + isStepRevertable?: (_step: any) => boolean; /** * 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。 * 本 tab 使用 `${prefix}-${id}-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组, diff --git a/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue b/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue index 2b6ec113..26059a06 100644 --- a/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue +++ b/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue @@ -193,7 +193,8 @@ const targetText = computed(() => { 'data-source': '数据源', 'code-block': '代码块', }; - const prefix = categoryText[payload.value.category] || ''; + const { category } = payload.value; + const prefix = category ? categoryText[category] : ''; const label = payload.value.targetLabel || payload.value.type || ''; const { id } = payload.value; const labelWithId = id !== undefined && id !== '' ? `${label}(${id})` : label; diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue index e243832b..b3a7c93e 100644 --- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -44,6 +44,7 @@ :describe-group="describeDataSourceGroup" :describe-step="describeDataSourceStep" :is-step-diffable="isDataSourceStepDiffable" + :is-step-revertable="isDataSourceStepRevertable" @toggle="toggleGroup" @goto="onDataSourceGoto" @goto-initial="onDataSourceGotoInitial" @@ -65,6 +66,7 @@ :describe-group="describeCodeBlockGroup" :describe-step="describeCodeBlockStep" :is-step-diffable="isCodeBlockStepDiffable" + :is-step-revertable="isCodeBlockStepRevertable" @toggle="toggleGroup" @goto="onCodeBlockGoto" @goto-initial="onCodeBlockGotoInitial" @@ -141,6 +143,8 @@ import { describeCodeBlockStep, describeDataSourceGroup, describeDataSourceStep, + isCodeBlockStepRevertable, + isDataSourceStepRevertable, useHistoryList, } from './composables'; import HistoryDiffDialog from './HistoryDiffDialog.vue'; diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue index 575c27e3..09562357 100644 --- a/packages/editor/src/layouts/history-list/PageTab.vue +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -21,7 +21,7 @@ isCurrent: s.isCurrent, desc: describePageStep(s.step), diffable: isPageStepDiffable(s.step), - revertable: s.applied, + revertable: s.applied && isPageStepRevertable(s.step), source: s.step.source, time: formatHistoryTime(s.step.timestamp), timeTitle: formatHistoryFullTime(s.step.timestamp), @@ -57,6 +57,7 @@ import { formatHistoryTime, groupSource, groupTimestamp, + isPageStepRevertable, } from './composables'; import GroupRow from './GroupRow.vue'; import InitialRow from './InitialRow.vue'; diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts index 94a77984..a6954700 100644 --- a/packages/editor/src/layouts/history-list/composables.ts +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -251,3 +251,36 @@ export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => { const target = labelWithId(rawName, group.id); return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`; }; + +/** + * 页面 step 是否支持「回滚」(类 git revert): + * - 新增 / 删除:不依赖 changeRecords,反向应用即删除 / 加回,始终可回滚; + * - 更新:必须每个被更新节点都带有 changeRecords,才支持按 propPath 局部反向 patch。 + * 缺失 changeRecords 的更新只能整节点替换,会冲掉该节点后续的无关变更,因此不支持回滚。 + */ +export const isPageStepRevertable = (step: StepValue): boolean => { + if (step.opType !== 'update') return true; + const items = step.updatedItems ?? []; + if (!items.length) return false; + return items.every((item) => Boolean(item.changeRecords?.length)); +}; + +/** + * 数据源 step 是否支持「回滚」: + * - 新增(oldSchema=null)/ 删除(newSchema=null):不依赖 changeRecords,始终可回滚; + * - 更新(前后 schema 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。 + */ +export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => { + if (step.oldSchema === null || step.newSchema === null) return true; + return Boolean(step.changeRecords?.length); +}; + +/** + * 代码块 step 是否支持「回滚」: + * - 新增(oldContent=null)/ 删除(newContent=null):不依赖 changeRecords,始终可回滚; + * - 更新(前后 content 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。 + */ +export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => { + if (step.oldContent === null || step.newContent === null) return true; + return Boolean(step.changeRecords?.length); +}; diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 021ac793..4b45b964 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -394,6 +394,8 @@ class CodeBlock extends BaseService { const list = historyService.getCodeBlockStepList(id); const entry = list[index]; if (!entry?.applied) return null; + // 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。 + if (entry.step.oldContent && entry.step.newContent && !entry.step.changeRecords?.length) return null; const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`; return await this.applyRevertStep(entry.step, description); } diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 05f5dc43..454c9314 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -297,6 +297,8 @@ class DataSource extends BaseService { const list = historyService.getDataSourceStepList(id); const entry = list[index]; if (!entry?.applied) return null; + // 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。 + if (entry.step.oldSchema && entry.step.newSchema && !entry.step.changeRecords?.length) return null; const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`; return this.applyRevertStep(entry.step, description); } diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 47fad822..59469a22 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -1239,6 +1239,12 @@ class Editor extends BaseService { const root = this.get('root'); if (!root) return null; + // 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。 + if (step.opType === 'update') { + const items = step.updatedItems ?? []; + if (!items.length || !items.every((item) => item.changeRecords?.length)) return null; + } + // 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。 let revertedStep: StepValue | null = null; const captureRevert = (s: StepValue) => { diff --git a/packages/editor/src/theme/history-list-panel.scss b/packages/editor/src/theme/history-list-panel.scss index 946b564d..3183979b 100644 --- a/packages/editor/src/theme/history-list-panel.scss +++ b/packages/editor/src/theme/history-list-panel.scss @@ -13,7 +13,7 @@ position: absolute; top: 4px; right: 4px; - z-index: 1; + z-index: 2; display: flex; align-items: center; height: 40px; diff --git a/packages/editor/src/theme/props-panel.scss b/packages/editor/src/theme/props-panel.scss index 14eb7b58..8354e303 100644 --- a/packages/editor/src/theme/props-panel.scss +++ b/packages/editor/src/theme/props-panel.scss @@ -58,7 +58,7 @@ position: absolute; right: 15px; bottom: 15px; - z-index: 30; + z-index: 32; opacity: 0.5; &:hover { @@ -70,7 +70,7 @@ position: absolute; right: 15px; bottom: 60px; - z-index: 30; + z-index: 31; opacity: 0.5; &:hover { @@ -82,7 +82,7 @@ position: absolute; left: 0; top: 0; - z-index: 10; + z-index: 31; } .m-editor-resizer { diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 7ec2fcb7..fe0de011 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -1186,7 +1186,7 @@ export interface DslOpOptions extends HistoryOpOptions { /** 差异对话框的入参 */ export interface DiffDialogPayload { /** 表单类别 */ - category: CompareCategory; + category?: CompareCategory; /** 节点类型 / 数据源类型 */ type?: string; /** 代码块场景下的数据源类型 */ 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 7d53669b..80a1b1f9 100644 --- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -17,6 +17,9 @@ import { formatHistoryFullTime, formatHistoryTime, groupTimestamp, + isCodeBlockStepRevertable, + isDataSourceStepRevertable, + isPageStepRevertable, opLabel, useHistoryList, } from '@editor/layouts/history-list/composables'; @@ -607,3 +610,82 @@ describe('useHistoryList', () => { expect(buckets.map((b) => b.id).sort()).toEqual(['code_1', 'code_2']); }); }); + +describe('isPageStepRevertable', () => { + test('add / remove 始终可回滚', () => { + expect(isPageStepRevertable({ opType: 'add', nodes: [{ id: 'n1' }] } as any)).toBe(true); + expect(isPageStepRevertable({ opType: 'remove', removedItems: [{ node: { id: 'n1' } }] } as any)).toBe(true); + }); + + test('update 每项都有 changeRecords 才可回滚', () => { + expect( + isPageStepRevertable({ + opType: 'update', + updatedItems: [{ oldNode: { id: 'n1' }, newNode: { id: 'n1' }, changeRecords: [{ propPath: 'style.color' }] }], + } as any), + ).toBe(true); + }); + + test('update 缺少 changeRecords 不可回滚', () => { + expect( + isPageStepRevertable({ + opType: 'update', + updatedItems: [{ oldNode: { id: 'n1' }, newNode: { id: 'n1' } }], + } as any), + ).toBe(false); + }); + + test('update 多项中任一缺少 changeRecords 不可回滚', () => { + expect( + isPageStepRevertable({ + opType: 'update', + updatedItems: [ + { oldNode: { id: 'n1' }, newNode: { id: 'n1' }, changeRecords: [{ propPath: 'a' }] }, + { oldNode: { id: 'n2' }, newNode: { id: 'n2' } }, + ], + } as any), + ).toBe(false); + }); + + test('update 无 updatedItems 不可回滚', () => { + expect(isPageStepRevertable({ opType: 'update' } as any)).toBe(false); + }); +}); + +describe('isDataSourceStepRevertable', () => { + test('新增 / 删除 始终可回滚', () => { + expect(isDataSourceStepRevertable({ oldSchema: null, newSchema: { id: 'ds_1' } } as any)).toBe(true); + expect(isDataSourceStepRevertable({ oldSchema: { id: 'ds_1' }, newSchema: null } as any)).toBe(true); + }); + + test('更新有 changeRecords 才可回滚', () => { + expect( + isDataSourceStepRevertable({ + oldSchema: { id: 'ds_1' }, + newSchema: { id: 'ds_1' }, + changeRecords: [{ propPath: 'title' }], + } as any), + ).toBe(true); + expect(isDataSourceStepRevertable({ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } } as any)).toBe(false); + }); +}); + +describe('isCodeBlockStepRevertable', () => { + test('新增 / 删除 始终可回滚', () => { + expect(isCodeBlockStepRevertable({ oldContent: null, newContent: { id: 'code_1' } } as any)).toBe(true); + expect(isCodeBlockStepRevertable({ oldContent: { id: 'code_1' }, newContent: null } as any)).toBe(true); + }); + + test('更新有 changeRecords 才可回滚', () => { + expect( + isCodeBlockStepRevertable({ + oldContent: { id: 'code_1' }, + newContent: { id: 'code_1' }, + changeRecords: [{ propPath: 'content' }], + } as any), + ).toBe(true); + expect(isCodeBlockStepRevertable({ oldContent: { id: 'code_1' }, newContent: { id: 'code_1' } } as any)).toBe( + false, + ); + }); +});