Files
Edu/apps/parent-portal/public/sw.js
SpecialX 7c8e0f5dea feat(parent-portal): 完成 P4-P6 全部任务 + ARB-022 §24.4 双 /v1 修正 + 413 测试通过
主要变更:
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 任务已完成,无剩余工作项。
2026-07-13 13:10:07 +08:00

116 lines
3.4 KiB
JavaScript
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.
// Service Worker — parent-portal PWA 离线缓存
// 依据01-understanding.md §18.4 Service Worker 策略
// 缓存策略:
// 静态资源 → Cache First + 网络更新TTL 24h
// 子女列表 → Stale While RevalidateTTL 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 和其他 APINetwork 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);
})
);
});