完善图片画布素材库持久化

新增账号级素材文件夹和素材表,并接入 SpacetimeDB procedure、spacetime-client facade 与 api-server BFF。

编辑器素材栏支持文件夹新建、折叠、重命名、删除、多文件上传、拖拽定向上传、框选和批量删除。

画布支持拖拽上传落点创建图层、图层打组、小地图拖拽、普通滚轮纵向滚动和 Ctrl 滚轮缩放。

更新图片画布技术方案、后端数据契约、TRACKING 和团队决策记录。
This commit is contained in:
2026-06-14 14:29:13 +08:00
parent 6bc2f11d04
commit a6025365f7
43 changed files with 4459 additions and 125 deletions

View File

@@ -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: {

View File

@@ -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,