Files
Genarrative/src/services/image-editor/editorProjectCoverCache.ts
T
kdletters 28e11c4718 修复项目封面本地兜底
新增项目封面 IndexedDB 本地缓存。

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

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

补充封面兜底测试和项目记忆文档。
2026-06-25 21:59:05 +08:00

153 lines
4.2 KiB
TypeScript

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();
}
}