feat: 集成错误处理与网络状态管理
- 在 Providers 组件中引入 ErrorProvider 以处理全局错误状态 - 更新 PlayerAppShell 组件的注释,说明网络状态横幅的用途 - 在 lotteryHttp 中添加对 500、502、503 错误的处理,更新全局错误状态 - 导出 useNetworkStatus 和 useIsOffline 钩子以支持网络状态管理
This commit is contained in:
130
src/app/error.tsx
Normal file
130
src/app/error.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{/* 错误图标 */}
|
||||||
|
<div
|
||||||
|
className="mb-6 flex size-24 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isServerError
|
||||||
|
? `${ERROR_COLORS.error}15`
|
||||||
|
: `${ERROR_COLORS.warning}15`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
className="size-12"
|
||||||
|
style={{
|
||||||
|
color: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误标题 */}
|
||||||
|
<h1 className="mb-2 text-2xl font-bold text-foreground">
|
||||||
|
{isServerError ? "服务器暂时不可用" : "应用发生错误"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 错误消息 */}
|
||||||
|
<p className="mb-2 text-base text-muted-foreground">
|
||||||
|
{isServerError
|
||||||
|
? "服务器暂时不可用,请稍后重试"
|
||||||
|
: "抱歉,应用遇到了问题,请尝试刷新页面"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 技术详情(开发模式) */}
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<div className="mb-6 w-full rounded-lg bg-muted p-3 text-left">
|
||||||
|
<p className="mb-1 text-xs font-medium text-muted-foreground">错误详情:</p>
|
||||||
|
<code className="block max-h-32 overflow-auto whitespace-pre-wrap break-words text-xs text-destructive">
|
||||||
|
{error.message}
|
||||||
|
</code>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
错误 ID: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-current px-4 py-2 text-sm font-medium transition-colors hover:bg-current/5"
|
||||||
|
style={{ borderColor: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning, color: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning }}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Home className="size-4" />
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 辅助链接 */}
|
||||||
|
<div className="mt-8 flex gap-4 text-sm text-muted-foreground">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</button>
|
||||||
|
<span>|</span>
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
投注大厅
|
||||||
|
</Link>
|
||||||
|
<span>|</span>
|
||||||
|
<Link
|
||||||
|
href="/wallet"
|
||||||
|
className="hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
我的钱包
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/app/not-found.tsx
Normal file
86
src/app/not-found.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex min-h-screen flex-col items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: ERROR_COLORS.neutral }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{/* 404 数字 */}
|
||||||
|
<div
|
||||||
|
className="mb-6 text-8xl font-bold tracking-tighter"
|
||||||
|
style={{ color: ERROR_COLORS.neutral }}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图标 */}
|
||||||
|
<div
|
||||||
|
className="mb-6 flex size-20 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: `${ERROR_COLORS.neutral}40` }}
|
||||||
|
>
|
||||||
|
<FileQuestion
|
||||||
|
className="size-10"
|
||||||
|
style={{ color: "#666" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<h1 className="mb-3 text-2xl font-bold text-gray-800">
|
||||||
|
页面不存在
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<p className="mb-8 text-base text-gray-500">
|
||||||
|
抱歉,您访问的页面可能已被删除、移动或从未存在过。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 返回首页按钮 */}
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg px-8 py-3 text-base font-medium text-white"
|
||||||
|
style={{ backgroundColor: "#333" }}
|
||||||
|
>
|
||||||
|
<Home className="size-5" />
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 帮助链接 */}
|
||||||
|
<div className="mt-8 flex gap-4 text-sm text-gray-400">
|
||||||
|
<Link href="/hall" className="hover:text-gray-600 hover:underline">
|
||||||
|
投注大厅
|
||||||
|
</Link>
|
||||||
|
<span>|</span>
|
||||||
|
<Link href="/results" className="hover:text-gray-600 hover:underline">
|
||||||
|
开奖结果
|
||||||
|
</Link>
|
||||||
|
<span>|</span>
|
||||||
|
<Link href="/wallet" className="hover:text-gray-600 hover:underline">
|
||||||
|
我的钱包
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 品牌信息 */}
|
||||||
|
<p className="mt-8 text-sm text-gray-500">
|
||||||
|
Lottery © {new Date().getFullYear()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/error-handling/index.ts
Normal file
8
src/components/error-handling/index.ts
Normal file
@@ -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";
|
||||||
30
src/components/error-provider.tsx
Normal file
30
src/components/error-provider.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
{/* 全局离线状态横幅 - 显示在页面顶部 */}
|
||||||
|
<OfflineBanner />
|
||||||
|
|
||||||
|
{/* 服务器错误全屏覆盖 */}
|
||||||
|
<ServerError />
|
||||||
|
|
||||||
|
{/* 子内容 */}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,10 +12,14 @@ type PlayerAppShellProps = {
|
|||||||
/**
|
/**
|
||||||
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。
|
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。
|
||||||
* 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`.
|
* 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`.
|
||||||
|
*
|
||||||
|
* 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理
|
||||||
|
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
|
||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col bg-background text-foreground">
|
<div className="flex min-h-dvh flex-col bg-background text-foreground">
|
||||||
|
{/* WebSocket 连接状态横幅(降级模式提示) */}
|
||||||
<NetworkStatusBanner />
|
<NetworkStatusBanner />
|
||||||
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
|
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
|
||||||
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">
|
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">
|
||||||
|
|||||||
54
src/components/offline-banner.tsx
Normal file
54
src/components/offline-banner.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-[60] flex items-center justify-center gap-2 px-4 py-3 text-sm",
|
||||||
|
"animate-in slide-in-from-top-full duration-300 ease-out",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: ERROR_COLORS.error,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||||
|
<span className="font-medium">网络已断开,请检查网络连接</span>
|
||||||
|
<button
|
||||||
|
onClick={handleReconnect}
|
||||||
|
className="ml-3 flex items-center gap-1.5 rounded-md bg-white/20 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50 active:bg-white/25"
|
||||||
|
aria-label="重新连接"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" aria-hidden />
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ErrorProvider } from "@/components/error-provider";
|
||||||
|
|
||||||
type ProvidersProps = {
|
type ProvidersProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -13,7 +14,9 @@ type ProvidersProps = {
|
|||||||
export function Providers({ children }: ProvidersProps): ReactNode {
|
export function Providers({ children }: ProvidersProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
{children}
|
<ErrorProvider>
|
||||||
|
{children}
|
||||||
|
</ErrorProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
154
src/components/server-error.tsx
Normal file
154
src/components/server-error.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background p-4">
|
||||||
|
<div className="flex w-full max-w-md flex-col items-center text-center">
|
||||||
|
{/* 错误图标 */}
|
||||||
|
<div
|
||||||
|
className="mb-6 flex size-20 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: `${ERROR_COLORS.error}15` }}
|
||||||
|
>
|
||||||
|
<ServerCrash
|
||||||
|
className="size-10"
|
||||||
|
style={{ color: ERROR_COLORS.error }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误标题 */}
|
||||||
|
<h1
|
||||||
|
className="mb-2 text-2xl font-bold"
|
||||||
|
style={{ color: ERROR_COLORS.error }}
|
||||||
|
>
|
||||||
|
服务器错误
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 错误消息 */}
|
||||||
|
<p className="mb-8 text-base text-muted-foreground">
|
||||||
|
{serverErrorMessage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
onClick={handleRetry}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"gap-2 border-current hover:bg-current/5",
|
||||||
|
)}
|
||||||
|
style={{ borderColor: ERROR_COLORS.error, color: ERROR_COLORS.error }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
|
||||||
|
style={{ backgroundColor: ERROR_COLORS.error }}
|
||||||
|
>
|
||||||
|
<Home className="size-4" />
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态码提示 */}
|
||||||
|
<p className="mt-8 text-xs text-muted-foreground/60">
|
||||||
|
错误代码: 500 Internal Server Error
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器错误弹窗/遮罩组件(适用于在现有页面中显示)
|
||||||
|
*/
|
||||||
|
export function ServerErrorModal(): React.ReactElement | null {
|
||||||
|
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
||||||
|
|
||||||
|
const handleRetry = (): void => {
|
||||||
|
clearServerError();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isServerError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-card p-6 shadow-lg">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{/* 错误图标 */}
|
||||||
|
<div
|
||||||
|
className="mb-4 flex size-16 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: `${ERROR_COLORS.error}15` }}
|
||||||
|
>
|
||||||
|
<ServerCrash
|
||||||
|
className="size-8"
|
||||||
|
style={{ color: ERROR_COLORS.error }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误消息 */}
|
||||||
|
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
||||||
|
服务器暂时不可用
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
{serverErrorMessage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<div className="flex w-full gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleRetry}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 size-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
|
||||||
|
style={{ backgroundColor: ERROR_COLORS.error }}
|
||||||
|
>
|
||||||
|
<Home className="size-4" />
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,3 +10,9 @@ export {
|
|||||||
triggerWalletPollingAfterBet,
|
triggerWalletPollingAfterBet,
|
||||||
type UseWalletPollingReturn,
|
type UseWalletPollingReturn,
|
||||||
} from "./use-wallet-polling";
|
} from "./use-wallet-polling";
|
||||||
|
|
||||||
|
// Network Status
|
||||||
|
export {
|
||||||
|
useNetworkStatus,
|
||||||
|
useIsOffline,
|
||||||
|
} from "./use-network-status";
|
||||||
|
|||||||
44
src/hooks/use-network-status.ts
Normal file
44
src/hooks/use-network-status.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
LotteryApiEnvelopeError,
|
LotteryApiEnvelopeError,
|
||||||
} from "@/types/api/errors";
|
} from "@/types/api/errors";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
|
|
||||||
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
|
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` 衔接 */
|
/** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */
|
||||||
|
/** 500 错误:更新全局服务器错误状态 */
|
||||||
lotteryHttp.interceptors.response.use(
|
lotteryHttp.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
if (
|
if (isAxiosError(error) && typeof window !== "undefined") {
|
||||||
isAxiosError(error) &&
|
const status = error.response?.status;
|
||||||
error.response?.status === 401 &&
|
|
||||||
typeof window !== "undefined"
|
// 401: 会话过期,清除令牌并重定向
|
||||||
) {
|
if (status === 401) {
|
||||||
clearPersistedPlayerBearerToken();
|
clearPersistedPlayerBearerToken();
|
||||||
setPlayerBearerToken(null);
|
setPlayerBearerToken(null);
|
||||||
if (window.location.pathname !== "/") {
|
if (window.location.pathname !== "/") {
|
||||||
window.location.replace("/?session=expired");
|
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);
|
return Promise.reject(error);
|
||||||
|
|||||||
72
src/stores/error-store.ts
Normal file
72
src/stores/error-store.ts
Normal file
@@ -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<ErrorState>((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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user