/** * Web Vitals RUM 采集(P6 硬化) * * - 参考 student-portal 和 admin-portal 的实现 * - 使用 web-vitals 库的 onCLS / onINP / onLCP / onTTFB / onFCP * - 将指标上报到 /api/v1/admin/web-vitals(navigator.sendBeacon) * - 导出 initWebVitals() + WebVitalMetric 类型 * * 关联:02-architecture-design.md §12 可观测性 */ import { getObservabilityConfig, isWebVitalsReportingEnabled } from "@/lib/observability/env"; /** * Web Vital 指标结构(与 web-vitals 库 Metric 对齐)。 */ export interface WebVitalMetric { /** 指标名(LCP / CLS / INP / FCP / TTFB) */ name: string; /** 指标值 */ value: number; /** 评级(good / needs-improvement / poor) */ rating: string; /** 指标唯一标识 */ id: string; /** 增量值(部分指标使用) */ delta?: number; } /** * web-vitals 库的回调函数类型(最小声明)。 * 避免对未安装包的静态类型依赖。 */ type MetricCallback = (metric: WebVitalMetric) => void; interface WebVitalsLib { onLCP(cb: MetricCallback): void; onCLS(cb: MetricCallback): void; onFCP(cb: MetricCallback): void; onINP(cb: MetricCallback): void; onTTFB(cb: MetricCallback): void; } /** 上报状态标记,避免重复注册回调 */ let initialized = false; /** * 上报单个 Web Vital 指标。 * * 使用 navigator.sendBeacon 优先(页面卸载时不丢失), * 降级到 fetch keepalive。 */ function sendMetric(metric: WebVitalMetric): void { if (typeof window === "undefined") return; if (!isWebVitalsReportingEnabled()) return; const config = getObservabilityConfig(); const payload = { name: metric.name, value: metric.value, rating: metric.rating, id: metric.id, delta: metric.delta, service: config.serviceName, portal: "teacher", page: window.location.pathname, timestamp: Date.now(), }; try { const body = JSON.stringify(payload); if (typeof navigator !== "undefined" && navigator.sendBeacon) { const blob = new Blob([body], { type: "application/json" }); navigator.sendBeacon(config.webVitalsEndpoint, blob); return; } // sendBeacon 不可用时降级 fetch keepalive void fetch(config.webVitalsEndpoint, { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true, }).catch(() => { // 上报失败不影响用户体验,静默忽略 }); } catch { // 上报失败静默忽略 } } /** * 初始化 Web Vitals 采集。 * * 使用动态 import 加载 web-vitals 库,避免未安装包导致构建失败。 * 包将在统一安装阶段补充到 package.json。 */ export async function initWebVitals(): Promise { if (initialized) return; if (typeof window === "undefined") return; try { const webVitals = (await import("web-vitals")) as unknown as WebVitalsLib; webVitals.onLCP(sendMetric); webVitals.onCLS(sendMetric); webVitals.onFCP(sendMetric); webVitals.onINP(sendMetric); webVitals.onTTFB(sendMetric); initialized = true; } catch (err) { // web-vitals 未安装或加载失败,降级到无采集 if (typeof console !== "undefined") { console.warn("[teacher-portal] web-vitals 加载失败", err); } } } /** * 手动上报单个 Web Vital 指标(供 Next.js reportWebVitals 使用)。 * * 用法:在 layout.tsx 中 export function reportWebVitals(metric) { sendWebVital(metric); } */ export function reportWebVitals(metric: WebVitalMetric): void { sendMetric(metric); } /** Web Vitals 是否已初始化 */ export function isWebVitalsInitialized(): boolean { return initialized; }