Files
Edu/apps/parent-portal/src/app/providers.tsx
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

108 lines
3.4 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.
// 客户端 ProvidersQueryClient + Urql + MSW + ErrorBoundary + SW + OTel + WebVitals
// 依据02-architecture-design.md §4.3 状态管理分层、§8 MSW、§12 可观测性
// - NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
// - QueryClient 单例useState 保持稳定)
// - Urql client 单例
// - ErrorBoundary 包裹全局渲染异常兜底
// - Service Worker 注册PWA 离线缓存P6-5
// - OTel browser SDK 初始化P6-1条件启用
// - Web Vitals 采集初始化P6-1生产环境
"use client";
import { useState, useEffect, type ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { Provider as UrqlProvider } from "urql";
import { createQueryClient } from "@/lib/query-client";
import { getGraphQLClient } from "@/lib/graphql-client";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { WebVitalsInitializer } from "@/components/WebVitalsInitializer";
const isMockingEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(() => createQueryClient());
const [urqlClient] = useState(() => getGraphQLClient());
const [mswReady, setMswReady] = useState(!isMockingEnabled);
// MSW 初始化(仅浏览器 + mock 开启时)
useEffect(() => {
if (!isMockingEnabled || typeof window === "undefined") return;
let active = true;
(async () => {
try {
const { worker } = await import("@/test/mocks/browser");
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
if (active) setMswReady(true);
} catch (err) {
// MSW 启动失败不阻塞应用
console.warn("[MSW] 启动失败,将使用真实 API", err);
if (active) setMswReady(true);
}
})();
return () => {
active = false;
};
}, []);
// Service Worker 注册PWAP6-5
useEffect(() => {
if (typeof window === "undefined") return;
if (process.env.NODE_ENV !== "production") return;
if (!("serviceWorker" in navigator)) return;
const registerSW = async () => {
try {
// MSW 启用时不注册 PWA Service Worker避免冲突
if (isMockingEnabled) return;
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch {
// SW 注册失败不阻塞应用
}
};
registerSW();
}, []);
// OTel browser SDK 初始化P6-1条件启用NEXT_PUBLIC_OTEL_ENABLED=true
useEffect(() => {
if (typeof window === "undefined") return;
if (process.env.NODE_ENV !== "production") return;
void import("@/lib/observability/otel").then(({ initOTel }) => {
void initOTel();
});
}, []);
// MSW 未就绪时显示加载态(避免 mock 数据未注入时闪烁)
if (!mswReady) {
return (
<div
role="status"
aria-live="polite"
className="flex min-h-screen items-center justify-center"
>
<span className="skeleton h-8 w-48 rounded" />
</div>
);
}
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<UrqlProvider value={urqlClient}>
<WebVitalsInitializer />
{children}
</UrqlProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}