完善图片画布素材与项目封面交互

新增画布素材导出能力并补充 JSZip 依赖

优化素材上传占位进度、拖拽添加和文件夹移动交互

接入未登录项目访问弹窗并完善项目卡片封面缩略图

补充图片画布与项目页回归测试
This commit is contained in:
2026-06-16 16:15:15 +08:00
parent 80a382b034
commit 1d570605af
8 changed files with 2754 additions and 206 deletions

View File

@@ -69,9 +69,11 @@ export type SidebarMediaItemProps = {
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
previewOverlay?: ReactNode;
footerNode?: ReactNode;
draggable?: boolean;
onDragStart?: DragEventHandler<HTMLDivElement>;
onDragEnd?: DragEventHandler<HTMLDivElement>;
onDragStart?: DragEventHandler<HTMLElement>;
onDragEnd?: DragEventHandler<HTMLElement>;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
onPointerDown?: PointerEventHandler<HTMLDivElement>;
@@ -93,6 +95,8 @@ export function SidebarMediaItem({
primaryClassName,
actions,
titleNode,
previewOverlay,
footerNode,
draggable,
onDragStart,
onDragEnd,
@@ -119,6 +123,9 @@ export function SidebarMediaItem({
className={primaryClassName}
onClick={onPrimaryClick}
aria-label={primaryLabel}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<PlatformMediaFrame
src={imageSrc}
@@ -127,11 +134,13 @@ export function SidebarMediaItem({
aspect="square"
surface="none"
className={thumbnailClassName}
previewOverlay={previewOverlay}
/>
</button>
<div className={metaClassName}>
{titleNode ?? <span>{title}</span>}
<span>{detail}</span>
{footerNode}
</div>
{actions}
</div>

View File

@@ -2,6 +2,8 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
@@ -19,7 +21,69 @@ const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const openLoginModalMock = vi.hoisted(() => vi.fn());
const authUiMockState = vi.hoisted(() => ({
value: null as {
user: AuthUser | null;
canAccessProtectedData: boolean;
openLoginModal: typeof openLoginModalMock;
requireAuth: ReturnType<typeof vi.fn>;
openSettingsModal: ReturnType<typeof vi.fn>;
openAccountModal: ReturnType<typeof vi.fn>;
setCurrentUser: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
musicVolume: number;
setMusicVolume: ReturnType<typeof vi.fn>;
platformTheme: 'light';
setPlatformTheme: ReturnType<typeof vi.fn>;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
} | null,
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMockState.value,
}));
const imageEditorTestUser: AuthUser = {
id: 'user-editor-test',
publicUserCode: 'UEDITOR',
displayName: '画布测试用户',
avatarUrl: null,
phoneNumberMasked: '139****4806',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
function createAuthUiMock(user: AuthUser | null = imageEditorTestUser) {
return {
user,
canAccessProtectedData: Boolean(user),
openLoginModal: openLoginModalMock,
requireAuth: vi.fn((action: () => void) => {
if (user) {
action();
return;
}
openLoginModalMock(action);
}),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(),
musicVolume: 0.5,
setMusicVolume: vi.fn(),
platformTheme: 'light' as const,
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
};
}
const defaultProjectLayers = [
{
@@ -116,6 +180,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
loadEditorProject: loadEditorProjectMock,
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
renameEditorProject: renameEditorProjectMock,
saveEditorProjectLayout: saveEditorProjectLayoutMock,
updateEditorAsset: updateEditorAssetMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
@@ -123,16 +188,20 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
});
function dispatchPointerEvent(
target: Element,
target: Element | Window,
type: string,
init: MouseEventInit & { pointerId: number },
init: PointerEventInit & { pointerId: number },
) {
const event = new MouseEvent(type, {
const PointerEventConstructor =
typeof PointerEvent === 'undefined' ? MouseEvent : PointerEvent;
const event = new PointerEventConstructor(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
Object.defineProperty(event, 'isPrimary', { value: true });
fireEvent(target, event);
}
@@ -161,8 +230,25 @@ async function renderLoadedEditor() {
await screen.findByRole('button', { name: '添加拼图素材' });
}
async function readZipText(zip: JSZip, path: string) {
const file = zip.file(path);
expect(file).toBeTruthy();
return file!.async('string');
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe('ImageCanvasEditorView', () => {
beforeEach(() => {
authUiMockState.value = createAuthUiMock();
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '默认项目',
@@ -192,6 +278,14 @@ describe('ImageCanvasEditorView', () => {
height: input.height,
sourceType: input.sourceType,
}));
renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
projectId,
title,
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultProjectLayers,
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
label: '角色上传',
@@ -252,7 +346,9 @@ describe('ImageCanvasEditorView', () => {
loadEditorAssetLibraryMock.mockReset();
loadEditorProjectMock.mockReset();
loadOrCreateRecentEditorProjectMock.mockReset();
renameEditorProjectMock.mockReset();
saveEditorProjectLayoutMock.mockReset();
openLoginModalMock.mockReset();
window.history.replaceState(null, '', '/editor/canvas');
});
@@ -279,6 +375,29 @@ describe('ImageCanvasEditorView', () => {
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
});
it('opens the login modal when direct project loading is unauthorized', async () => {
authUiMockState.value = createAuthUiMock(null);
loadEditorProjectMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-private',
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(openLoginModalMock).toHaveBeenCalledWith(expect.any(Function));
});
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
});
it('offers a topbar entry back to the project page', async () => {
await renderLoadedEditor();
@@ -294,6 +413,237 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
});
it('renames the current project from the canvas topbar', async () => {
await renderLoadedEditor();
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
const titleInput = screen.getByLabelText('项目名称');
fireEvent.change(titleInput, { target: { value: '新画布项目' } });
fireEvent.click(screen.getByRole('button', { name: '保存项目名称' }));
await waitFor(() => {
expect(renameEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',
'新画布项目',
);
});
expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy();
expect(screen.queryByLabelText('项目名称')).toBeNull();
});
it('cancels project rename editing with Escape', async () => {
await renderLoadedEditor();
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
const titleInput = screen.getByLabelText('项目名称');
fireEvent.change(titleInput, { target: { value: '不会保存' } });
fireEvent.keyDown(titleInput, { key: 'Escape' });
expect(renameEditorProjectMock).not.toHaveBeenCalled();
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
expect(screen.queryByLabelText('项目名称')).toBeNull();
});
it('exports valid canvas assets as a zip from the topbar', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-export',
title: '导出项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-data-a',
resourceId: 'resource-data-a',
title: '素材/A',
src: 'data:image/png;base64,YQ==',
x: 12,
y: 24,
width: 320,
height: 220,
originalWidth: 640,
originalHeight: 440,
zIndex: 1,
sourceType: 'uploaded',
sourceAssetId: 'asset-data-a',
groupId: 'group-a',
hidden: true,
locked: true,
flipX: true,
},
{
layerId: 'layer-data-a-copy',
resourceId: 'resource-data-a-copy',
title: '素材/A 副本',
src: 'data:image/png;base64,YQ==',
x: 42,
y: 54,
width: 320,
height: 220,
originalWidth: 640,
originalHeight: 440,
zIndex: 2,
sourceType: 'uploaded',
sourceAssetId: 'asset-data-a',
},
{
layerId: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: '/generated-ok.png',
x: 70,
y: 80,
width: 360,
height: 360,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
sourceType: 'generated',
prompt: '明亮主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
},
{
layerId: 'layer-failed',
resourceId: 'resource-failed',
title: '失败图',
src: '/missing.png',
x: 90,
y: 100,
width: 120,
height: 120,
originalWidth: 120,
originalHeight: 120,
zIndex: 4,
sourceType: 'generated',
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-data-a',
folderId: 'project',
label: '素材/A',
imageSrc: 'data:image/png;base64,YQ==',
width: 640,
height: 440,
sourceType: 'uploaded',
},
],
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url === '/generated-ok.png') {
return new Response(new Blob(['generated'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
const originalAnchorClick = HTMLAnchorElement.prototype.click;
let exportedBlob: Blob | null = null;
let downloadName = '';
URL.createObjectURL = vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:editor-export';
});
URL.revokeObjectURL = vi.fn();
HTMLAnchorElement.prototype.click = vi.fn(function click(this: HTMLAnchorElement) {
downloadName = this.download;
});
try {
render(<ImageCanvasEditorView />);
await screen.findByRole('heading', { name: '导出项目' });
await waitFor(() => {
expect(
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: '下载画布素材' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(/^--\d{8}\.zip$/u);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBe('editor-project-export');
expect(metadata.layers).toHaveLength(4);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[0].canvas.hidden).toBe(true);
expect(metadata.layers[0].canvas.locked).toBe(true);
expect(metadata.layers[0].canvas.flipX).toBe(true);
expect(metadata.layers[0].canvas.groupId).toBe('group-a');
expect(metadata.layers[2].sourceType).toBe('generated');
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
expect(metadata.layers[3].file).toBeNull();
expect(metadata.layers[3].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('失败素材数量1');
expect(screen.getByText('部分素材未能导出')).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectUrl;
URL.revokeObjectURL = originalRevokeObjectUrl;
HTMLAnchorElement.prototype.click = originalAnchorClick;
}
});
it('disables the canvas asset export entry when there are no valid layers', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-empty',
title: '空项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('heading', { name: '空项目' });
expect(
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('does not inject built-in mock assets or layers when persistence returns empty data', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-empty',
@@ -326,7 +676,7 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByRole('region', { name: '项目素材' })).toBeTruthy();
});
it('removes invalid uploaded layers when the canvas opens', async () => {
it('keeps canvas layers when their account asset has been removed from the library', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-invalid-layer',
title: '包含失效素材的项目',
@@ -393,21 +743,67 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(screen.getByAltText('画布图片账号素材A')).toBeTruthy();
expect(screen.queryByAltText('画布图片:已删除素材')).toBeNull();
expect(screen.getByAltText('画布图片:已删除素材')).toBeTruthy();
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith(
'editor-project-invalid-layer',
expect.objectContaining({
layers: [
expect.objectContaining({
layerId: 'layer-valid-asset',
sourceAssetId: 'asset-a',
}),
],
}),
);
});
it('keeps resource-backed canvas layers when their account asset is not loaded', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-resource-layer',
title: '历史工程资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-resource-backed',
resourceId: 'resource-backed',
title: '历史画布图片',
src: 'data:image/png;base64,aGlzdG9yeQ==',
x: 120,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-invalid-layer',
expect.objectContaining({
layers: [
expect.objectContaining({
layerId: 'layer-valid-asset',
sourceAssetId: 'asset-a',
}),
],
}),
);
expect(screen.getByAltText('画布图片:历史画布图片')).toBeTruthy();
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith(
'editor-project-resource-layer',
expect.objectContaining({ layers: [] }),
);
});
it('toggles the shared sidebar from canvas panel buttons', async () => {
@@ -623,6 +1019,12 @@ describe('ImageCanvasEditorView', () => {
fireEvent.dragOver(roleFolder, {
dataTransfer,
});
await waitFor(() => {
expect(screen.queryByText('添加到素材')).toBeNull();
expect(roleFolder.className).toContain(
'image-canvas-editor__asset-folder--move-target',
);
});
fireEvent.drop(roleFolder, {
dataTransfer,
});
@@ -637,6 +1039,107 @@ describe('ImageCanvasEditorView', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('pins the asset move target when the target folder name is outside the asset panel viewport', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色',
sortOrder: 100,
collapsed: false,
systemDefault: false,
},
],
assets: [
{
assetId: 'asset-puzzle',
folderId: 'project',
label: '拼图素材',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
},
],
});
render(<ImageCanvasEditorView />);
const sourceAsset = await screen.findByRole('button', { name: '添加拼图素材' });
const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row');
const roleFolder = screen.getByRole('region', { name: '角色' });
const assetList = document.querySelector('.image-canvas-editor__asset-list');
const roleHeader = roleFolder.querySelector(
'[data-asset-folder-header-id="folder-role"]',
);
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow || !assetList || !roleHeader) {
throw new Error('asset drag elements should exist');
}
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 260,
bottom: 200,
width: 260,
height: 200,
toJSON: () => ({}),
} as DOMRect);
const roleHeaderRect = vi
.spyOn(roleHeader, 'getBoundingClientRect')
.mockReturnValue({
x: 0,
y: 260,
top: 260,
left: 0,
right: 260,
bottom: 288,
width: 260,
height: 28,
toJSON: () => ({}),
} as DOMRect);
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.dragOver(roleFolder, { dataTransfer });
await waitFor(() => {
expect(
document.querySelector(
'.image-canvas-editor__asset-folder-sticky-target',
)?.textContent,
).toContain('角色');
});
roleHeaderRect.mockReturnValue({
x: 0,
y: 40,
top: 40,
left: 0,
right: 260,
bottom: 68,
width: 260,
height: 28,
toJSON: () => ({}),
} as DOMRect);
fireEvent.dragOver(roleFolder, { dataTransfer });
await waitFor(() => {
expect(
document.querySelector('.image-canvas-editor__asset-folder-sticky-target'),
).toBeNull();
});
});
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
render(<ImageCanvasEditorView />);
@@ -654,6 +1157,55 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
});
it('shows an uploading placeholder card before restoring the normal asset card', async () => {
const deferredAsset = createDeferred<{
assetId: string;
folderId: string;
label: string;
imageSrc: string;
width: number;
height: number;
sourceType: 'uploaded';
}>();
createEditorAssetMock.mockImplementationOnce(async (input) => {
await deferredAsset.promise;
return {
assetId: 'asset-uploading-finished',
folderId: input.folderId,
label: input.label,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: 'uploaded',
};
});
render(<ImageCanvasEditorView />);
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
new File(['image'], '上传进度.png', { type: 'image/png' }),
);
expect(await screen.findByRole('button', { name: '上传中上传进度.png' })).toBeTruthy();
expect(screen.getByLabelText('素材上传进度.png上传进度')).toBeTruthy();
expect(screen.queryByRole('button', { name: '添加上传进度.png' })).toBeNull();
deferredAsset.resolve({
assetId: 'asset-uploading-finished',
folderId: 'project',
label: '上传进度.png',
imageSrc: 'data:image/png;base64,aW1hZ2U=',
width: 420,
height: 315,
sourceType: 'uploaded',
});
await waitFor(() => {
expect(screen.getByRole('button', { name: '添加上传进度.png' })).toBeTruthy();
});
expect(screen.queryByRole('button', { name: '上传中上传进度.png' })).toBeNull();
});
it('supports asset selection mode and batch delete with shared toolbar', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -1028,6 +1580,101 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByRole('button', { name: '选择图层测试上传B.png' })).toBeTruthy();
});
it('adds an asset library image to the canvas by dragging it onto the viewport', async () => {
await renderLoadedEditor();
const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' });
const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row');
const viewport = screen.getByLabelText('画布工作区');
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow) {
throw new Error('asset row should exist');
}
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.dragOver(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
await waitFor(() => {
expect(screen.getByText('添加到画布')).toBeTruthy();
});
fireEvent.drop(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
await waitFor(() => {
expect(screen.queryByText('添加到画布')).toBeNull();
});
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy();
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/creation-type-references/match3d.webp',
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('adds an asset library image to the canvas with pointer dragging', async () => {
await renderLoadedEditor();
const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' });
const viewport = screen.getByLabelText('画布工作区');
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
x: 320,
y: 80,
top: 80,
left: 320,
right: 1120,
bottom: 680,
width: 800,
height: 600,
toJSON: () => ({}),
} as DOMRect);
dispatchPointerEvent(sourceAsset, 'pointerdown', {
button: 0,
pointerId: 81,
clientX: 160,
clientY: 220,
});
dispatchPointerEvent(window, 'pointermove', {
pointerId: 81,
clientX: 520,
clientY: 300,
});
await waitFor(() => {
expect(screen.getByText('添加到画布')).toBeTruthy();
});
expect(screen.getAllByText('抓大鹅素材').length).toBeGreaterThan(1);
dispatchPointerEvent(window, 'pointerup', {
pointerId: 81,
clientX: 520,
clientY: 300,
});
expect(screen.queryByText('添加到画布')).toBeNull();
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/creation-type-references/match3d.webp',
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('shows a canvas drop overlay while dragging uploaded images over the canvas', async () => {
await renderLoadedEditor();
@@ -1057,6 +1704,42 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(screen.getByAltText('画布图片:画布提示.png')).toBeTruthy();
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '画布提示.png',
resourceId: 'resource-editor-project-default-420',
sourceAssetId: 'persisted-画布提示.png',
}),
]),
}),
);
});
});
it('removes a dropped canvas upload when project resource persistence fails', async () => {
createEditorProjectResourceMock.mockRejectedValueOnce(new Error('unauthorized'));
await renderLoadedEditor();
const viewport = screen.getByLabelText('画布工作区');
fireEvent.drop(viewport, {
clientX: 430,
clientY: 260,
dataTransfer: {
files: [new File(['image'], '未保存图片.png', { type: 'image/png' })],
types: ['Files'],
},
});
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByAltText('画布图片:未保存图片.png')).toBeNull();
});
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
@@ -1258,6 +1941,7 @@ describe('ImageCanvasEditorView', () => {
it('adds assets from the sidebar and supports zoom buttons', async () => {
await renderLoadedEditor();
const user = userEvent.setup();
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }).className,
@@ -1267,7 +1951,7 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
expect(screen.getByRole('button', { name: '当前缩放比例 116%' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
await user.click(screen.getByRole('button', { name: '添加声浪素材' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy();
@@ -1311,10 +1995,42 @@ describe('ImageCanvasEditorView', () => {
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(screen.getByRole('dialog', { name: '画布背景设置' })).toBeTruthy();
expect(screen.getByText('画布背景色')).toBeTruthy();
expect(screen.getByRole('button', { name: '关闭画布背景设置' })).toBeTruthy();
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(243, 240, 234)');
fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' }));
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)');
fireEvent.change(screen.getByLabelText('自定义画布背景色'), {
target: { value: '#ffffff' },
});
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(255, 255, 255)');
const hexInput = screen.getByLabelText('画布背景十六进制颜色') as HTMLInputElement;
fireEvent.change(hexInput, {
target: { value: '#abc' },
});
expect(hexInput.value).toBe('AABBCC');
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)');
fireEvent.change(hexInput, {
target: { value: 'not-a-color' },
});
expect(hexInput.value).toBe('not-a-color');
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)');
fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' }));
expect(hexInput.value).toBe('F8FAFC');
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)');
fireEvent.click(screen.getByRole('button', { name: '关闭画布背景设置' }));
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' }));
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' }));
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
@@ -1549,7 +2265,7 @@ describe('ImageCanvasEditorView', () => {
expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain(
'image-canvas-editor__generation-submit',
);
expect(screen.queryByRole('toolbar', { name: 'AI画布工具栏' })).toBeNull();
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
@@ -1580,6 +2296,21 @@ describe('ImageCanvasEditorView', () => {
});
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(/^/u),
sourceType: 'generated',
sourceAssetId: 'persisted-生成图片 3',
resourceId: 'resource-editor-project-default-1024',
}),
]),
}),
);
});
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,11 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ProjectGalleryView } from './ProjectGalleryView';
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
@@ -11,6 +14,29 @@ const createEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
return {
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn((action: () => void) => action()),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(),
musicVolume: 0.5,
setMusicVolume: vi.fn(),
platformTheme: 'light' as const,
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
...overrides,
};
}
vi.mock('../../services/image-editor/editorProjectClient', () => ({
listEditorProjects: listEditorProjectsMock,
createEditorProject: createEditorProjectMock,
@@ -67,6 +93,115 @@ describe('ProjectGalleryView', () => {
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('uses canvas-center layer composition as the project cover', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-cover',
title: '封面项目',
viewport: { x: 120, y: -60, scale: 0.8 },
layers: [
{
layerId: 'layer-cover-back',
resourceId: 'resource-back',
title: '背景图',
x: 200,
y: 160,
width: 300,
height: 180,
zIndex: 1,
},
{
layerId: 'layer-cover-front',
resourceId: 'resource-front',
title: '前景图',
x: 420,
y: 260,
width: 160,
height: 120,
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
{
resourceId: 'resource-back',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,back',
width: 300,
height: 180,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
await screen.findByText('封面项目');
const cover = document.querySelector('.project-gallery__canvas-cover');
const coverLayers = document.querySelectorAll(
'.project-gallery__canvas-cover-layer',
);
expect(cover).toBeTruthy();
expect(coverLayers).toHaveLength(2);
expect(
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
).toBe('data:image/png;base64,back');
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
});
it('opens the login modal when project list loading is unauthorized', async () => {
const openLoginModal = vi.fn();
listEditorProjectsMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
<ProjectGalleryView onOpenProject={vi.fn()} />
</AuthUiContext.Provider>,
);
await waitFor(() => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
expect(screen.queryByRole('alert')).toBeNull();
});
it('requires login before opening a project card while logged out', async () => {
const onOpenProject = vi.fn();
const requireAuth = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
render(
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
<ProjectGalleryView onOpenProject={onOpenProject} />
</AuthUiContext.Provider>,
);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
expect(onOpenProject).not.toHaveBeenCalled();
});
it('renders project loading errors through the shared status message', async () => {
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));

View File

@@ -14,8 +14,13 @@ import {
deleteEditorProject,
listEditorProjects,
renameEditorProject,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
@@ -29,6 +34,9 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
};
@@ -38,16 +46,110 @@ type RenameDraft = {
title: string;
};
function resolveProjectPreview(project: EditorProjectSnapshot) {
const layerResourceIds = new Set(
project.layers
.map((layer) => layer.resourceId)
.filter((resourceId) => resourceId.trim().length > 0),
function isUnauthorizedError(error: unknown) {
return error instanceof ApiClientError && error.status === 401;
}
type ProjectCoverLayer = {
layerId: string;
title: string;
imageSrc: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
};
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
const value = layer[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
const value = layer[key];
return typeof value === 'string' ? value.trim() : '';
}
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
return layer.hidden === true;
}
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
project.resources.map((resource) => [resource.resourceId, resource]),
);
return project.layers
.filter((layer) => !isLayerHidden(layer))
.map((layer) => {
const resource = resourcesById.get(layer.resourceId);
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
if (!imageSrc) {
return null;
}
return {
layerId: layer.layerId,
title: stringFromLayer(layer, 'title') || '画布图片',
imageSrc,
x: numberFromLayer(layer, 'x', 0),
y: numberFromLayer(layer, 'y', 0),
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
zIndex: numberFromLayer(layer, 'zIndex', 0),
} satisfies ProjectCoverLayer;
})
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
.sort((left, right) => left.zIndex - right.zIndex);
}
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
const coverLayers = resolveProjectCoverLayers(project);
if (!coverLayers.length) {
return <span className="project-gallery__preview-empty" />;
}
const safeScale =
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
? project.viewport.scale
: 1;
const viewportCenterX =
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
const viewportCenterY =
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
const scale = Math.min(
PROJECT_COVER_SIZE.width / worldPreviewWidth,
PROJECT_COVER_SIZE.height / worldPreviewHeight,
);
const offsetX = -previewMinX * scale;
const offsetY = -previewMinY * scale;
return (
project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ??
project.resources[0] ??
null
<span className="project-gallery__canvas-cover" aria-hidden="true">
{coverLayers.map((layer) => (
<span
key={layer.layerId}
className="project-gallery__canvas-cover-layer"
style={{
left: offsetX + layer.x * scale,
top: offsetY + layer.y * scale,
width: layer.width * scale,
height: layer.height * scale,
zIndex: layer.zIndex,
}}
>
<ResolvedAssetImage
src={layer.imageSrc}
alt=""
className="project-gallery__canvas-cover-image"
/>
</span>
))}
</span>
);
}
@@ -65,6 +167,7 @@ function formatProjectUpdatedAt(value: string) {
}
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const authUi = useAuthUi();
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -94,18 +197,26 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
});
})
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(refreshProjects);
return;
}
setErrorMessage(
error instanceof Error ? error.message : '读取项目列表失败',
);
})
.finally(() => setIsLoading(false));
}, []);
}, [authUi]);
useEffect(() => {
refreshProjects();
}, [refreshProjects]);
const createProject = useCallback(() => {
if (authUi && !authUi.user) {
authUi.openLoginModal(createProject);
return;
}
setErrorMessage(null);
createEditorProject()
.then((project) => onOpenProject(project.projectId))
@@ -114,7 +225,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
error instanceof Error ? error.message : '创建项目失败',
);
});
}, [onOpenProject]);
}, [authUi, onOpenProject]);
const closeSelectionMode = useCallback(() => {
setIsSelectionMode(false);
@@ -191,7 +302,6 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const projectCards = useMemo(
() =>
projects.map((project) => {
const preview = resolveProjectPreview(project);
const selected = selectedProjectIds.has(project.projectId);
return (
<article
@@ -211,18 +321,22 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
toggleProjectSelection(project.projectId);
return;
}
if (authUi) {
authUi.requireAuth(() => onOpenProject(project.projectId));
return;
}
onOpenProject(project.projectId);
}}
aria-label={`打开项目${project.title}`}
>
<PlatformMediaFrame
src={preview?.imageSrc}
src={null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
className="project-gallery__preview"
fallbackContent={<span className="project-gallery__preview-empty" />}
fallbackContent={<ProjectCanvasCover project={project} />}
>
{isSelectionMode ? (
<span className="project-gallery__checkbox">
@@ -279,6 +393,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
}),
[
activeMenuProjectId,
authUi,
deleteProjects,
isSelectionMode,
onOpenProject,