From c8f8f905156e0072af316bebef7ed4e00eef36c0 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 15:14:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E4=B8=8E=E7=BD=91=E7=BB=9C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Providers 组件中引入 ErrorProvider 以处理全局错误状态 - 更新 PlayerAppShell 组件的注释,说明网络状态横幅的用途 - 在 lotteryHttp 中添加对 500、502、503 错误的处理,更新全局错误状态 - 导出 useNetworkStatus 和 useIsOffline 钩子以支持网络状态管理 --- src/app/error.tsx | 130 +++++++++++++++++ src/app/not-found.tsx | 86 ++++++++++++ src/components/error-handling/index.ts | 8 ++ src/components/error-provider.tsx | 30 ++++ src/components/layout/player-app-shell.tsx | 4 + src/components/offline-banner.tsx | 54 ++++++++ src/components/providers.tsx | 5 +- src/components/server-error.tsx | 154 +++++++++++++++++++++ src/hooks/index.ts | 6 + src/hooks/use-network-status.ts | 44 ++++++ src/lib/lottery-http.ts | 46 ++++-- src/stores/error-store.ts | 72 ++++++++++ 12 files changed, 629 insertions(+), 10 deletions(-) create mode 100644 src/app/error.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/error-handling/index.ts create mode 100644 src/components/error-provider.tsx create mode 100644 src/components/offline-banner.tsx create mode 100644 src/components/server-error.tsx create mode 100644 src/hooks/use-network-status.ts create mode 100644 src/stores/error-store.ts diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..0770111 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { AlertTriangle, Home, RotateCcw } from "lucide-react"; + +import { ERROR_COLORS } from "@/stores/error-store"; + +/** + * Next.js 错误边界组件 + * 当页面渲染过程中发生错误时显示 + * 自动捕获 React 错误和客户端异常 + */ +export default function ErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + useEffect(() => { + // 记录错误日志 + console.error("[ErrorBoundary] Application error:", error); + }, [error]); + + // 判断是否是 500 服务器错误 + const isServerError = error.message?.includes("500") || + error.message?.includes("服务器") || + error.name === "LotteryApiBizError"; + + return ( +
+
+
+ {/* 错误图标 */} +
+ +
+ + {/* 错误标题 */} +

+ {isServerError ? "服务器暂时不可用" : "应用发生错误"} +

+ + {/* 错误消息 */} +

+ {isServerError + ? "服务器暂时不可用,请稍后重试" + : "抱歉,应用遇到了问题,请尝试刷新页面"} +

+ + {/* 技术详情(开发模式) */} + {process.env.NODE_ENV === "development" && ( +
+

错误详情:

+ + {error.message} + + {error.digest && ( +

+ 错误 ID: {error.digest} +

+ )} +
+ )} + + {/* 操作按钮 */} +
+ + + + + 返回首页 + +
+ + {/* 辅助链接 */} +
+ + | + + 投注大厅 + + | + + 我的钱包 + +
+
+
+
+ ); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..19e0236 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Link from "next/link"; +import { FileQuestion, Home } from "lucide-react"; + +import { ERROR_COLORS } from "@/stores/error-store"; + +/** + * 全局 404 页面 + * 当访问不存在的路由时显示 + * 消息:"页面不存在" + * 包含 "返回首页" 按钮 + * 使用中性颜色 #d9d9d9 作为背景/插图 + */ +export default function NotFoundPage(): React.ReactElement { + return ( +
+
+
+ {/* 404 数字 */} +
+ 404 +
+ + {/* 图标 */} +
+ +
+ + {/* 标题 */} +

+ 页面不存在 +

+ + {/* 描述 */} +

+ 抱歉,您访问的页面可能已被删除、移动或从未存在过。 +

+ + {/* 返回首页按钮 */} + + + 返回首页 + + + {/* 帮助链接 */} +
+ + 投注大厅 + + | + + 开奖结果 + + | + + 我的钱包 + +
+
+
+ + {/* 品牌信息 */} +

+ Lottery © {new Date().getFullYear()} +

+
+ ); +} diff --git a/src/components/error-handling/index.ts b/src/components/error-handling/index.ts new file mode 100644 index 0000000..6ad684f --- /dev/null +++ b/src/components/error-handling/index.ts @@ -0,0 +1,8 @@ +// 错误处理组件和 Hooks 统一导出 + +export { OfflineBanner } from "@/components/offline-banner"; +export { ServerError, ServerErrorModal } from "@/components/server-error"; +export { ErrorProvider } from "@/components/error-provider"; + +export { useNetworkStatus, useIsOffline } from "@/hooks/use-network-status"; +export { useErrorStore, ERROR_COLORS } from "@/stores/error-store"; diff --git a/src/components/error-provider.tsx b/src/components/error-provider.tsx new file mode 100644 index 0000000..16f881e --- /dev/null +++ b/src/components/error-provider.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useNetworkStatus } from "@/hooks/use-network-status"; +import { OfflineBanner } from "@/components/offline-banner"; +import { ServerError } from "@/components/server-error"; + +/** + * 全局错误状态提供者 + * - 监听网络状态变化 + * - 渲染离线横幅和服务器错误覆盖层 + */ +export function ErrorProvider({ children }: { children: ReactNode }): ReactNode { + // 初始化网络状态监听 + useNetworkStatus(); + + return ( + <> + {/* 全局离线状态横幅 - 显示在页面顶部 */} + + + {/* 服务器错误全屏覆盖 */} + + + {/* 子内容 */} + {children} + + ); +} diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 977cb7f..221bde0 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -12,10 +12,14 @@ type PlayerAppShellProps = { /** * 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。 * 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`. + * + * 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理 + * 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示 */ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode { return (
+ {/* WebSocket 连接状态横幅(降级模式提示) */}
diff --git a/src/components/offline-banner.tsx b/src/components/offline-banner.tsx new file mode 100644 index 0000000..333f7f6 --- /dev/null +++ b/src/components/offline-banner.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { WifiOff, RefreshCw } from "lucide-react"; +import { useCallback } from "react"; + +import { useErrorStore, ERROR_COLORS } from "@/stores/error-store"; +import { cn } from "@/lib/utils"; + +/** + * 网络断开状态横幅组件 + * 当浏览器检测到离线状态 (navigator.onLine === false) 时显示 + * 使用错误颜色 #ff4d4f (红色) + */ +export function OfflineBanner(): React.ReactElement | null { + const isOffline = useErrorStore((state) => state.isOffline); + + const handleReconnect = useCallback(() => { + // 尝试重新加载页面 + if (typeof window !== "undefined") { + window.location.reload(); + } + }, []); + + // 在线时不显示 + if (!isOffline) { + return null; + } + + return ( +
+ + 网络已断开,请检查网络连接 + +
+ ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 36c4985..9dad5fc 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/ui/sonner"; +import { ErrorProvider } from "@/components/error-provider"; type ProvidersProps = { children: ReactNode; @@ -13,7 +14,9 @@ type ProvidersProps = { export function Providers({ children }: ProvidersProps): ReactNode { return ( - {children} + + {children} + ); diff --git a/src/components/server-error.tsx b/src/components/server-error.tsx new file mode 100644 index 0000000..86f9f62 --- /dev/null +++ b/src/components/server-error.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { ServerCrash, Home, RefreshCw } from "lucide-react"; +import Link from "next/link"; + +import { useErrorStore, ERROR_COLORS } from "@/stores/error-store"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** + * 服务器错误 (500) 全屏错误页面组件 + * 当 API 返回 500 错误时显示 + * 消息:"服务器暂时不可用,请稍后重试" + * 包含 "返回首页" 按钮 + */ +export function ServerError(): React.ReactElement | null { + const { isServerError, serverErrorMessage, clearServerError } = useErrorStore(); + + const handleRetry = (): void => { + clearServerError(); + // 刷新页面尝试重新加载 + if (typeof window !== "undefined") { + window.location.reload(); + } + }; + + // 没有服务器错误时不显示 + if (!isServerError) { + return null; + } + + return ( +
+
+ {/* 错误图标 */} +
+ +
+ + {/* 错误标题 */} +

+ 服务器错误 +

+ + {/* 错误消息 */} +

+ {serverErrorMessage} +

+ + {/* 操作按钮 */} +
+ + + + + 返回首页 + +
+ + {/* 状态码提示 */} +

+ 错误代码: 500 Internal Server Error +

+
+
+ ); +} + +/** + * 服务器错误弹窗/遮罩组件(适用于在现有页面中显示) + */ +export function ServerErrorModal(): React.ReactElement | null { + const { isServerError, serverErrorMessage, clearServerError } = useErrorStore(); + + const handleRetry = (): void => { + clearServerError(); + window.location.reload(); + }; + + if (!isServerError) { + return null; + } + + return ( +
+
+
+ {/* 错误图标 */} +
+ +
+ + {/* 错误消息 */} +

+ 服务器暂时不可用 +

+

+ {serverErrorMessage} +

+ + {/* 按钮 */} +
+ + + + 返回首页 + +
+
+
+
+ ); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c3ae4c5..f1f7287 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -10,3 +10,9 @@ export { triggerWalletPollingAfterBet, type UseWalletPollingReturn, } from "./use-wallet-polling"; + +// Network Status +export { + useNetworkStatus, + useIsOffline, +} from "./use-network-status"; diff --git a/src/hooks/use-network-status.ts b/src/hooks/use-network-status.ts new file mode 100644 index 0000000..e988f2d --- /dev/null +++ b/src/hooks/use-network-status.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useCallback } from "react"; +import { useErrorStore } from "@/stores/error-store"; + +/** + * 监听浏览器网络状态变化 (navigator.onLine) + * 当网络断开时更新全局错误状态 + */ +export function useNetworkStatus(): void { + const setIsOffline = useErrorStore((state) => state.setIsOffline); + + const handleOnline = useCallback(() => { + setIsOffline(false); + }, [setIsOffline]); + + const handleOffline = useCallback(() => { + setIsOffline(true); + }, [setIsOffline]); + + useEffect(() => { + // 只在客户端执行 + if (typeof window === "undefined") return; + + // 初始化当前状态 + setIsOffline(!navigator.onLine); + + // 监听网络状态变化 + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [handleOnline, handleOffline, setIsOffline]); +} + +/** + * Hook 返回当前网络状态(用于组件内部使用) + */ +export function useIsOffline(): boolean { + return useErrorStore((state) => state.isOffline); +} diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index d86dfbb..b5cb449 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -12,6 +12,7 @@ import { LotteryApiEnvelopeError, } from "@/types/api/errors"; import { isApiEnvelope } from "@/types/api/envelope"; +import { useErrorStore } from "@/stores/error-store"; const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim(); @@ -25,18 +26,45 @@ export const lotteryHttp = axios.create({ }); /** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */ +/** 500 错误:更新全局服务器错误状态 */ lotteryHttp.interceptors.response.use( (response) => response, (error: unknown) => { - if ( - isAxiosError(error) && - error.response?.status === 401 && - typeof window !== "undefined" - ) { - clearPersistedPlayerBearerToken(); - setPlayerBearerToken(null); - if (window.location.pathname !== "/") { - window.location.replace("/?session=expired"); + if (isAxiosError(error) && typeof window !== "undefined") { + const status = error.response?.status; + + // 401: 会话过期,清除令牌并重定向 + if (status === 401) { + clearPersistedPlayerBearerToken(); + setPlayerBearerToken(null); + if (window.location.pathname !== "/") { + window.location.replace("/?session=expired"); + } + } + + // 500/502/503: 服务器错误,更新全局错误状态 + if (status === 500 || status === 502 || status === 503) { + const setServerError = useErrorStore.getState().setServerError; + let message = "服务器暂时不可用,请稍后重试"; + + // 尝试从响应中获取更详细的错误信息 + const responseData = error.response?.data; + if ( + typeof responseData === "object" && + responseData !== null && + "msg" in responseData && + typeof responseData.msg === "string" + ) { + message = responseData.msg; + } + + setServerError(true, message); + } + + // 网络错误 (无响应): 检查网络状态 + if (!error.response && error.message?.includes("Network Error")) { + const setIsOffline = useErrorStore.getState().setIsOffline; + setIsOffline(true); } } return Promise.reject(error); diff --git a/src/stores/error-store.ts b/src/stores/error-store.ts new file mode 100644 index 0000000..65854d5 --- /dev/null +++ b/src/stores/error-store.ts @@ -0,0 +1,72 @@ +"use client"; + +import { create } from "zustand"; + +// 颜色常量 +export const ERROR_COLORS = { + success: "#52c41a", + error: "#ff4d4f", + warning: "#faad14", + neutral: "#d9d9d9", +} as const; + +type ErrorType = "network" | "server" | null; + +interface ErrorState { + // 网络断开状态 + isOffline: boolean; + setIsOffline: (value: boolean) => void; + + // 服务器错误状态 (500) + isServerError: boolean; + serverErrorMessage: string; + setServerError: (isError: boolean, message?: string) => void; + clearServerError: () => void; + + // 当前主要错误类型(优先级:server > network) + currentErrorType: ErrorType; +} + +export const useErrorStore = create((set, get) => ({ + // 初始状态 + isOffline: typeof window !== "undefined" ? !navigator.onLine : false, + isServerError: false, + serverErrorMessage: "服务器暂时不可用,请稍后重试", + currentErrorType: null, + + // Actions + setIsOffline: (value: boolean) => { + set({ isOffline: value }); + // 更新当前错误类型 + const { isServerError } = get(); + if (isServerError) { + set({ currentErrorType: "server" }); + } else if (value) { + set({ currentErrorType: "network" }); + } else { + set({ currentErrorType: null }); + } + }, + + setServerError: (isError: boolean, message?: string) => { + set({ + isServerError: isError, + serverErrorMessage: message || "服务器暂时不可用,请稍后重试", + }); + // 更新当前错误类型 - 服务器错误优先级更高 + if (isError) { + set({ currentErrorType: "server" }); + } else { + const { isOffline } = get(); + set({ currentErrorType: isOffline ? "network" : null }); + } + }, + + clearServerError: () => { + const { isOffline } = get(); + set({ + isServerError: false, + currentErrorType: isOffline ? "network" : null, + }); + }, +}));