mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-06 15:40:15 +00:00
fix(editor): 禁止缺少变更记录的历史回滚
This commit is contained in:
parent
27b2c2c685
commit
10b70c36bb
@ -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。 */
|
||||
|
||||
@ -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 索引而非展示位置标识分组,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1186,7 +1186,7 @@ export interface DslOpOptions extends HistoryOpOptions {
|
||||
/** 差异对话框的入参 */
|
||||
export interface DiffDialogPayload {
|
||||
/** 表单类别 */
|
||||
category: CompareCategory;
|
||||
category?: CompareCategory;
|
||||
/** 节点类型 / 数据源类型 */
|
||||
type?: string;
|
||||
/** 代码块场景下的数据源类型 */
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user