P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
199 lines
5.3 KiB
TypeScript
199 lines
5.3 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback } from "react";
|
||
|
||
/**
|
||
* useErrorReport - 客户端错误上报 Hook(对齐 CICD use-error-report.ts)
|
||
*
|
||
* 通过 navigator.sendBeacon 上报到 /api/log(fallback 到 fetch keepalive)
|
||
* 节流:基于 error.digest 在 sessionStorage 中记录,1 分钟内同 digest 只上报一次
|
||
* 上报失败静默降级,不影响用户体验
|
||
*
|
||
* 上报 payload 结构:
|
||
* {
|
||
* level: "error" | "warning",
|
||
* message: string,
|
||
* stack?: string,
|
||
* digest?: string, // Next.js 自动生成的错误摘要
|
||
* path: string, // window.location.pathname
|
||
* userAgent: string,
|
||
* timestamp: string, // ISO 8601
|
||
* pluginId?: string, // 插件级错误标识
|
||
* userId?: string, // 当前用户 ID(从 localStorage 读取)
|
||
* context?: Record<string, unknown> // 额外上下文
|
||
* }
|
||
*
|
||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||
*/
|
||
|
||
/** 错误上报 payload */
|
||
export interface ErrorReportPayload {
|
||
level: "error" | "warning";
|
||
message: string;
|
||
stack?: string;
|
||
digest?: string;
|
||
path: string;
|
||
userAgent: string;
|
||
timestamp: string;
|
||
pluginId?: string;
|
||
userId?: string;
|
||
context?: Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* 上报端点
|
||
*
|
||
* P3:生产环境使用 api-gateway 的 /api/v1/log(通过 next.config.js rewrites 代理)
|
||
* 开发环境 DEV_MODE=true 时回退到 Next.js API Route /api/log(mock 端点)
|
||
*/
|
||
const REPORT_ENDPOINT =
|
||
typeof process !== "undefined" && process.env.NODE_ENV === "production"
|
||
? "/api/v1/log"
|
||
: "/api/log";
|
||
|
||
/** 节流窗口(1 分钟内同 digest 只上报一次) */
|
||
const THROTTLE_WINDOW_MS = 60_000;
|
||
|
||
/** sessionStorage key 前缀 */
|
||
const THROTTLE_KEY_PREFIX = "edu_err_reported_";
|
||
|
||
/**
|
||
* 读取当前用户 ID(从 localStorage,避免引入 auth 依赖)
|
||
*/
|
||
function readUserId(): string | undefined {
|
||
try {
|
||
const raw = localStorage.getItem("edu_user_id");
|
||
return raw ?? undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成错误 digest(简单 hash,用于节流去重)
|
||
*
|
||
* 优先使用 error.digest(Next.js 自动生成),
|
||
* 否则基于 message + stack 前 200 字符生成简单 hash
|
||
*/
|
||
function makeDigest(error: Error): string {
|
||
const nextDigest = (error as Error & { digest?: string }).digest;
|
||
if (nextDigest) return nextDigest;
|
||
const stackSnippet = (error.stack ?? "").slice(0, 200);
|
||
const input = `${error.message}::${stackSnippet}`;
|
||
// 简单 FNV-1a hash
|
||
let hash = 2166136261;
|
||
for (let i = 0; i < input.length; i++) {
|
||
hash ^= input.charCodeAt(i);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return (hash >>> 0).toString(36);
|
||
}
|
||
|
||
/**
|
||
* 检查并更新节流记录
|
||
*
|
||
* @returns true 表示应该上报,false 表示已被节流
|
||
*/
|
||
function checkThrottle(digest: string): boolean {
|
||
try {
|
||
const key = `${THROTTLE_KEY_PREFIX}${digest}`;
|
||
const now = Date.now();
|
||
const last = sessionStorage.getItem(key);
|
||
if (last) {
|
||
const lastTime = parseInt(last, 10);
|
||
if (Number.isFinite(lastTime) && now - lastTime < THROTTLE_WINDOW_MS) {
|
||
return false; // 节流窗口内,跳过
|
||
}
|
||
}
|
||
sessionStorage.setItem(key, String(now));
|
||
return true;
|
||
} catch {
|
||
// sessionStorage 不可用时不过节流,直接上报
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 实际执行上报
|
||
*/
|
||
function sendReport(payload: ErrorReportPayload): void {
|
||
const body = JSON.stringify(payload);
|
||
|
||
// 优先 sendBeacon(不阻塞页面卸载)
|
||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||
try {
|
||
const blob = new Blob([body], { type: "application/json" });
|
||
if (navigator.sendBeacon(REPORT_ENDPOINT, blob)) {
|
||
return;
|
||
}
|
||
} catch {
|
||
// sendBeacon 失败,降级到 fetch
|
||
}
|
||
}
|
||
|
||
// 降级到 fetch keepalive
|
||
try {
|
||
void fetch(REPORT_ENDPOINT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body,
|
||
keepalive: true,
|
||
credentials: "include",
|
||
}).catch(() => {
|
||
// 上报失败静默降级
|
||
});
|
||
} catch {
|
||
// 完全失败,静默
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 错误上报 Hook
|
||
*
|
||
* @example
|
||
* function MyComponent() {
|
||
* const reportError = useErrorReport();
|
||
* try { riskyOperation(); }
|
||
* catch (e) { reportError(e, { pluginId: "grades-widget" }); }
|
||
* }
|
||
*
|
||
* @example 与 ErrorBoundary 配合
|
||
* <ErrorBoundary onError={(err) => reportError(err)}>
|
||
* <Plugin />
|
||
* </ErrorBoundary>
|
||
*/
|
||
export function useErrorReport() {
|
||
const reportError = useCallback(
|
||
(
|
||
error: Error,
|
||
options?: {
|
||
pluginId?: string;
|
||
level?: "error" | "warning";
|
||
context?: Record<string, unknown>;
|
||
},
|
||
): void => {
|
||
const digest = makeDigest(error);
|
||
if (!checkThrottle(digest)) return;
|
||
|
||
const payload: ErrorReportPayload = {
|
||
level: options?.level ?? "error",
|
||
message: error.message,
|
||
stack: error.stack,
|
||
digest,
|
||
path: typeof window !== "undefined" ? window.location.pathname : "/",
|
||
userAgent:
|
||
typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
|
||
timestamp: new Date().toISOString(),
|
||
pluginId: options?.pluginId,
|
||
userId: readUserId(),
|
||
context: options?.context,
|
||
};
|
||
|
||
sendReport(payload);
|
||
},
|
||
[],
|
||
);
|
||
|
||
return reportError;
|
||
}
|