修复项目封面本地兜底

新增项目封面 IndexedDB 本地缓存。

封面 Blob 生成后先写入本地缓存,上传失败仍可展示。

项目列表优先服务端封面,其次本地缓存,再回退画布图层。

补充封面兜底测试和项目记忆文档。
This commit is contained in:
2026-06-25 21:59:05 +08:00
parent b7594d0a03
commit 28e11c4718
8 changed files with 811 additions and 78 deletions
@@ -191,6 +191,14 @@
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
## 图片画布项目封面上传失败要有本地展示兜底
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
- 关联:`src/services/image-editor/editorProjectCoverCache.ts``src/components/project/ProjectGalleryView.tsx``src/components/project/ProjectCanvasCover.tsx``src/components/image-editor/useImageCanvasProjectPersistence.ts`
## 图片画布框选预览要复用源图换签缓存
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
@@ -16,6 +16,7 @@ const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const putEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY =
@@ -48,6 +49,10 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
putEditorProjectCoverCache: putEditorProjectCoverCacheMock,
}));
function createLayer(
id: string,
overrides: Partial<CanvasLayer> = {},
@@ -167,7 +172,10 @@ function ProjectPersistenceHarness({
</span>
<span data-testid="generation-dialogs">
{generationDialogs
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`)
.map(
(dialog) =>
`${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`,
)
.join(',')}
</span>
<button
@@ -390,6 +398,7 @@ describe('useImageCanvasProjectPersistence', () => {
updatedAt: '2026-06-12T00:00:00.000Z',
});
createProjectCoverSnapshotBlobMock.mockResolvedValue(null);
putEditorProjectCoverCacheMock.mockResolvedValue(undefined);
uploadEditorMediaAssetFileMock.mockResolvedValue({
src: 'https://signed.example.test/project-cover.png',
objectKey:
@@ -405,9 +414,7 @@ describe('useImageCanvasProjectPersistence', () => {
expect(await screen.findByText('editor-project-default')).toBeTruthy();
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
expect(screen.getByTestId('project-rename').textContent).toBe(
'空画布项目',
);
expect(screen.getByTestId('project-rename').textContent).toBe('空画布项目');
act(() => {
screen.getByRole('button', { name: 'append' }).click();
@@ -521,6 +528,12 @@ describe('useImageCanvasProjectPersistence', () => {
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-default',
blob: expect.any(Blob),
}),
);
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.any(File),
'image',
@@ -557,6 +570,66 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('keeps the local project cover cache when snapshot upload fails', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '本地封面画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-cover',
resourceId: 'resource-cover-source',
title: '封面源图',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
},
],
resources: [
{
resourceId: 'resource-cover-source',
projectId: 'editor-project-default',
imageSrc: '/generated/source.png',
objectKey: 'generated/source.png',
assetObjectId: 'asset-object-source',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
});
const coverBlob = new Blob(['cover'], { type: 'image/png' });
createProjectCoverSnapshotBlobMock.mockResolvedValueOnce(coverBlob);
uploadEditorMediaAssetFileMock.mockRejectedValueOnce(
new TypeError('Failed to fetch'),
);
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
projectId: 'editor-project-default',
blob: coverBlob,
});
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
assetKind: 'project-cover-snapshot',
}),
);
});
it('loads and saves viewport scale with display zoom semantics', async () => {
render(<ProjectPersistenceHarness />);
@@ -1,7 +1,14 @@
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import { putEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache';
import {
createEditorProjectResource,
type EditorProjectResourceSnapshot,
@@ -173,7 +180,10 @@ function writeEditorProjectSessionCache(project: EditorProjectSnapshot) {
};
try {
const serialized = JSON.stringify(cached);
storage.setItem(editorProjectSessionCacheKey(project.projectId), serialized);
storage.setItem(
editorProjectSessionCacheKey(project.projectId),
serialized,
);
storage.setItem(EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, serialized);
} catch {
// ignore quota or privacy mode failures
@@ -265,8 +275,9 @@ export function useImageCanvasProjectPersistence({
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
);
const pendingProjectLayoutSaveRef =
useRef<PendingProjectLayoutSave | null>(null);
const pendingProjectLayoutSaveRef = useRef<PendingProjectLayoutSave | null>(
null,
);
const isProjectLayoutSaveRunningRef = useRef(false);
const skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
@@ -360,6 +371,10 @@ export function useImageCanvasProjectPersistence({
if (coverSnapshotUploadRequestRef.current !== requestId) {
return;
}
void putEditorProjectCoverCache({
projectId: nextProjectId,
blob,
});
const coverFile = new File([blob], `${nextProjectId}-cover.png`, {
type: 'image/png',
});
@@ -510,7 +525,9 @@ export function useImageCanvasProjectPersistence({
setLayers(nextLayers);
if (nextLayers.length) {
queueProjectLayoutSave(readyProjectId, {
viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current),
viewport: viewportToCanvasDisplayViewport(
refs.viewportRef.current,
),
layers: serializeCanvasLayout({
layers: nextLayers,
canvasGenerationDialogs:
@@ -1,16 +1,21 @@
import { describe, expect, it } from 'vitest';
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
import { resolveProjectCoverSnapshotResource } from './ProjectCanvasCover';
import {
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
resolveProjectCoverResource,
resolveProjectCoverSnapshotResource,
} from './ProjectCanvasCover';
function project(
resources: EditorProjectSnapshot['resources'],
layers: EditorProjectSnapshot['layers'] = [],
): EditorProjectSnapshot {
return {
projectId: 'editor-project-cover',
title: '封面项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
layers,
resources,
updatedAt: '2026-06-12T00:00:00.000Z',
};
@@ -118,3 +123,190 @@ describe('resolveProjectCoverSnapshotResource', () => {
expect(cover?.resourceId).toBe('resource-snake-cover');
});
});
describe('resolveProjectCoverResource', () => {
it('prefers the saved cover snapshot over canvas layer resources', () => {
const cover = resolveProjectCoverResource(
project(
[
{
resourceId: 'resource-layer',
projectId: 'editor-project-cover',
imageSrc: '/generated/layer.png',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
resourceId: 'resource-cover',
projectId: 'editor-project-cover',
imageSrc: '/generated/cover.png',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'project-cover-snapshot',
},
],
[{ layerId: 'layer-top', resourceId: 'resource-layer', zIndex: 9 }],
),
);
expect(cover?.resourceId).toBe('resource-cover');
});
it('uses local cached covers only when no saved cover snapshot exists', () => {
const cover = resolveProjectCoverResource(
project([
{
resourceId: 'resource-local-cover',
projectId: 'editor-project-cover',
imageSrc: 'blob:local-cover',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
updatedAt: '2026-06-12T09:10:00.000Z',
},
{
resourceId: 'resource-saved-cover',
projectId: 'editor-project-cover',
imageSrc: '/generated/cover.png',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'project-cover-snapshot',
updatedAt: '2026-06-12T09:00:00.000Z',
},
]),
);
expect(cover?.resourceId).toBe('resource-saved-cover');
const localCover = resolveProjectCoverResource(
project([
{
resourceId: 'resource-local-cover',
projectId: 'editor-project-cover',
imageSrc: 'blob:local-cover',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
},
]),
);
expect(localCover?.resourceId).toBe('resource-local-cover');
});
it('falls back to the top visible canvas layer resource', () => {
const cover = resolveProjectCoverResource(
project(
[
{
resourceId: 'resource-back',
projectId: 'editor-project-cover',
imageSrc: '/generated/back.png',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
resourceId: 'resource-front',
projectId: 'editor-project-cover',
imageSrc: '/generated/front.png',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
[
{ layerId: 'layer-back', resourceId: 'resource-back', zIndex: 1 },
{ layerId: 'layer-front', resourceId: 'resource-front', zIndex: 3 },
],
),
);
expect(cover?.resourceId).toBe('resource-front');
});
it('ignores hidden and audio layers when falling back', () => {
const cover = resolveProjectCoverResource(
project(
[
{
resourceId: 'resource-hidden',
projectId: 'editor-project-cover',
imageSrc: '/generated/hidden.png',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
resourceId: 'resource-audio',
projectId: 'editor-project-cover',
imageSrc: '/generated/audio.mp3',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
resourceId: 'resource-visible',
projectId: 'editor-project-cover',
imageSrc: '/generated/visible.png',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
[
{
layerId: 'layer-hidden',
resourceId: 'resource-hidden',
hidden: true,
zIndex: 9,
},
{
layerId: 'layer-audio',
resourceId: 'resource-audio',
mediaType: 'audio',
zIndex: 8,
},
{
layerId: 'layer-visible',
resourceId: 'resource-visible',
zIndex: 1,
},
],
),
);
expect(cover?.resourceId).toBe('resource-visible');
});
it('returns null when the project has no visible layer image resource', () => {
const cover = resolveProjectCoverResource(
project(
[
{
resourceId: 'resource-hidden',
projectId: 'editor-project-cover',
imageSrc: '/generated/hidden.png',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
[
{
layerId: 'layer-hidden',
resourceId: 'resource-hidden',
hidden: true,
},
],
),
);
expect(cover).toBeNull();
});
});
+92 -5
View File
@@ -1,4 +1,5 @@
import type {
EditorProjectLayerSnapshot,
EditorProjectResourceSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
@@ -6,12 +7,25 @@ import { PROJECT_COVER_SNAPSHOT_ASSET_KIND } from '../image-editor/ImageCanvasPr
const PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY =
'editor_project_cover_snapshot';
export const LOCAL_PROJECT_COVER_CACHE_ASSET_KIND = 'local-project-cover-cache';
type ProjectResourceSnakeKind = {
asset_kind?: string | null;
src?: string | null;
};
type ProjectCoverResourceCandidate = EditorProjectResourceSnapshot & {
imageSrc: string;
};
function stringFromUnknown(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function numberFromUnknown(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readProjectCoverSnapshotKind(resource: EditorProjectResourceSnapshot) {
const legacyResource = resource as ProjectResourceSnakeKind;
return (
@@ -39,6 +53,31 @@ function hasProjectCoverSnapshotSource(
return Boolean(resourceObjectKey || resourceImageSrc);
}
function hasProjectCoverSource(
resource: EditorProjectResourceSnapshot,
): resource is ProjectCoverResourceCandidate {
return hasProjectCoverSnapshotSource(resource);
}
function isDedicatedProjectCoverSnapshot(
resource: EditorProjectResourceSnapshot,
) {
return [
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY,
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
].includes(readProjectCoverSnapshotKind(resource));
}
function isLocalProjectCoverCacheResource(
resource: EditorProjectResourceSnapshot,
) {
return (
readProjectCoverSnapshotKind(resource) ===
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND
);
}
function projectCoverSnapshotObjectTimestamp(
resource: EditorProjectResourceSnapshot,
) {
@@ -71,13 +110,16 @@ export function resolveProjectCoverSnapshotResource(
[...project.resources]
.filter(
(resource) =>
[
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY,
].includes(readProjectCoverSnapshotKind(resource)) &&
hasProjectCoverSnapshotSource(resource),
isDedicatedProjectCoverSnapshot(resource) &&
hasProjectCoverSource(resource),
)
.sort((left, right) => {
if (
isLocalProjectCoverCacheResource(left) !==
isLocalProjectCoverCacheResource(right)
) {
return isLocalProjectCoverCacheResource(left) ? 1 : -1;
}
const byUpdatedAt =
projectCoverSnapshotSortScore(right) -
projectCoverSnapshotSortScore(left);
@@ -85,3 +127,48 @@ export function resolveProjectCoverSnapshotResource(
})[0] ?? null
);
}
function readLayerResourceId(layer: EditorProjectLayerSnapshot) {
return stringFromUnknown(layer.resourceId);
}
function isLayerVisible(layer: EditorProjectLayerSnapshot) {
return layer.hidden !== true && layer.mediaType !== 'audio';
}
function layerCoverSortScore(layer: EditorProjectLayerSnapshot, index: number) {
return {
zIndex: numberFromUnknown(layer.zIndex, 0),
index,
};
}
export function resolveProjectCoverResource(project: EditorProjectSnapshot) {
const snapshot = resolveProjectCoverSnapshotResource(project);
if (snapshot) {
return snapshot;
}
const resourcesById = new Map(
project.resources
.filter((resource) => !isDedicatedProjectCoverSnapshot(resource))
.filter(hasProjectCoverSource)
.map((resource) => [resource.resourceId, resource]),
);
return (
project.layers
.map((layer, index) => ({ layer, index }))
.filter(({ layer }) => isLayerVisible(layer))
.flatMap(({ layer, index }) => {
const resource = resourcesById.get(readLayerResourceId(layer));
return resource
? [{ resource, score: layerCoverSortScore(layer, index) }]
: [];
})
.sort((left, right) => {
const byZIndex = right.score.zIndex - left.score.zIndex;
return byZIndex || right.score.index - left.score.index;
})[0]?.resource ?? null
);
}
@@ -3,7 +3,7 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps, ContextType } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { AuthUiContext } from '../auth/AuthUiContext';
@@ -13,6 +13,7 @@ const listEditorProjectsMock = vi.hoisted(() => vi.fn());
const createEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
const readEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
@@ -44,6 +45,10 @@ vi.mock('../../services/image-editor/editorProjectClient', () => ({
deleteEditorProject: deleteEditorProjectMock,
}));
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
readEditorProjectCoverCache: readEditorProjectCoverCacheMock,
}));
const projectItems = [
{
projectId: 'editor-project-1',
@@ -75,20 +80,22 @@ const projectItems = [
function renderProjectGalleryView(
props: Partial<ComponentProps<typeof ProjectGalleryView>> = {},
) {
return render(
<ProjectGalleryView
onOpenProject={vi.fn()}
{...props}
/>,
);
return render(<ProjectGalleryView onOpenProject={vi.fn()} {...props} />);
}
describe('ProjectGalleryView', () => {
beforeEach(() => {
readEditorProjectCoverCacheMock.mockResolvedValue(new Map());
URL.createObjectURL = vi.fn(() => 'blob:project-cover-cache');
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
listEditorProjectsMock.mockReset();
createEditorProjectMock.mockReset();
renameEditorProjectMock.mockReset();
deleteEditorProjectMock.mockReset();
readEditorProjectCoverCacheMock.mockReset();
});
it('opens a project from the gallery card', async () => {
@@ -99,7 +106,9 @@ describe('ProjectGalleryView', () => {
renderProjectGalleryView({ onOpenProject });
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
await user.click(
screen.getByRole('button', { name: '打开项目角色设定板' }),
);
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
@@ -168,9 +177,7 @@ describe('ProjectGalleryView', () => {
await screen.findByText('封面项目');
expect(
document.querySelector('.project-gallery__canvas-cover'),
).toBeNull();
expect(document.querySelector('.project-gallery__canvas-cover')).toBeNull();
expect(
document
.querySelector('.project-gallery__preview img')
@@ -178,6 +185,115 @@ describe('ProjectGalleryView', () => {
).toBe('data:image/png;base64,cover');
});
it('falls back to a visible canvas layer image when no cover snapshot exists', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-layer-cover',
title: '图层封面项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-back',
resourceId: 'resource-back',
title: '背景图',
zIndex: 1,
},
{
layerId: 'layer-front',
resourceId: 'resource-front',
title: '前景图',
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-layer-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
{
resourceId: 'resource-back',
projectId: 'editor-project-layer-cover',
imageSrc: 'data:image/png;base64,back',
width: 300,
height: 180,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
renderProjectGalleryView();
await screen.findByText('图层封面项目');
expect(
document
.querySelector('.project-gallery__preview img')
?.getAttribute('src'),
).toBe('data:image/png;base64,front');
});
it('uses locally cached cover snapshots before falling back to canvas layers', async () => {
readEditorProjectCoverCacheMock.mockResolvedValueOnce(
new Map([
[
'editor-project-local-cover',
{
projectId: 'editor-project-local-cover',
blob: new Blob(['cover'], { type: 'image/png' }),
updatedAt: '2026-06-12T09:30:00.000Z',
cachedAt: Date.now(),
},
],
]),
);
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-local-cover',
title: '本地封面项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-front',
resourceId: 'resource-front',
title: '前景图',
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-local-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
renderProjectGalleryView();
await screen.findByText('本地封面项目');
await waitFor(() => {
expect(
document
.querySelector('.project-gallery__preview img')
?.getAttribute('src'),
).toBe('blob:project-cover-cache');
});
expect(readEditorProjectCoverCacheMock).toHaveBeenCalledWith([
'editor-project-local-cover',
]);
});
it('opens the login modal when project list loading is unauthorized', async () => {
const openLoginModal = vi.fn();
listEditorProjectsMock.mockRejectedValueOnce(
@@ -190,9 +306,7 @@ describe('ProjectGalleryView', () => {
render(
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
<ProjectGalleryView
onOpenProject={vi.fn()}
/>
<ProjectGalleryView onOpenProject={vi.fn()} />
</AuthUiContext.Provider>,
);
@@ -211,14 +325,14 @@ describe('ProjectGalleryView', () => {
render(
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
<ProjectGalleryView
onOpenProject={onOpenProject}
/>
<ProjectGalleryView onOpenProject={onOpenProject} />
</AuthUiContext.Provider>,
);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
await user.click(
screen.getByRole('button', { name: '打开项目角色设定板' }),
);
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
expect(onOpenProject).not.toHaveBeenCalled();
@@ -248,7 +362,9 @@ describe('ProjectGalleryView', () => {
renderProjectGalleryView();
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' }));
await user.click(
screen.getByRole('button', { name: '打开项目角色设定板菜单' }),
);
await user.click(screen.getByRole('menuitem', { name: //u }));
await user.clear(screen.getByLabelText('项目名称'));
await user.type(screen.getByLabelText('项目名称'), '新角色设定板');
@@ -260,7 +376,9 @@ describe('ProjectGalleryView', () => {
);
await screen.findByText('新角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目场景草图菜单' }));
await user.click(
screen.getByRole('button', { name: '打开项目场景草图菜单' }),
);
await user.click(screen.getByRole('menuitem', { name: //u }));
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
+128 -42
View File
@@ -7,7 +7,7 @@ import {
Square,
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
@@ -17,6 +17,7 @@ import {
listEditorProjects,
renameEditorProject,
} from '../../services/image-editor/editorProjectClient';
import { readEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
@@ -30,7 +31,10 @@ import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { resolveProjectCoverSnapshotResource } from './ProjectCanvasCover';
import {
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
resolveProjectCoverResource,
} from './ProjectCanvasCover';
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
@@ -41,6 +45,12 @@ type RenameDraft = {
title: string;
};
type ProjectCoverObjectUrl = {
projectId: string;
url: string;
updatedAt: string;
};
function isUnauthorizedError(error: unknown) {
return error instanceof ApiClientError && error.status === 401;
}
@@ -58,9 +68,7 @@ function formatProjectUpdatedAt(value: string) {
}).format(date);
}
export function ProjectGalleryView({
onOpenProject,
}: ProjectGalleryViewProps) {
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const authUi = useAuthUi();
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -72,19 +80,60 @@ export function ProjectGalleryView({
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(
() => new Set(),
);
const [localCoverObjectUrls, setLocalCoverObjectUrls] = useState<
ProjectCoverObjectUrl[]
>([]);
const localCoverObjectUrlsRef = useRef<ProjectCoverObjectUrl[]>([]);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const selectedCount = selectedProjectIds.size;
const allSelected =
projects.length > 0 && selectedProjectIds.size === projects.length;
const localCoverUrlByProjectId = useMemo(
() =>
new Map(
localCoverObjectUrls.map((cover) => [cover.projectId, cover] as const),
),
[localCoverObjectUrls],
);
const replaceLocalCoverObjectUrls = useCallback(
(nextCoverObjectUrls: ProjectCoverObjectUrl[]) => {
localCoverObjectUrlsRef.current.forEach((cover) =>
URL.revokeObjectURL(cover.url),
);
localCoverObjectUrlsRef.current = nextCoverObjectUrls;
setLocalCoverObjectUrls(nextCoverObjectUrls);
},
[],
);
const refreshProjects = useCallback(() => {
setIsLoading(true);
setErrorMessage(null);
listEditorProjects()
.then((items) => {
setProjects(items);
void readEditorProjectCoverCache(
items.map((project) => project.projectId),
)
.then((cachedCovers) => {
const nextCoverObjectUrls = [...cachedCovers.values()].map(
(cover) => ({
projectId: cover.projectId,
url: URL.createObjectURL(cover.blob),
updatedAt: cover.updatedAt,
}),
);
replaceLocalCoverObjectUrls(nextCoverObjectUrls);
})
.catch(() => {
replaceLocalCoverObjectUrls([]);
});
setSelectedProjectIds((currentIds) => {
const availableIds = new Set(items.map((project) => project.projectId));
const availableIds = new Set(
items.map((project) => project.projectId),
);
return new Set(
[...currentIds].filter((projectId) => availableIds.has(projectId)),
);
@@ -100,7 +149,16 @@ export function ProjectGalleryView({
);
})
.finally(() => setIsLoading(false));
}, [authUi]);
}, [authUi, replaceLocalCoverObjectUrls]);
useEffect(() => {
return () => {
localCoverObjectUrlsRef.current.forEach((cover) =>
URL.revokeObjectURL(cover.url),
);
localCoverObjectUrlsRef.current = [];
};
}, []);
useEffect(() => {
refreshProjects();
@@ -138,34 +196,33 @@ export function ProjectGalleryView({
});
}, []);
const deleteProjects = useCallback(
(projectIds: string[]) => {
if (projectIds.length === 0) {
return;
}
setErrorMessage(null);
void Promise.all(projectIds.map((projectId) => deleteEditorProject(projectId)))
.then(() => {
setProjects((currentProjects) =>
currentProjects.filter(
(project) => !projectIds.includes(project.projectId),
),
);
setSelectedProjectIds((currentIds) => {
const nextIds = new Set(currentIds);
projectIds.forEach((projectId) => nextIds.delete(projectId));
return nextIds;
});
setActiveMenuProjectId(null);
})
.catch((error: unknown) => {
setErrorMessage(
error instanceof Error ? error.message : '删除项目失败',
);
const deleteProjects = useCallback((projectIds: string[]) => {
if (projectIds.length === 0) {
return;
}
setErrorMessage(null);
void Promise.all(
projectIds.map((projectId) => deleteEditorProject(projectId)),
)
.then(() => {
setProjects((currentProjects) =>
currentProjects.filter(
(project) => !projectIds.includes(project.projectId),
),
);
setSelectedProjectIds((currentIds) => {
const nextIds = new Set(currentIds);
projectIds.forEach((projectId) => nextIds.delete(projectId));
return nextIds;
});
},
[],
);
setActiveMenuProjectId(null);
})
.catch((error: unknown) => {
setErrorMessage(
error instanceof Error ? error.message : '删除项目失败',
);
});
}, []);
const submitRename = useCallback(() => {
if (!renameDraft) {
@@ -197,7 +254,28 @@ export function ProjectGalleryView({
() =>
projects.map((project) => {
const selected = selectedProjectIds.has(project.projectId);
const coverSnapshot = resolveProjectCoverSnapshotResource(project);
const localCover = localCoverUrlByProjectId.get(project.projectId);
const projectWithLocalCover = localCover
? {
...project,
resources: [
...project.resources,
{
resourceId: `local-cover-cache:${project.projectId}`,
projectId: project.projectId,
imageSrc: localCover.url,
width: 320,
height: 240,
sourceType: 'uploaded' as const,
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
updatedAt: localCover.updatedAt,
},
],
}
: project;
const coverResource = resolveProjectCoverResource(
projectWithLocalCover,
);
return (
<article
key={project.projectId}
@@ -225,16 +303,16 @@ export function ProjectGalleryView({
aria-label={`打开项目${project.title}`}
>
<PlatformMediaFrame
src={coverSnapshot?.imageSrc ?? null}
objectKey={coverSnapshot?.objectKey ?? null}
src={coverResource?.imageSrc ?? null}
objectKey={coverResource?.objectKey ?? null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
refreshKey={
coverSnapshot?.updatedAt ??
coverSnapshot?.createdAt ??
coverSnapshot?.resourceId ??
coverResource?.updatedAt ??
coverResource?.createdAt ??
coverResource?.resourceId ??
null
}
className="project-gallery__preview"
@@ -297,6 +375,7 @@ export function ProjectGalleryView({
authUi,
deleteProjects,
isSelectionMode,
localCoverUrlByProjectId,
onOpenProject,
projects,
selectedProjectIds,
@@ -372,7 +451,10 @@ export function ProjectGalleryView({
>
</PlatformActionButton>
<PlatformActionButton type="submit" form="project-gallery-rename-form">
<PlatformActionButton
type="submit"
form="project-gallery-rename-form"
>
</PlatformActionButton>
</>
@@ -433,7 +515,11 @@ export function ProjectGalleryView({
<Trash2 className="h-4 w-4" />
</PlatformActionButton>
<PlatformActionButton tone="secondary" size="sm" onClick={closeSelectionMode}>
<PlatformActionButton
tone="secondary"
size="sm"
onClick={closeSelectionMode}
>
</PlatformActionButton>
</PlatformBatchActionToolbar>
@@ -0,0 +1,152 @@
const PROJECT_COVER_CACHE_DB_NAME = 'genarrative-editor-project-covers';
const PROJECT_COVER_CACHE_DB_VERSION = 1;
const PROJECT_COVER_CACHE_STORE_NAME = 'covers';
const PROJECT_COVER_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export type CachedEditorProjectCover = {
projectId: string;
blob: Blob;
updatedAt: string;
cachedAt: number;
};
function canUseIndexedDb() {
return typeof indexedDB !== 'undefined';
}
function idbRequestToPromise<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => {
reject(request.error ?? new Error('IndexedDB request failed'));
});
});
}
function openProjectCoverCacheDb() {
return new Promise<IDBDatabase>((resolve, reject) => {
if (!canUseIndexedDb()) {
reject(new Error('IndexedDB unavailable'));
return;
}
const request = indexedDB.open(
PROJECT_COVER_CACHE_DB_NAME,
PROJECT_COVER_CACHE_DB_VERSION,
);
request.addEventListener('upgradeneeded', () => {
const db = request.result;
if (!db.objectStoreNames.contains(PROJECT_COVER_CACHE_STORE_NAME)) {
db.createObjectStore(PROJECT_COVER_CACHE_STORE_NAME, {
keyPath: 'projectId',
});
}
});
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => {
reject(request.error ?? new Error('IndexedDB open failed'));
});
});
}
async function withProjectCoverCacheStore<T>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>,
) {
const db = await openProjectCoverCacheDb();
try {
const transaction = db.transaction(PROJECT_COVER_CACHE_STORE_NAME, mode);
const result = await idbRequestToPromise(
run(transaction.objectStore(PROJECT_COVER_CACHE_STORE_NAME)),
);
await new Promise<void>((resolve, reject) => {
transaction.addEventListener('complete', () => resolve());
transaction.addEventListener('abort', () => {
reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
});
transaction.addEventListener('error', () => {
reject(transaction.error ?? new Error('IndexedDB transaction failed'));
});
});
return result;
} finally {
db.close();
}
}
function isFreshCachedCover(
cover: CachedEditorProjectCover | undefined,
): cover is CachedEditorProjectCover {
return Boolean(
cover?.projectId &&
cover.blob instanceof Blob &&
cover.cachedAt > Date.now() - PROJECT_COVER_CACHE_MAX_AGE_MS,
);
}
export async function putEditorProjectCoverCache({
projectId,
blob,
updatedAt = new Date().toISOString(),
}: {
projectId: string;
blob: Blob;
updatedAt?: string;
}) {
const normalizedProjectId = projectId.trim();
if (!normalizedProjectId || !canUseIndexedDb()) {
return;
}
try {
await withProjectCoverCacheStore('readwrite', (store) =>
store.put({
projectId: normalizedProjectId,
blob,
updatedAt,
cachedAt: Date.now(),
} satisfies CachedEditorProjectCover),
);
} catch {
// IndexedDB is best-effort local fallback only.
}
}
export async function readEditorProjectCoverCache(
projectIds: readonly string[],
) {
const normalizedProjectIds = [
...new Set(projectIds.map((id) => id.trim())),
].filter(Boolean);
if (!normalizedProjectIds.length || !canUseIndexedDb()) {
return new Map<string, CachedEditorProjectCover>();
}
let db: IDBDatabase | null = null;
try {
db = await openProjectCoverCacheDb();
const transaction = db.transaction(
PROJECT_COVER_CACHE_STORE_NAME,
'readonly',
);
const store = transaction.objectStore(PROJECT_COVER_CACHE_STORE_NAME);
const covers = await Promise.all(
normalizedProjectIds.map((projectId) =>
idbRequestToPromise(
store.get(projectId) as IDBRequest<
CachedEditorProjectCover | undefined
>,
),
),
);
return new Map(
covers
.filter(isFreshCachedCover)
.map((cover) => [cover.projectId, cover]),
);
} catch {
return new Map<string, CachedEditorProjectCover>();
} finally {
db?.close();
}
}