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({
>
拆分图集
+