From cb5fdb5c94eb698af3deaaa97725a39405408295 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 3 Aug 2026 05:47:01 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E7=BE=8E=E5=83=8F=E7=B4=A0=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E4=B8=A4=E5=A4=84=E9=9D=99=E9=BB=98=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8E=E7=BC=93=E5=AD=98=E5=89=A5=E7=A6=BB=E7=9A=84=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A3-1906。对账分支发现权威快照里占位已被 completion 消费、本地占位也被用户 删除时直接静默 return。不应用快照是对的(删除意图胜出),但结论与相邻分支 一致——结果已落库,用户删掉的是占位不是素材,必须告诉他素材库多了一份。 同一形状的孪生分支在成功路径:!result.project 时提示被写在 if (hasCanvasGenerationDialogById(...)) 里面,占位不在就整段静默。改为无条件 提示、条件移除。 A4。会话缓存剥掉的占位数单独计,不并入提示计数——完美像素成功后缓存可能停留 在完成之前的版本,并入会报「上次处理未完成」的假告警。只在权威加载失败、没有 第二个来源纠正画面时才提示。已核实 isProjectReady 不挡画布渲染,该场景下用户 确实看得到一张静默少了占位的画布。 新增用例在去掉 1906 提示后失败。A4 无专用测试,理由记在 decision-log。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 10 ++++ .../useImageCanvasGenerationWorkflow.test.tsx | 57 +++++++++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 14 ++++- .../useImageCanvasProjectPersistence.ts | 22 +++++-- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2db2a1e53..dce2e8496 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6018,3 +6018,13 @@ - 验证结果:api-server 676 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致);`vitest src/components/image-editor` 894 通过 / 72 文件;`cargo fmt --check`、typecheck、eslint、`check:encoding` 通过。 - 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素静默路径收口(审查第一批续) + +- 背景:第一批只修了失败尾部那一处静默(`else` 兜底)。复审指出对账分支还有一处早返回同样不说话,追查后发现它有个孪生分支在成功路径上,两处形状一致——操作其实已经落库、用户删掉了占位、没人告诉他素材库多了一份。 +- 决策:提示与「移除占位」解绑。成功路径 `!result.project` 分支原先把提示写在 `if (hasCanvasGenerationDialogById(...))` 里面,占位不在就整段静默;改为无条件提示、条件移除。对账分支 `!placeholderSurvived` 且本地占位也没了时,不应用快照仍然正确(删除意图胜出),但要补同一句提示——走到这里意味着权威快照里占位已被 completion 消费,本分支下一步正是据此把结果当成功套用,结论一致:结果已落库。 +- A4 的取舍:会话缓存里剥掉的占位数**不能**直接并入提示计数。完美像素成功后 `applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 不落库,所以会话缓存可能停留在完成之前的版本;重新加载时缓存里那个陈旧占位被剥掉、计数加一,而权威快照其实是成功的,并入就会报一条「上次处理未完成」的假告警。改为单独计数,只在权威加载失败、没有第二个来源可以纠正这幅画面时才提示。 +- A4 的可观测性核实:`isProjectReady` 只被启动意图消费和自动保存 effect 使用,不参与画布渲染门禁,所以权威加载失败时画布照常显示,用户看到的确实是一张静默少了占位的画布,提示有必要。鉴权失败与项目失访两条路径各自弹窗或跳转,不在这里重复打扰——为此在 `replaceAppHistoryPath` 后补了 `return`,该分支原本就没有后续语句,行为不变。 +- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。 +- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index d637c3d37..63cc89982 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -2748,6 +2748,63 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toBe('空闲'); }); + it('reports the asset-library outcome when reconciliation finds both placeholders gone', async () => { + // 中文注释:对账发现权威快照里占位已被 completion 消费——同一分支下一步正是据此把结果 + // 当成功套用,所以结论一致:结果已落库。本地占位也没了(用户删的)时不应用快照是对的, + // 但不能连话都不说:用户删掉的是占位而不是素材,不提示他就既看不到画布结果、也不知道 + // 素材库多了一份,进而重复执行。 + let rejectPerfectPixel: ((error: unknown) => void) | undefined; + snapImageToPerfectPixelsMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectPerfectPixel = reject; + }), + ); + // 中文注释:对账 GET 返回的项目里不含该占位,模拟 completion 已消费。 + loadEditorProjectMock.mockResolvedValueOnce({ + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-08-03T00:00:00.000Z', + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toBe( + 'quick-edit:generating:closed:-:placeholder', + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); + await waitFor(() => { + expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); + }); + + // 中文注释:裸 Error 表达 transport 异常(未知结果),这样才会进入对账分支。 + rejectPerfectPixel?.(new Error('网络中断')); + + await waitFor(() => { + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '完美像素结果已保存到素材库,画布占位已不存在。', + ); + }); + }); + it('does not apply a completed project after the perfect-pixel placeholder was deleted', async () => { let resolvePerfectPixel: | ((value: { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index fa32257c1..17e33039f 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1840,12 +1840,13 @@ export function useImageCanvasGenerationWorkflow({ }); upsertGeneratedAsset?.(result.asset); if (!result.project) { + // 中文注释:提示不能挂在「占位还在」这个条件里。占位已被删除时结果同样已经落库, + // 而且此时用户更需要知道——他删掉的是占位,不是已经生成的素材。原先提示与移除 + // 绑在同一个 if 里,占位不在就整段静默,用户不知道素材库多了一份。 if (hasCanvasGenerationDialogById(perfectPixelDialogId)) { updateCanvasGenerationDialogById(perfectPixelDialogId, () => null); - showGenerationWarning( - '完美像素结果已保存到素材库,画布占位已不存在。', - ); } + showGenerationWarning('完美像素结果已保存到素材库,画布占位已不存在。'); return; } if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { @@ -1904,6 +1905,13 @@ export function useImageCanvasGenerationWorkflow({ // 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有 // 这道检查,对账路径不能漏。 if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { + // 中文注释:不应用快照是对的(删除意图胜出),但不能连话都不说。走到这里 + // 意味着权威快照里占位已被 completion 消费——本分支下一步正是据此把结果 + // 当成功套用,所以结论一致:结果已落库。用户删掉的是占位而不是素材,必须 + // 告诉他素材库多了一份,否则他既看不到画布结果也不知道素材已生成。 + showGenerationWarning( + '完美像素结果已保存到素材库,画布占位已不存在。', + ); return; } applyProjectSnapshot(reconciled, { diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 3dc30ccd3..d66ef7ba0 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -1197,13 +1197,17 @@ export function useImageCanvasProjectPersistence({ projectIdFromQuery, currentUserId, ); + // 中文注释:缓存里剥掉的数量单独记,不能直接并入提示计数。会话缓存可能停留在完美像素 + // 完成之前的版本(`applyProjectSnapshot` 置了 skipNext,成功后的第一次 effect 不落库), + // 此时缓存里的占位是陈旧的而权威快照其实已经成功——并入就会报一条「上次处理未完成」的 + // 假告警。只有权威加载没能纠正这幅画面时,这个计数才代表真的有孤儿占位被静默清掉。 + let cachedDeadPlaceholderCount = 0; if (cachedProject) { // 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位, // 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。 - applyProjectSnapshot( - dropDeadInlineGenerationPlaceholders(cachedProject.project).project, - { authoritative: false }, - ); + const cached = dropDeadInlineGenerationPlaceholders(cachedProject.project); + cachedDeadPlaceholderCount = cached.droppedCount; + applyProjectSnapshot(cached.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) @@ -1262,6 +1266,16 @@ export function useImageCanvasProjectPersistence({ return; } replaceAppHistoryPath('/project'); + return; + } + // 中文注释:权威快照没能到达,缓存里剥掉的占位就没有第二个来源可以纠正。此时画布 + // 仍在渲染(isProjectReady 只管启动意图与自动保存,不挡画面),用户看到的是一张 + // 静默少了占位的画布——必须提示,否则他既不知道占位被清掉,也不知道素材库可能已有 + // 派生图。鉴权失败与项目失访这两条路径已各自跳转或弹窗,不在这里重复打扰。 + if (cachedDeadPlaceholderCount > 0) { + setDeadInlinePlaceholderDropCount( + (currentCount) => currentCount + cachedDeadPlaceholderCount, + ); } });