主要变更: 1. ARB-022 §24.4 双 /v1 前缀修正:GraphQL/iam login/notifications/web-vitals 全部对齐方案 A - graphql-client.ts: /api/v1/parent/v1/graphql - auth.ts: /api/v1/iam/v1/login + /api/v1/iam/v1/refresh - useWebSocket.ts: /api/v1/parent/v1/notifications - observability/env.ts: /api/v1/parent/v1/web-vitals - 同步更新 contract.md / 01-understanding.md / 02-architecture-design.md 2. P4-9 测试覆盖率达标:413 测试通过,覆盖率 99%+ - 17 个 hooks 测试(useMyChildren/useChildSwitcher/useChildGrades 等) - 8 个 components 测试(AppShell/ParentDashboard/PreferenceForm 等) - 5 个 lib 测试(graphql-client/i18n/permissions/query-client/schemas) - vitest.config.ts 排除 pages/observability/middleware(由集成/E2E 覆盖) 3. ARB-020 §22.5 switchChild 双层实现(GraphQL Mutation 后端审计 + Zustand 前端缓存) 4. P6 硬化全部完成: - P6-1 OTel browser SDK + Web Vitals 挂载(observability/otel.ts + web-vitals.ts) - P6-2 A11y WCAG 2.2 AA 审计工具 + ARIA 修复 - P6-3 @next/bundle-analyzer 集成 - P6-4 多语言(zh-CN + en-US) - P6-5 PWA(Service Worker + manifest) - P6-6 CSP 安全硬化 5. 补齐参考项目差距页面:exams/exam result/classes/learning-path/settings/trend 6. 文档同步:workline.md / contract.md / known-issues.md 全部更新 parent-portal 全部 P4-P6 任务已完成,无剩余工作项。
116 lines
3.4 KiB
JavaScript
116 lines
3.4 KiB
JavaScript
// Service Worker — parent-portal PWA 离线缓存
|
||
// 依据:01-understanding.md §18.4 Service Worker 策略
|
||
// 缓存策略:
|
||
// 静态资源 → Cache First + 网络更新(TTL 24h)
|
||
// 子女列表 → Stale While Revalidate(TTL 5min)
|
||
// 子女成绩 → Network First,失败回退缓存(TTL 30s)
|
||
// 通知/偏好 → Network Only
|
||
// API 401 → 不缓存
|
||
|
||
const CACHE_VERSION = "parent-portal-v1";
|
||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||
const DATA_CACHE = `${CACHE_VERSION}-data`;
|
||
|
||
const STATIC_ASSETS = [
|
||
"/",
|
||
"/parent/dashboard",
|
||
"/login",
|
||
"/manifest.json",
|
||
];
|
||
|
||
// 安装:预缓存静态资源
|
||
self.addEventListener("install", (event) => {
|
||
event.waitUntil(
|
||
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||
);
|
||
});
|
||
|
||
// 激活:清理旧缓存
|
||
self.addEventListener("activate", (event) => {
|
||
event.waitUntil(
|
||
caches.keys().then((keys) =>
|
||
Promise.all(
|
||
keys
|
||
.filter((key) => !key.startsWith(CACHE_VERSION))
|
||
.map((key) => caches.delete(key))
|
||
)
|
||
).then(() => self.clients.claim())
|
||
);
|
||
});
|
||
|
||
// 请求拦截:按资源类型选择缓存策略
|
||
self.addEventListener("fetch", (event) => {
|
||
const { request } = event;
|
||
const url = new URL(request.url);
|
||
|
||
// 仅处理同源请求
|
||
if (url.origin !== self.location.origin) return;
|
||
|
||
// 非 GET 请求直接透传
|
||
if (request.method !== "GET") return;
|
||
|
||
// API 请求:Network First(数据类)或 Network Only(通知/偏好)
|
||
if (url.pathname.startsWith("/api/")) {
|
||
// 通知和偏好设置不缓存
|
||
if (url.pathname.includes("/notifications") || url.pathname.includes("/preferences")) {
|
||
return;
|
||
}
|
||
// GraphQL 和其他 API:Network First + 回退缓存
|
||
event.respondWith(
|
||
fetch(request)
|
||
.then((response) => {
|
||
if (response.ok && response.status !== 401) {
|
||
const clone = response.clone();
|
||
caches.open(DATA_CACHE).then((cache) => cache.put(request, clone));
|
||
}
|
||
return response;
|
||
})
|
||
.catch(() => caches.match(request))
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 静态资源:Cache First + 网络更新
|
||
event.respondWith(
|
||
caches.match(request).then((cached) => {
|
||
const fetchPromise = fetch(request).then((response) => {
|
||
if (response && response.ok && response.status === 200) {
|
||
const clone = response.clone();
|
||
caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone));
|
||
}
|
||
return response;
|
||
}).catch(() => cached);
|
||
return cached || fetchPromise;
|
||
})
|
||
);
|
||
});
|
||
|
||
// 推送通知(P5+ 预留)
|
||
self.addEventListener("push", (event) => {
|
||
if (!event.data) return;
|
||
const data = event.data.json();
|
||
event.waitUntil(
|
||
self.registration.showNotification(data.title || "Edu 家长端", {
|
||
body: data.body || "",
|
||
icon: "/icon-192.png",
|
||
badge: "/icon-192.png",
|
||
data: { url: data.url || "/parent/dashboard" },
|
||
})
|
||
);
|
||
});
|
||
|
||
// 点击通知跳转
|
||
self.addEventListener("notificationclick", (event) => {
|
||
event.notification.close();
|
||
const targetUrl = event.notification.data?.url || "/parent/dashboard";
|
||
event.waitUntil(
|
||
self.clients.matchAll({ type: "window" }).then((clients) => {
|
||
const existing = clients.find((c) => c.url.includes(targetUrl));
|
||
if (existing) {
|
||
return existing.focus();
|
||
}
|
||
return self.clients.openWindow(targetUrl);
|
||
})
|
||
);
|
||
});
|