feat: 拦截浏览器返回键,退出房间前弹窗确认

This commit is contained in:
2026-02-24 20:21:47 +08:00
parent a6fc523f4f
commit fc0a2a018b
3 changed files with 124 additions and 6 deletions
+46 -2
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import TopNav from "@/components/TopNav";
import SwipeDeck from "@/components/SwipeDeck";
import LeaveConfirmModal from "@/components/LeaveConfirmModal";
import { useRoomPolling } from "@/hooks/useRoomPolling";
import { getUserId } from "@/lib/userId";
@@ -15,6 +16,8 @@ export default function RoomPage() {
const [userId, setUserId] = useState("");
const [joined, setJoined] = useState(false);
const [joinFailed, setJoinFailed] = useState(false);
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
const leavingRef = useRef(false);
const {
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, restaurants, notFound, mutate,
@@ -34,6 +37,42 @@ export default function RoomPage() {
}).catch(() => setJoinFailed(true));
}, [roomId]);
useEffect(() => {
window.history.pushState({ roomGuard: true }, "");
const handlePopState = () => {
if (leavingRef.current) return;
window.history.pushState({ roomGuard: true }, "");
setShowLeaveConfirm(true);
};
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (leavingRef.current) return;
e.preventDefault();
};
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, []);
const confirmLeave = useCallback(() => {
leavingRef.current = true;
setShowLeaveConfirm(false);
router.push("/");
}, [router]);
const cancelLeave = useCallback(() => {
setShowLeaveConfirm(false);
}, []);
const handleExitRequest = useCallback(() => {
setShowLeaveConfirm(true);
}, []);
const handleReset = useCallback(async () => {
await fetch(`/api/room/${roomId}/reset`, { method: "POST" });
await mutate();
@@ -78,7 +117,7 @@ export default function RoomPage() {
return (
<div className="flex h-dvh flex-col bg-background">
<TopNav roomId={roomId} userCount={userCount} />
<TopNav roomId={roomId} userCount={userCount} onExit={handleExitRequest} />
<SwipeDeck
restaurants={restaurants}
roomId={roomId}
@@ -94,6 +133,11 @@ export default function RoomPage() {
onReset={handleReset}
onNarrow={handleNarrow}
/>
<LeaveConfirmModal
open={showLeaveConfirm}
onConfirm={confirmLeave}
onCancel={cancelLeave}
/>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { LogOut } from "lucide-react";
interface LeaveConfirmModalProps {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export default function LeaveConfirmModal({
open,
onConfirm,
onCancel,
}: LeaveConfirmModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onCancel();
};
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={handleBackdropClick}
>
<motion.div
className="mx-6 w-full max-w-xs rounded-2xl bg-white px-6 py-6 shadow-2xl"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 350 }}
>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-50">
<LogOut size={22} className="text-rose-500" />
</div>
<h2 className="mt-4 text-base font-bold text-zinc-900">
退
</h2>
<p className="mt-1.5 text-center text-xs leading-relaxed text-zinc-400">
退
</p>
<div className="mt-5 flex w-full gap-2.5">
<button
onClick={onCancel}
className="flex h-11 flex-1 items-center justify-center rounded-xl border border-zinc-200 bg-white text-sm font-semibold text-zinc-700 transition-colors active:bg-zinc-50"
>
</button>
<button
onClick={onConfirm}
className="flex h-11 flex-1 items-center justify-center rounded-xl bg-rose-500 text-sm font-semibold text-white shadow-md shadow-rose-200 transition-colors active:bg-rose-600"
>
退
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+3 -4
View File
@@ -1,7 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Users, QrCode, LogOut } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import QrInviteModal from "./QrInviteModal";
@@ -9,10 +8,10 @@ import QrInviteModal from "./QrInviteModal";
interface TopNavProps {
roomId: string;
userCount: number;
onExit?: () => void;
}
export default function TopNav({ roomId, userCount }: TopNavProps) {
const router = useRouter();
export default function TopNav({ roomId, userCount, onExit }: TopNavProps) {
const [toast, setToast] = useState("");
const [showQr, setShowQr] = useState(false);
@@ -50,7 +49,7 @@ export default function TopNav({ roomId, userCount }: TopNavProps) {
<span className="font-semibold text-emerald-500">{userCount}</span>
</div>
<button
onClick={() => router.push("/")}
onClick={onExit}
className="ml-1 flex items-center justify-center rounded-full p-1 text-zinc-400 transition-colors active:bg-zinc-100 active:text-zinc-600"
aria-label="退出房间"
>