恢复完美像素占位的用户删除权

低层删除不再对未收口 operation 抗命,随源图层与多选删除同等对待

删除确认判据收敛为 requiresGenerationDeleteConfirmation,免费的完美像素不再弹泥点确认

标记 operation 失效时一并丢弃快照,消除重试消失但占位仍在的矛盾态

翻转钉死删除保护的用例,改为覆盖删除后结果仍落库并走 asset-only 提示

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 04:50:37 +00:00
parent 291dd75a8c
commit 5925b396d0
8 changed files with 190 additions and 135 deletions
@@ -79,7 +79,7 @@ import {
} from './ImageCanvasSelectionModel';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
import {
isUnsettledPerfectPixelOperationDialog,
requiresGenerationDeleteConfirmation,
useCanvasGenerationDialogs,
} from './useCanvasGenerationDialogs';
import { useCanvasHistory } from './useCanvasHistory';
@@ -1719,31 +1719,14 @@ export function ImageCanvasEditorView({
);
const requestRemoveCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (isUnsettledPerfectPixelOperationDialog(dialog)) {
const previousSelectedLayerId = selectedLayerIdRef.current;
const previousSelectedLayerIds = [...selectedLayerIdsRef.current];
activateCanvasGenerationDialog(dialog);
setSelectedLayerId(previousSelectedLayerId);
setSelectedLayerIds(previousSelectedLayerIds);
showGenerationWarning(
'完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。',
);
return;
}
if (dialog.status === 'generating') {
if (requiresGenerationDeleteConfirmation(dialog)) {
activateCanvasGenerationDialog(dialog);
setPendingGenerationDeleteDialog(dialog);
return;
}
removeCanvasGenerationDialog(dialog.id);
},
[
activateCanvasGenerationDialog,
removeCanvasGenerationDialog,
setSelectedLayerId,
setSelectedLayerIds,
showGenerationWarning,
],
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
);
const contextMenuLayer =
contextMenu?.kind === 'layer'
@@ -4,7 +4,10 @@ import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
requiresGenerationDeleteConfirmation,
useCanvasGenerationDialogs,
} from './useCanvasGenerationDialogs';
function createDialog(
mode: CanvasGenerationDialogState['mode'],
@@ -26,6 +29,45 @@ function createDialog(
};
}
function durablePerfectPixelDialog(
dialogId: string,
status: 'generating' | 'pending-confirmation' | 'failed',
): CanvasGenerationDialogState {
const submittedAt = 1_700_000_000_000;
return {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status,
composerOpen: true,
sourceLayerId: 'layer-source',
perfectPixelOperation: {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: {
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
},
};
}
describe('useCanvasGenerationDialogs', () => {
it('archives, activates, updates, and removes canvas generation dialogs', () => {
const onActivate = vi.fn();
@@ -306,69 +348,73 @@ describe('useCanvasGenerationDialogs', () => {
]);
});
it('preserves unsettled durable perfect-pixel operations across ordinary delete paths', () => {
// 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口,
// 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出
// 「历史记了一笔、占位还在」的伪历史。
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
'deletes an unsettled durable perfect-pixel operation by id while %s',
(status) => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
const dialogId = 'perfect-pixel-durable';
act(() => {
result.current.restoreCanvasGenerationDialogs([
durablePerfectPixelDialog(dialogId, status),
]);
});
act(() => {
result.current.removeCanvasGenerationDialogById(dialogId);
});
expect(result.current.activeCanvasGenerationDialog).toBeNull();
expect(result.current.canvasGenerationDialogs).toEqual([]);
},
);
// 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除
// 占位不撤销在途请求,所以它在任何状态都直接删。
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
'never asks for delete confirmation on a perfect-pixel placeholder while %s',
(status) => {
expect(
requiresGenerationDeleteConfirmation(
durablePerfectPixelDialog('perfect-pixel-confirm', status),
),
).toBe(false);
},
);
it('still asks for delete confirmation on an ordinary generating placeholder', () => {
expect(
requiresGenerationDeleteConfirmation({
id: 'generation-dialog-1',
...createDialog('generate', '一只猫'),
status: 'generating',
}),
).toBe(true);
expect(
requiresGenerationDeleteConfirmation({
id: 'generation-dialog-2',
...createDialog('generate', '一只猫'),
status: 'failed',
}),
).toBe(false);
});
it('drops unsettled durable perfect-pixel operations together with their source layer', () => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
const dialogId = 'perfect-pixel-durable';
const submittedAt = 1_700_000_000_000;
act(() => {
result.current.restoreCanvasGenerationDialogs([
{
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'failed',
composerOpen: true,
sourceLayerId: 'layer-source',
perfectPixelOperation: {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: {
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
},
},
durablePerfectPixelDialog(dialogId, 'pending-confirmation'),
]);
});
act(() => {
result.current.removeCanvasGenerationDialogById(dialogId);
result.current.removeCanvasGenerationDialogsByLayerId('layer-source');
});
expect(result.current.activeCanvasGenerationDialog).toEqual(
expect.objectContaining({
id: dialogId,
status: 'failed',
perfectPixelOperation: expect.objectContaining({
operationId: dialogId,
}),
}),
);
act(() => {
result.current.updateCanvasGenerationDialogById(dialogId, (dialog) => ({
...dialog,
status: 'idle',
}));
result.current.removeCanvasGenerationDialogById(dialogId);
});
expect(result.current.activeCanvasGenerationDialog).toBeNull();
expect(result.current.canvasGenerationDialogs).toEqual([]);
});
});
@@ -24,15 +24,18 @@ export type CanvasGenerationDialogDraft = Omit<
id?: string;
};
export function isUnsettledPerfectPixelOperationDialog(
/**
* 中文注释:删除生成占位前是否需要二次确认。
*
* 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素
* `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常
* 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。
* 所以完美像素占位在任何状态都直接删,不额外解释。
*/
export function requiresGenerationDeleteConfirmation(
dialog: CanvasGenerationDialogState,
) {
return (
Boolean(dialog.perfectPixelOperation) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation' ||
dialog.status === 'failed')
);
return dialog.status === 'generating' && !dialog.perfectPixelOperation;
}
function withGenerationTimestamps<T extends GenerateDialogState | null>(
@@ -227,11 +230,13 @@ export function useCanvasGenerationDialogs({
[],
);
// 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求
// ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在
// 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利,
// 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。
const removeCanvasGenerationDialogById = useCallback(
(dialogId: string) => {
updateCanvasGenerationDialogById(dialogId, (dialog) =>
isUnsettledPerfectPixelOperationDialog(dialog) ? dialog : null,
);
updateCanvasGenerationDialogById(dialogId, () => null);
},
[updateCanvasGenerationDialogById],
);
@@ -312,9 +317,8 @@ export function useCanvasGenerationDialogs({
const removeCanvasGenerationDialogsByLayerId = useCallback(
(targetLayerId: string) => {
const keepDialog = (dialog: CanvasGenerationDialogState) =>
isUnsettledPerfectPixelOperationDialog(dialog) ||
(dialog.sourceLayerId !== targetLayerId &&
dialog.generatedLayerId !== targetLayerId);
dialog.sourceLayerId !== targetLayerId &&
dialog.generatedLayerId !== targetLayerId;
const currentDialog = generateDialogRef.current;
const nextActiveDialog =
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
@@ -3373,7 +3373,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
it('keeps the durable operation when deletion is attempted and applies a later GET result', async () => {
// 中文注释:删除占位不撤销在途请求。结果照常落库,但既然用户已经把占位拿掉,就不再回填画布,
// 只提示结果已进素材库。
it('deletes the durable operation and reports a later applied GET result as asset-only', async () => {
const applyProjectSnapshot = vi.fn();
let operationId = '';
let rejectPerfectPixel: ((error: unknown) => void) | undefined;
@@ -3408,22 +3410,17 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-');
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
rejectPerfectPixel?.(new Error('网络中断'));
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
}),
{
type: 'perfect-pixel',
count: 1,
},
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。',
);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => {
@@ -3794,7 +3791,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
).toBe('空闲');
});
it('refuses to delete a durable in-flight operation and keeps a later known failure visible', async () => {
// 中文注释:占位属于用户文档,在途也允许删除——删除不撤销任何请求。占位没了之后,已知失败
// 没有地方可挂,必须退回全局提示,不能静默。
it('deletes a durable in-flight operation and reports the later known failure globally', async () => {
let rejectPerfectPixel: ((error: unknown) => void) | undefined;
snapImageToPerfectPixelsMock.mockImplementationOnce(
() =>
@@ -3824,7 +3823,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-');
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
// 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。
rejectPerfectPixel?.(
@@ -3836,12 +3835,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
await waitFor(() => {
expect(screen.getByTestId('dialog-error').textContent).toBe(
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素画布占位不存在或尚未保存,请重试。',
);
});
expect(screen.getByTestId('dialog').textContent).toContain('failed');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
@@ -3888,8 +3887,6 @@ describe('useImageCanvasGenerationWorkflow', () => {
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-');
resolvePerfectPixel?.(
createPerfectPixelResult(
@@ -4142,6 +4139,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(
screen.getByTestId('perfect-pixel-operation-invalid').textContent,
).toBe('invalid');
// 中文注释:标记失效必须同时丢弃快照,与 hydrateCanvasGenerationDialog 一致。两者并存会
// 造出「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。
expect(screen.getByTestId('perfect-pixel-operation').textContent).toBe('-');
expect(screen.getByTestId('dialog-error').textContent).toBe(
'完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。',
);
@@ -2781,17 +2781,27 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller);
void (async () => {
if (operation.request.projectId !== normalizedProjectId) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
isSameUnsettledPerfectPixelOperation(current, operation.operationId)
? {
...current,
status: 'failed' as const,
perfectPixelOperationInvalid: true,
errorMessage:
'完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。',
}
: current,
);
updateCanvasGenerationDialogById(dialog.id, (current) => {
if (
!isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
)
) {
return current;
}
// 中文注释:标记快照失效时必须同时丢弃 operation,与 hydrateCanvasGenerationDialog
// 的口径一致。两者并存会造出「重试按钮因 invalid 而消失、快照却还挂着」的矛盾态,
// 用户既重试不了也看不懂占位为什么还在。
const { perfectPixelOperation: _discarded, ...rest } = current;
return {
...rest,
status: 'failed' as const,
perfectPixelOperationInvalid: true,
errorMessage:
'完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。',
};
});
return;
}
const verdict = await reconcilePerfectPixelProject(
@@ -756,8 +756,11 @@ describe('useImageCanvasLayerCommands', () => {
);
});
// 中文注释:未收口的完美像素 operation 不再豁免删除。删除占位不撤销任何在途请求,结果照常
// 落库并进素材库;封锁换来的只是「结果自动回填画布」这一便利,代价是用户画布上出现删不掉
// 的对象。快捷键删除因此与其它占位同等对待。
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
'keeps a selected durable perfect-pixel dialog as a complete no-op while %s',
'deletes a selected durable perfect-pixel dialog while %s',
(status) => {
const dialog = createDurablePerfectPixelDialog(
`perfect-pixel-${status}`,
@@ -782,15 +785,18 @@ describe('useImageCanvasLayerCommands', () => {
fireEvent.click(screen.getByRole('button', { name: '删除选中' }));
expect(screen.getByTestId('history').textContent).toBe('0');
expect(screen.getByTestId('selection').textContent).toBe(
`${selectionId}:${selectionId}`,
);
expect(screen.getByTestId('context').textContent).toBe('open');
expect(screen.getByTestId('image-context-closed').textContent).toBe('0');
expect(onCaptureCanvasHistory).not.toHaveBeenCalled();
expect(screen.getByTestId('history').textContent).toBe('1');
expect(onCaptureCanvasHistory).toHaveBeenCalledWith({
type: 'delete-generation-result',
count: 1,
});
expect(screen.getByTestId('selection').textContent).toBe('-:');
expect(screen.getByTestId('context').textContent).toBe('closed');
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
expect(onDeleteLayerSideEffects).not.toHaveBeenCalled();
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith(
dialog.id,
);
},
);
@@ -836,7 +842,7 @@ describe('useImageCanvasLayerCommands', () => {
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
});
it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => {
it('deletes both layer and durable-dialog targets in a mixed selection', () => {
const dialog = createDurablePerfectPixelDialog(
'perfect-pixel-pending',
'pending-confirmation',
@@ -861,7 +867,8 @@ describe('useImageCanvasLayerCommands', () => {
fireEvent.click(screen.getByRole('button', { name: '删除选中' }));
expect(screen.getByTestId('layers').textContent).not.toContain('first');
expect(screen.getByTestId('selection').textContent).toBe('third:third');
// 中文注释:混合选择里存在占位目标时统一清空选择,计数沿用既有口径——只数图层。
expect(screen.getByTestId('selection').textContent).toBe('-:');
expect(screen.getByTestId('history').textContent).toBe('1');
expect(onCaptureCanvasHistory).toHaveBeenCalledWith({
type: 'delete-image',
@@ -869,6 +876,7 @@ describe('useImageCanvasLayerCommands', () => {
});
expect(onDeleteLayerSideEffects).toHaveBeenCalledTimes(1);
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first');
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledTimes(1);
expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith(dialog.id);
});
});
@@ -37,9 +37,6 @@ import {
getCanvasGenerationDialogIdFromSelectionId,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
import {
isUnsettledPerfectPixelOperationDialog,
} from './useCanvasGenerationDialogs';
import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow';
type LayerCommandsOptions = {
@@ -703,11 +700,7 @@ export function useImageCanvasLayerCommands({
.map(getCanvasGenerationDialogIdFromSelectionId)
.filter((dialogId): dialogId is string => Boolean(dialogId))
.filter((dialogId) =>
canvasGenerationDialogs.some(
(dialog) =>
dialog.id === dialogId &&
!isUnsettledPerfectPixelOperationDialog(dialog),
),
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
);
if (!targetLayerIds.length && !targetDialogIds.length) {
return;