From d465e9b66cbbdef1af545f412300f3f06ef64fa2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 19:52:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=BE=E9=9B=86=E6=8B=86?= =?UTF-8?q?=E5=88=86=E4=B8=AD=E7=9A=84=E5=89=8D=E7=AB=AF=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护逐图层拆分状态并在请求结束后可靠清理 源图集显示扫描蒙层和拆图中状态胶囊 工具栏显示旋转图标并禁用重复提交 补充工作流、画布、工具栏测试及入口验收文档 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 2 + ...ageCanvasSelectedLayerToolbarView.test.tsx | 30 +++++++++ .../ImageCanvasSelectedLayerToolbarView.tsx | 19 ++++-- .../image-editor/ImageCanvasStageView.tsx | 9 +++ .../ImageCanvasWorldView.test.tsx | 16 +++++ .../image-editor/ImageCanvasWorldView.tsx | 15 +++-- .../useImageCanvasGenerationWorkflow.test.tsx | 64 +++++++++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 17 +++++ 9 files changed, 165 insertions(+), 9 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 7e64fed07..041607f94 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -80,5 +80,5 @@ - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图。 -- 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 +- 选中图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 367cae2e7..cc46041ba 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1355,6 +1355,7 @@ export function ImageCanvasEditorView({ openCropExpandPanel, removeSelectedLayerBackground, splitSelectedIconSpritesheet, + splittingIconSpritesheetLayerIds, extractUiDesignAssets, pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, @@ -2119,6 +2120,7 @@ export function ImageCanvasEditorView({ quickEditSelectionSourceLayer, generationComposerStyle, selectedToolbarStyle, + splittingIconSpritesheetLayerIds, uploadDropTarget, contextMenu, canvasClipboard, diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 9ab35425c..ba91a51c7 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -42,6 +42,7 @@ function renderSelectedToolbar( onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + isSplittingIconSpritesheet: false, onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), @@ -155,6 +156,33 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer); }); + it('renders a disabled loading state while splitting an icon spritesheet', () => { + const layer = createLayer({ + sourceType: 'generated', + assetKind: 'icon-spritesheet', + }); + const props = renderSelectedToolbar({ + selectedLayer: layer, + isSplittingIconSpritesheet: true, + }); + const splitButton = screen.getByRole('button', { name: '拆图中' }); + + expect(splitButton.getAttribute('title')).toBe('拆图中'); + expect(splitButton.getAttribute('aria-busy')).toBe('true'); + expect((splitButton as HTMLButtonElement).disabled).toBe(true); + expect(splitButton.textContent).toContain('拆图中'); + expect(splitButton.querySelector('.lucide-loader-circle')).toBeTruthy(); + expect( + splitButton.querySelector('.lucide-loader-circle.animate-spin'), + ).toBeTruthy(); + expect(splitButton.querySelector('.lucide-scissors')).toBeNull(); + + fireEvent.click(splitButton); + fireEvent.click(splitButton); + + expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); + }); + it('keeps only remodel and download for audio layers', () => { const layer = createLayer({ title: '游戏音效', @@ -293,6 +321,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + isSplittingIconSpritesheet={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} @@ -310,6 +339,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + isSplittingIconSpritesheet={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 2f0550523..5c187ea95 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -2,6 +2,7 @@ import { Crop, Download, ImageOff, + Loader2, PersonStanding, Scissors, Sparkles, @@ -21,6 +22,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + isSplittingIconSpritesheet: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; @@ -34,6 +36,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + isSplittingIconSpritesheet, onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, @@ -120,12 +123,20 @@ export function ImageCanvasSelectedLayerToolbarView({ {selectedLayer.assetKind === 'icon-spritesheet' ? ( } + label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} + title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} + icon={ + isSplittingIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isSplittingIconSpritesheet} + aria-busy={isSplittingIconSpritesheet} onClick={() => onSplitIconSpritesheet(selectedLayer)} > - 拆分图集 + {isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} ) : null} {selectedLayer.assetKind === 'ui-design' ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index a4c40de95..8fadf766d 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -40,6 +40,8 @@ import type { import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView'; import { ImageCanvasWorldView } from './ImageCanvasWorldView'; +const EMPTY_LAYER_ID_SET: ReadonlySet = new Set(); + export type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; specToolWrapRef: RefObject; @@ -73,6 +75,7 @@ export type ImageCanvasStageViewProps = { quickEditSelectionSourceLayer: CanvasLayer | null; generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; + splittingIconSpritesheetLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; canvasClipboard: CanvasClipboard | null; @@ -225,6 +228,7 @@ export function ImageCanvasStageView({ quickEditSelectionSourceLayer, generationComposerStyle, selectedToolbarStyle, + splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, canvasClipboard, @@ -356,6 +360,7 @@ export function ImageCanvasStageView({ cropExpandPanel={cropExpandPanel} cropExpandSourceLayer={cropExpandSourceLayer} generationComposerStyle={generationComposerStyle} + splittingIconSpritesheetLayerIds={splittingIconSpritesheetLayerIds} onLayerPointerDown={onLayerPointerDown} onLayerClick={onLayerClick} onSelectLayer={onSelectLayer} @@ -377,6 +382,10 @@ export function ImageCanvasStageView({ : selectedLayer } selectedToolbarStyle={selectedToolbarStyle} + isSplittingIconSpritesheet={Boolean( + selectedLayer && + splittingIconSpritesheetLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 5c9977ab9..446a3fa26 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -138,6 +138,22 @@ describe('ImageCanvasWorldView', () => { expect(screen.queryByRole('status', { name: '' })).toBeNull(); }); + it('shows the atlas splitting treatment on the source layer', () => { + const layer = createLayer({ assetKind: 'icon-spritesheet' }); + renderWorldView({ + layers: [layer], + splittingIconSpritesheetLayerIds: new Set([layer.id]), + }); + + const sourceLayer = screen.getByRole('button', { + name: `选择${layer.title}`, + }); + expect(sourceLayer.className).toContain( + 'image-canvas-editor__layer--generating', + ); + expect(within(sourceLayer).getByRole('status').textContent).toBe('拆图中'); + }); + it('keeps the image layer frame visible while the image is loading', () => { useResolvedAssetReadUrlMock.mockImplementation(() => ({ resolvedUrl: '', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 99fafa4fd..97ef196df 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -250,6 +250,7 @@ export type ImageCanvasWorldViewProps = { cropExpandPanel: CropExpandPanelState | null; cropExpandSourceLayer: CanvasLayer | null; generationComposerStyle: CSSProperties | null; + splittingIconSpritesheetLayerIds?: ReadonlySet; onLayerPointerDown: ( event: ReactPointerEvent, layer: CanvasLayer, @@ -285,6 +286,8 @@ export type ImageCanvasWorldViewProps = { ) => void; }; +const EMPTY_LAYER_ID_SET: ReadonlySet = new Set(); + const CROP_EXPAND_HANDLES: Array<{ value: CropExpandResizeHandle; label: string; @@ -916,6 +919,7 @@ export function ImageCanvasWorldView({ cropExpandPanel, cropExpandSourceLayer, generationComposerStyle, + splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, onLayerPointerDown, onLayerClick, onSelectLayer, @@ -1007,10 +1011,13 @@ export function ImageCanvasWorldView({ layer.flipX || layer.flipY ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` : undefined; - const layerGeneratingLabel = - generateDialog?.mode === 'edit' && - generateDialog.status === 'generating' && - generateDialog.sourceLayerId === layer.id + const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( + layer.id, + ) + ? '拆图中' + : generateDialog?.mode === 'edit' && + generateDialog.status === 'generating' && + generateDialog.sourceLayerId === layer.id ? '修改中' : null; const isMediaContainer = isCanvasMediaContainerLayer(layer); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index f226754fe..6012edb28 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -535,6 +535,11 @@ function GenerationWorkflowHarness({ > 拆分图集 + + {workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id) + ? '拆图中' + : '空闲'} +