refactor: 提取 useToast hook + Toast 组件,消除 4 处重复的通知逻辑

将 state + setTimeout 自动消失逻辑封装为 useToast hook,
Toast UI 统一为组件支持 top/bottom 两种位置,净减约 12 行。
This commit is contained in:
2026-02-26 17:50:28 +08:00
parent 948274bcb9
commit b98920858c
6 changed files with 86 additions and 98 deletions
+9 -26
View File
@@ -23,6 +23,8 @@ import {
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId"; import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal"; import ShareCardModal from "@/components/ShareCardModal";
import Toast from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
import { BlindboxRoomSkeleton } from "@/components/Skeleton"; import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
@@ -182,7 +184,7 @@ export default function BlindboxRoomPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showInvite, setShowInvite] = useState(false); const [showInvite, setShowInvite] = useState(false);
const [showShareCard, setShowShareCard] = useState(false); const [showShareCard, setShowShareCard] = useState(false);
const [toast, setToast] = useState(""); const toast = useToast();
const [confirmLeave, setConfirmLeave] = useState(false); const [confirmLeave, setConfirmLeave] = useState(false);
const [leaving, setLeaving] = useState(false); const [leaving, setLeaving] = useState(false);
@@ -314,8 +316,7 @@ export default function BlindboxRoomPage() {
} }
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i))); setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i)));
} catch (e) { } catch (e) {
setToast(e instanceof Error ? e.message : "编辑失败"); toast.show(e instanceof Error ? e.message : "编辑失败");
setTimeout(() => setToast(""), 2200);
} }
}, [profile]); }, [profile]);
@@ -334,8 +335,7 @@ export default function BlindboxRoomPage() {
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
setPoolCount((c) => Math.max(0, c - 1)); setPoolCount((c) => Math.max(0, c - 1));
} catch (e) { } catch (e) {
setToast(e instanceof Error ? e.message : "删除失败"); toast.show(e instanceof Error ? e.message : "删除失败");
setTimeout(() => setToast(""), 2200);
} }
}, [profile]); }, [profile]);
@@ -395,8 +395,7 @@ export default function BlindboxRoomPage() {
if (!room) return; if (!room) return;
try { try {
await navigator.clipboard.writeText(room.code); await navigator.clipboard.writeText(room.code);
setToast("房间号已复制"); toast.show("房间号已复制");
setTimeout(() => setToast(""), 2000);
} catch { /* ignore */ } } catch { /* ignore */ }
}; };
@@ -441,8 +440,7 @@ export default function BlindboxRoomPage() {
} }
router.replace("/blindbox"); router.replace("/blindbox");
} catch (e) { } catch (e) {
setToast(e instanceof Error ? e.message : "操作失败"); toast.show(e instanceof Error ? e.message : "操作失败");
setTimeout(() => setToast(""), 2200);
setConfirmLeave(false); setConfirmLeave(false);
} finally { } finally {
setLeaving(false); setLeaving(false);
@@ -817,10 +815,7 @@ export default function BlindboxRoomPage() {
drawer: revealedIdea.drawnBy ?? undefined, drawer: revealedIdea.drawnBy ?? undefined,
roomName: room.name, roomName: room.name,
}} }}
onToast={(msg) => { onToast={toast.show}
setToast(msg);
setTimeout(() => setToast(""), 2200);
}}
/> />
)} )}
@@ -855,19 +850,7 @@ export default function BlindboxRoomPage() {
</motion.div> </motion.div>
)} )}
{/* Toast */} <Toast message={toast.message} position="bottom" />
<AnimatePresence>
{toast && (
<motion.div
className="fixed bottom-8 left-1/2 -translate-x-1/2 rounded-full bg-surface px-4 py-2 text-xs font-semibold text-secondary shadow-xl ring-1 ring-border"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
<div className="h-8 shrink-0" /> <div className="h-8 shrink-0" />
</div> </div>
+10 -25
View File
@@ -24,6 +24,8 @@ import {
Heart, Heart,
} from "lucide-react"; } from "lucide-react";
import EmptyState from "@/components/EmptyState"; import EmptyState from "@/components/EmptyState";
import Toast from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
import RestaurantImage from "@/components/RestaurantImage"; import RestaurantImage from "@/components/RestaurantImage";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton"; import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId"; import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
@@ -68,12 +70,7 @@ export default function ProfilePage() {
const [showHistory, setShowHistory] = useState(true); const [showHistory, setShowHistory] = useState(true);
const [showFavorites, setShowFavorites] = useState(true); const [showFavorites, setShowFavorites] = useState(true);
const [toast, setToast] = useState(""); const toast = useToast();
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
useEffect(() => { useEffect(() => {
const cached = getCachedProfile(); const cached = getCachedProfile();
@@ -141,7 +138,7 @@ export default function ProfilePage() {
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev); setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar }); setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
setEditingUsername(false); setEditingUsername(false);
showToast("用户名已更新"); toast.show("用户名已更新");
} else { } else {
setUsernameMsg(data.error ?? "更新失败"); setUsernameMsg(data.error ?? "更新失败");
} }
@@ -180,7 +177,7 @@ export default function ProfilePage() {
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
showToast("密码已更新"); toast.show("密码已更新");
} else { } else {
setPasswordMsg(data.error ?? "更新失败"); setPasswordMsg(data.error ?? "更新失败");
} }
@@ -202,10 +199,10 @@ export default function ProfilePage() {
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev); setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji }); setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
setEditingAvatar(false); setEditingAvatar(false);
showToast("头像已更新"); toast.show("头像已更新");
} }
} catch { } catch {
showToast("更新失败"); toast.show("更新失败");
} }
}; };
@@ -244,9 +241,9 @@ export default function ProfilePage() {
body: JSON.stringify({ userId, favoriteId: favId }), body: JSON.stringify({ userId, favoriteId: favId }),
}); });
setFavorites((f) => f.filter((x) => x.id !== favId)); setFavorites((f) => f.filter((x) => x.id !== favId));
showToast("已取消收藏"); toast.show("已取消收藏");
} catch { } catch {
showToast("操作失败"); toast.show("操作失败");
} }
}; };
@@ -714,19 +711,7 @@ export default function ProfilePage() {
</motion.div> </motion.div>
</div> </div>
<AnimatePresence> <Toast message={toast.message} />
{toast && (
<motion.div
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
+9 -24
View File
@@ -34,6 +34,8 @@ import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal"; import ShareCardModal from "@/components/ShareCardModal";
import RestaurantImage from "@/components/RestaurantImage"; import RestaurantImage from "@/components/RestaurantImage";
import AuthModal from "@/components/AuthModal"; import AuthModal from "@/components/AuthModal";
import Toast from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
interface MatchResultProps { interface MatchResultProps {
restaurant: Restaurant; restaurant: Restaurant;
@@ -194,7 +196,7 @@ export default function MatchResult({
const router = useRouter(); const router = useRouter();
const [showRunnerUps, setShowRunnerUps] = useState(false); const [showRunnerUps, setShowRunnerUps] = useState(false);
const [showShareCard, setShowShareCard] = useState(false); const [showShareCard, setShowShareCard] = useState(false);
const [toast, setToast] = useState(""); const toast = useToast();
const celebratedRef = useRef(false); const celebratedRef = useRef(false);
const historySavedRef = useRef(false); const historySavedRef = useRef(false);
const isSolo = userCount <= 1; const isSolo = userCount <= 1;
@@ -204,11 +206,6 @@ export default function MatchResult({
const [registered, setRegistered] = useState(() => isRegistered()); const [registered, setRegistered] = useState(() => isRegistered());
const [showAuth, setShowAuth] = useState(false); const [showAuth, setShowAuth] = useState(false);
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
useEffect(() => { useEffect(() => {
if (isUnanimous && !celebratedRef.current) { if (isUnanimous && !celebratedRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -247,9 +244,9 @@ export default function MatchResult({
(profile: UserProfile) => { (profile: UserProfile) => {
setRegistered(true); setRegistered(true);
setShowAuth(false); setShowAuth(false);
showToast(`欢迎,${profile.username}!记录已保存`); toast.show(`欢迎,${profile.username}!记录已保存`);
}, },
[showToast], [toast],
); );
const handleFavorite = useCallback(async () => { const handleFavorite = useCallback(async () => {
@@ -263,13 +260,13 @@ export default function MatchResult({
}); });
if (res.ok) { if (res.ok) {
setFavorited(true); setFavorited(true);
showToast("已收藏"); toast.show("已收藏");
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
setFavLoading(false); setFavLoading(false);
}, [registered, userId, restaurant, favorited, favLoading, showToast]); }, [registered, userId, restaurant, favorited, favLoading, toast]);
if (matchType === "no_match") { if (matchType === "no_match") {
return <NoMatchResult onReset={onReset} resetting={resetting} />; return <NoMatchResult onReset={onReset} resetting={resetting} />;
@@ -635,22 +632,10 @@ export default function MatchResult({
userCount, userCount,
scene, scene,
}} }}
onToast={showToast} onToast={toast.show}
/> />
<AnimatePresence> <Toast message={toast.message} />
{toast && (
<motion.div
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-surface px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-border"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
</motion.div> </motion.div>
); );
} }
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
interface ToastProps {
message: string;
position?: "top" | "bottom";
}
const positionClass = {
top: "top-10",
bottom: "bottom-8",
};
export default function Toast({ message, position = "top" }: ToastProps) {
const isTop = position === "top";
const y = isTop ? -12 : 12;
return (
<AnimatePresence>
{message && (
<motion.div
className={`fixed left-1/2 z-60 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle ${positionClass[position]}`}
initial={{ opacity: 0, y, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{message}
</motion.div>
)}
</AnimatePresence>
);
}
+7 -23
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState } from "react";
import { QrCode, LogOut, Crown, Lock } from "lucide-react"; import { QrCode, LogOut, Crown, Lock } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import QrInviteModal from "./QrInviteModal"; import QrInviteModal from "./QrInviteModal";
import RoomManageModal from "./RoomManageModal"; import RoomManageModal from "./RoomManageModal";
import Toast from "@/components/Toast";
import type { UserProfile, SceneType } from "@/types"; import type { UserProfile, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import { useToast } from "@/hooks/useToast";
interface TopNavProps { interface TopNavProps {
roomId: string; roomId: string;
@@ -36,15 +37,10 @@ export default function TopNav({
scene = "eat", scene = "eat",
}: TopNavProps) { }: TopNavProps) {
const sceneConfig = getSceneConfig(scene); const sceneConfig = getSceneConfig(scene);
const [toast, setToast] = useState(""); const toast = useToast();
const [showQr, setShowQr] = useState(false); const [showQr, setShowQr] = useState(false);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
return ( return (
<> <>
<nav className="relative z-10 flex h-14 items-center px-4"> <nav className="relative z-10 flex h-14 items-center px-4">
@@ -83,25 +79,13 @@ export default function TopNav({
</h1> </h1>
</nav> </nav>
<AnimatePresence> <Toast message={toast.message} />
{toast && (
<motion.div
className="fixed left-1/2 top-16 z-50 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
<QrInviteModal <QrInviteModal
open={showQr} open={showQr}
onClose={() => setShowQr(false)} onClose={() => setShowQr(false)}
roomId={roomId} roomId={roomId}
onToast={showToast} onToast={toast.show}
scene={scene} scene={scene}
/> />
@@ -116,7 +100,7 @@ export default function TopNav({
swipeCounts={swipeCounts} swipeCounts={swipeCounts}
totalCards={totalCards} totalCards={totalCards}
userProfiles={userProfiles} userProfiles={userProfiles}
onToast={showToast} onToast={toast.show}
/> />
)} )}
</> </>
+17
View File
@@ -0,0 +1,17 @@
import { useState, useCallback, useRef } from "react";
export function useToast(duration = 2200) {
const [message, setMessage] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const show = useCallback(
(msg: string) => {
clearTimeout(timerRef.current);
setMessage(msg);
timerRef.current = setTimeout(() => setMessage(""), duration);
},
[duration],
);
return { message, show } as const;
}