拆分图片画布生成工作流

新增 useImageCanvasGenerationWorkflow 承接生成入口、提交和结果落图

主视图改为通过生成工作流 hook 处理生成态清理和工具入口

补充生成工作流单测、拆分文档和 TRACKING 浏览器回归记录
This commit is contained in:
2026-06-17 08:11:51 +08:00
parent f38493a07e
commit e07002c1dc
5 changed files with 1616 additions and 866 deletions

View File

@@ -129,3 +129,4 @@
- 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3登录后控制台无前端 error。
- 2026-06-17 前端拆分第十三阶段:新增 `useImageCanvasAssetExportWorkflow`,把画布素材导出状态、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、metadata / manifest 和下载链接副作用从主视图抽出;主视图保留右键目标解析和状态提示渲染。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后下载按钮启用,点击后触发真实下载 `未命名画布-画布素材-20260617.zip` 并显示导出状态;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具``生成图片` 对话框出现且 `AI画布工具栏` 保持可见,登录后控制台无前端 error。
- 2026-06-17 前端拆分第十四阶段:新增 `useImageCanvasLayerCommands`,把画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显隐、锁定、翻转、删除选中图层、按 id 删除和单图导出委托从主视图抽出;主视图保留菜单定位、画布事件、生成、上传、项目持久化和实际导出下载。验证命令:`npm run test -- src/components/image-editor/useImageCanvasLayerCommands.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭后 `画布背景色` 打开完整 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具``Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后新标签素材、画布图层、返回项目入口、小地图和底部工具栏可见,控制台无前端 error。
- 2026-06-17 前端拆分第十五阶段:新增 `useImageCanvasGenerationWorkflow`,把生成入口、规范 / 角色 / 图标 / 修改 / 快速编辑 / 角色动画状态机、真实生成提交、结果落图、失败恢复和删除图层后的生成态清理从主视图抽出;主视图保留画布事件、浮层定位、上传、项目资源持久化和历史捕获。验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,关闭登录后 `画布背景色` 打开完整 `画布背景设置` 面板,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`;点击 `生成工具``Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 均可见;登录临时开发账号后上传素材成功,素材数增加,点击素材可加入画布,切换 `图层` 面板可看到对应图层,登录后控制台无前端 error。

View File

@@ -131,10 +131,17 @@
- 主视图继续负责右键菜单定位、画布事件、生成提交、素材上传、项目持久化和实际下载实现hook 只接收必要 setter 与副作用回调,不反向读取 DOM 或路由。
- 该 hook 用独立单测覆盖菜单关闭、历史捕获、选中态更新、删除副作用、剪贴板和导出委托,避免主视图后续继续堆积右键菜单 orchestration。
## 第十五阶段模块
- `useImageCanvasGenerationWorkflow.ts`
- 承载图片画布生成工作流:打开普通生图 / 规范 / 角色 / 图标生成对象、打开修改图片 / 快速编辑 / 角色动画面板、选择画布规范参考、提交真实生成 API、失败恢复、生成结果落图、侧栏切换、适合视图和删除图层后的生成态清理。
- 主视图继续负责画布事件、生成对话框定位样式、占位框拖拽、DOM 渲染、上传入口、工程资源持久化和历史捕获;生成 hook 只接收状态 setter 与落图 / 选中 / 适合视图回调。
- 该 hook 用独立单测覆盖打开生成占位、普通生图落图、快速编辑落图并适配源图、删除源图时清理 quick edit / edit dialog、角色动画入口过滤和隐藏生成面板不删除占位框避免后续拆分再次导致生成工具或底部工具栏状态回退。
## 后续阶段
- 生成工作流 hook等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传
- 生成工作流 hook 之前,不再单独把 quick edit、角色动画或图标提交切成浅模块避免破坏多生成对象同时存在、完成时读取最新占位框和角色动画优先传 `objectKey` 的历史保护规则。
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper
- 生成对象定位、画布 pointer 事件、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框和角色动画优先传 `objectKey` 的历史保护规则。
## 验证计划

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', () => ({
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
}));
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: editEditorImageMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
};
});
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'data:image/png;base64,source',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
function createGenerated(overrides = {}) {
return {
imageSrc: 'data:image/png;base64,generated',
width: 1024,
height: 1024,
sourceType: 'generated' as const,
prompt: '生成提示词',
actualPrompt: '生成提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-generated',
...overrides,
};
}
function GenerationWorkflowHarness({
initialLayers = [createLayer()],
}: {
initialLayers?: CanvasLayer[];
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [imageContextMenu, setImageContextMenu] =
useState<ImageContextMenuState | null>({
layerId: 'layer-source',
x: 1,
y: 2,
});
const fitLayersMockRef = useRef(vi.fn());
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
const workflow = useImageCanvasGenerationWorkflow({
layers,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
layerCounterRef,
generateDialog: dialogs.generateDialog,
setGenerateDialog: dialogs.setGenerateDialog,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
selectSingleLayer: setSelectedLayerId,
fitLayers: fitLayersMockRef.current,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
});
const activeDialog = dialogs.generateDialog;
const activeCanvasDialog =
activeDialog && 'id' in activeDialog
? (activeDialog as CanvasGenerationDialogState)
: null;
return (
<div>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
<span data-testid="metadata">{metadataLayer?.id ?? '-'}</span>
<span data-testid="image-context">{imageContextMenu ? 'open' : '-'}</span>
<span data-testid="layers">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}`,
)
.join('|')}
</span>
<span data-testid="dialog">
{activeDialog
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
: '-'}
</span>
<span data-testid="quick-edit">
{workflow.quickEditPanel
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
: '-'}
</span>
<span data-testid="character-animation">
{workflow.characterAnimationPanel
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
: '-'}
</span>
<span data-testid="fit-count">
{fitLayersMockRef.current.mock.calls.length}
</span>
<button type="button" onClick={workflow.openGenerateDialog}>
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog({
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: 'layer-source',
})
}
>
</button>
<button
type="button"
onClick={() => workflow.openQuickEditPanel(layers[0]!)}
>
</button>
<button
type="button"
onClick={() =>
workflow.openCharacterAnimationPanel(
createLayer({ id: 'layer-character', assetKind: 'character' }),
)
}
>
</button>
<button
type="button"
onClick={() =>
workflow.openCharacterAnimationPanel(createLayer({ id: 'layer-plain' }))
}
>
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog
? { ...currentDialog, prompt: '一张生成图' }
: currentDialog,
)
}
>
</button>
<button
type="button"
onClick={() => {
if (activeDialog) {
void workflow.submitImageGeneration(activeDialog);
}
}}
>
</button>
<button
type="button"
onClick={() =>
workflow.setQuickEditPanel((currentPanel) =>
currentPanel ? { ...currentPanel, prompt: '快速修图' } : currentPanel,
)
}
>
</button>
<button
type="button"
onClick={() => void workflow.submitQuickEdit()}
>
</button>
<button
type="button"
onClick={() => workflow.clearDeletedLayerGenerationState('layer-source')}
>
</button>
<button
type="button"
onClick={() => {
if (activeCanvasDialog) {
dialogs.updateCanvasGenerationDialogById(
activeCanvasDialog.id,
(dialog) => ({
...dialog,
generatedLayerId: 'layer-source',
}),
);
}
}}
>
</button>
<button type="button" onClick={workflow.hideGeneratedLayerPanelAfterBlur}>
</button>
</div>
);
}
describe('useImageCanvasGenerationWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('opens a movable canvas generation placeholder and keeps toolbar state active', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
expect(screen.getByTestId('tool').textContent).toBe('generate');
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:-:placeholder',
);
});
it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '一张生成图' }),
);
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '一张生成图',
});
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:generating:closed:-:placeholder',
);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:生成图片 1',
);
});
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
expect(screen.getByTestId('selected').textContent).toBe(
'layer-generated-1',
);
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:-',
);
});
it('submits quick edits beside the source and fits source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:-',
);
});
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:快速修图',
);
});
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '快速修图',
size: '1024x768',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,source'],
});
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('clears generation side panels and linked edit dialogs when a source layer is deleted', async () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:-',
);
});
fireEvent.click(screen.getByRole('button', { name: '打开修改状态' }));
fireEvent.click(screen.getByRole('button', { name: '清理源图状态' }));
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
it('only opens the character animation panel for character layers', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开普通动画' }));
expect(screen.getByTestId('character-animation').textContent).toBe('-');
fireEvent.click(screen.getByRole('button', { name: '打开角色动画' }));
expect(screen.getByTestId('character-animation').textContent).toBe(
'layer-character:idle',
);
expect(screen.getByTestId('image-context').textContent).toBe('-');
});
it('hides non-generating canvas generation composers without deleting placeholders', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
fireEvent.click(screen.getByRole('button', { name: '隐藏生成面板' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:closed:-:placeholder',
);
});
});

File diff suppressed because it is too large Load Diff