From aed9a0c4da7b639a4a8a9fa53e7ee1b7ca42283e Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 11:04:41 +0000 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=9F=E6=88=90=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E5=8F=B3=E9=94=AE=E5=88=A0=E9=99=A4=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 generation 占位右键删除在历史和选择副作用前进入统一请求入口。 保留未收口完美像素身份并恢复普通生成中的删除确认。 补充层命令定向测试并同步技术方案与决策记录。 --- .../shared-memory/decision-log.md | 7 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + .../image-editor/ImageCanvasEditorView.tsx | 85 ++++++++++--------- .../useImageCanvasLayerCommands.test.tsx | 51 ++++++++++- .../useImageCanvasLayerCommands.ts | 28 +++++- 5 files changed, 125 insertions(+), 47 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 84ce6f515..8c03f785c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6246,3 +6246,10 @@ - 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。这里的 object-only 是指 OSS 中已有私有源图文件、数据库也已有对应 `asset_object / objectKey` 登记,但尚无 `perfectPixelOperation` journal、项目 resource、素材库 asset、完美像素结果或画布结果图层。用户界面不可见且刷新后无法复用该 identity,再次点击可能重新上传;影响限于不可达的源图存储与垃圾记录累积,不代表结果已生成、重复扣费、越权或数据泄露。 - 本 PR 的修复边界到此为止:只保证 operation 已形成后,strict 保存失败或超时不会丢失 identity、不会重新上传,并且迟到 PATCH 不会继续触发 POST;不继续引入服务端 durable upload journal、上传 reservation、孤儿对象扫描/回收或历史数据清理,也不宣称撤销已发送的 PATCH。彻底消除上述崩溃窗口需要独立设计、评审和交付,不作为本 PR 的合并阻断项。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 generation 占位右键删除复用统一请求保护 + +- 缺陷:快捷键删除已在写历史前过滤未收口完美像素 operation,但 generation 占位的右键菜单仍直接调用低层 `removeCanvasGenerationDialogById`。低层会保留受保护 operation,上层却已写入 `delete-generation-result` 历史、清空选择并关闭交互,形成“占位未删但出现伪历史和 UI 副作用”的不一致;普通 generating dialog 也会绕过既有删除确认。 +- 决策:纯 generation-dialog 的右键删除在任何历史或选择副作用前委托给 `requestRemoveCanvasGenerationDialog`。未收口完美像素只激活原占位并显示继续对账/原样重试提示;普通 generating 进入现有确认弹窗;终态占位才执行真实删除。层命令保留低层回调给快捷键和混合选择的既有可删除目标,不扩大本次改动为删除系统重构。 +- 验证:层命令定向测试构造 `pending-confirmation + perfectPixelOperation` 右键目标,断言请求保护入口只调用一次、历史为零、选择保持、低层删除未调用且菜单收口。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index a40d5ce10..87d8964c8 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -55,6 +55,7 @@ - strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 - `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 - unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 +- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 只激活原占位并提示继续对账/原样重试,不写 `delete-generation-result` 伪历史、不清选择也不移除 identity;普通 generating dialog 继续进入既有删除确认,只有可立即删除的终态占位才真正写历史并清理。 - 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。 - 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 38efa392b..6347cc6d9 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1801,6 +1801,48 @@ export function ImageCanvasEditorView({ }, [openLayerGenerationDialog], ); + const removeCanvasGenerationDialog = useCallback( + (dialogId: string) => { + captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); + removeCanvasGenerationDialogById(dialogId); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + setContextMenu(null); + setActiveTool('select'); + }, + [ + captureCanvasHistory, + removeCanvasGenerationDialogById, + setActiveTool, + setContextMenu, + setImageContextMenu, + setSelectedLayerId, + setSelectedLayerIds, + ], + ); + const requestRemoveCanvasGenerationDialog = useCallback( + (dialog: CanvasGenerationDialogState) => { + if (isUnsettledPerfectPixelOperationDialog(dialog)) { + activateCanvasGenerationDialog(dialog); + showGenerationWarning( + '完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。', + ); + return; + } + if (dialog.status === 'generating') { + activateCanvasGenerationDialog(dialog); + setPendingGenerationDeleteDialog(dialog); + return; + } + removeCanvasGenerationDialog(dialog.id); + }, + [ + activateCanvasGenerationDialog, + removeCanvasGenerationDialog, + showGenerationWarning, + ], + ); const contextMenuLayer = contextMenu?.kind === 'layer' ? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null) @@ -1842,6 +1884,7 @@ export function ImageCanvasEditorView({ selectSingleLayer, onDeleteLayerSideEffects: clearDeletedLayerGenerationState, onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById, + onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog, exportLayerImage, }); const { @@ -1940,26 +1983,6 @@ export function ImageCanvasEditorView({ (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], ); - const removeCanvasGenerationDialog = useCallback( - (dialogId: string) => { - captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); - removeCanvasGenerationDialogById(dialogId); - setSelectedLayerId(null); - setSelectedLayerIds([]); - setImageContextMenu(null); - setContextMenu(null); - setActiveTool('select'); - }, - [ - captureCanvasHistory, - removeCanvasGenerationDialogById, - setActiveTool, - setContextMenu, - setImageContextMenu, - setSelectedLayerId, - setSelectedLayerIds, - ], - ); // 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理, // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 useInlineGenerationPlaceholderExpiry({ @@ -1969,28 +1992,6 @@ export function ImageCanvasEditorView({ onPlaceholdersExpired: handleInlinePlaceholdersExpired, }); - const requestRemoveCanvasGenerationDialog = useCallback( - (dialog: CanvasGenerationDialogState) => { - if (isUnsettledPerfectPixelOperationDialog(dialog)) { - activateCanvasGenerationDialog(dialog); - showGenerationWarning( - '完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。', - ); - return; - } - if (dialog.status === 'generating') { - activateCanvasGenerationDialog(dialog); - setPendingGenerationDeleteDialog(dialog); - return; - } - removeCanvasGenerationDialog(dialog.id); - }, - [ - activateCanvasGenerationDialog, - removeCanvasGenerationDialog, - showGenerationWarning, - ], - ); const confirmRemoveGeneratingDialog = useCallback(() => { const dialog = pendingGenerationDeleteDialog; if (!dialog) { diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 32a334f3c..922e1f0a5 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -109,8 +109,10 @@ function LayerCommandsHarness({ exportLayerImage = vi.fn(), onDeleteLayerSideEffects = vi.fn(), onDeleteGenerationDialogSideEffects = vi.fn(), + onRequestDeleteGenerationDialog, onCaptureCanvasHistory = vi.fn(), initialLayers, + initialContextMenu, initialSelectedLayerId = 'first', initialSelectedLayerIds = ['first', 'second'], initialCanvasGenerationDialogs, @@ -121,8 +123,12 @@ function LayerCommandsHarness({ ) => void; onDeleteLayerSideEffects?: (layerId: string) => void; onDeleteGenerationDialogSideEffects?: (dialogId: string) => void; + onRequestDeleteGenerationDialog?: ( + dialog: CanvasGenerationDialogState, + ) => void; onCaptureCanvasHistory?: (action: CanvasHistoryAction) => void; initialLayers?: CanvasLayer[]; + initialContextMenu?: CanvasContextMenuState; initialSelectedLayerId?: string | null; initialSelectedLayerIds?: string[]; initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; @@ -163,7 +169,7 @@ function LayerCommandsHarness({ ); const [hoveredLayerId, setHoveredLayerId] = useState('first'); const [contextMenu, setContextMenu] = useState( - { + initialContextMenu ?? { kind: 'layer', layerId: 'first', x: 0, @@ -202,6 +208,7 @@ function LayerCommandsHarness({ selectSingleLayer, onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, + onRequestDeleteGenerationDialog, exportLayerImage, }); @@ -666,6 +673,48 @@ describe('useImageCanvasLayerCommands', () => { }, ); + it('delegates a generation-dialog context delete before history and selection side effects', () => { + const dialog = createDurablePerfectPixelDialog( + 'perfect-pixel-context', + 'pending-confirmation', + ); + const selectionId = `generation-dialog:${dialog.id}`; + const onCaptureCanvasHistory = vi.fn(); + const onDeleteGenerationDialogSideEffects = vi.fn(); + const onRequestDeleteGenerationDialog = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '删除右键目标' })); + + expect(onRequestDeleteGenerationDialog).toHaveBeenCalledTimes(1); + expect(onRequestDeleteGenerationDialog).toHaveBeenCalledWith(dialog); + expect(screen.getByTestId('history').textContent).toBe('0'); + expect(screen.getByTestId('selection').textContent).toBe( + `${selectionId}:${selectionId}`, + ); + expect(screen.getByTestId('context').textContent).toBe('closed'); + expect(onCaptureCanvasHistory).not.toHaveBeenCalled(); + expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); + }); + it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => { const dialog = createDurablePerfectPixelDialog( 'perfect-pixel-pending', diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index ba99193df..65f966d79 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -57,6 +57,9 @@ type LayerCommandsOptions = { selectSingleLayer: (layerId: string | null) => void; onDeleteLayerSideEffects: (targetLayerId: string) => void; onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void; + onRequestDeleteGenerationDialog?: ( + targetDialog: CanvasGenerationDialogState, + ) => void; exportLayerImage: ( layer: CanvasLayer | null, options?: { mode?: ImageSequenceExportMode }, @@ -146,6 +149,7 @@ export function useImageCanvasLayerCommands({ selectSingleLayer, onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, + onRequestDeleteGenerationDialog, exportLayerImage, }: LayerCommandsOptions) { const [canvasClipboard, setCanvasClipboard] = @@ -527,12 +531,27 @@ export function useImageCanvasLayerCommands({ const deleteContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayerIds = getSelectedLayerIds(targetIds); - const targetDialogIds = targetIds + const targetDialogs = targetIds .map(getCanvasGenerationDialogIdFromSelectionId) .filter((dialogId): dialogId is string => Boolean(dialogId)) - .filter((dialogId) => - canvasGenerationDialogs.some((dialog) => dialog.id === dialogId), - ); + .flatMap((dialogId) => { + const dialog = canvasGenerationDialogs.find( + (candidate) => candidate.id === dialogId, + ); + return dialog ? [dialog] : []; + }); + if ( + !targetLayerIds.length && + targetDialogs.length && + onRequestDeleteGenerationDialog + ) { + closeContextMenus(); + targetDialogs.forEach((dialog) => { + onRequestDeleteGenerationDialog(dialog); + }); + return; + } + const targetDialogIds = targetDialogs.map((dialog) => dialog.id); if (!targetLayerIds.length && !targetDialogIds.length) { return; } @@ -566,6 +585,7 @@ export function useImageCanvasLayerCommands({ getContextTargetLayerIds, onDeleteGenerationDialogSideEffects, onDeleteLayerSideEffects, + onRequestDeleteGenerationDialog, selectSingleLayer, setHoveredLayerId, setLayers,