完善 AI 设计资产预览与视频首帧选择

需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。

实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。

验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
2026-08-06 11:31:47 +08:00
parent 2a3c9850d1
commit 15b17775cb
11 changed files with 1149 additions and 31 deletions

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
@@ -20,6 +20,8 @@ const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
const saveImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
const uploadImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
type EventListener = (event: MessageEvent<string>) => void;
@@ -66,6 +68,12 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
resolveImageWorkspaceAssetUrlMock(...args)
),
saveImageWorkspaceAsset: (...args: unknown[]) => (
saveImageWorkspaceAssetMock(...args)
),
uploadImageWorkspaceAsset: (...args: unknown[]) => (
uploadImageWorkspaceAssetMock(...args)
),
};
});
@@ -177,6 +185,11 @@ describe('ImageCanvas Workspace-first design experience', () => {
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
'http://127.0.0.1:13210/content?token=host',
);
saveImageWorkspaceAssetMock.mockResolvedValue({ status: 'saved' });
uploadImageWorkspaceAssetMock.mockResolvedValue({
...taskFixture.resultAssets[0],
assetId: 'asset-uploaded',
});
});
it('shows an honest unavailable state without fabricating projects', async () => {
@@ -260,6 +273,42 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
});
it('opens a generated image at full size and saves it through Main', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '放大查看生成图片' }));
const dialog = screen.getByRole('dialog', { name: '图片预览' });
expect(dialog).toHaveTextContent('1024 × 1024');
expect(screen.getByRole('img', { name: 'AI 设计大图预览' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
fireEvent.click(screen.getByRole('button', { name: '下载原图' }));
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(
taskFixture.resultAssets[0],
));
});
it('announces the image download progress while Main is saving', async () => {
let finishSaving: ((value: { status: 'saved' }) => void) | undefined;
saveImageWorkspaceAssetMock.mockReturnValue(new Promise((resolve) => {
finishSaving = resolve;
}));
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '下载生成图片' }));
const savingButton = screen.getByRole('button', { name: '正在保存生成图片' });
expect(savingButton).toBeDisabled();
expect(savingButton).toHaveAttribute('aria-busy', 'true');
finishSaving?.({ status: 'saved' });
await waitFor(() => expect(
screen.getByRole('button', { name: '下载生成图片' }),
).toBeEnabled());
});
it('keeps the task list visible in a 1404px desktop workspace', async () => {
const previousWidth = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 });
@@ -482,6 +531,118 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
});
it('opens the successful work picker when the Agent asks for a video first frame', async () => {
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
...workspaceFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'video',
summary: '让海洋公益海报动起来',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...workspaceFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['先生成一张首帧图片(推荐)', '从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
} satisfies DesignWorkspace);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
expect(await within(picker).findByRole('img', { name: '可选作品图片' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
expect(within(picker).getByRole('button', { name: '选择此图片' })).toBeEnabled();
expect(within(picker).getByLabelText('上传本地图片')).toBeInTheDocument();
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
});
it('submits a successful work Asset id when it is selected as the video first frame', async () => {
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
...workspaceFixture(),
turnRevision: 2,
messages: [
...workspaceFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
} satisfies DesignWorkspace);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const selectButton = within(
screen.getByRole('dialog', { name: '选择视频首帧' }),
).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
});
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
...workspaceFixture(),
messages: [
...workspaceFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignWorkspace);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' });
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
2,
'使用上传的图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
});
it('renders policy-blocked generation tasks with a user-facing design message', async () => {
fetchImageWorkspaceTasksMock.mockResolvedValue([{
...taskFixture,