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,
+ });
+ },
+}));