恢复完美像素占位的用户删除权
低层删除不再对未收口 operation 抗命,随源图层与多选删除同等对待 删除确认判据收敛为 requiresGenerationDeleteConfirmation,免费的完美像素不再弹泥点确认 标记 operation 失效时一并丢弃快照,消除重试消失但占位仍在的矛盾态 翻转钉死删除保护的用例,改为覆盖删除后结果仍落库并走 asset-only 提示 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6370,3 +6370,14 @@
|
||||
- 遗留:历史资源行的 `model` 列仍存有 2026-07-30 之前写入的内部处理模型,读边界继续脱敏它。把该列回填为源生图模型、原值移入 `generationInputs.mattingModel`,并据此删掉两侧的脱敏与推导逻辑,另行排期,不在本次范围。
|
||||
- 验证方式:前端覆盖 `serializeLayer` 产物不含 `model` / `provider`,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错。运行 `npx vitest run src/components/image-editor`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project::`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml --all-targets`、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`。spacetime-module 的单测二进制在 Windows 本机链接失败(缺 SpacetimeDB 宿主符号),本机只能做到 `cargo check --all-targets`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-05 完美像素占位恢复为可删除,删除保护条款作废
|
||||
|
||||
- 背景:2026-08-04「完美像素第二批:object-only 上传与 GET-only unknown 收口」把「未收口的完美像素 operation」定为不可删除,理由是结果 unknown 时要保留 identity 供原样重试;同日「confirm 后 strict 保存失败保留原 operation」又把 POST 尚未发出的 `failed` 一并纳入,理由是从源图重开会重新执行 ticket / PUT / confirm,让上一次已 confirm 的源图对象失去引用。两条叠加后,右键删除、Delete 快捷键、随源图层删除、多选删除全部豁免该占位,到期清理也不覆盖它,用户画布上出现了删不掉的元素。
|
||||
- 缺陷判定:删除占位**不撤销任何在途请求**——完美像素没有取消接口,结果照常落库并进素材库;服务端 completion 发现 dialog 已不在会返回 `DialogMissing`,客户端本就有对应提示。封锁买到的只是「结果自动回填画布」这一便利,代价却是用户文档不可编辑。至于孤儿源图对象,「confirm 后 strict 保存失败保留原 operation」自己的「剩余边界」一节已把同类残留定性为「影响限于不可达的源图存储与垃圾记录累积……不作为本 PR 的合并阻断项」;为避免同一种残留而禁止用户删除自己画布上的元素,权衡不自洽。
|
||||
- 决策:本条取代上述两条中关于**删除**的条款。完美像素占位在 `generating` / `pending-confirmation` / `failed` 任何状态都可删,且不弹确认。低层 `removeCanvasGenerationDialogById` 恢复为无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因;随源图层删除不再豁免;Delete 快捷键与多选删除不再过滤该目标。删除确认的判据收敛为具名的 `requiresGenerationDeleteConfirmation`:现成弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立,而完美像素 `generation_cost_mud_points = 0`。运行时标记 `perfectPixelOperationInvalid` 时必须同时丢弃 `perfectPixelOperation`,与 `hydrateCanvasGenerationDialog` 口径一致,不再留下「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。
|
||||
- 保留不变:保留 operation identity 供「在原占位原样重试」的能力不变,重试仍复用同一 request、source objectKey 与 dialog / operation / task identity。从源图重新发起仍被 `existingOperation` 闸拦住,另行处理。带 operation 的占位继续豁免 inline 占位 TTL 到期清理——用户主动删除与系统替用户删除是两回事。
|
||||
- 已知后果:删掉 `pending-confirmation` 占位后,刷新恢复不再对账这条 operation;结果若已生成只会出现在素材库,不回填画布。这是用户主动放弃的结果,不是回归,不得据此判定为缺陷。
|
||||
- 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx` 的 `requestRemoveCanvasGenerationDialog`、`useImageCanvasLayerCommands.ts` 的 `deleteSelectedLayer`、`useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。
|
||||
- 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor`、`npx vitest run src/components/platform-entry`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user