fix: 修复竞态条件、重置逻辑、无匹配终态等关键问题

- 用 Prisma $transaction 实现 atomicUpdateRoom,防止并发写入覆盖
- 新增 POST /api/room/[id]/reset 端点,修复"再来一轮"按钮死循环
- 新增 swipeCounts 字段追踪滑动进度,检测"无人匹配"终态
- 着陆页 handleCreate 增加 res.ok 检查,防止跳转到无效房间
- 匹配或无匹配后停止轮询,减少无效请求
This commit is contained in:
2026-02-24 17:04:16 +08:00
parent d87d30ccc0
commit 77d15f29e3
11 changed files with 204 additions and 78 deletions
+54 -22
View File
@@ -1,17 +1,20 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { AnimatePresence } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import SwipeableCard from "./SwipeableCard";
import ActionButtons from "./ActionButtons";
import MatchResult from "./MatchResult";
import { Restaurant, SwipeDirection } from "@/types";
import { Frown, RotateCcw } from "lucide-react";
interface SwipeDeckProps {
restaurants: Restaurant[];
roomId: string;
userId: string;
matchedRestaurantId: string | null;
noMatch: boolean;
onReset: () => Promise<void>;
}
export default function SwipeDeck({
@@ -19,20 +22,23 @@ export default function SwipeDeck({
roomId,
userId,
matchedRestaurantId,
noMatch,
onReset,
}: SwipeDeckProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [showMatch, setShowMatch] = useState(false);
const [localMatchId, setLocalMatchId] = useState<string | null>(null);
const [resetting, setResetting] = useState(false);
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
const swipingRef = useRef(false);
const resolvedMatchId = matchedRestaurantId ?? localMatchId;
useEffect(() => {
if (matchedRestaurantId != null && !showMatch) {
if (resolvedMatchId != null && !showMatch) {
setShowMatch(true);
}
}, [matchedRestaurantId, showMatch]);
}, [resolvedMatchId, showMatch]);
const registerSwipe = useCallback(
(fn: (direction: SwipeDirection) => void) => {
@@ -70,15 +76,9 @@ export default function SwipeDeck({
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
swipeFnRef.current = null;
if (nextIndex >= restaurants.length && !resolvedMatchId) {
setTimeout(() => {
if (!showMatch) setShowMatch(true);
}, 300);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentIndex, restaurants, roomId, userId, resolvedMatchId, showMatch],
[currentIndex, restaurants, roomId, userId],
);
const handleButtonAction = useCallback(
@@ -91,26 +91,32 @@ export default function SwipeDeck({
[],
);
const handleReset = useCallback(() => {
setCurrentIndex(0);
setShowMatch(false);
setLocalMatchId(null);
}, []);
const handleReset = useCallback(async () => {
setResetting(true);
try {
await onReset();
setCurrentIndex(0);
setShowMatch(false);
setLocalMatchId(null);
} finally {
setResetting(false);
}
}, [onReset]);
const isDone = currentIndex >= restaurants.length || resolvedMatchId != null;
const allSwiped = currentIndex >= restaurants.length;
const isDone = allSwiped || resolvedMatchId != null;
const matchRestaurant = resolvedMatchId
? restaurants.find((r) => r.id === resolvedMatchId) ?? restaurants[0]
: restaurants[0];
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
: null;
const showWaiting =
currentIndex >= restaurants.length && !resolvedMatchId && !showMatch;
const showWaiting = allSwiped && !resolvedMatchId && !noMatch;
return (
<>
<div className="relative flex flex-1 items-center justify-center px-4">
<div className="relative h-[70vh] w-full max-w-sm">
{!resolvedMatchId && (
{!resolvedMatchId && !noMatch && (
<AnimatePresence>
{restaurants.map((restaurant, index) => {
if (index < currentIndex || index > currentIndex + 1)
@@ -135,10 +141,36 @@ export default function SwipeDeck({
<p className="text-sm text-zinc-400">...</p>
</div>
)}
{noMatch && !showMatch && (
<motion.div
className="flex h-full flex-col items-center justify-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100">
<Frown size={32} className="text-zinc-400" />
</div>
<div className="text-center">
<p className="text-lg font-bold text-zinc-700"></p>
<p className="mt-1 text-sm text-zinc-400">
</p>
</div>
<button
onClick={handleReset}
disabled={resetting}
className="mt-2 flex items-center gap-2 rounded-xl bg-emerald-500 px-6 py-2.5 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
<RotateCcw size={16} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</button>
</motion.div>
)}
</div>
</div>
<ActionButtons onAction={handleButtonAction} disabled={isDone} />
<ActionButtons onAction={handleButtonAction} disabled={isDone || noMatch} />
{showMatch && matchRestaurant && (
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />