feat: 两级匹配机制 - 全票通过即时匹配 + 滑完自动推荐得票最高

- 后端 GET /api/room/[id] 新增 findBestMatch,滑完后选出得票最高餐厅
- 平票时取高德评分更高的一家,永远不会出现"无结果"死局
- 返回 matchType (unanimous/best) 和 matchLikes 区分匹配类型
- 全票通过:绿色庆祝 + "大家一拍即合!"
- 得票最高:橙色推荐 + "N/M 人想去这家"
- 移除 noMatch 死局页面,简化 SwipeDeck 状态管理
This commit is contained in:
2026-02-24 17:26:16 +08:00
parent bdab39d866
commit e2c3b869eb
6 changed files with 116 additions and 52 deletions
+40 -9
View File
@@ -8,12 +8,18 @@ import {
Navigation,
Phone,
Clock,
Trophy,
RotateCcw,
} from "lucide-react";
import { Restaurant } from "@/types";
import { Restaurant, MatchType } from "@/types";
interface MatchResultProps {
restaurant: Restaurant;
matchType: MatchType;
matchLikes: number;
userCount: number;
onReset: () => Promise<void>;
resetting: boolean;
}
function buildNavUrl(restaurant: Restaurant): string {
@@ -24,10 +30,23 @@ function buildNavUrl(restaurant: Restaurant): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
}
export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
export default function MatchResult({
restaurant,
matchType,
matchLikes,
userCount,
onReset,
resetting,
}: MatchResultProps) {
const isUnanimous = matchType === "unanimous";
return (
<motion.div
className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-linear-to-b from-emerald-500 to-teal-600 px-6 py-10"
className={`fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto px-6 py-10 ${
isUnanimous
? "bg-linear-to-b from-emerald-500 to-teal-600"
: "bg-linear-to-b from-amber-500 to-orange-500"
}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
@@ -37,7 +56,11 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
>
<PartyPopper size={56} className="text-yellow-300" />
{isUnanimous ? (
<PartyPopper size={56} className="text-yellow-300" />
) : (
<Trophy size={56} className="text-yellow-200" />
)}
</motion.div>
<motion.h1
@@ -50,12 +73,14 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
</motion.h1>
<motion.p
className="mt-1 text-sm font-medium text-emerald-100"
className={`mt-1 text-sm font-medium ${isUnanimous ? "text-emerald-100" : "text-amber-100"}`}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.45 }}
>
Everyone agreed on this one
{isUnanimous
? "大家一拍即合!"
: `${matchLikes}/${userCount} 人想去这家`}
</motion.p>
<motion.div
@@ -139,7 +164,9 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3 text-sm font-bold text-emerald-600 shadow-lg transition-colors hover:bg-emerald-50"
className={`flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3 text-sm font-bold shadow-lg transition-colors hover:bg-emerald-50 ${
isUnanimous ? "text-emerald-600" : "text-orange-600"
}`}
whileTap={{ scale: 0.95 }}
>
<Navigation size={16} />
@@ -159,13 +186,17 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
</motion.div>
<motion.button
className="mt-4 text-sm font-medium text-emerald-200 underline underline-offset-2 hover:text-white"
className={`mt-4 flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
isUnanimous ? "text-emerald-200" : "text-amber-200"
}`}
onClick={onReset}
disabled={resetting}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
<RotateCcw size={13} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</motion.button>
</motion.div>
);
+19 -35
View File
@@ -1,19 +1,20 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence } 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";
import { Restaurant, SwipeDirection, MatchType } from "@/types";
interface SwipeDeckProps {
restaurants: Restaurant[];
roomId: string;
userId: string;
matchedRestaurantId: string | null;
noMatch: boolean;
matchType: MatchType;
matchLikes: number;
userCount: number;
onReset: () => Promise<void>;
}
@@ -22,7 +23,9 @@ export default function SwipeDeck({
roomId,
userId,
matchedRestaurantId,
noMatch,
matchType,
matchLikes,
userCount,
onReset,
}: SwipeDeckProps) {
const [currentIndex, setCurrentIndex] = useState(0);
@@ -110,13 +113,13 @@ export default function SwipeDeck({
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
: null;
const showWaiting = allSwiped && !resolvedMatchId && !noMatch;
const showWaiting = allSwiped && !resolvedMatchId;
return (
<>
<div className="relative flex flex-1 items-center justify-center px-4">
<div className="relative h-[70vh] w-full max-w-sm">
{!resolvedMatchId && !noMatch && (
{!resolvedMatchId && (
<AnimatePresence>
{restaurants.map((restaurant, index) => {
if (index < currentIndex || index > currentIndex + 1)
@@ -141,39 +144,20 @@ 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 || noMatch} />
<ActionButtons onAction={handleButtonAction} disabled={isDone} />
{showMatch && matchRestaurant && (
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />
<MatchResult
restaurant={matchRestaurant}
matchType={matchType ?? "unanimous"}
matchLikes={matchLikes}
userCount={userCount}
onReset={handleReset}
resetting={resetting}
/>
)}
</>
);