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:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -0,0 +1,190 @@
"use client";
import { useCallback } from "react";
/**
* useErrorReport - 客户端错误上报 Hook对齐 CICD use-error-report.ts
*
* 通过 navigator.sendBeacon 上报到 /api/logfallback 到 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.digestNext.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;
}