From 53f06055a43fcdf55d907cea6b36137bef2f1845 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 3 Aug 2026 15:20:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E5=BF=AB=E7=85=A7=E5=90=88=E5=B9=B6=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一生成完成态与本地布局的生命周期合并 保留同项目权威刷新中的有效画布选择 同步生成集成夹具与真实后端完成契约 补齐画布持久化回归测试和项目记忆 --- .../shared-memory/decision-log.md | 2 + docs/project-memory/shared-memory/pitfalls.md | 3 +- ...CanvasEditorGenerationIntegration.test.tsx | 74 ++++++----- .../image-editor/ImageCanvasEditorView.tsx | 6 + .../useImageCanvasProjectPersistence.test.tsx | 122 ++++++++++++++---- .../useImageCanvasProjectPersistence.ts | 40 +++++- 6 files changed, 192 insertions(+), 55 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a09a0a9a6..b5962f16f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -196,6 +196,8 @@ - Windows 私有 ACL 检查复用 `Get-Item` 对象的 `GetAccessControl()`,避免从 PowerShell 7 启动时继承的模块路径让 Windows PowerShell 5.1 的 `Get-Acl` 加载不兼容模块;静态配置门禁禁止重新引入该命令。 - 编辑器持久化的 `prompt` 统一表示规范化用户意图;provider `actual_prompt` 只保留在 resource / asset 审计字段,系统 prompt 不进入跨资源检索字段。角色和图标的透明图、切片继承源用户 prompt;本次只修新写入,不迁移历史记录,不修改 SpacetimeDB schema。 - 画布收到生成完成等较新权威快照时,必须把同项目待保存或在途的本地布局重放到新 revision:后端资源与生成终态优先,本地布局编辑优先;后端新增项合入,后端删除项和用户本地删除项均不得复活,合并后立即进入既有串行 CAS 保存队列。 +- 生成器合并必须把 `status / composerOpen / generatedLayerId / errorMessage / generation timestamps / characterAnimationResult` 视为后端生命周期事实;生成完成快照继续保持 `composerOpen=false`,不得被本地在途快照重新展开。提示词、参数和占位位置等本地布局编辑继续保留。 +- 同项目权威快照刷新不得无条件选择第一张图层:当前仍有效的单选、多选和生成占位选择保持,已删除的选择过滤,本来未选择时保持空选;只有首次载入或切换到另一项目时才默认选择第一张可用图层。生成完成结果需要用户显式点击后才进入选中态,后台完成回包不能偷走用户当前焦点。 ## 2026-07-30 Provider 503 等待与耗尽状态使用严格字段派生的安全摘要 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 514c71889..73bac7f44 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4035,7 +4035,8 @@ - 现象:用户拖动、缩放、改层序、背景色或 viewport 后,生成完成回包立即覆盖画布;450ms 防抖尚未触发或布局保存仍在途时,编辑静默丢失,undo 也可能被生成保护项阻断。 - 原因:服务端 revision 只能排序已提交事实,本地未落库布局没有 revision;直接清空 pending save 并整体应用权威快照等同于把“服务端更新更晚”误判成“服务端知道本地编辑”。 - 处理:保留同项目最新本地 dirty snapshot,权威回包先更新资源和生成终态,再按稳定 item ID 合并本地布局字段并基于新 revision 保存。旧权威项在新快照缺失表示后端删除,不能从 pending 或在途旧输入复活;新权威项必须合入,本地删除的旧项不能从权威回包复活。 -- 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态。 +- 生成器边界:`composerOpen` 与 `status / generatedLayerId / errorMessage` 一样属于后端生命周期事实;生成完成快照要求保持面板关闭时,不得被本地在途快照重新展开。提示词、参数和占位位置等本地布局编辑继续保留。集成测试夹具必须模拟后端真实完成快照:既有布局保持原位,完成结果层追加到末尾。同项目权威刷新还必须保留仍有效的单选、多选、生成占位选择或空选,只过滤已删除目标,不得无条件降成第一张图层的单选;首次载入 / 项目切换才设置默认选择。不要只跑 persistence Hook 单测,必须同时运行图片画布生成集成测试,覆盖完成后面板关闭、显式选择结果、背景清选和合并后 CAS 保存。 +- 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态;运行 `npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 ## Provider schema 能力不能从统一工具标记直接推断(2026-08-03) diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index c307a36ee..ffe305bff 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -428,6 +428,24 @@ describe('ImageCanvasEditorView generation integration', () => { }), ], layers: [ + ...projectLayers, + { + itemType: 'generation-dialog', + layerId: `generation-dialog:${canvasCompletion.dialogId}`, + resourceId: `generation-dialog:${canvasCompletion.dialogId}`, + dialog: { + id: canvasCompletion.dialogId, + mode: dialogMode, + prompt, + status: 'idle', + composerOpen: false, + generatedLayerId: layerId, + placeholder, + imageModel: model, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + }, + }, { layerId, resourceId, @@ -450,24 +468,6 @@ describe('ImageCanvasEditorView generation integration', () => { assetKind: input.assetKind ?? undefined, generationInputs: input.generationInputs, }, - { - itemType: 'generation-dialog', - layerId: `generation-dialog:${canvasCompletion.dialogId}`, - resourceId: `generation-dialog:${canvasCompletion.dialogId}`, - dialog: { - id: canvasCompletion.dialogId, - mode: dialogMode, - prompt, - status: 'idle', - composerOpen: true, - generatedLayerId: layerId, - placeholder, - imageModel: model, - aspectRatio: input.aspectRatio, - imageSize: input.imageSize, - }, - }, - ...projectLayers, ], updatedAt: '2026-06-19T00:00:00.000Z', }, @@ -887,10 +887,7 @@ describe('ImageCanvasEditorView generation integration', () => { .getByAltText(/画布图片:生成图片/) .closest('button')!; expect(generatedLayer).toBeTruthy(); - const anchoredGenerateDialog = screen.getByRole('dialog', { - name: '生成图片', - }); - expect(anchoredGenerateDialog).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect( Number.isFinite( Number.parseFloat((generatedLayer as HTMLElement).style.top), @@ -978,10 +975,7 @@ describe('ImageCanvasEditorView generation integration', () => { const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; - const anchoredGenerateDialog = screen.getByRole('dialog', { - name: '生成图片', - }); - expect(anchoredGenerateDialog).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); expect( Number.parseFloat((generatedLayer as HTMLElement).style.left) + @@ -1522,7 +1516,28 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); - expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 1, + layers: expect.arrayContaining([ + expect.objectContaining({ + itemType: 'generation-dialog', + dialog: expect.objectContaining({ + mode: 'spec', + status: 'idle', + generatedLayerId: 'layer-editor-spec-role-1', + specValues: expect.objectContaining({ + playSetting: '平台跳跃玩法', + artStyle: '低多边形卡通', + bodyRatio: '4', + characterView: '左向三分之二侧身站姿', + }), + }), + }), + ]), + }), + ); }); it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => { @@ -2094,10 +2109,11 @@ describe('ImageCanvasEditorView generation integration', () => { const generatedImage = await screen.findByAltText(/画布图片:生成图片/u); const generatedLayerButton = generatedImage.closest('button')!; + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); + fireEvent.click(generatedLayerButton); expect(generatedLayerButton.className).toContain( 'image-canvas-editor__layer--selected', ); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, @@ -3652,7 +3668,7 @@ describe('ImageCanvasEditorView generation integration', () => { .closest('button') as HTMLElement; expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024); expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); const metadataCornerButton = screen.getAllByRole('button', { name: /查看生成图片 .*图片信息/, diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e6f8a7236..f608fe9c1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1152,6 +1152,8 @@ export function ImageCanvasEditorView({ viewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, + selectedLayerIdRef, + selectedLayerIdsRef, }), [], ); @@ -1161,6 +1163,8 @@ export function ImageCanvasEditorView({ setProjectRenameValue, setViewport, setLayers, + setSelectedLayerId, + setSelectedLayerIds, selectSingleLayer, setLayerCounter: (value: number) => { layerCounterRef.current = value; @@ -1170,6 +1174,8 @@ export function ImageCanvasEditorView({ }), [ applyCanvasBackgroundColor, + setSelectedLayerId, + setSelectedLayerIds, restoreCanvasGenerationDialogs, selectSingleLayer, setLayers, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 75d7a4036..3dae18b95 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -108,6 +108,27 @@ function createCompletedEditorProjectSnapshot( }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '后端完成生成器', + status: 'idle', + composerOpen: false, + generatedLayerId: 'layer-generated', + placeholder: { + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 420, + originalHeight: 420, + }, + }, + }, { layerId: 'layer-generated', resourceId: 'resource-generated', @@ -121,27 +142,6 @@ function createCompletedEditorProjectSnapshot( zIndex: 1, sourceType: 'generated', }, - { - itemType: 'generation-dialog', - layerId: 'generation-dialog:generation-dialog-1', - resourceId: 'generation-dialog:generation-dialog-1', - dialog: { - id: 'generation-dialog-1', - mode: 'generate', - prompt: '后端完成生成器', - status: 'idle', - composerOpen: true, - generatedLayerId: 'layer-generated', - placeholder: { - x: 42, - y: 56, - width: 420, - height: 420, - originalWidth: 420, - originalHeight: 420, - }, - }, - }, ], resources: [ { @@ -314,7 +314,7 @@ it('merges pending geometry and dialog edits while retaining backend additions a dialog: expect.objectContaining({ prompt: '请求在途期间的新提示词', status: 'idle', - composerOpen: true, + composerOpen: false, generatedLayerId: 'layer-generated-by-backend', placeholder: expect.objectContaining({ x: 88, y: 99 }), }), @@ -359,11 +359,14 @@ function ProjectPersistenceHarness({ const [projectTitle, setProjectTitle] = useState(''); const [projectRenameValue, setProjectRenameValue] = useState(''); const [flushCompleted, setFlushCompleted] = useState(false); + const [selectedLayerId, setSelectedLayerId] = useState(null); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); const layersRef = useRef(layers); const viewportRef = useRef(viewport); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); - const selectedLayerRef = useRef(null); + const selectedLayerRef = useRef(selectedLayerId); + const selectedLayerIdsRef = useRef(selectedLayerIds); const layerCounterRef = useRef(0); const openEditorLoginModalRef = useRef(vi.fn()); @@ -371,8 +374,11 @@ function ProjectPersistenceHarness({ viewportRef.current = viewport; canvasGenerationDialogsRef.current = generationDialogs; canvasBackgroundColorRef.current = canvasBackgroundColor; + selectedLayerRef.current = selectedLayerId; + selectedLayerIdsRef.current = selectedLayerIds; const selectSingleLayer = useCallback((layerId: string | null) => { - selectedLayerRef.current = layerId; + setSelectedLayerId(layerId); + setSelectedLayerIds(layerId ? [layerId] : []); }, []); const setLayerCounter = useCallback((value: number) => { layerCounterRef.current = value; @@ -391,6 +397,8 @@ function ProjectPersistenceHarness({ viewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, + selectedLayerIdRef: selectedLayerRef, + selectedLayerIdsRef, }), [], ); @@ -400,6 +408,8 @@ function ProjectPersistenceHarness({ setProjectRenameValue, setViewport, setLayers, + setSelectedLayerId, + setSelectedLayerIds, selectSingleLayer, setLayerCounter, restoreCanvasGenerationDialogs: setGenerationDialogs, @@ -439,6 +449,9 @@ function ProjectPersistenceHarness({ .join(',')} {selectedLayerRef.current ?? '-'} + + {selectedLayerIdsRef.current.join(',') || '-'} + {layerCounterRef.current} {viewport.x},{viewport.y},{viewport.scale} @@ -651,6 +664,28 @@ function ProjectPersistenceHarness({ > apply completed project + +