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

@@ -10,16 +10,17 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/shared-ts": "workspace:*",
"@edu/ui-components": "workspace:*"
},
"peerDependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.3.0 || ^19.0.0",
"react-dom": "^18.3.0 || ^19.0.0",
"urql": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}

View File

@@ -20,42 +20,39 @@
* - token 存储使用 localStorageF12 裁决P2 阶段)
*/
export { useAuth } from "./use-auth.js";
export type { UseAuthReturn } from "./use-auth.js";
export { useAuth } from "./use-auth";
export type { UseAuthReturn } from "./use-auth";
export { usePermission } from "./use-permission.js";
export type {
UsePermissionProps,
UsePermissionReturn,
} from "./use-permission.js";
export { usePermission } from "./use-permission";
export type { UsePermissionProps, UsePermissionReturn } from "./use-permission";
export { useViewports } from "./use-viewports.js";
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports.js";
export { useViewports } from "./use-viewports";
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports";
export { useApi } from "./use-api.js";
export type { UseApiProps, UseApiReturn } from "./use-api.js";
export { useApi } from "./use-api";
export type { UseApiProps, UseApiReturn } from "./use-api";
export {
useA11yId,
useA11yIds,
mergeA11yProps,
describeInput,
} from "./use-a11y-id.js";
} from "./use-a11y-id";
export { useAriaLive } from "./use-aria-live.js";
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live.js";
export { useAriaLive } from "./use-aria-live";
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live";
export { useToast } from "./use-toast.js";
export type { UseToastReturn } from "./use-toast.js";
export { useToast } from "./use-toast";
export type { UseToastReturn } from "./use-toast";
export { useTraceId } from "./use-trace-id.js";
export type { UseTraceIdReturn } from "./use-trace-id.js";
export { useTraceId } from "./use-trace-id";
export type { UseTraceIdReturn } from "./use-trace-id";
export {
useGraphQLClient,
GraphQLClientContext,
} from "./use-graphql-client.js";
export type { UseGraphQLClientReturn } from "./use-graphql-client.js";
export { useErrorReport } from "./use-error-report";
export type { ErrorReportPayload } from "./use-error-report";
export { useGraphQLClient, GraphQLClientContext } from "./use-graphql-client";
export type { UseGraphQLClientReturn } from "./use-graphql-client";
// 共享类型
export type {
@@ -65,4 +62,14 @@ export type {
Viewport,
ToastMessage,
AuthState,
} from "./types.js";
} from "./types";
// portal-shell 插件系统 Hooksv2.1 spec §9.3
export { usePluginStore, injectPluginStore } from "./use-plugin-store";
export type { PluginStoreInstance } from "./use-plugin-store";
export { usePluginConfig } from "./use-plugin-config";
export type {
UsePluginConfigOptions,
UsePluginConfigReturn,
} from "./use-plugin-config";

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { AuthState, UserSession } from "./types.js";
import type { AuthState, UserSession } from "./types";
/**
* useAuth - 会话状态管理

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import type { PermissionContext } from "./types.js";
import type { PermissionContext } from "./types";
/**
* usePermission - 权限查询 Hook

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
/**
* usePluginConfig - 插件配置静默刷新 Hookportal-shell spec §6.4
*
* 通用封装,不直接依赖 SWR 或 Apollo Client。
* fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 的设计原则。
*
* 特性:
* - 支持 fallbackDataRSC 预取的 initialData
* - 支持轮询刷新refreshInterval
* - 支持配置变化回调onChanged
*
* 关联portal-shell spec §6.4、§9.3
*/
export interface UsePluginConfigOptions {
/** RSC 直出的初始配置fallbackData */
initialConfig: PluginConfigResponse;
/** 当前用户 ID */
userId: string;
/** 当前用户角色 */
role: string;
/** 配置变化回调(上层用于 Toast 提示) */
onChanged?: () => void;
/** 刷新间隔ms默认 5 分钟 */
refreshInterval?: number;
/** fetcher 函数(由消费者注入) */
fetcher: (userId: string, role: string) => Promise<PluginConfigResponse>;
}
export interface UsePluginConfigReturn {
/** 当前配置 */
config: PluginConfigResponse;
/** 手动刷新 */
refresh: () => Promise<void>;
/** 是否正在刷新 */
isValidating: boolean;
/** 刷新错误 */
error: Error | null;
}
/**
* 静默刷新插件配置。
*
* @example
* const { config, refresh } = usePluginConfig({
* initialConfig,
* userId,
* role,
* fetcher: async (uid, r) => {
* const client = getApolloClient();
* const { data } = await client.query({ query: GET_PLUGIN_CONFIG, variables: { userId: uid, role: r } });
* return data.pluginConfig;
* },
* onChanged: () => showToast("配置已更新"),
* });
*/
export function usePluginConfig(
options: UsePluginConfigOptions,
): UsePluginConfigReturn {
const {
initialConfig,
userId,
role,
onChanged,
fetcher,
refreshInterval = 300_000,
} = options;
const [config, setConfig] = useState<PluginConfigResponse>(initialConfig);
const [isValidating, setIsValidating] = useState(false);
const [error, setError] = useState<Error | null>(null);
const doRefresh = async (): Promise<void> => {
setIsValidating(true);
setError(null);
try {
const newConfig = await fetcher(userId, role);
if (hasConfigChanged(config, newConfig)) {
setConfig(newConfig);
onChanged?.();
}
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setIsValidating(false);
}
};
// 轮询刷新 + 网络恢复刷新
useEffect(() => {
const interval = setInterval(doRefresh, refreshInterval);
const handleOnline = (): void => {
void doRefresh();
};
const handleVisibility = (): void => {
if (document.visibilityState === "visible") {
void doRefresh();
}
};
window.addEventListener("online", handleOnline);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
clearInterval(interval);
window.removeEventListener("online", handleOnline);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [refreshInterval, userId, role]);
return {
config,
refresh: doRefresh,
isValidating,
error,
};
}
/** 浅比较配置是否变化layoutId / 插件集合 / 可见性) */
function hasConfigChanged(
prev: PluginConfigResponse,
next: PluginConfigResponse,
): boolean {
if (prev.activeLayout?.layoutId !== next.activeLayout?.layoutId) return true;
if (prev.plugins.length !== next.plugins.length) return true;
const prevIds = prev.plugins
.map((p) => `${p.pluginId}:${p.isVisible}`)
.sort();
const nextIds = next.plugins
.map((p) => `${p.pluginId}:${p.isVisible}`)
.sort();
return prevIds.some((id, i) => id !== nextIds[i]);
}

View File

@@ -0,0 +1,80 @@
import { useSyncExternalStore } from "react";
import type {
PluginStoreState,
ThemeMode,
Locale,
} from "@edu/shared-ts/contracts";
/**
* usePluginStore - Zustand 全局状态 Hookportal-shell spec §5.2.2
*
* 封装 Zustand store 的订阅,提供 theme/locale/sidebarCollapsed 状态管理。
* 此 hook 是通用封装,实际 store 实例由 portal-shell 创建并注入。
*
* 设计原则(@edu/hookshooks 不直接依赖特定 store 实例,
* 通过 subscribe/getSnapshot 与外部 store 交互。
*
* 关联portal-shell spec §5.2.2、§9.3
*/
/** Store 实例接口(与 Zustand create() 返回值兼容) */
export interface PluginStoreInstance extends PluginStoreState {
subscribe: (listener: () => void) => () => void;
getState: () => PluginStoreState;
}
/** 全局 store 引用(由 portal-shell 注入) */
let globalStore: PluginStoreInstance | null = null;
/**
* 注入全局 PluginStore 实例portal-shell 启动时调用)
*
* @example
* import { usePluginStore as originalStore } from "@/shell/PluginStore";
* injectPluginStore(originalStore as PluginStoreInstance);
*/
export function injectPluginStore(store: PluginStoreInstance): void {
globalStore = store;
}
/**
* 读取 PluginStore 全局状态theme/locale/sidebarCollapsed
*
* @example
* const { theme, setTheme } = usePluginStore();
*/
export function usePluginStore(): PluginStoreState {
return useSyncExternalStore(
(listener) => {
if (!globalStore) return () => {};
return globalStore.subscribe(listener);
},
() => {
if (!globalStore) {
return DEFAULT_STATE;
}
return globalStore.getState();
},
() => DEFAULT_STATE,
);
}
/** 默认状态store 未注入时使用) */
const DEFAULT_STATE: PluginStoreState = {
theme: "light",
setTheme: (_theme: ThemeMode) => {
// store 未注入时的空操作
},
locale: "zh-CN",
setLocale: (_locale: Locale) => {
// store 未注入时的空操作
},
sidebarCollapsed: false,
toggleSidebar: () => {
// store 未注入时的空操作
},
unreadNotificationIds: [],
markNotificationsRead: (_ids: string[]) => {
// store 未注入时的空操作
},
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import type { ToastMessage } from "./types.js";
import type { ToastMessage } from "./types";
/**
* useToast - 全局 Toast 通知管理

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { Viewport } from "./types.js";
import type { Viewport } from "./types";
/**
* useViewports - 视口列表查询