refactor: Toast 升级为全局 Context,消除 onToast prop 透传

将 useToast 从独立 state hook 改为 Context-based,在 layout 中
挂载 ToastProvider 全局渲染 Toast。QrInviteModal、RoomManageModal、
ShareCardModal 不再需要 onToast prop,直接 useToast() 调用即可。
父组件 TopNav、MatchResult、profile、blindbox 移除了本地 Toast
渲染和 onToast 传递逻辑。
This commit is contained in:
2026-02-26 17:57:34 +08:00
parent b98920858c
commit d4c6da57a1
10 changed files with 68 additions and 55 deletions
-4
View File
@@ -23,7 +23,6 @@ import {
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import Toast from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types";
@@ -815,7 +814,6 @@ export default function BlindboxRoomPage() {
drawer: revealedIdea.drawnBy ?? undefined,
roomName: room.name,
}}
onToast={toast.show}
/>
)}
@@ -850,8 +848,6 @@ export default function BlindboxRoomPage() {
</motion.div>
)}
<Toast message={toast.message} position="bottom" />
<div className="h-8 shrink-0" />
</div>
);
+5 -2
View File
@@ -4,6 +4,7 @@ import "./globals.css";
import GlobalUserBadge from "@/components/GlobalUserBadge";
import ServiceWorkerRegistrar from "@/components/ServiceWorkerRegistrar";
import PageTransition from "@/components/PageTransition";
import ToastProvider from "@/components/ToastProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -40,8 +41,10 @@ export default function RootLayout({
</head>
<body className={`${geistSans.variable} font-sans antialiased`}>
<ServiceWorkerRegistrar />
<PageTransition>{children}</PageTransition>
<GlobalUserBadge />
<ToastProvider>
<PageTransition>{children}</PageTransition>
<GlobalUserBadge />
</ToastProvider>
</body>
</html>
);
-2
View File
@@ -24,7 +24,6 @@ import {
Heart,
} from "lucide-react";
import EmptyState from "@/components/EmptyState";
import Toast from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
import RestaurantImage from "@/components/RestaurantImage";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
@@ -711,7 +710,6 @@ export default function ProfilePage() {
</motion.div>
</div>
<Toast message={toast.message} />
</div>
);
}
-4
View File
@@ -34,7 +34,6 @@ import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import RestaurantImage from "@/components/RestaurantImage";
import AuthModal from "@/components/AuthModal";
import Toast from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
interface MatchResultProps {
@@ -632,10 +631,7 @@ export default function MatchResult({
userCount,
scene,
}}
onToast={toast.show}
/>
<Toast message={toast.message} />
</motion.div>
);
}
+5 -5
View File
@@ -6,12 +6,12 @@ import { X, Copy, Share2, QrCode } from "lucide-react";
import type { SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
import Modal from "@/components/Modal";
import { useToast } from "@/hooks/useToast";
interface QrInviteModalProps {
open: boolean;
onClose: () => void;
roomId: string;
onToast: (msg: string) => void;
scene?: SceneType;
}
@@ -19,9 +19,9 @@ export default function QrInviteModal({
open,
onClose,
roomId,
onToast,
scene = "eat",
}: QrInviteModalProps) {
const toast = useToast();
const sceneConfig = getSceneConfig(scene);
const inviteUrl =
typeof window !== "undefined"
@@ -31,11 +31,11 @@ export default function QrInviteModal({
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(inviteUrl);
onToast("邀请链接已复制,快去发给朋友吧!");
toast.show("邀请链接已复制,快去发给朋友吧!");
} catch {
onToast("复制失败,请手动复制链接");
toast.show("复制失败,请手动复制链接");
}
}, [inviteUrl, onToast]);
}, [inviteUrl, toast]);
const handleShare = useCallback(async () => {
const shareData = {
+9 -9
View File
@@ -13,6 +13,7 @@ import {
import { UserProfile } from "@/types";
import { getAvatar, getAvatarBg } from "@/lib/avatars";
import Modal from "@/components/Modal";
import { useToast } from "@/hooks/useToast";
interface RoomManageModalProps {
open: boolean;
@@ -24,7 +25,6 @@ interface RoomManageModalProps {
swipeCounts: Record<string, number>;
totalCards: number;
userProfiles: Record<string, UserProfile>;
onToast: (msg: string) => void;
}
export default function RoomManageModal({
@@ -37,8 +37,8 @@ export default function RoomManageModal({
swipeCounts,
totalCards,
userProfiles,
onToast,
}: RoomManageModalProps) {
const toast = useToast();
const [loading, setLoading] = useState<string | null>(null);
const [confirmKick, setConfirmKick] = useState<string | null>(null);
const [confirmEnd, setConfirmEnd] = useState(false);
@@ -54,33 +54,33 @@ export default function RoomManageModal({
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
onToast(data.error ?? "操作失败");
toast.show(data.error ?? "操作失败");
return;
}
switch (action) {
case "lock":
onToast("房间已锁定,其他人无法加入");
toast.show("房间已锁定,其他人无法加入");
break;
case "unlock":
onToast("房间已解锁");
toast.show("房间已解锁");
break;
case "kick":
onToast("已将该用户移出房间");
toast.show("已将该用户移出房间");
setConfirmKick(null);
break;
case "end_voting":
onToast("已结束投票,正在结算结果");
toast.show("已结束投票,正在结算结果");
setConfirmEnd(false);
onClose();
break;
}
} catch {
onToast("操作失败,请重试");
toast.show("操作失败,请重试");
} finally {
setLoading(null);
}
},
[roomId, userId, onToast, onClose],
[roomId, userId, toast, onClose],
);
const otherUsers = users.filter((u) => u !== userId);
+8 -8
View File
@@ -6,6 +6,7 @@ import { X, Download, Share2, Loader2, Star, MapPin, Zap } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { toPng } from "html-to-image";
import type { Restaurant, MatchType, SceneType } from "@/types";
import { useToast } from "@/hooks/useToast";
type ShareCardData =
| {
@@ -28,7 +29,6 @@ interface ShareCardModalProps {
open: boolean;
onClose: () => void;
data: ShareCardData;
onToast?: (msg: string) => void;
}
async function loadImageAsDataUrl(src: string): Promise<string | null> {
@@ -775,8 +775,8 @@ export default function ShareCardModal({
open,
onClose,
data,
onToast,
}: ShareCardModalProps) {
const toast = useToast();
const cardRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const [generating, setGenerating] = useState(false);
@@ -821,7 +821,7 @@ export default function ShareCardModal({
const handleSave = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
onToast?.("生成图片失败,请重试");
toast.show("生成图片失败,请重试");
return;
}
const name =
@@ -829,13 +829,13 @@ export default function ShareCardModal({
? `NoWhatever_${data.restaurant.name}.png`
: `NoWhatever_周末契约.png`;
downloadDataUrl(png, name);
onToast?.("图片已保存");
}, [handleGenerate, onToast, data]);
toast.show("图片已保存");
}, [handleGenerate, toast, data]);
const handleShare = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
onToast?.("生成图片失败,请重试");
toast.show("生成图片失败,请重试");
return;
}
@@ -852,8 +852,8 @@ export default function ShareCardModal({
}
downloadDataUrl(png, "NoWhatever.png");
onToast?.("图片已保存,快去分享吧!");
}, [handleGenerate, onToast]);
toast.show("图片已保存,快去分享吧!");
}, [handleGenerate, toast]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { ToastContext, type ToastPosition } from "@/hooks/useToast";
import Toast from "./Toast";
export default function ToastProvider({
children,
}: {
children: React.ReactNode;
}) {
const [message, setMessage] = useState("");
const [position, setPosition] = useState<ToastPosition>("top");
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const show = useCallback((msg: string, pos: ToastPosition = "top") => {
clearTimeout(timerRef.current);
setMessage(msg);
setPosition(pos);
timerRef.current = setTimeout(() => setMessage(""), 2200);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
<Toast message={message} position={position} />
</ToastContext.Provider>
);
}
-7
View File
@@ -4,10 +4,8 @@ import { useState } from "react";
import { QrCode, LogOut, Crown, Lock } from "lucide-react";
import QrInviteModal from "./QrInviteModal";
import RoomManageModal from "./RoomManageModal";
import Toast from "@/components/Toast";
import type { UserProfile, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
import { useToast } from "@/hooks/useToast";
interface TopNavProps {
roomId: string;
@@ -37,7 +35,6 @@ export default function TopNav({
scene = "eat",
}: TopNavProps) {
const sceneConfig = getSceneConfig(scene);
const toast = useToast();
const [showQr, setShowQr] = useState(false);
const [showManage, setShowManage] = useState(false);
@@ -79,13 +76,10 @@ export default function TopNav({
</h1>
</nav>
<Toast message={toast.message} />
<QrInviteModal
open={showQr}
onClose={() => setShowQr(false)}
roomId={roomId}
onToast={toast.show}
scene={scene}
/>
@@ -100,7 +94,6 @@ export default function TopNav({
swipeCounts={swipeCounts}
totalCards={totalCards}
userProfiles={userProfiles}
onToast={toast.show}
/>
)}
</>
+12 -14
View File
@@ -1,17 +1,15 @@
import { useState, useCallback, useRef } from "react";
import { createContext, useContext } from "react";
export function useToast(duration = 2200) {
const [message, setMessage] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
export type ToastPosition = "top" | "bottom";
const show = useCallback(
(msg: string) => {
clearTimeout(timerRef.current);
setMessage(msg);
timerRef.current = setTimeout(() => setMessage(""), duration);
},
[duration],
);
return { message, show } as const;
export interface ToastContextValue {
show: (msg: string, position?: ToastPosition) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}