feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
190
packages/hooks/src/use-error-report.ts
Normal file
190
packages/hooks/src/use-error-report.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
"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>;
|
||||
}
|
||||
|
||||
/** 上报端点(Next.js API Route mock,未来切换到 OTel / Sentry) */
|
||||
const REPORT_ENDPOINT = "/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;
|
||||
}
|
||||
Reference in New Issue
Block a user