feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现
- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步 - P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote - P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告 - P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移 - P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块 - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页) - classes/[id] 详情 + classes/schedule 课表 - course-plans(2 页)/diagnostic(2 页)/error-book/practice - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析 - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考 - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡 - homework/submissions 列表 + assignments/[id]/submissions 批量批改 - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改 - lesson-plans 编辑器 + library + calendar + heatmap 5 页 - elective 选修课 3 页 /leave 请假 /schedule-changes 调课 - P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports - 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序) - viewports.ts 扩展 11 个新导航项 - 验证:tsc --noEmit 零错误 + eslint 零错误 - 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
This commit is contained in:
131
apps/teacher-portal/src/lib/observability/web-vitals.ts
Normal file
131
apps/teacher-portal/src/lib/observability/web-vitals.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user