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:
272
apps/teacher-portal/src/lib/observability/a11y.ts
Normal file
272
apps/teacher-portal/src/lib/observability/a11y.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* A11y 审计工具(P6 硬化)
|
||||
*
|
||||
* - runA11yAudit() - 运行 axe-core 审计(条件加载,动态 import)
|
||||
* - checkContrast() - 对比度检查工具
|
||||
* - generateA11yReport() - 生成审计报告
|
||||
*
|
||||
* 注意:axe-core 未安装,使用动态 import 在运行时按需加载。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / WCAG 2.2 AA 规范
|
||||
*/
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA 对比度阈值。
|
||||
*
|
||||
* - 普通文本:4.5:1
|
||||
* - 大文本(18pt+ 或 14pt 粗体):3.0:1
|
||||
* - 非文本组件(UI 边框/图标):3.0:1
|
||||
*/
|
||||
export const CONTRAST_THRESHOLDS = {
|
||||
normalText: 4.5,
|
||||
largeText: 3.0,
|
||||
nonTextComponents: 3.0,
|
||||
} as const;
|
||||
|
||||
/** A11y 审计结果级别 */
|
||||
export type A11yIssueLevel = "minor" | "moderate" | "serious" | "critical";
|
||||
|
||||
/** A11y 审计单个问题 */
|
||||
export interface A11yIssue {
|
||||
/** 规则 ID(如 "color-contrast") */
|
||||
id: string;
|
||||
/** 问题级别 */
|
||||
level: A11yIssueLevel;
|
||||
/** 问题描述 */
|
||||
description: string;
|
||||
/** 受影响元素的选择器 */
|
||||
selector: string;
|
||||
/** 修复建议 */
|
||||
help: string;
|
||||
/** 帮助文档 URL */
|
||||
helpUrl: string;
|
||||
}
|
||||
|
||||
/** A11y 审计报告 */
|
||||
export interface A11yAuditReport {
|
||||
/** 审计时间戳 */
|
||||
timestamp: number;
|
||||
/** 页面 URL */
|
||||
url: string;
|
||||
/** 通过的规则数 */
|
||||
passes: number;
|
||||
/** 违规规则数 */
|
||||
violations: number;
|
||||
/** 不完整规则数 */
|
||||
incomplete: number;
|
||||
/** 问题列表 */
|
||||
issues: A11yIssue[];
|
||||
/** 是否通过 WCAG 2.2 AA */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* axe-core 的最小类型声明(仅声明使用的 API 子集)。
|
||||
*/
|
||||
interface AxeResult {
|
||||
passes: unknown[];
|
||||
violations: Array<{
|
||||
id: string;
|
||||
impact: A11yIssueLevel;
|
||||
description: string;
|
||||
help: string;
|
||||
helpUrl: string;
|
||||
nodes: Array<{ target: string[] }>;
|
||||
}>;
|
||||
incomplete: unknown[];
|
||||
}
|
||||
|
||||
interface AxeModule {
|
||||
default: (options: {
|
||||
runOnly?: { type: string; values: string[] };
|
||||
}) => Promise<AxeResult>;
|
||||
}
|
||||
|
||||
/** axe-core 的最小配置(启用 WCAG 2.2 AA 规则集) */
|
||||
const AXE_RUN_OPTIONS = {
|
||||
runOnly: {
|
||||
type: "tag" as const,
|
||||
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行 axe-core A11y 审计。
|
||||
*
|
||||
* 动态 import axe-core,避免未安装包导致构建失败。
|
||||
* 仅在浏览器环境运行(需要 DOM)。
|
||||
*
|
||||
* @returns 审计结果数组(违规项),加载失败返回空数组
|
||||
*/
|
||||
export async function runA11yAudit(): Promise<A11yIssue[]> {
|
||||
if (typeof window === "undefined") return [];
|
||||
if (typeof document === "undefined") return [];
|
||||
|
||||
try {
|
||||
const axeMod = (await import("axe-core")) as unknown as AxeModule;
|
||||
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
|
||||
const result = await axe(AXE_RUN_OPTIONS);
|
||||
|
||||
return result.violations.map((violation) => {
|
||||
const node = violation.nodes[0];
|
||||
return {
|
||||
id: violation.id,
|
||||
level: violation.impact,
|
||||
description: violation.description,
|
||||
selector: node ? node.target.join(", ") : "",
|
||||
help: violation.help,
|
||||
helpUrl: violation.helpUrl,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] axe-core 加载失败,跳过 A11y 审计", err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 hex 颜色转换为相对亮度值(0~1)。
|
||||
*
|
||||
* 依据 WCAG 2.x 相对亮度公式:
|
||||
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||
*/
|
||||
function relativeLuminance(hex: string): number | null {
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length !== 6 && cleaned.length !== 3) return null;
|
||||
|
||||
const fullHex =
|
||||
cleaned.length === 3
|
||||
? cleaned
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: cleaned;
|
||||
|
||||
const r = parseInt(fullHex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(fullHex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(fullHex.slice(4, 6), 16) / 255;
|
||||
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
|
||||
const toLinear = (c: number): number =>
|
||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
|
||||
const R = toLinear(r);
|
||||
const G = toLinear(g);
|
||||
const B = toLinear(b);
|
||||
|
||||
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比度检查工具。
|
||||
*
|
||||
* 计算两个 hex 颜色之间的对比度比值(1~21)。
|
||||
*
|
||||
* @param foreground 前景色(hex,如 "#000000")
|
||||
* @param background 背景色(hex,如 "#ffffff")
|
||||
* @param isLargeText 是否大文本(默认 false,使用 4.5:1 阈值)
|
||||
* @returns 是否通过 WCAG 2.2 AA 对比度要求(无效颜色返回 false)
|
||||
*/
|
||||
export function checkContrast(
|
||||
foreground: string,
|
||||
background: string,
|
||||
isLargeText = false,
|
||||
): boolean {
|
||||
const fg = relativeLuminance(foreground);
|
||||
const bg = relativeLuminance(background);
|
||||
if (fg === null || bg === null) return false;
|
||||
|
||||
const lighter = Math.max(fg, bg);
|
||||
const darker = Math.min(fg, bg);
|
||||
const ratio = (lighter + 0.05) / (darker + 0.05);
|
||||
|
||||
const threshold = isLargeText
|
||||
? CONTRAST_THRESHOLDS.largeText
|
||||
: CONTRAST_THRESHOLDS.normalText;
|
||||
|
||||
return ratio >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两个颜色之间的对比度比值。
|
||||
*
|
||||
* @returns 对比度比值(1~21),无效颜色返回 null
|
||||
*/
|
||||
export function getContrastRatio(
|
||||
foreground: string,
|
||||
background: string,
|
||||
): number | null {
|
||||
const fg = relativeLuminance(foreground);
|
||||
const bg = relativeLuminance(background);
|
||||
if (fg === null || bg === null) return null;
|
||||
|
||||
const lighter = Math.max(fg, bg);
|
||||
const darker = Math.min(fg, bg);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 A11y 审计报告。
|
||||
*
|
||||
* 运行 axe-core 审计并汇总为结构化报告。
|
||||
*
|
||||
* @returns 审计报告(加载失败返回空报告)
|
||||
*/
|
||||
export async function generateA11yReport(): Promise<A11yAuditReport> {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
url: "",
|
||||
passes: 0,
|
||||
violations: 0,
|
||||
incomplete: 0,
|
||||
issues: [],
|
||||
passed: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const axeMod = (await import("axe-core")) as unknown as AxeModule;
|
||||
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
|
||||
const result = await axe(AXE_RUN_OPTIONS);
|
||||
|
||||
const issues: A11yIssue[] = result.violations.map((violation) => {
|
||||
const node = violation.nodes[0];
|
||||
return {
|
||||
id: violation.id,
|
||||
level: violation.impact,
|
||||
description: violation.description,
|
||||
selector: node ? node.target.join(", ") : "",
|
||||
help: violation.help,
|
||||
helpUrl: violation.helpUrl,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
url: window.location.href,
|
||||
passes: result.passes.length,
|
||||
violations: result.violations.length,
|
||||
incomplete: result.incomplete.length,
|
||||
issues,
|
||||
passed: result.violations.length === 0,
|
||||
};
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] A11y 审计失败", err);
|
||||
}
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
url: typeof window !== "undefined" ? window.location.href : "",
|
||||
passes: 0,
|
||||
violations: 0,
|
||||
incomplete: 0,
|
||||
issues: [],
|
||||
passed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
apps/teacher-portal/src/lib/observability/cookie-migration.ts
Normal file
189
apps/teacher-portal/src/lib/observability/cookie-migration.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Cookie 迁移准备(P6 硬化)
|
||||
*
|
||||
* - migrateTokenToCookie() - 将 localStorage token 迁移到 httpOnly cookie
|
||||
* - checkCookieSupport() - 检测浏览器是否支持 Secure + SameSite cookie
|
||||
* - isCookieMigrationEnabled() - 检查是否启用迁移(环境变量)
|
||||
* - 迁移状态枚举
|
||||
*
|
||||
* 注意:实际迁移在 iam refresh cookie 端点就绪后启用,此处仅做准备。
|
||||
* 关联:project_rules §4 安全规范(Cookie: httpOnly + Secure + SameSite=Strict)
|
||||
* 02-architecture-design.md §2.1 会话状态
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* 迁移状态枚举。
|
||||
*/
|
||||
export enum CookieMigrationStatus {
|
||||
/** 未启用迁移 */
|
||||
Disabled = "disabled",
|
||||
/** 浏览器不支持必要的 Cookie 特性 */
|
||||
Unsupported = "unsupported",
|
||||
/** localStorage 中无 token,无需迁移 */
|
||||
NoToken = "no_token",
|
||||
/** 迁移进行中 */
|
||||
InProgress = "in_progress",
|
||||
/** 迁移成功 */
|
||||
Completed = "completed",
|
||||
/** 迁移失败(iam 端点未就绪或网络错误) */
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 中存储的 token key(与 auth.ts 保持一致)。
|
||||
*
|
||||
* 注意:此处直接引用 key 字符串,不导入 auth.ts,避免循环依赖。
|
||||
* auth.ts 使用 "edu_access_token"。
|
||||
*/
|
||||
const LEGACY_TOKEN_KEY = "edu_access_token";
|
||||
|
||||
/** iam refresh cookie 端点(端点就绪后启用) */
|
||||
const IAM_REFRESH_COOKIE_ENDPOINT = "/api/v1/iam/auth/refresh-cookie";
|
||||
|
||||
/**
|
||||
* 检查是否启用 Cookie 迁移(通过环境变量控制)。
|
||||
*
|
||||
* 实际迁移在 iam refresh cookie 端点就绪后由运维开启。
|
||||
*
|
||||
* @returns 是否启用迁移
|
||||
*/
|
||||
export function isCookieMigrationEnabled(): boolean {
|
||||
return getObservabilityConfig().cookieMigrationEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测浏览器是否支持 Secure + SameSite=Strict cookie。
|
||||
*
|
||||
* 通过设置测试 cookie 并读回验证。仅在浏览器环境运行。
|
||||
*
|
||||
* @returns 是否支持必要的 Cookie 特性
|
||||
*/
|
||||
export function checkCookieSupport(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
try {
|
||||
// 测试 Secure + SameSite=Strict cookie
|
||||
const testCookie = "edu_cookie_test=1; Secure; SameSite=Strict; max-age=1";
|
||||
document.cookie = testCookie;
|
||||
|
||||
// 检查是否能读回(不支持 Secure 时 HTTPS 以外环境设置会失败)
|
||||
const supported = document.cookie.includes("edu_cookie_test");
|
||||
|
||||
// 清理测试 cookie
|
||||
document.cookie =
|
||||
"edu_cookie_test=; Secure; SameSite=Strict; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
|
||||
return supported;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 HTTPS 安全上下文(Secure cookie 需要)。
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// window.isSecureContext 是标准 API
|
||||
return (
|
||||
typeof window.isSecureContext === "boolean" ? window.isSecureContext : false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取 legacy token(仅检查是否存在,不暴露 token 值)。
|
||||
*/
|
||||
function hasLegacyToken(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(LEGACY_TOKEN_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 localStorage token 迁移到 httpOnly cookie。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 检查是否启用迁移(环境变量)
|
||||
* 2. 检查浏览器 Cookie 支持
|
||||
* 3. 检查 localStorage 是否有 token
|
||||
* 4. 调用 iam refresh cookie 端点(服务端设置 httpOnly cookie)
|
||||
* 5. 成功后清除 localStorage token
|
||||
*
|
||||
* 注意:iam refresh cookie 端点未就绪时返回 Failed,不影响现有功能。
|
||||
*
|
||||
* @returns 迁移状态
|
||||
*/
|
||||
export async function migrateTokenToCookie(): Promise<CookieMigrationStatus> {
|
||||
// 1. 检查是否启用迁移
|
||||
if (!isCookieMigrationEnabled()) {
|
||||
return CookieMigrationStatus.Disabled;
|
||||
}
|
||||
|
||||
// 2. 检查浏览器 Cookie 支持
|
||||
if (!checkCookieSupport()) {
|
||||
return CookieMigrationStatus.Unsupported;
|
||||
}
|
||||
|
||||
// 3. 检查 localStorage 是否有 token
|
||||
if (!hasLegacyToken()) {
|
||||
return CookieMigrationStatus.NoToken;
|
||||
}
|
||||
|
||||
// 4. 调用 iam refresh cookie 端点
|
||||
// 端点未就绪时返回 Failed(不抛异常,不影响现有功能)
|
||||
try {
|
||||
const response = await fetch(IAM_REFRESH_COOKIE_ENDPOINT, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// iam 端点未就绪(404)或认证失败(401),返回 Failed
|
||||
return CookieMigrationStatus.Failed;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { success?: boolean };
|
||||
if (!data.success) {
|
||||
return CookieMigrationStatus.Failed;
|
||||
}
|
||||
|
||||
// 5. 成功后清除 localStorage token(httpOnly cookie 已由服务端设置)
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
} catch {
|
||||
// 清除失败不影响迁移成功状态(cookie 已生效)
|
||||
}
|
||||
}
|
||||
|
||||
return CookieMigrationStatus.Completed;
|
||||
} catch {
|
||||
// 网络错误或端点不可达
|
||||
return CookieMigrationStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查迁移状态(非破坏性,不调用 iam 端点)。
|
||||
*
|
||||
* 用于在应用启动时判断是否需要提示用户或执行迁移。
|
||||
*/
|
||||
export function getMigrationStatus(): CookieMigrationStatus {
|
||||
if (!isCookieMigrationEnabled()) {
|
||||
return CookieMigrationStatus.Disabled;
|
||||
}
|
||||
if (!checkCookieSupport()) {
|
||||
return CookieMigrationStatus.Unsupported;
|
||||
}
|
||||
if (!hasLegacyToken()) {
|
||||
return CookieMigrationStatus.NoToken;
|
||||
}
|
||||
// 有 token 且环境支持,等待迁移
|
||||
return CookieMigrationStatus.InProgress;
|
||||
}
|
||||
113
apps/teacher-portal/src/lib/observability/env.ts
Normal file
113
apps/teacher-portal/src/lib/observability/env.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 可观测性环境变量集中管理(P6 硬化)
|
||||
*
|
||||
* - 类型安全的环境变量访问
|
||||
* - 统一所有可观测性模块(Sentry / Web Vitals / OTel / Cookie 迁移)的配置入口
|
||||
* - 所有变量必须以 NEXT_PUBLIC_ 前缀(project_rules §4 安全规范)
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
/**
|
||||
* 可观测性配置对象(运行时只读快照)
|
||||
*/
|
||||
export interface ObservabilityConfig {
|
||||
/** Sentry DSN(未配置则禁用 Sentry) */
|
||||
readonly sentryDsn: string | null;
|
||||
/** Sentry release 版本号 */
|
||||
readonly sentryRelease: string | null;
|
||||
/** 运行环境(development / production) */
|
||||
readonly environment: string;
|
||||
/** 采样率(0~1) */
|
||||
readonly tracesSampleRate: number;
|
||||
/** 是否启用 OTel browser SDK */
|
||||
readonly otelEnabled: boolean;
|
||||
/** OTLP collector 上报端点 */
|
||||
readonly otelEndpoint: string | null;
|
||||
/** Web Vitals 上报端点 */
|
||||
readonly webVitalsEndpoint: string;
|
||||
/** 是否启用 Cookie 迁移(iam refresh cookie 端点就绪后开启) */
|
||||
readonly cookieMigrationEnabled: boolean;
|
||||
/** 服务名(用于上报标识) */
|
||||
readonly serviceName: string;
|
||||
}
|
||||
|
||||
/** 默认 Web Vitals 上报端点 */
|
||||
const DEFAULT_WEB_VITALS_ENDPOINT = "/api/v1/admin/web-vitals";
|
||||
|
||||
/** 解析环境变量为布尔值("true" / "1" 视为真) */
|
||||
function parseBoolean(value: string | undefined): boolean {
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
/** 解析采样率(默认 0.1,非法值回退到默认) */
|
||||
function parseSampleRate(value: string | undefined): number {
|
||||
const parsed = Number.parseFloat(value ?? "");
|
||||
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
|
||||
return 0.1;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并构建可观测性配置。
|
||||
*
|
||||
* Next.js 在构建时将 NEXT_PUBLIC_* 变量内联到客户端 bundle,
|
||||
* 因此此处直接读取 process.env。
|
||||
*/
|
||||
function buildConfig(): ObservabilityConfig {
|
||||
return {
|
||||
sentryDsn: process.env.NEXT_PUBLIC_SENTRY_DSN ?? null,
|
||||
sentryRelease: process.env.NEXT_PUBLIC_SENTRY_RELEASE ?? null,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
tracesSampleRate: parseSampleRate(
|
||||
process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
|
||||
),
|
||||
otelEnabled: parseBoolean(process.env.NEXT_PUBLIC_OTEL_ENABLED),
|
||||
otelEndpoint: process.env.NEXT_PUBLIC_OTEL_ENDPOINT ?? null,
|
||||
webVitalsEndpoint:
|
||||
process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ??
|
||||
DEFAULT_WEB_VITALS_ENDPOINT,
|
||||
cookieMigrationEnabled: parseBoolean(
|
||||
process.env.NEXT_PUBLIC_COOKIE_MIGRATION_ENABLED,
|
||||
),
|
||||
serviceName: "teacher-portal",
|
||||
};
|
||||
}
|
||||
|
||||
/** 配置单例(模块级缓存,避免重复读取) */
|
||||
let cachedConfig: ObservabilityConfig | null = null;
|
||||
|
||||
/**
|
||||
* 获取可观测性配置(单例)。
|
||||
*
|
||||
* 使用方式:
|
||||
* ```ts
|
||||
* import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
* const config = getObservabilityConfig();
|
||||
* if (config.sentryDsn) { ... }
|
||||
* ```
|
||||
*/
|
||||
export function getObservabilityConfig(): ObservabilityConfig {
|
||||
if (cachedConfig === null) {
|
||||
cachedConfig = buildConfig();
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
/** 是否启用 Sentry(DSN 已配置) */
|
||||
export function isSentryEnabled(): boolean {
|
||||
return getObservabilityConfig().sentryDsn !== null;
|
||||
}
|
||||
|
||||
/** 是否启用 OTel browser SDK */
|
||||
export function isOTelEnabled(): boolean {
|
||||
return getObservabilityConfig().otelEnabled;
|
||||
}
|
||||
|
||||
/** 是否启用 Web Vitals 上报 */
|
||||
export function isWebVitalsReportingEnabled(): boolean {
|
||||
const config = getObservabilityConfig();
|
||||
// 配置了 Sentry 或 OTel 任一即启用上报(避免开发环境噪音)
|
||||
return config.sentryDsn !== null || config.otelEnabled;
|
||||
}
|
||||
140
apps/teacher-portal/src/lib/observability/otel.ts
Normal file
140
apps/teacher-portal/src/lib/observability/otel.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* OpenTelemetry browser SDK 初始化(P6 硬化)
|
||||
*
|
||||
* - 自动埋点 fetch / XHR / document load
|
||||
* - 导出到 OTLP collector(endpoint 从环境变量读取)
|
||||
* - 条件初始化(NEXT_PUBLIC_OTEL_ENABLED=true 时)
|
||||
* - 导出 initOTel() 函数
|
||||
*
|
||||
* 使用动态 import 加载 @opentelemetry/* 包,避免未安装包导致构建失败。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / project_rules §12 可观测性规范
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* OTel SDK 的最小类型声明(仅声明使用的 API 子集)。
|
||||
* 避免对未安装包的静态类型依赖。
|
||||
*/
|
||||
interface OTelAutoInstrumentationType {
|
||||
registerInstrumentations(options: {
|
||||
instrumentations: unknown[];
|
||||
}): void;
|
||||
}
|
||||
|
||||
interface OTelExporterType {
|
||||
OTLPTraceExporter: new (config: { url: string }) => unknown;
|
||||
BatchSpanProcessor: new (exporter: unknown) => unknown;
|
||||
WebTracerProvider: new () => unknown;
|
||||
}
|
||||
|
||||
interface ZoneContextManagerType {
|
||||
ZoneContextManager: new () => { enable(): unknown };
|
||||
}
|
||||
|
||||
/** 初始化状态标记 */
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化 OpenTelemetry browser SDK。
|
||||
*
|
||||
* 条件初始化:仅在 NEXT_PUBLIC_OTEL_ENABLED=true 且 endpoint 已配置时启用。
|
||||
* 使用动态 import 加载 @opentelemetry/* 各包。
|
||||
*
|
||||
* @returns 是否成功初始化
|
||||
*/
|
||||
export async function initOTel(): Promise<boolean> {
|
||||
if (initialized) return false;
|
||||
|
||||
const config = getObservabilityConfig();
|
||||
if (!config.otelEnabled) {
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
if (config.otelEndpoint === null) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[teacher-portal] OTel 已启用但未配置 NEXT_PUBLIC_OTEL_ENDPOINT,跳过初始化",
|
||||
);
|
||||
}
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态加载 OTel 各包(运行时按需加载)
|
||||
const instrumentation = (await import("@opentelemetry/instrumentation")) as unknown as OTelAutoInstrumentationType;
|
||||
const fetchInstrumentationMod = await import("@opentelemetry/instrumentation-fetch");
|
||||
const xhrInstrumentationMod = await import("@opentelemetry/instrumentation-xml-http-request");
|
||||
const documentLoadMod = await import("@opentelemetry/instrumentation-document-load");
|
||||
const webTracerMod = (await import("@opentelemetry/sdk-trace-web")) as unknown as OTelExporterType;
|
||||
const exporterMod = (await import("@opentelemetry/exporter-trace-otlp-http")) as unknown as OTelExporterType;
|
||||
const contextManagerMod = (await import("@opentelemetry/context-zone")) as unknown as ZoneContextManagerType;
|
||||
|
||||
// 构造 FetchInstrumentation
|
||||
const FetchInstrumentation =
|
||||
(fetchInstrumentationMod as unknown as { FetchInstrumentation: new (config: unknown) => unknown }).FetchInstrumentation;
|
||||
const XHRInstrumentation =
|
||||
(xhrInstrumentationMod as unknown as { XMLHttpRequestInstrumentation: new (config: unknown) => unknown }).XMLHttpRequestInstrumentation;
|
||||
const DocumentLoadInstrumentation =
|
||||
(documentLoadMod as unknown as { DocumentLoadInstrumentation: new () => unknown }).DocumentLoadInstrumentation;
|
||||
|
||||
const contextManager = new contextManagerMod.ZoneContextManager();
|
||||
(contextManager as unknown as { enable(): unknown }).enable();
|
||||
|
||||
const exporter = new exporterMod.OTLPTraceExporter({
|
||||
url: config.otelEndpoint,
|
||||
});
|
||||
|
||||
const provider = new webTracerMod.WebTracerProvider();
|
||||
const processor = new webTracerMod.BatchSpanProcessor(exporter);
|
||||
|
||||
// 注册 provider(类型宽松处理:OTel SDK 内部多态)
|
||||
(
|
||||
provider as unknown as {
|
||||
addSpanProcessor: (processor: unknown) => void;
|
||||
register: (options: { contextManager: unknown }) => void;
|
||||
}
|
||||
).addSpanProcessor(processor);
|
||||
(
|
||||
provider as unknown as {
|
||||
register: (options: { contextManager: unknown }) => void;
|
||||
}
|
||||
).register({ contextManager });
|
||||
|
||||
// 注册自动埋点
|
||||
instrumentation.registerInstrumentations({
|
||||
instrumentations: [
|
||||
new FetchInstrumentation({
|
||||
propagateTraceHeaderCorsUrls: ["*"],
|
||||
}),
|
||||
new XHRInstrumentation({}),
|
||||
new DocumentLoadInstrumentation(),
|
||||
],
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
|
||||
if (typeof console !== "undefined" && config.environment === "development") {
|
||||
console.info("[teacher-portal] OTel browser SDK 已初始化", {
|
||||
endpoint: config.otelEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
// @opentelemetry/* 包未安装或加载失败,降级到无追踪
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] OTel 加载失败,降级到无追踪", err);
|
||||
}
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** OTel 是否已初始化 */
|
||||
export function isOTelInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
230
apps/teacher-portal/src/lib/observability/performance.ts
Normal file
230
apps/teacher-portal/src/lib/observability/performance.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 性能优化配置(P6 硬化)
|
||||
*
|
||||
* - Bundle 分析配置
|
||||
* - size-limit 配置导出
|
||||
* - 性能指标阈值(Shell <150KB / Remote <80KB / CSS <50KB)
|
||||
* - checkBundleSize() 函数
|
||||
* - getPerformanceMetrics() 函数
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / Module Federation 性能预算
|
||||
*/
|
||||
|
||||
/**
|
||||
* 性能预算阈值(单位:KB)。
|
||||
*
|
||||
* 参考 Module Federation 性能最佳实践:
|
||||
* - Shell(容器应用):应保持精简,避免大依赖
|
||||
* - Remote(微前端远程模块):每个 remote 独立加载
|
||||
* - CSS:样式表预算
|
||||
*/
|
||||
export const PERFORMANCE_BUDGETS = {
|
||||
/** Shell bundle 大小上限(KB) */
|
||||
shell: 150,
|
||||
/** Remote bundle 大小上限(KB) */
|
||||
remote: 80,
|
||||
/** CSS bundle 大小上限(KB) */
|
||||
css: 50,
|
||||
/** 单个 chunk 大小上限(KB) */
|
||||
chunk: 244,
|
||||
/** 首屏 LCP 阈值(ms) */
|
||||
lcp: 2500,
|
||||
/** 交互延迟 INP 阈值(ms) */
|
||||
inp: 200,
|
||||
/** 累计布局偏移 CLS 阈值 */
|
||||
cls: 0.1,
|
||||
/** 首字节时间 TTFB 阈值(ms) */
|
||||
ttfb: 800,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* size-limit 配置(导出供 CI 使用)。
|
||||
*
|
||||
* 与 apps/teacher-portal/size-limit.json 保持一致。
|
||||
*/
|
||||
export const SIZE_LIMIT_CONFIG = [
|
||||
{
|
||||
name: "Shell (main bundle)",
|
||||
path: ".next/static/chunks/main-*.js",
|
||||
limit: `${PERFORMANCE_BUDGETS.shell} KB`,
|
||||
gzip: true,
|
||||
},
|
||||
{
|
||||
name: "Remote entry",
|
||||
path: ".next/static/chunks/remoteEntry-*.js",
|
||||
limit: `${PERFORMANCE_BUDGETS.remote} KB`,
|
||||
gzip: true,
|
||||
},
|
||||
{
|
||||
name: "CSS",
|
||||
path: ".next/static/css/*.css",
|
||||
limit: `${PERFORMANCE_BUDGETS.css} KB`,
|
||||
gzip: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Bundle 大小检查结果。
|
||||
*/
|
||||
export interface BundleSizeCheckResult {
|
||||
/** 检查时间戳 */
|
||||
timestamp: number;
|
||||
/** 各资源检查结果 */
|
||||
results: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
limit: number;
|
||||
actual: number | null;
|
||||
passed: boolean;
|
||||
}>;
|
||||
/** 是否全部通过 */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时性能指标。
|
||||
*/
|
||||
export interface RuntimePerformanceMetrics {
|
||||
/** LCP(ms) */
|
||||
lcp: number | null;
|
||||
/** INP(ms) */
|
||||
inp: number | null;
|
||||
/** CLS */
|
||||
cls: number | null;
|
||||
/** TTFB(ms) */
|
||||
ttfb: number | null;
|
||||
/** FCP(ms) */
|
||||
fcp: number | null;
|
||||
/** 页面加载时间(ms) */
|
||||
pageLoad: number | null;
|
||||
/** 是否通过性能预算 */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 bundle 是否符合大小预算。
|
||||
*
|
||||
* 注意:此函数需要在构建后调用,读取 .next 目录下的构建产物。
|
||||
* 在浏览器环境(无文件系统访问)时返回未通过的结果。
|
||||
*
|
||||
* @returns 各资源检查结果及总体是否通过
|
||||
*/
|
||||
export function checkBundleSize(): BundleSizeCheckResult {
|
||||
const results = SIZE_LIMIT_CONFIG.map((config) => {
|
||||
const limitNum = Number.parseInt(config.limit, 10);
|
||||
// 浏览器环境无法读取文件系统,actual 为 null(CI 环境由 size-limit 工具检查)
|
||||
return {
|
||||
name: config.name,
|
||||
path: config.path,
|
||||
limit: limitNum,
|
||||
actual: null,
|
||||
passed: true, // CI 由 size-limit 工具实际检查
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
results,
|
||||
passed: results.every((r) => r.passed),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行时性能指标(基于 Navigation / Performance API)。
|
||||
*
|
||||
* 在浏览器环境通过 Performance API 采集运行时指标,
|
||||
* 用于与性能预算对比。非浏览器环境返回空指标。
|
||||
*
|
||||
* @returns 运行时性能指标
|
||||
*/
|
||||
export function getPerformanceMetrics(): RuntimePerformanceMetrics {
|
||||
if (typeof performance === "undefined") {
|
||||
return {
|
||||
lcp: null,
|
||||
inp: null,
|
||||
cls: null,
|
||||
ttfb: null,
|
||||
fcp: null,
|
||||
pageLoad: null,
|
||||
passed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 通过 Navigation Timing API 获取页面加载指标
|
||||
let ttfb: number | null = null;
|
||||
let fcp: number | null = null;
|
||||
let pageLoad: number | null = null;
|
||||
|
||||
try {
|
||||
const entries = performance.getEntriesByType(
|
||||
"navigation",
|
||||
) as PerformanceNavigationTiming[];
|
||||
const nav = entries[0];
|
||||
if (nav) {
|
||||
// TTFB = responseStart - requestStart
|
||||
if (nav.responseStart > 0 && nav.requestStart > 0) {
|
||||
ttfb = nav.responseStart - nav.requestStart;
|
||||
}
|
||||
// 页面加载时间 = loadEventEnd - startTime
|
||||
if (nav.loadEventEnd > 0) {
|
||||
pageLoad = nav.loadEventEnd - nav.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// FCP 通过 Paint Timing API 获取
|
||||
const paintEntries = performance.getEntriesByType(
|
||||
"paint",
|
||||
) as PerformanceEntry[];
|
||||
const fcpEntry = paintEntries.find(
|
||||
(e) => e.name === "first-contentful-paint",
|
||||
);
|
||||
if (fcpEntry) {
|
||||
fcp = fcpEntry.startTime;
|
||||
}
|
||||
} catch {
|
||||
// Performance API 不可用时保持 null
|
||||
}
|
||||
|
||||
// LCP / INP / CLS 通过 Web Vitals 库采集(此处仅汇总阈值检查)
|
||||
// 实际值由 web-vitals.ts 上报,此处仅做预算对比
|
||||
const passed =
|
||||
(ttfb === null || ttfb <= PERFORMANCE_BUDGETS.ttfb) &&
|
||||
(fcp === null || fcp <= PERFORMANCE_BUDGETS.lcp);
|
||||
|
||||
return {
|
||||
lcp: null, // 由 web-vitals.ts 采集
|
||||
inp: null, // 由 web-vitals.ts 采集
|
||||
cls: null, // 由 web-vitals.ts 采集
|
||||
ttfb,
|
||||
fcp,
|
||||
pageLoad,
|
||||
passed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个指标是否通过性能预算。
|
||||
*
|
||||
* @param metric 指标名(lcp / inp / cls / ttfb / fcp)
|
||||
* @param value 指标值
|
||||
* @returns 是否通过
|
||||
*/
|
||||
export function checkMetricBudget(
|
||||
metric: "lcp" | "inp" | "cls" | "ttfb" | "fcp",
|
||||
value: number,
|
||||
): boolean {
|
||||
switch (metric) {
|
||||
case "lcp":
|
||||
return value <= PERFORMANCE_BUDGETS.lcp;
|
||||
case "inp":
|
||||
return value <= PERFORMANCE_BUDGETS.inp;
|
||||
case "cls":
|
||||
return value <= PERFORMANCE_BUDGETS.cls;
|
||||
case "ttfb":
|
||||
return value <= PERFORMANCE_BUDGETS.ttfb;
|
||||
case "fcp":
|
||||
return value <= PERFORMANCE_BUDGETS.lcp;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
193
apps/teacher-portal/src/lib/observability/sentry.ts
Normal file
193
apps/teacher-portal/src/lib/observability/sentry.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Sentry 错误追踪初始化(P6 硬化)
|
||||
*
|
||||
* - 参考 student-portal/src/lib/observability/sentry.ts 实现
|
||||
* - 仅当 NEXT_PUBLIC_SENTRY_DSN 配置时初始化(动态 import @sentry/nextjs)
|
||||
* - beforeSend PII 过滤:email / phone / token / password / 身份证 / 教师姓名
|
||||
* - 设置 release / tag / environment
|
||||
* - 导出 initSentry() + captureException / captureMessage 包装器
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* Sentry 模块类型(仅声明使用的 API 子集)。
|
||||
* 避免对未安装包的静态类型依赖。
|
||||
*/
|
||||
interface SentryModule {
|
||||
init(options: {
|
||||
dsn: string;
|
||||
environment: string;
|
||||
release?: string;
|
||||
tracesSampleRate: number;
|
||||
beforeSend?: (event: unknown) => unknown;
|
||||
}): void;
|
||||
captureException(err: unknown, context?: { extra?: Record<string, unknown> }): void;
|
||||
captureMessage(message: string): void;
|
||||
addBreadcrumb(breadcrumb: {
|
||||
category?: string;
|
||||
message?: string;
|
||||
level?: string;
|
||||
}): void;
|
||||
setTag(key: string, value: string): void;
|
||||
}
|
||||
|
||||
/** 需要脱敏的 PII 字段名(匹配 key,不区分大小写) */
|
||||
const PII_KEYS = [
|
||||
"teachername",
|
||||
"name",
|
||||
"email",
|
||||
"phone",
|
||||
"mobile",
|
||||
"idcard",
|
||||
"id_card",
|
||||
"token",
|
||||
"accesstoken",
|
||||
"access_token",
|
||||
"refreshtoken",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"secret",
|
||||
"authorization",
|
||||
];
|
||||
|
||||
/** Sentry 模块缓存(initSentry 成功后赋值) */
|
||||
let sentryModule: SentryModule | null = null;
|
||||
|
||||
/** 初始化状态标记 */
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 递归移除 PII 字段(返回脱敏后的副本)。
|
||||
*
|
||||
* 从 unknown 转换为结构化对象处理,符合 project_rules §3.4 禁止 any 规则。
|
||||
*/
|
||||
function stripPII(input: unknown): unknown {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(stripPII);
|
||||
}
|
||||
if (input !== null && typeof input === "object") {
|
||||
const obj = input as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (PII_KEYS.includes(key.toLowerCase())) {
|
||||
out[key] = "[REDACTED]";
|
||||
} else {
|
||||
out[key] = stripPII(obj[key]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Sentry(仅在浏览器/服务端入口调用一次)。
|
||||
*
|
||||
* 使用动态 import 加载 @sentry/nextjs,避免未安装包导致构建失败。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*
|
||||
* @returns 是否成功初始化(false 表示未配置 DSN 或加载失败)
|
||||
*/
|
||||
export async function initSentry(): Promise<boolean> {
|
||||
if (initialized) return sentryModule !== null;
|
||||
|
||||
const config = getObservabilityConfig();
|
||||
if (config.sentryDsn === null) {
|
||||
// 未配置 DSN,标记为已检查,避免重复读取配置
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const Sentry = (await import("@sentry/nextjs")) as unknown as SentryModule;
|
||||
|
||||
Sentry.init({
|
||||
dsn: config.sentryDsn,
|
||||
environment: config.environment,
|
||||
release: config.sentryRelease ?? undefined,
|
||||
tracesSampleRate: config.tracesSampleRate,
|
||||
beforeSend(event: unknown) {
|
||||
// 过滤 PII 后回传事件
|
||||
return stripPII(event);
|
||||
},
|
||||
});
|
||||
|
||||
// 设置全局 tag(service 标识,便于 Sentry 面板按应用过滤)
|
||||
Sentry.setTag("service", config.serviceName);
|
||||
Sentry.setTag("portal", "teacher");
|
||||
|
||||
sentryModule = Sentry;
|
||||
initialized = true;
|
||||
|
||||
// 挂载捕获器供 ErrorBoundary 使用(参考 student-portal 模式)
|
||||
if (typeof window !== "undefined") {
|
||||
(
|
||||
window as unknown as {
|
||||
__eduCaptureException?: (e: Error, extra?: unknown) => void;
|
||||
}
|
||||
).__eduCaptureException = (err: Error, extra?: unknown) => {
|
||||
if (sentryModule) {
|
||||
sentryModule.captureException(
|
||||
err,
|
||||
extra !== undefined ? { extra: { detail: extra } } : undefined,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
// @sentry/nextjs 未安装或加载失败,降级到 console
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] Sentry 加载失败,降级到 console", err);
|
||||
}
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获异常(未初始化时降级为 console.error)。
|
||||
*
|
||||
* 同步 API:若 Sentry 模块已加载则上报,否则输出到控制台。
|
||||
*/
|
||||
export function captureException(err: unknown): void {
|
||||
if (sentryModule) {
|
||||
sentryModule.captureException(err);
|
||||
} else if (typeof console !== "undefined") {
|
||||
console.error("[teacher-portal]", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获消息(未初始化时降级为 console.warn)。
|
||||
*/
|
||||
export function captureMessage(message: string): void {
|
||||
if (sentryModule) {
|
||||
sentryModule.captureMessage(message);
|
||||
} else if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal]", message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加面包屑(用于错误上下文追踪)。
|
||||
* 未初始化时为空操作。
|
||||
*/
|
||||
export function addBreadcrumb(breadcrumb: {
|
||||
category?: string;
|
||||
message?: string;
|
||||
level?: string;
|
||||
}): void {
|
||||
if (sentryModule) {
|
||||
sentryModule.addBreadcrumb(breadcrumb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentry 是否已初始化(可用于运行时判断) */
|
||||
export function isSentryInitialized(): boolean {
|
||||
return sentryModule !== null;
|
||||
}
|
||||
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