完善图片画布素材库持久化
新增账号级素材文件夹和素材表,并接入 SpacetimeDB procedure、spacetime-client facade 与 api-server BFF。 编辑器素材栏支持文件夹新建、折叠、重命名、删除、多文件上传、拖拽定向上传、框选和批量删除。 画布支持拖拽上传落点创建图层、图层打组、小地图拖拽、普通滚轮纵向滚动和 Ctrl 滚轮缩放。 更新图片画布技术方案、后端数据契约、TRACKING 和团队决策记录。
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type {
|
||||
ComponentType,
|
||||
DragEventHandler,
|
||||
PointerEventHandler,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type IconComponent = ComponentType<{ className?: string }>;
|
||||
|
||||
@@ -55,6 +60,9 @@ export type SidebarMediaItemProps = {
|
||||
primaryClassName?: string;
|
||||
actions?: ReactNode;
|
||||
titleNode?: ReactNode;
|
||||
onDragOver?: DragEventHandler<HTMLDivElement>;
|
||||
onDrop?: DragEventHandler<HTMLDivElement>;
|
||||
onPointerEnter?: PointerEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function SidebarMediaItem({
|
||||
@@ -71,10 +79,16 @@ export function SidebarMediaItem({
|
||||
primaryClassName,
|
||||
actions,
|
||||
titleNode,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onPointerEnter,
|
||||
}: SidebarMediaItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${rowClassName} ${selected ? `${rowClassName}--selected` : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onPointerEnter={onPointerEnter}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,8 +9,16 @@ import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
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 saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -19,9 +27,17 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
return {
|
||||
...actual,
|
||||
editEditorImage: editEditorImageMock,
|
||||
createEditorAsset: createEditorAssetMock,
|
||||
createEditorAssetFolder: createEditorAssetFolderMock,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
deleteEditorAsset: deleteEditorAssetMock,
|
||||
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
||||
generateEditorImage: generateEditorImageMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
loadEditorProject: loadEditorProjectMock,
|
||||
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||||
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
||||
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,14 +65,78 @@ describe('ImageCanvasEditorView', () => {
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
loadEditorAssetLibraryMock.mockResolvedValue({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
});
|
||||
createEditorAssetMock.mockImplementation(async (input) => ({
|
||||
assetId: `persisted-${input.label}`,
|
||||
folderId: input.folderId,
|
||||
label: input.label,
|
||||
imageSrc: input.imageSrc,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
sourceType: input.sourceType,
|
||||
}));
|
||||
createEditorAssetFolderMock.mockResolvedValue({
|
||||
folderId: 'folder-role-persisted',
|
||||
label: '角色上传',
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
});
|
||||
updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
|
||||
folderId,
|
||||
label: input.label ?? '角色上传',
|
||||
collapsed: input.collapsed ?? false,
|
||||
systemDefault: false,
|
||||
}));
|
||||
deleteEditorAssetFolderMock.mockResolvedValue({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
});
|
||||
deleteEditorAssetMock.mockResolvedValue({});
|
||||
createEditorProjectResourceMock.mockImplementation(async (projectId, input) => ({
|
||||
resourceId: `resource-${projectId}-${input.width}`,
|
||||
projectId,
|
||||
imageSrc: input.imageSrc,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
sourceType: input.sourceType,
|
||||
}));
|
||||
saveEditorProjectLayoutMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
generateEditorImageMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
createEditorAssetMock.mockReset();
|
||||
createEditorProjectResourceMock.mockReset();
|
||||
createEditorAssetFolderMock.mockReset();
|
||||
updateEditorAssetFolderMock.mockReset();
|
||||
deleteEditorAssetFolderMock.mockReset();
|
||||
deleteEditorAssetMock.mockReset();
|
||||
loadEditorAssetLibraryMock.mockReset();
|
||||
loadEditorProjectMock.mockReset();
|
||||
loadOrCreateRecentEditorProjectMock.mockReset();
|
||||
saveEditorProjectLayoutMock.mockReset();
|
||||
window.history.replaceState(null, '', '/editor/canvas');
|
||||
});
|
||||
|
||||
@@ -170,6 +250,215 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renames and deletes asset folders through the persisted asset library API', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
{
|
||||
folderId: 'folder-role',
|
||||
label: '角色',
|
||||
sortOrder: 100,
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await screen.findByRole('region', { name: '角色' });
|
||||
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
|
||||
const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
|
||||
await user.clear(folderRenameInput);
|
||||
await user.type(folderRenameInput, '角色参考');
|
||||
await user.click(screen.getByRole('button', { name: '保存文件夹角色名称' }));
|
||||
|
||||
expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', {
|
||||
label: '角色参考',
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '删除文件夹角色参考' }));
|
||||
|
||||
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
|
||||
});
|
||||
|
||||
it('uploads multiple files and persists them as account-level assets', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
|
||||
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
|
||||
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||
new File(['image-a'], '第一张.png', { type: 'image/png' }),
|
||||
new File(['image-b'], '第二张.png', { type: 'image/png' }),
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('画布图片:第一张.png')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:第二张.png')).toBeTruthy();
|
||||
});
|
||||
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('supports asset selection mode and batch delete with shared toolbar', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-a',
|
||||
folderId: 'project',
|
||||
label: '账号素材A',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
assetId: 'asset-b',
|
||||
folderId: 'project',
|
||||
label: '账号素材B',
|
||||
imageSrc: 'data:image/png;base64,Yg==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await screen.findByRole('button', { name: '添加账号素材A' });
|
||||
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
||||
await user.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
|
||||
|
||||
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
||||
expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy();
|
||||
await user.click(within(batchToolbar).getByRole('button', { name: '删除' }));
|
||||
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
||||
expect(screen.queryByRole('button', { name: '选择素材账号素材A' })).toBeNull();
|
||||
});
|
||||
|
||||
it('selects multiple assets with a marquee in asset selection mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-a',
|
||||
folderId: 'project',
|
||||
label: '账号素材A',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
assetId: 'asset-b',
|
||||
folderId: 'project',
|
||||
label: '账号素材B',
|
||||
imageSrc: 'data:image/png;base64,Yg==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const firstAssetButton = await screen.findByRole('button', {
|
||||
name: '添加账号素材A',
|
||||
});
|
||||
const secondAssetButton = screen.getByRole('button', { name: '添加账号素材B' });
|
||||
const assetList = firstAssetButton.closest(
|
||||
'.image-canvas-editor__asset-list',
|
||||
) as HTMLElement;
|
||||
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 600,
|
||||
width: 320,
|
||||
height: 600,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(
|
||||
firstAssetButton.closest('[data-asset-id]') as HTMLElement,
|
||||
'getBoundingClientRect',
|
||||
).mockReturnValue({
|
||||
x: 16,
|
||||
y: 120,
|
||||
left: 16,
|
||||
top: 120,
|
||||
right: 280,
|
||||
bottom: 200,
|
||||
width: 264,
|
||||
height: 80,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(
|
||||
secondAssetButton.closest('[data-asset-id]') as HTMLElement,
|
||||
'getBoundingClientRect',
|
||||
).mockReturnValue({
|
||||
x: 16,
|
||||
y: 240,
|
||||
left: 16,
|
||||
top: 240,
|
||||
right: 280,
|
||||
bottom: 320,
|
||||
width: 264,
|
||||
height: 80,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
||||
dispatchPointerEvent(assetList, 'pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 88,
|
||||
clientX: 8,
|
||||
clientY: 100,
|
||||
});
|
||||
dispatchPointerEvent(assetList, 'pointermove', {
|
||||
button: 0,
|
||||
pointerId: 88,
|
||||
clientX: 300,
|
||||
clientY: 330,
|
||||
});
|
||||
dispatchPointerEvent(assetList, 'pointerup', {
|
||||
button: 0,
|
||||
pointerId: 88,
|
||||
clientX: 300,
|
||||
clientY: 330,
|
||||
});
|
||||
|
||||
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
||||
expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows image size on hover and placeholder toolbar after selecting a layer', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
render(<ImageCanvasEditorView />);
|
||||
@@ -231,11 +520,6 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('uploads an image file as a new canvas layer', async () => {
|
||||
const createObjectUrlSpy = vi.fn(() => 'blob:uploaded-image');
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectUrlSpy,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
@@ -251,8 +535,15 @@ describe('ImageCanvasEditorView', () => {
|
||||
new File(['image'], '测试上传.png', { type: 'image/png' }),
|
||||
);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
||||
});
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '测试上传.png',
|
||||
imageSrc: expect.stringMatching(/^data:image\/png;base64,/u),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -348,6 +639,75 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy();
|
||||
|
||||
fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy();
|
||||
|
||||
fireEvent.wheel(viewport, {
|
||||
deltaY: -120,
|
||||
ctrlKey: true,
|
||||
clientX: 400,
|
||||
clientY: 280,
|
||||
});
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 90%' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drags the minimap to move the canvas viewport', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const minimap = screen.getByRole('button', { name: '画布小地图' });
|
||||
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 132,
|
||||
bottom: 84,
|
||||
width: 132,
|
||||
height: 84,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
dispatchPointerEvent(minimap, 'pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 71,
|
||||
clientX: 120,
|
||||
clientY: 72,
|
||||
});
|
||||
|
||||
const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!;
|
||||
expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470);
|
||||
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('persists layer groups in the canvas layer snapshot', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图层打组' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/已打组/u)).toBeTruthy();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: '拼图素材',
|
||||
groupId: expect.stringMatching(/^layer-group-/u),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3363,7 +3363,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-header-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-list {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
@@ -3378,15 +3384,33 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 0.35rem;
|
||||
color: #475569;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header > span:first-of-type,
|
||||
.image-canvas-editor__asset-folder-header > input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header input {
|
||||
height: 1.8rem;
|
||||
border: 1px solid #8fb8ff;
|
||||
border-radius: 0.35rem;
|
||||
background: #ffffff;
|
||||
padding: 0 0.45rem;
|
||||
color: #1f2937;
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header button {
|
||||
display: inline-flex;
|
||||
width: 1.8rem;
|
||||
@@ -3445,6 +3469,26 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-row--selected {
|
||||
border-color: #2563eb;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-batch-toolbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
justify-content: center;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-marquee {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
border: 1px solid #2563eb;
|
||||
background: rgb(37 99 235 / 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-button {
|
||||
display: block;
|
||||
border: 0;
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorAssetFolder,
|
||||
createEditorProject,
|
||||
createEditorProjectResource,
|
||||
deleteEditorAsset,
|
||||
deleteEditorAssetFolder,
|
||||
deleteEditorProject,
|
||||
editEditorImage,
|
||||
generateEditorImage,
|
||||
loadEditorAssetLibrary,
|
||||
listEditorProjects,
|
||||
loadEditorProject,
|
||||
loadOrCreateRecentEditorProject,
|
||||
renameEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
updateEditorAsset,
|
||||
updateEditorAssetFolder,
|
||||
} from './editorProjectClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
@@ -308,6 +315,180 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('loads and mutates the account-level asset library', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
library: {
|
||||
folders: [
|
||||
{
|
||||
folderId: 'folder-project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
folder: {
|
||||
folderId: 'folder-role',
|
||||
label: '角色',
|
||||
sortOrder: 100,
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
folder: {
|
||||
folderId: 'folder-role',
|
||||
label: '角色参考',
|
||||
sortOrder: 100,
|
||||
collapsed: true,
|
||||
systemDefault: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
library: {
|
||||
folders: [],
|
||||
assets: [],
|
||||
},
|
||||
});
|
||||
|
||||
await loadEditorAssetLibrary();
|
||||
await createEditorAssetFolder('角色', 100);
|
||||
await updateEditorAssetFolder('folder-role', {
|
||||
label: '角色参考',
|
||||
collapsed: true,
|
||||
});
|
||||
await deleteEditorAssetFolder('folder-role');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/editor/assets/library',
|
||||
{ method: 'GET' },
|
||||
'读取图片画布素材库失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/assets/folders',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ label: '角色', sortOrder: 100 }),
|
||||
}),
|
||||
'创建图片画布素材文件夹失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/editor/assets/folders/folder-role',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ label: '角色参考', collapsed: true }),
|
||||
}),
|
||||
'更新图片画布素材文件夹失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/api/editor/assets/folders/folder-role',
|
||||
{ method: 'DELETE' },
|
||||
'删除图片画布素材文件夹失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates, updates, and deletes account-level image assets', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
asset: {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-project',
|
||||
label: '主视觉.png',
|
||||
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
asset: {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-role',
|
||||
label: '角色主视觉.png',
|
||||
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
asset: {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-role',
|
||||
label: '角色主视觉.png',
|
||||
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
});
|
||||
|
||||
await createEditorAsset({
|
||||
folderId: 'folder-project',
|
||||
label: '主视觉.png',
|
||||
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
await updateEditorAsset('asset-1', {
|
||||
label: '角色主视觉.png',
|
||||
folderId: 'folder-role',
|
||||
});
|
||||
await deleteEditorAsset('asset-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/editor/assets',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
folderId: 'folder-project',
|
||||
label: '主视觉.png',
|
||||
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
}),
|
||||
}),
|
||||
'创建图片画布素材失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/assets/asset-1',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
label: '角色主视觉.png',
|
||||
folderId: 'folder-role',
|
||||
}),
|
||||
}),
|
||||
'更新图片画布素材失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/editor/assets/asset-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除图片画布素材失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an explicit project from title input', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
|
||||
const EDITOR_ASSET_API_BASE = '/api/editor/assets';
|
||||
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
||||
@@ -44,6 +45,40 @@ export type EditorProjectResourceSnapshot = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type EditorAssetFolderSnapshot = {
|
||||
folderId: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
collapsed: boolean;
|
||||
systemDefault: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type EditorAssetSnapshot = {
|
||||
assetId: string;
|
||||
folderId: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: EditorProjectResourceSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type EditorAssetLibrarySnapshot = {
|
||||
folders: EditorAssetFolderSnapshot[];
|
||||
assets: EditorAssetSnapshot[];
|
||||
};
|
||||
|
||||
export type EditorImageGenerationInput = {
|
||||
prompt: string;
|
||||
};
|
||||
@@ -109,6 +144,27 @@ export type EditorProjectResourceCreateInput = {
|
||||
sourceResourceId?: string | null;
|
||||
};
|
||||
|
||||
export type EditorAssetCreateInput = {
|
||||
folderId: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: EditorProjectResourceSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
};
|
||||
|
||||
export type EditorAssetUpdateInput = {
|
||||
label?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
type EditorProjectResponse = {
|
||||
project: EditorProjectSnapshot;
|
||||
};
|
||||
@@ -121,6 +177,22 @@ type EditorProjectResourceResponse = {
|
||||
resource: EditorProjectResourceSnapshot;
|
||||
};
|
||||
|
||||
type EditorAssetLibraryResponse = {
|
||||
library: EditorAssetLibrarySnapshot;
|
||||
};
|
||||
|
||||
type EditorAssetFolderResponse = {
|
||||
folder: EditorAssetFolderSnapshot;
|
||||
};
|
||||
|
||||
type EditorAssetFolderDeleteResponse = {
|
||||
library: EditorAssetLibrarySnapshot;
|
||||
};
|
||||
|
||||
type EditorAssetResponse = {
|
||||
asset: EditorAssetSnapshot;
|
||||
};
|
||||
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
|
||||
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
|
||||
@@ -227,6 +299,79 @@ export async function createEditorProjectResource(
|
||||
return response.resource;
|
||||
}
|
||||
|
||||
export async function loadEditorAssetLibrary() {
|
||||
const response = await requestJson<EditorAssetLibraryResponse>(
|
||||
`${EDITOR_ASSET_API_BASE}/library`,
|
||||
{ method: 'GET' },
|
||||
'读取图片画布素材库失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.library;
|
||||
}
|
||||
|
||||
export async function createEditorAssetFolder(label: string, sortOrder?: number) {
|
||||
const response = await requestJson<EditorAssetFolderResponse>(
|
||||
`${EDITOR_ASSET_API_BASE}/folders`,
|
||||
jsonRequest('POST', { label, sortOrder }),
|
||||
'创建图片画布素材文件夹失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.folder;
|
||||
}
|
||||
|
||||
export async function updateEditorAssetFolder(
|
||||
folderId: string,
|
||||
input: { label?: string; collapsed?: boolean },
|
||||
) {
|
||||
const response = await requestJson<EditorAssetFolderResponse>(
|
||||
`${EDITOR_ASSET_API_BASE}/folders/${encodeURIComponent(folderId)}`,
|
||||
jsonRequest('PATCH', input),
|
||||
'更新图片画布素材文件夹失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.folder;
|
||||
}
|
||||
|
||||
export async function deleteEditorAssetFolder(folderId: string) {
|
||||
const response = await requestJson<EditorAssetFolderDeleteResponse>(
|
||||
`${EDITOR_ASSET_API_BASE}/folders/${encodeURIComponent(folderId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除图片画布素材文件夹失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.library;
|
||||
}
|
||||
|
||||
export async function createEditorAsset(input: EditorAssetCreateInput) {
|
||||
const response = await requestJson<EditorAssetResponse>(
|
||||
EDITOR_ASSET_API_BASE,
|
||||
jsonRequest('POST', input),
|
||||
'创建图片画布素材失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.asset;
|
||||
}
|
||||
|
||||
export async function updateEditorAsset(assetId: string, input: EditorAssetUpdateInput) {
|
||||
const response = await requestJson<EditorAssetResponse>(
|
||||
`${EDITOR_ASSET_API_BASE}/${encodeURIComponent(assetId)}`,
|
||||
jsonRequest('PATCH', input),
|
||||
'更新图片画布素材失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.asset;
|
||||
}
|
||||
|
||||
export async function deleteEditorAsset(assetId: string) {
|
||||
const response = await requestJson<EditorAssetResponse>(
|
||||
`${EDITOR_ASSET_API_BASE}/${encodeURIComponent(assetId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除图片画布素材失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.asset;
|
||||
}
|
||||
|
||||
export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
return requestJson<EditorImageGenerationResponse>(
|
||||
EDITOR_IMAGE_GENERATION_API,
|
||||
|
||||
Reference in New Issue
Block a user