fix(editor): 禁止缺少变更记录的历史回滚

This commit is contained in:
roymondchen 2026-06-04 16:48:24 +08:00
parent 27b2c2c685
commit 10b70c36bb
13 changed files with 144 additions and 8 deletions

View File

@ -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<string, boolean>;
/** 是否支持「跳转到该记录」(goto)。默认 true。 */

View File

@ -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 索引而非展示位置标识分组

View File

@ -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;

View File

@ -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';

View File

@ -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';

View File

@ -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);
};

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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) => {

View File

@ -13,7 +13,7 @@
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
z-index: 2;
display: flex;
align-items: center;
height: 40px;

View File

@ -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 {

View File

@ -1186,7 +1186,7 @@ export interface DslOpOptions extends HistoryOpOptions {
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
category: CompareCategory;
category?: CompareCategory;
/** 节点类型 / 数据源类型 */
type?: string;
/** 代码块场景下的数据源类型 */

View File

@ -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,
);
});
});