28e11c4718
新增项目封面 IndexedDB 本地缓存。 封面 Blob 生成后先写入本地缓存,上传失败仍可展示。 项目列表优先服务端封面,其次本地缓存,再回退画布图层。 补充封面兜底测试和项目记忆文档。
153 lines
4.2 KiB
TypeScript
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();
|
|
}
|
|
}
|