Files
ClassHelper/packages/desktop-client/src/renderer/cache/db.ts
T
LoveChengke c7f215a09c feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档
- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
2026-10-18 11:53:37 +08:00

178 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T> {
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<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
if (databasePromise) return databasePromise;
let failed = false;
const opening = new Promise<IDBDatabase>((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<T>(
store: CacheStore,
mode: IDBTransactionMode,
action: (objectStore: IDBObjectStore) => IDBRequest,
): Promise<T> {
return openDatabase().then(
(db) =>
new Promise<T>((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<T>(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<T>(
store: CacheStore,
key: string,
value: T,
scope = getCacheScope(),
): Promise<void> {
const record: CacheRecord<T> = {
key: store === 'profile' ? key : scopedKey(key, scope),
value: toPlain(value),
updatedAt: Date.now(),
};
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
}
export function cacheGet<T>(
store: CacheStore,
key: string,
scope = getCacheScope(),
): Promise<CacheRecord<T> | null> {
const resolvedKey = store === 'profile' ? key : scopedKey(key, scope);
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
objectStore.get(resolvedKey),
).then((record) => record ?? null);
}
export function cacheList<T>(store: CacheStore): Promise<Array<CacheRecord<T>>> {
return runRequest<Array<CacheRecord<T>>>(store, 'readonly', (objectStore) => objectStore.getAll());
}
export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
const resolvedKey = store === 'profile' ? key : scopedKey(key);
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(resolvedKey));
}
export async function cacheClearStore(store: CacheStore): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.clear());
}
export async function cacheClearAll(): Promise<void> {
for (const store of CACHE_STORES) {
await cacheClearStore(store);
}
}
/** 各业务域的条目数与最近更新时间,用于「设置」页展示离线缓存状况 */
export async function cacheStats(): Promise<CacheStoreStat[]> {
const stats: CacheStoreStat[] = [];
for (const store of CACHE_STORES) {
const records = await cacheList<unknown>(store);
const latest = records.reduce<number | null>(
(max, record) => (max === null || record.updatedAt > max ? record.updatedAt : max),
null,
);
stats.push({ store, count: records.length, updatedAt: latest });
}
return stats;
}