完善图片画布素材库持久化
新增账号级素材文件夹和素材表,并接入 SpacetimeDB procedure、spacetime-client facade 与 api-server BFF。 编辑器素材栏支持文件夹新建、折叠、重命名、删除、多文件上传、拖拽定向上传、框选和批量删除。 画布支持拖拽上传落点创建图层、图层打组、小地图拖拽、普通滚轮纵向滚动和 Ctrl 滚轮缩放。 更新图片画布技术方案、后端数据契约、TRACKING 和团队决策记录。
This commit is contained in:
@@ -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