调整图片画布路由和画布数据模型

将图片画布入口改为 /editor/canvas

新增 editor_canvas 表并关联 editor_project 默认画布

更新 project API 响应中的 canvas 快照兼容层

统一图片画布侧栏列表项和图标按钮组件

同步前端测试、SpacetimeDB bindings、技术文档和 TRACKING 记录
This commit is contained in:
2026-06-13 22:09:45 +08:00
parent a1b9ac8544
commit 242860e2d3
21 changed files with 1649 additions and 295 deletions

View File

@@ -284,7 +284,27 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByRole('button', { name: '当前缩放比例 50%' })).toBeTruthy();
});
it('opens a generation dialog before creating a generated layer', async () => {
it('shows the Lovart-style minimap and canvas background controls', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
expect(within(panelToolbar).getByRole('button', { name: '画布背景色' })).toBeTruthy();
expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy();
fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' }));
expect(screen.getByRole('menu', { name: '画布背景色菜单' })).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '切换画布背景色为暖灰' }));
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(243, 240, 234)');
fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' }));
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
});
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
width: 1024,
@@ -302,12 +322,19 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '生成工具' }));
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(generateDialog).toBeTruthy();
const initialComposerTop = Number.parseFloat(
(generateDialog as HTMLElement).style.top,
);
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
expect(within(generateDialog).getByText('参考图')).toBeTruthy();
expect(within(generateDialog).getByRole('button', { name: '生成比例 1:1 2k 1张' })).toBeTruthy();
expect(within(generateDialog).getByRole('button', { name: '生成模型 GPT Image' })).toBeTruthy();
expect(screen.queryByRole('toolbar', { name: 'AI画布工具栏' })).toBeNull();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
fireEvent.click(within(generateDialog).getByRole('button', { name: '生成' }));
expect(screen.getByRole('status').textContent).toContain('生成中');
expect(generateEditorImageMock).toHaveBeenCalledWith({
@@ -315,15 +342,113 @@ describe('ImageCanvasEditorView', () => {
});
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(anchoredGenerateDialog).toBeTruthy();
expect(
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
).toBeCloseTo(initialComposerTop, 1);
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
const metadataButtons = screen.getAllByRole('button', {
name: /查看生成图片 .*元数据/,
});
expect(metadataButtons[0]).toBeTruthy();
});
it('drags the generation placeholder and places the generated layer there', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '拖拽后的生成图',
actualPrompt: '拖拽后的生成图',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-drag-frame-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
const initialComposerTop = Number.parseFloat(
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top,
);
const frame = screen.getByLabelText('图像生成占位图');
dispatchPointerEvent(frame, 'pointerdown', {
button: 0,
pointerId: 61,
clientX: 500,
clientY: 260,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 61,
clientX: 582,
clientY: 342,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 61,
clientX: 582,
clientY: 342,
});
const draggedComposerTop = Number.parseFloat(
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top,
);
expect(draggedComposerTop).toBeGreaterThan(initialComposerTop);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '拖拽后的生成图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(anchoredGenerateDialog).toBeTruthy();
expect(
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
).toBeCloseTo(draggedComposerTop, 1);
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(700);
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(150);
});
it('keeps the generation composer when selecting another image', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
button: 0,
pointerId: 62,
clientX: 120,
clientY: 120,
});
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('keeps the generation composer when clicking the canvas outside generation controls', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
pointerId: 63,
clientX: 260,
clientY: 180,
});
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('shows generation errors instead of falling back to mock images', async () => {
generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置'));
render(<ImageCanvasEditorView />);
@@ -339,6 +464,7 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toContain('VectorEngine 未配置');
});
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
});
@@ -505,8 +631,9 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
const metadataCornerButton = screen.getAllByRole('button', {
name: /查看生成图片 .*元数据/,

File diff suppressed because it is too large Load Diff