feat: 集成错误处理与网络状态管理

- 在 Providers 组件中引入 ErrorProvider 以处理全局错误状态
- 更新 PlayerAppShell 组件的注释,说明网络状态横幅的用途
- 在 lotteryHttp 中添加对 500、502、503 错误的处理,更新全局错误状态
- 导出 useNetworkStatus 和 useIsOffline 钩子以支持网络状态管理
This commit is contained in:
2026-05-13 15:14:02 +08:00
parent 1e7a06dc86
commit c8f8f90515
12 changed files with 629 additions and 10 deletions

View 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";

View 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}
</>
);
}

View File

@@ -12,10 +12,14 @@ type PlayerAppShellProps = {
/**
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**H5
* 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`.
*
* 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
*/
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
return (
<div className="flex min-h-dvh flex-col bg-background text-foreground">
{/* WebSocket 连接状态横幅(降级模式提示) */}
<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">
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">

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

View File

@@ -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 (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<ErrorProvider>
{children}
</ErrorProvider>
<Toaster />
</ThemeProvider>
);

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