feat: 两级匹配机制 - 全票通过即时匹配 + 滑完自动推荐得票最高
- 后端 GET /api/room/[id] 新增 findBestMatch,滑完后选出得票最高餐厅 - 平票时取高德评分更高的一家,永远不会出现"无结果"死局 - 返回 matchType (unanimous/best) 和 matchLikes 区分匹配类型 - 全票通过:绿色庆祝 + "大家一拍即合!" - 得票最高:橙色推荐 + "N/M 人想去这家" - 移除 noMatch 死局页面,简化 SwipeDeck 状态管理
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRoomData } from "@/lib/store";
|
import { getRoomData } from "@/lib/store";
|
||||||
|
import type { MatchType } from "@/types";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
@@ -21,13 +22,29 @@ export async function GET(
|
|||||||
const allFinished =
|
const allFinished =
|
||||||
data.users.length > 0 &&
|
data.users.length > 0 &&
|
||||||
data.users.every((u) => (data.swipeCounts[u] ?? 0) >= total);
|
data.users.every((u) => (data.swipeCounts[u] ?? 0) >= total);
|
||||||
const noMatch = allFinished && data.match === null;
|
|
||||||
|
let match = data.match;
|
||||||
|
let matchType: MatchType = null;
|
||||||
|
let matchLikes = 0;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
matchType = "unanimous";
|
||||||
|
matchLikes = data.users.length;
|
||||||
|
} else if (allFinished) {
|
||||||
|
const best = findBestMatch(data.likes, data.restaurants);
|
||||||
|
if (best) {
|
||||||
|
match = best.id;
|
||||||
|
matchType = "best";
|
||||||
|
matchLikes = best.likes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
roomId: id,
|
roomId: id,
|
||||||
userCount: data.users.length,
|
userCount: data.users.length,
|
||||||
match: data.match,
|
match,
|
||||||
noMatch,
|
matchType,
|
||||||
|
matchLikes,
|
||||||
restaurants: data.restaurants,
|
restaurants: data.restaurants,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -38,3 +55,29 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findBestMatch(
|
||||||
|
likes: Record<string, string[]>,
|
||||||
|
restaurants: { id: string; rating: number }[],
|
||||||
|
): { id: string; likes: number } | null {
|
||||||
|
let bestId: string | null = null;
|
||||||
|
let bestLikes = 0;
|
||||||
|
let bestRating = 0;
|
||||||
|
|
||||||
|
for (const r of restaurants) {
|
||||||
|
const count = likes[r.id]?.length ?? 0;
|
||||||
|
if (count === 0) continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
count > bestLikes ||
|
||||||
|
(count === bestLikes && r.rating > bestRating)
|
||||||
|
) {
|
||||||
|
bestId = r.id;
|
||||||
|
bestLikes = count;
|
||||||
|
bestRating = r.rating;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestId) return null;
|
||||||
|
return { id: bestId, likes: bestLikes };
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function RoomPage() {
|
|||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
|
|
||||||
const { userCount, match, noMatch, restaurants, mutate } =
|
const { userCount, match, matchType, matchLikes, restaurants, mutate } =
|
||||||
useRoomPolling(roomId);
|
useRoomPolling(roomId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,7 +52,9 @@ export default function RoomPage() {
|
|||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
matchedRestaurantId={match}
|
matchedRestaurantId={match}
|
||||||
noMatch={noMatch}
|
matchType={matchType}
|
||||||
|
matchLikes={matchLikes}
|
||||||
|
userCount={userCount}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ import {
|
|||||||
Navigation,
|
Navigation,
|
||||||
Phone,
|
Phone,
|
||||||
Clock,
|
Clock,
|
||||||
|
Trophy,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Restaurant } from "@/types";
|
import { Restaurant, MatchType } from "@/types";
|
||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
|
matchType: MatchType;
|
||||||
|
matchLikes: number;
|
||||||
|
userCount: number;
|
||||||
onReset: () => Promise<void>;
|
onReset: () => Promise<void>;
|
||||||
|
resetting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNavUrl(restaurant: Restaurant): string {
|
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)}`;
|
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 (
|
return (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
@@ -37,7 +56,11 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
|
|||||||
animate={{ scale: 1, rotate: 0 }}
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
|
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
|
{isUnanimous ? (
|
||||||
<PartyPopper size={56} className="text-yellow-300" />
|
<PartyPopper size={56} className="text-yellow-300" />
|
||||||
|
) : (
|
||||||
|
<Trophy size={56} className="text-yellow-200" />
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
@@ -50,12 +73,14 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
|
|||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
<motion.p
|
<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 }}
|
initial={{ y: 20, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.45 }}
|
transition={{ delay: 0.45 }}
|
||||||
>
|
>
|
||||||
Everyone agreed on this one
|
{isUnanimous
|
||||||
|
? "大家一拍即合!"
|
||||||
|
: `${matchLikes}/${userCount} 人想去这家`}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -139,7 +164,9 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
|
|||||||
href={buildNavUrl(restaurant)}
|
href={buildNavUrl(restaurant)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<Navigation size={16} />
|
<Navigation size={16} />
|
||||||
@@ -159,13 +186,17 @@ export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.button
|
<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}
|
onClick={onReset}
|
||||||
|
disabled={resetting}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.8 }}
|
transition={{ delay: 0.8 }}
|
||||||
>
|
>
|
||||||
再来一轮
|
<RotateCcw size={13} className={resetting ? "animate-spin" : ""} />
|
||||||
|
{resetting ? "重置中..." : "再来一轮"}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import SwipeableCard from "./SwipeableCard";
|
import SwipeableCard from "./SwipeableCard";
|
||||||
import ActionButtons from "./ActionButtons";
|
import ActionButtons from "./ActionButtons";
|
||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import { Restaurant, SwipeDirection } from "@/types";
|
import { Restaurant, SwipeDirection, MatchType } from "@/types";
|
||||||
import { Frown, RotateCcw } from "lucide-react";
|
|
||||||
|
|
||||||
interface SwipeDeckProps {
|
interface SwipeDeckProps {
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
roomId: string;
|
roomId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
matchedRestaurantId: string | null;
|
matchedRestaurantId: string | null;
|
||||||
noMatch: boolean;
|
matchType: MatchType;
|
||||||
|
matchLikes: number;
|
||||||
|
userCount: number;
|
||||||
onReset: () => Promise<void>;
|
onReset: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +23,9 @@ export default function SwipeDeck({
|
|||||||
roomId,
|
roomId,
|
||||||
userId,
|
userId,
|
||||||
matchedRestaurantId,
|
matchedRestaurantId,
|
||||||
noMatch,
|
matchType,
|
||||||
|
matchLikes,
|
||||||
|
userCount,
|
||||||
onReset,
|
onReset,
|
||||||
}: SwipeDeckProps) {
|
}: SwipeDeckProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
@@ -110,13 +113,13 @@ export default function SwipeDeck({
|
|||||||
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
|
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const showWaiting = allSwiped && !resolvedMatchId && !noMatch;
|
const showWaiting = allSwiped && !resolvedMatchId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex flex-1 items-center justify-center px-4">
|
<div className="relative flex flex-1 items-center justify-center px-4">
|
||||||
<div className="relative h-[70vh] w-full max-w-sm">
|
<div className="relative h-[70vh] w-full max-w-sm">
|
||||||
{!resolvedMatchId && !noMatch && (
|
{!resolvedMatchId && (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{restaurants.map((restaurant, index) => {
|
{restaurants.map((restaurant, index) => {
|
||||||
if (index < currentIndex || index > currentIndex + 1)
|
if (index < currentIndex || index > currentIndex + 1)
|
||||||
@@ -141,39 +144,20 @@ export default function SwipeDeck({
|
|||||||
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionButtons onAction={handleButtonAction} disabled={isDone || noMatch} />
|
<ActionButtons onAction={handleButtonAction} disabled={isDone} />
|
||||||
|
|
||||||
{showMatch && matchRestaurant && (
|
{showMatch && matchRestaurant && (
|
||||||
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />
|
<MatchResult
|
||||||
|
restaurant={matchRestaurant}
|
||||||
|
matchType={matchType ?? "unanimous"}
|
||||||
|
matchLikes={matchLikes}
|
||||||
|
userCount={userCount}
|
||||||
|
onReset={handleReset}
|
||||||
|
resetting={resetting}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function useRoomPolling(roomId: string) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data?.match != null || data?.noMatch) {
|
if (data?.match != null) {
|
||||||
settled.current = true;
|
settled.current = true;
|
||||||
} else {
|
} else {
|
||||||
settled.current = false;
|
settled.current = false;
|
||||||
@@ -27,7 +27,8 @@ export function useRoomPolling(roomId: string) {
|
|||||||
return {
|
return {
|
||||||
userCount: data?.userCount ?? 0,
|
userCount: data?.userCount ?? 0,
|
||||||
match: data?.match ?? null,
|
match: data?.match ?? null,
|
||||||
noMatch: data?.noMatch ?? false,
|
matchType: data?.matchType ?? null,
|
||||||
|
matchLikes: data?.matchLikes ?? 0,
|
||||||
restaurants: data?.restaurants ?? [],
|
restaurants: data?.restaurants ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
+4
-1
@@ -15,10 +15,13 @@ export interface Restaurant {
|
|||||||
|
|
||||||
export type SwipeDirection = "left" | "right";
|
export type SwipeDirection = "left" | "right";
|
||||||
|
|
||||||
|
export type MatchType = "unanimous" | "best" | null;
|
||||||
|
|
||||||
export interface RoomStatus {
|
export interface RoomStatus {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
match: string | null;
|
match: string | null;
|
||||||
noMatch: boolean;
|
matchType: MatchType;
|
||||||
|
matchLikes: number;
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user