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,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,
};
}
}

View 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 tokenhttpOnly 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;
}

View 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;
}
/** 是否启用 SentryDSN 已配置) */
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;
}

View File

@@ -0,0 +1,140 @@
/**
* OpenTelemetry browser SDK 初始化P6 硬化)
*
* - 自动埋点 fetch / XHR / document load
* - 导出到 OTLP collectorendpoint 从环境变量读取)
* - 条件初始化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;
}

View 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 {
/** LCPms */
lcp: number | null;
/** INPms */
inp: number | null;
/** CLS */
cls: number | null;
/** TTFBms */
ttfb: number | null;
/** FCPms */
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 为 nullCI 环境由 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;
}
}

View 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);
},
});
// 设置全局 tagservice 标识,便于 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;
}

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;
}