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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,42 +20,39 @@
|
||||
* - token 存储使用 localStorage(F12 裁决,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 插件系统 Hooks(v2.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";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AuthState, UserSession } from "./types.js";
|
||||
import type { AuthState, UserSession } from "./types";
|
||||
|
||||
/**
|
||||
* useAuth - 会话状态管理
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { PermissionContext } from "./types.js";
|
||||
import type { PermissionContext } from "./types";
|
||||
|
||||
/**
|
||||
* usePermission - 权限查询 Hook
|
||||
|
||||
133
packages/hooks/src/use-plugin-config.ts
Normal file
133
packages/hooks/src/use-plugin-config.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* usePluginConfig - 插件配置静默刷新 Hook(portal-shell spec §6.4)
|
||||
*
|
||||
* 通用封装,不直接依赖 SWR 或 Apollo Client。
|
||||
* fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 的设计原则。
|
||||
*
|
||||
* 特性:
|
||||
* - 支持 fallbackData(RSC 预取的 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]);
|
||||
}
|
||||
80
packages/hooks/src/use-plugin-store.ts
Normal file
80
packages/hooks/src/use-plugin-store.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type {
|
||||
PluginStoreState,
|
||||
ThemeMode,
|
||||
Locale,
|
||||
} from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* usePluginStore - Zustand 全局状态 Hook(portal-shell spec §5.2.2)
|
||||
*
|
||||
* 封装 Zustand store 的订阅,提供 theme/locale/sidebarCollapsed 状态管理。
|
||||
* 此 hook 是通用封装,实际 store 实例由 portal-shell 创建并注入。
|
||||
*
|
||||
* 设计原则(@edu/hooks):hooks 不直接依赖特定 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 未注入时的空操作
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ToastMessage } from "./types.js";
|
||||
import type { ToastMessage } from "./types";
|
||||
|
||||
/**
|
||||
* useToast - 全局 Toast 通知管理
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Viewport } from "./types.js";
|
||||
import type { Viewport } from "./types";
|
||||
|
||||
/**
|
||||
* useViewports - 视口列表查询
|
||||
|
||||
Reference in New Issue
Block a user