补充图集拆分中的前端表现

维护逐图层拆分状态并在请求结束后可靠清理
源图集显示扫描蒙层和拆图中状态胶囊
工具栏显示旋转图标并禁用重复提交
补充工作流、画布、工具栏测试及入口验收文档
This commit is contained in:
2026-07-18 19:52:35 +08:00
parent 4ceb91cd48
commit d465e9b66c
9 changed files with 165 additions and 9 deletions
@@ -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` 校验错误,不继续调用上游生成。
@@ -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,
@@ -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()}
@@ -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' ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="拆分图集"
title="拆分图集"
icon={<Scissors className="h-4 w-4" />}
label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
icon={
isSplittingIconSpritesheet ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Scissors className="h-4 w-4" />
)
}
disabled={isSplittingIconSpritesheet}
aria-busy={isSplittingIconSpritesheet}
onClick={() => onSplitIconSpritesheet(selectedLayer)}
>
<span></span>
<span>{isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}</span>
</PlatformIconButton>
) : null}
{selectedLayer.assetKind === 'ui-design' ? (
@@ -40,6 +40,8 @@ import type {
import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView';
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = new Set();
export type ImageCanvasStageViewProps = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
@@ -73,6 +75,7 @@ export type ImageCanvasStageViewProps = {
quickEditSelectionSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
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}
@@ -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: '',
@@ -250,6 +250,7 @@ export type ImageCanvasWorldViewProps = {
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
onLayerPointerDown: (
event: ReactPointerEvent<HTMLElement>,
layer: CanvasLayer,
@@ -285,6 +286,8 @@ export type ImageCanvasWorldViewProps = {
) => void;
};
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = 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);
@@ -535,6 +535,11 @@ function GenerationWorkflowHarness({
>
</button>
<output aria-label="拆图状态">
{workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id)
? '拆图中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
@@ -1910,6 +1915,65 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
});
it('exposes a splitting state while the manual atlas request is pending', async () => {
let resolveSplit:
| ((value: { iconImageSrcs: never[]; project: unknown }) => void)
| undefined;
splitEditorIconSpritesheetMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSplit = resolve;
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={vi.fn()}
initialLayers={[
createLayer({
assetKind: 'icon-spritesheet',
originalWidth: 1024,
originalHeight: 1024,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe(
'拆图中',
);
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledTimes(1);
resolveSplit?.({
iconImageSrcs: [],
project: {
projectId: 'project-1',
title: '未命名画布',
canvas: {
canvasId: 'project-1:canvas:default',
projectId: 'project-1',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
updatedAt: '2026-07-18T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-18T00:00:00.000Z',
},
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '拆图状态' }).textContent,
).toBe('空闲');
});
});
it('queues background removal for private character images', async () => {
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
'data:image/png;base64,resolved-character',
@@ -670,6 +670,8 @@ export function useImageCanvasGenerationWorkflow({
}, []);
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
useState<Set<string>>(() => new Set());
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
@@ -1724,6 +1726,11 @@ export function useImageCanvasGenerationWorkflow({
return;
}
splittingIconSpritesheetLayerIdsRef.current.add(sourceLayer.id);
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(sourceLayer.id);
return nextLayerIds;
});
closeGenerationTransientState();
setImageContextMenu(null);
setMetadataLayer(null);
@@ -1757,6 +1764,14 @@ export function useImageCanvasGenerationWorkflow({
);
} finally {
splittingIconSpritesheetLayerIdsRef.current.delete(sourceLayer.id);
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
if (!currentLayerIds.has(sourceLayer.id)) {
return currentLayerIds;
}
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.delete(sourceLayer.id);
return nextLayerIds;
});
}
},
[
@@ -2529,6 +2544,7 @@ export function useImageCanvasGenerationWorkflow({
startCropExpandFrameResize,
removeSelectedLayerBackground,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
taskListRefreshKey,
refreshTaskList,
isTaskSidebarOpen,
@@ -2631,6 +2647,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSourceLayer,
removeSelectedLayerBackground,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,