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:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View File

@@ -0,0 +1,131 @@
/**
* Web Vitals RUM 采集P6 硬化)
*
* - 参考 student-portal 和 admin-portal 的实现
* - 使用 web-vitals 库的 onCLS / onINP / onLCP / onTTFB / onFCP
* - 将指标上报到 /api/v1/admin/web-vitalsnavigator.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;
}