From d4c6da57a12fdf3e24a7fd9ba9255f000e9c205a Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 17:57:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Toast=20=E5=8D=87=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E5=85=A8=E5=B1=80=20Context=EF=BC=8C=E6=B6=88=E9=99=A4=20onToa?= =?UTF-8?q?st=20prop=20=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 useToast 从独立 state hook 改为 Context-based,在 layout 中 挂载 ToastProvider 全局渲染 Toast。QrInviteModal、RoomManageModal、 ShareCardModal 不再需要 onToast prop,直接 useToast() 调用即可。 父组件 TopNav、MatchResult、profile、blindbox 移除了本地 Toast 渲染和 onToast 传递逻辑。 --- src/app/blindbox/[code]/page.tsx | 4 ---- src/app/layout.tsx | 7 +++++-- src/app/profile/page.tsx | 2 -- src/components/MatchResult.tsx | 4 ---- src/components/QrInviteModal.tsx | 10 +++++----- src/components/RoomManageModal.tsx | 18 +++++++++--------- src/components/ShareCardModal.tsx | 16 ++++++++-------- src/components/ToastProvider.tsx | 29 +++++++++++++++++++++++++++++ src/components/TopNav.tsx | 7 ------- src/hooks/useToast.ts | 26 ++++++++++++-------------- 10 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 src/components/ToastProvider.tsx diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 4f02594..97d737f 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -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() { )} - -
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e2b3ac..1c31eaf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - {children} - + + {children} + + ); diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 9d17bbd..d8a0500 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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() { - ); } diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 9c7c99d..9919a58 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -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} /> - - ); } diff --git a/src/components/QrInviteModal.tsx b/src/components/QrInviteModal.tsx index 467362f..3f5f4ac 100644 --- a/src/components/QrInviteModal.tsx +++ b/src/components/QrInviteModal.tsx @@ -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 = { diff --git a/src/components/RoomManageModal.tsx b/src/components/RoomManageModal.tsx index f700c5b..81efe0a 100644 --- a/src/components/RoomManageModal.tsx +++ b/src/components/RoomManageModal.tsx @@ -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; totalCards: number; userProfiles: Record; - 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(null); const [confirmKick, setConfirmKick] = useState(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); diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index a6b1790..8c6da4e 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -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 { @@ -775,8 +775,8 @@ export default function ShareCardModal({ open, onClose, data, - onToast, }: ShareCardModalProps) { + const toast = useToast(); const cardRef = useRef(null); const backdropRef = useRef(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(); diff --git a/src/components/ToastProvider.tsx b/src/components/ToastProvider.tsx new file mode 100644 index 0000000..85d50d7 --- /dev/null +++ b/src/components/ToastProvider.tsx @@ -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("top"); + const timerRef = useRef>(undefined); + + const show = useCallback((msg: string, pos: ToastPosition = "top") => { + clearTimeout(timerRef.current); + setMessage(msg); + setPosition(pos); + timerRef.current = setTimeout(() => setMessage(""), 2200); + }, []); + + return ( + + {children} + + + ); +} diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index edccb5f..e13bf03 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -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({ - - 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} /> )} diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts index 16abab4..8b561c4 100644 --- a/src/hooks/useToast.ts +++ b/src/hooks/useToast.ts @@ -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>(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(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within ToastProvider"); + return ctx; }