import { getCacheScope, scopedKey } from './scope.js'; /** * IndexedDB 缓存层(无第三方依赖)。 * * 设计: * - 一个数据库 classhelper-cache,按业务域拆分为多个 object store * - 每条记录 = { key, value, updatedAt },key 形如 `${classId}` / `${classId}:${week}` * - 所有操作都包成 Promise;任何一步失败都不会抛出到 UI(离线时降级为空) */ export const CACHE_STORES = [ 'courseware', 'profile', 'classes', 'schedules', 'homeworks', 'notifications', 'grades', ] as const; export type CacheStore = (typeof CACHE_STORES)[number]; export interface CacheRecord { key: string; value: T; updatedAt: number; } export interface CacheStoreStat { store: CacheStore; count: number; updatedAt: number | null; } const DB_NAME = 'classhelper-cache'; const DB_VERSION = 2; let databasePromise: Promise | null = null; function openDatabase(): Promise { if (databasePromise) return databasePromise; let failed = false; const opening = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; for (const store of CACHE_STORES) { if (!db.objectStoreNames.contains(store)) { db.createObjectStore(store, { keyPath: 'key' }); } } }; request.onsuccess = () => { const db = request.result; if (failed) { db.close(); return; } const reset = (): void => { if (databasePromise === opening) databasePromise = null; }; db.onversionchange = () => { db.close(); reset(); }; db.onclose = reset; resolve(db); }; request.onerror = () => { failed = true; reject(request.error ?? new Error('IndexedDB 打开失败')); }; request.onblocked = () => { failed = true; reject(new Error('IndexedDB 被其它窗口占用')); }; }); databasePromise = opening; void opening.catch(() => { if (databasePromise === opening) databasePromise = null; }); return opening; } function runRequest( store: CacheStore, mode: IDBTransactionMode, action: (objectStore: IDBObjectStore) => IDBRequest, ): Promise { return openDatabase().then( (db) => new Promise((resolve, reject) => { const transaction = db.transaction(store, mode); const request = action(transaction.objectStore(store)); transaction.oncomplete = () => resolve(request.result as T); transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB 事务已中止')); request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败')); }), ); } /** * 转成可被 IndexedDB 结构化克隆的纯数据。 * * 关键:Vue 的 reactive/ref 包装对象是 Proxy,直接 put 会抛 * "could not be cloned"。缓存内容都是后端 JSON 响应,因此用 JSON 往返即可, * 既去掉代理又保证与 DTO(日期为 ISO 字符串)一致。 */ function toPlain(value: T): T { if (value === undefined) return null as unknown as T; try { return JSON.parse(JSON.stringify(value)) as T; } catch { return value; } } export async function cacheSet( store: CacheStore, key: string, value: T, scope = getCacheScope(), ): Promise { const record: CacheRecord = { key: store === 'profile' ? key : scopedKey(key, scope), value: toPlain(value), updatedAt: Date.now(), }; await runRequest(store, 'readwrite', (objectStore) => objectStore.put(record)); } export function cacheGet( store: CacheStore, key: string, scope = getCacheScope(), ): Promise | null> { const resolvedKey = store === 'profile' ? key : scopedKey(key, scope); return runRequest | undefined>(store, 'readonly', (objectStore) => objectStore.get(resolvedKey), ).then((record) => record ?? null); } export function cacheList(store: CacheStore): Promise>> { return runRequest>>(store, 'readonly', (objectStore) => objectStore.getAll()); } export async function cacheRemove(store: CacheStore, key: string): Promise { const resolvedKey = store === 'profile' ? key : scopedKey(key); await runRequest(store, 'readwrite', (objectStore) => objectStore.delete(resolvedKey)); } export async function cacheClearStore(store: CacheStore): Promise { await runRequest(store, 'readwrite', (objectStore) => objectStore.clear()); } export async function cacheClearAll(): Promise { for (const store of CACHE_STORES) { await cacheClearStore(store); } } /** 各业务域的条目数与最近更新时间,用于「设置」页展示离线缓存状况 */ export async function cacheStats(): Promise { const stats: CacheStoreStat[] = []; for (const store of CACHE_STORES) { const records = await cacheList(store); const latest = records.reduce( (max, record) => (max === null || record.updatedAt > max ? record.updatedAt : max), null, ); stats.push({ store, count: records.length, updatedAt: latest }); } return stats; }