From 83f4e528450be3971eea25b59dab126e04ac4df4 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 29 Jun 2026 20:23:19 +0800 Subject: [PATCH] feat(editor): support custom width and initial diff mode for history diff dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为历史差异/回滚确认弹窗新增可配置宽度(dialogWidth / width 透传至 TMagicDialog), 并支持通过 payload.mode 指定打开时的初始对比模式,不可用时回退自动推断。 --- docs/guide/advanced/history-list.md | 1 + .../history-list/HistoryDiffDialog.vue | 16 ++- .../layouts/history-list/useHistoryRevert.ts | 26 +++- packages/editor/src/type.ts | 19 +++ .../history-list/useHistoryRevert.spec.ts | 121 ++++++++++++++++++ 5 files changed, 174 insertions(+), 9 deletions(-) diff --git a/docs/guide/advanced/history-list.md b/docs/guide/advanced/history-list.md index ce6ce72e..9ced53e2 100644 --- a/docs/guide/advanced/history-list.md +++ b/docs/guide/advanced/history-list.md @@ -102,6 +102,7 @@ onCodeBlockDiff(id, index); | --- | --- | --- | | `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件(Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 | | `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。 | +| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog` 的 `width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 | > 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`。 diff --git a/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue b/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue index a2f3d5e6..c2b38fbf 100644 --- a/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue +++ b/packages/editor/src/layouts/history-list/HistoryDiffDialog.vue @@ -162,11 +162,19 @@ const hasCurrent = computed(() => payload.value?.currentValue !== undefined && p /** 是否存在该步「修改后的值」:不存在(如仅删除)时「与修改前对比」无意义,置灰禁用。 */ const hasValue = computed(() => payload.value?.value !== undefined && payload.value?.value !== null); +/** 指定模式当前是否可用:before 依赖「修改后的值」,current 依赖「当前值」。 */ +const isModeAvailable = (m: DiffMode): boolean => (m === 'current' ? hasCurrent.value : hasValue.value); + /** * 计算 open 时的初始对比模式: - * 没有「修改后的值」但有当前值时(「与修改前对比」不可用),默认进入「与当前对比」;否则默认「与修改前对比」。 + * - 调用方通过 payload.mode 指定且该模式可用时,优先使用指定模式; + * - 否则没有「修改后的值」但有当前值时(「与修改前对比」不可用),默认进入「与当前对比」; + * - 其余情况默认「与修改前对比」。 */ -const resolveInitialMode = (): DiffMode => (!hasValue.value && hasCurrent.value ? 'current' : 'before'); +const resolveInitialMode = (specified?: DiffMode): DiffMode => { + if (specified && isModeAvailable(specified)) return specified; + return !hasValue.value && hasCurrent.value ? 'current' : 'before'; +}; /** 左侧(旧/参照)值 */ const leftValue = computed>(() => { @@ -222,8 +230,8 @@ const targetText = computed(() => { const open = (p: DiffDialogPayload) => { payload.value = p; // 每次打开按 payload 重置对比模式(hasValue / hasCurrent 依赖 payload,需先赋值): - // 无「修改后的值」但有当前值时默认「与当前对比」,否则默认「与修改前对比」 - mode.value = resolveInitialMode(); + // 优先使用 payload.mode 指定的模式;不可用或未指定时按数据自动推断。 + mode.value = resolveInitialMode(p.mode); // 默认回到表单对比形态,避免残留上一次选择的源码模式 viewMode.value = 'form'; visible.value = true; diff --git a/packages/editor/src/layouts/history-list/useHistoryRevert.ts b/packages/editor/src/layouts/history-list/useHistoryRevert.ts index 54bd071d..6c241298 100644 --- a/packages/editor/src/layouts/history-list/useHistoryRevert.ts +++ b/packages/editor/src/layouts/history-list/useHistoryRevert.ts @@ -94,6 +94,7 @@ const mountHistoryDiffDialog = async ( loadConfig: options.loadConfig, selfDiffFieldTypes: options.selfDiffFieldTypes, compareFormState: options.compareFormState, + width: options.width, onClose: options.onClose, }); if (options.appContext) { @@ -192,7 +193,7 @@ const viewHistoryDiffDialog = async ( export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => { // 自动捕获调用方所在组件的 appContext(在 setup 中调用时),业务方亦可显式覆盖。 const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null; - const { extendState, getPropsPanelFormState } = options; + const { extendState, getPropsPanelFormState, dialogWidth } = options; /** 目标数据已被删除、无法回滚时的统一提示。 */ const showRevertTargetMissing = () => { @@ -216,7 +217,13 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services */ const runRevert = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise => { if (payload) { - return confirmRevertWithDiffDialog(payload, { appContext, extendState, services, ...extra }); + return confirmRevertWithDiffDialog(payload, { + appContext, + extendState, + services, + ...extra, + width: extra?.width ?? dialogWidth, + }); } return confirmRevert(); }; @@ -334,6 +341,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services appContext, extendState, services, + width: dialogWidth, compareFormState: getPropsPanelFormState?.(), }); } @@ -341,12 +349,12 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services const onDataSourceDiff = (id: string | number, index: number): Promise | void => { const payload = buildDataSourceDiffPayload(id, index); - if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services }); + if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth }); }; const onCodeBlockDiff = (id: string | number, index: number): Promise | void => { const payload = buildCodeBlockDiffPayload(id, index); - if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services }); + if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth }); }; /** @@ -373,6 +381,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services const confirmed = await runRevert(revertOptions.diffPayload ?? null, { loadConfig: revertOptions.loadConfig, selfDiffFieldTypes: revertOptions.selfDiffFieldTypes, + width: revertOptions.width, }); if (!confirmed) return null; return await revertOptions.revert(); @@ -383,7 +392,14 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services * 可透传 `loadConfig` / `selfDiffFieldTypes`。payload 为 null(不可对比)时静默返回。 */ const viewDiff = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise | void => { - if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, ...extra }); + if (payload) + return viewHistoryDiffDialog(payload, { + appContext, + extendState, + services, + ...extra, + width: extra?.width ?? dialogWidth, + }); }; return { diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index ab23a260..84b64d91 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -1352,6 +1352,14 @@ export interface DiffDialogPayload { targetLabel?: string; /** 用于标题展示的目标 id */ id?: string | number; + /** + * 指定打开时的初始对比模式: + * - before:该步骤修改前 vs 修改后 + * - current:该步骤修改后 vs 当前最新值 + * 不传时按是否存在「修改后的值」/「当前值」自动推断。 + * 若指定的模式当前不可用(对应数据缺失),将回退到自动推断结果。 + */ + mode?: 'before' | 'current'; } /** @@ -1408,6 +1416,12 @@ export interface UseHistoryRevertOptions { * 以保证两处 filterFunction 读取到一致的运行态上下文。 */ getPropsPanelFormState?: () => FormState | undefined; + /** + * 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 TMagicDialog 的 `width`), + * 如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(900px)。 + * 业务自有历史(`viewDiff` / `confirmAndRevert`)可在调用时通过各自入参的 `width` 单独覆盖。 + */ + dialogWidth?: string; } /** @@ -1428,6 +1442,11 @@ export interface CustomDiffFormOptions { * 对比弹窗会用它覆盖 CompareForm 中同名扩展字段,避免上下文不一致。 */ compareFormState?: FormState; + /** + * 差异 / 确认回滚弹窗宽度(透传给 HistoryDiffDialog 的 TMagicDialog `width`), + * 如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(900px)。 + */ + width?: string; } /** diff --git a/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts b/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts index 02b0ab52..aee49756 100644 --- a/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts @@ -24,6 +24,36 @@ vi.mock('@editor/layouts/history-list/composables', async () => { }; }); +// 捕获动态挂载 HistoryDiffDialog 时传入 createApp 的 props(用于断言 width 透传), +// 并桩掉弹窗实例的 open / confirm,避免真正渲染组件。 +const dialogInstance = { + open: vi.fn(), + confirm: vi.fn(async () => true), +}; +const appMock = { + _context: {}, + mount: vi.fn(() => dialogInstance), + unmount: vi.fn(), +}; +const createAppMock = vi.fn((..._args: any[]) => appMock); + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue'); + return { + ...actual, + createApp: (...args: any[]) => createAppMock(...args), + // setup 之外调用,强制返回 null,避免依赖宿主组件上下文 + getCurrentInstance: () => null, + }; +}); + +vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({ + default: { name: 'MEditorHistoryDiffDialog' }, +})); + +/** 读取最近一次挂载弹窗时传入的 props(createApp 第二个参数)。 */ +const lastDialogProps = () => createAppMock.mock.calls.at(-1)?.[1] as unknown as Record; + const createServices = () => ({ editorService: { @@ -45,6 +75,19 @@ const createServices = () => }, }) as any; +/** 构造一个可差异对比(单实体 update,前后值都在)的历史分组,用于触发差异弹窗。 */ +const diffableGroups = (id: string | number = 'p1') => [ + { + id, + steps: [ + { + index: 0, + step: { diff: [{ oldSchema: { id: 'n1', name: 'A' }, newSchema: { id: 'n1', name: 'B' } }] }, + }, + ], + }, +]; + afterEach(() => { vi.clearAllMocks(); }); @@ -105,4 +148,82 @@ describe('useHistoryRevert', () => { expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除'); expect(services.dataSourceService.revert).not.toHaveBeenCalled(); }); + + describe('弹窗宽度透传', () => { + test('onPageDiff 使用 options.dialogWidth 作为弹窗宽度并打开弹窗', async () => { + const services = createServices(); + services.historyService.getHistoryGroups.mockReturnValue(diffableGroups()); + + const { onPageDiff } = useHistoryRevert({ dialogWidth: '1200px' }, services); + await onPageDiff(0); + + expect(createAppMock).toHaveBeenCalledTimes(1); + expect(lastDialogProps().width).toBe('1200px'); + expect(lastDialogProps().isConfirm).toBe(false); + expect(dialogInstance.open).toHaveBeenCalled(); + }); + + test('未配置 dialogWidth 时不强制 width(由弹窗内置默认值兜底)', async () => { + const services = createServices(); + services.historyService.getHistoryGroups.mockReturnValue(diffableGroups()); + + const { onPageDiff } = useHistoryRevert({}, services); + await onPageDiff(0); + + expect(createAppMock).toHaveBeenCalledTimes(1); + expect(lastDialogProps().width).toBeUndefined(); + }); + + test('onPageRevert 在可差异步骤上走差异确认弹窗并透传 dialogWidth', async () => { + const services = createServices(); + services.historyService.getStepList.mockReturnValue([ + { step: { opType: 'update', diff: [{ newSchema: { id: 'n1' }, oldSchema: { id: 'n1' } }] } }, + ]); + services.editorService.getNodeById.mockReturnValue({ id: 'n1' }); + services.historyService.getHistoryGroups.mockReturnValue(diffableGroups()); + + const { onPageRevert } = useHistoryRevert({ dialogWidth: '80%' }, services); + await onPageRevert(0); + + expect(lastDialogProps().width).toBe('80%'); + expect(lastDialogProps().isConfirm).toBe(true); + expect(dialogInstance.confirm).toHaveBeenCalled(); + expect(services.editorService.revertPageStep).toHaveBeenCalledWith(0); + }); + + test('viewDiff 的逐次 width 入参可覆盖 dialogWidth 默认值', async () => { + const services = createServices(); + const { viewDiff } = useHistoryRevert({ dialogWidth: '1000px' }, services); + + await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any, { width: '600px' }); + + expect(lastDialogProps().width).toBe('600px'); + }); + + test('viewDiff 未传 width 时回退到 dialogWidth 默认值', async () => { + const services = createServices(); + const { viewDiff } = useHistoryRevert({ dialogWidth: '1000px' }, services); + + await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any); + + expect(lastDialogProps().width).toBe('1000px'); + }); + + test('confirmAndRevert 透传 width 至确认弹窗并在确认后执行 revert', async () => { + const services = createServices(); + const revert = vi.fn(async () => 'done'); + const { confirmAndRevert } = useHistoryRevert({ dialogWidth: '1000px' }, services); + + const result = await confirmAndRevert({ + diffPayload: { category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any, + width: '720px', + revert, + }); + + expect(lastDialogProps().width).toBe('720px'); + expect(lastDialogProps().isConfirm).toBe(true); + expect(revert).toHaveBeenCalled(); + expect(result).toBe('done'); + }); + }); });