feat(desktop-client): 阶段4-6 Electron EXE 客户端(登录/课表/作业/通知/成绩/设置 + Socket.IO 实时 + IndexedDB 离线缓存与自动同步 + electron-builder 打包)

This commit is contained in:
LoveChengke committed 2026-09-11 22:46:31 +08:00
1 parent 4566d7cd5e
commit 6e4c7c1050
42 files changed
+5767 -60

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
/**
* IndexedDB 缓存层(无第三方依赖)。
*
* 设计:
* - 一个数据库 classhelper-cache,按业务域拆分为多个 object store
* - 每条记录 = { key, value, updatedAt },key 形如 `${classId}` / `${classId}:${week}`
* - 所有操作都包成 Promise;任何一步失败都不会抛出到 UI(离线时降级为空)
*/
export const CACHE_STORES = [
'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 = 1;
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
if (databasePromise) return databasePromise;
databasePromise = 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 = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 打开失败'));
request.onblocked = () => reject(new Error('IndexedDB 被其它窗口占用'));
});
return databasePromise;
}
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));
request.onsuccess = () => resolve(request.result as T);
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): Promise<void> {
const record: CacheRecord<T> = { key, value: toPlain(value), updatedAt: Date.now() };
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
}
export function cacheGet<T>(store: CacheStore, key: string): Promise<CacheRecord<T> | null> {
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
objectStore.get(key),
).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> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(key));
}
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;
}
+54
View File
@@ -0,0 +1,54 @@
import { cacheGet, cacheSet, type CacheStore } from './db.js';
export interface CachedFetchResult<T> {
data: T;
/** true 表示本次数据来自本地缓存(网络失败或未联网) */
fromCache: boolean;
/** 缓存写入时间(来自缓存时有效) */
updatedAt: number | null;
/** 网络失败原因(成功时为 null) */
error: string | null;
}
/**
* 带离线回退的取数:
* 1) 先请求网络,成功则写入缓存并返回
* 2) 网络失败则读取缓存;缓存缺失时返回调用方提供的兜底值
*
* 「联网后自动同步」由调用方在重连事件里再次调用本函数完成。
*/
export async function fetchWithCache<T>(
store: CacheStore,
key: string,
loader: () => Promise<T>,
fallback: T,
): Promise<CachedFetchResult<T>> {
try {
const data = await loader();
await cacheSet(store, key, data);
return { data, fromCache: false, updatedAt: Date.now(), error: null };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const cached = await cacheGet<T>(store, key).catch(() => null);
if (cached) {
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
}
return { data: fallback, fromCache: false, updatedAt: null, error: message };
}
}
/** 只读缓存(不再发请求),用于启动时先渲染旧数据 */
export async function readCache<T>(
store: CacheStore,
key: string,
fallback: T,
): Promise<CachedFetchResult<T>> {
const cached = await cacheGet<T>(store, key).catch(() => null);
if (cached) return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: null };
return { data: fallback, fromCache: false, updatedAt: null, error: null };
}
/** 把实时推送的数据直接写入缓存,保证离线时看到的是最新内容 */
export async function writeCache<T>(store: CacheStore, key: string, value: T): Promise<void> {
await cacheSet(store, key, value);
}
+152
View File
@@ -0,0 +1,152 @@
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
import { fetchWithCache } from './index.js';
export interface SmokeCheckResult {
ok: boolean;
detail: string;
}
/**
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
* 由主进程通过 webContents.executeJavaScript 调用。
*/
export async function cacheSelfTest(): Promise<SmokeCheckResult> {
const key = '__smoke_selftest__';
const payload = { text: 'ok', at: Date.now() };
await cacheSet('profile', key, payload);
const readBack = await cacheGet<typeof payload>('profile', key);
const stats = await cacheStats();
await cacheRemove('profile', key);
const removed = await cacheGet<typeof payload>('profile', key);
const ok = readBack?.value.text === 'ok' && removed === null && stats.length > 0;
return {
ok,
detail: `readBack=${readBack?.value.text ?? '-'} removed=${removed === null} stores=${stats.length}`,
};
}
/**
* 冒烟自检 2:断网回退。
* 先写入缓存,再向一个不存在的端口发请求(必然失败),
* 验证 fetchWithCache 能回退到本地缓存。
*/
export async function offlineScenario(): Promise<SmokeCheckResult> {
const key = '__smoke_offline__';
const payload = [{ id: 'offline-1', title: '离线缓存中的作业' }];
await cacheSet('homeworks', key, payload);
const result = await fetchWithCache<Array<{ id: string; title: string }>>(
'homeworks',
key,
async () => {
const response = await fetch('http://127.0.0.1:59999/api/homeworks');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Array<{ id: string; title: string }>;
},
[],
);
await cacheRemove('homeworks', key);
const ok = result.fromCache === true && result.data.length === 1 && Boolean(result.error);
return {
ok,
detail: `fromCache=${result.fromCache} items=${result.data.length} error=${result.error ?? '-'}`,
};
}
/**
* 冒烟自检 3(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
*/
export async function onlineScenario(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'),
import('../stores/app.js'),
import('../stores/realtime.js'),
import('../api/index.js'),
import('./index.js'),
]);
const auth = useAuthStore();
const appStore = useAppStore();
const realtime = useRealtimeStore();
const serverUrl = 'http://127.0.0.1:4000';
const detail: string[] = [];
try {
await auth.login(serverUrl, 'student01', 'student123');
detail.push(`login=${auth.user?.name ?? '-'}`);
await appStore.init(serverUrl);
detail.push(`reachable=${appStore.serverReachable}`);
const [classes, homeworks, notifications, grades] = await Promise.all([
api.classApi.list(),
api.homeworkApi.list(),
api.notificationApi.list(),
api.gradeApi.my(),
]);
const term = await api.dashboardApi.term();
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
detail.push(`classes=${classes.length}`);
detail.push(`homeworks=${homeworks.length}`);
detail.push(`notifications=${notifications.length}`);
detail.push(`grades=${grades.length}`);
detail.push(`week${term.currentWeek}Schedules=${schedules.length}`);
// 写入缓存(离线可用的前提)
await cache.writeCache('homeworks', 'self', homeworks);
await cache.writeCache('grades', 'my', grades);
await cache.writeCache('notifications', 'self', notifications);
// Socket.IO:连接并等待服务端回执
if (auth.token) {
realtime.connect(serverUrl, auth.token);
const connected = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 8000);
const handler = (): void => {
clearTimeout(timer);
resolve(true);
};
realtime.on('__connected__', handler);
if (realtime.connected) handler();
});
detail.push(`socket=${connected}`);
if (!connected) {
realtime.disconnect();
await auth.logout();
return { ok: false, detail: `${detail.join(' ')} (实时通道未连接)` };
}
}
const ok =
classes.length === 1 &&
schedules.length > 0 &&
homeworks.length > 0 &&
notifications.length > 0 &&
grades.length > 0 &&
realtime.connected;
realtime.disconnect();
await auth.logout(); // 清理登录态,保证下次冒烟从登录页开始
return { ok, detail: detail.join(' ') };
} catch (error) {
realtime.disconnect();
await auth.logout().catch(() => undefined);
return {
ok: false,
detail: `${detail.join(' ')} error=${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = { cacheSelfTest, offlineScenario, onlineScenario };
}