feat: best 匹配结果页展示 Top 3 候选排行,支持折叠查看备选餐厅

This commit is contained in:
2026-02-24 19:30:10 +08:00
parent 30329df136
commit 5d297684fc
6 changed files with 278 additions and 170 deletions
+14 -25
View File
@@ -26,18 +26,20 @@ export async function GET(
let match = data.match; let match = data.match;
let matchType: MatchType = null; let matchType: MatchType = null;
let matchLikes = 0; let matchLikes = 0;
let runnerUps: { id: string; likes: number }[] = [];
if (match) { if (match) {
matchType = "unanimous"; matchType = "unanimous";
matchLikes = data.users.length; matchLikes = data.users.length;
} else if (allFinished && data.restaurants.length > 0) { } else if (allFinished && data.restaurants.length > 0) {
const best = findBestMatch(data.likes, data.restaurants); const ranked = rankRestaurants(data.likes, data.restaurants);
if (best.likes > 0) { if (ranked[0].likes > 0) {
match = best.id; match = ranked[0].id;
matchType = "best"; matchType = "best";
matchLikes = best.likes; matchLikes = ranked[0].likes;
runnerUps = ranked.slice(1, 3).filter((r) => r.likes > 0);
} else { } else {
match = best.id; match = ranked[0].id;
matchType = "no_match"; matchType = "no_match";
matchLikes = 0; matchLikes = 0;
} }
@@ -56,6 +58,7 @@ export async function GET(
match, match,
matchType, matchType,
matchLikes, matchLikes,
runnerUps,
likeCounts, likeCounts,
swipeCounts: data.swipeCounts, swipeCounts: data.swipeCounts,
restaurants: data.restaurants, restaurants: data.restaurants,
@@ -69,26 +72,12 @@ export async function GET(
} }
} }
function findBestMatch( function rankRestaurants(
likes: Record<string, string[]>, likes: Record<string, string[]>,
restaurants: { id: string; rating: number }[], restaurants: { id: string; rating: number }[],
): { id: string; likes: number } { ): { id: string; likes: number }[] {
let bestId = restaurants[0].id; return restaurants
let bestLikes = 0; .map((r) => ({ id: r.id, likes: likes[r.id]?.length ?? 0, rating: r.rating }))
let bestRating = restaurants[0].rating; .sort((a, b) => b.likes - a.likes || b.rating - a.rating)
.map(({ id, likes: l }) => ({ id, likes: l }));
for (const r of restaurants) {
const count = likes[r.id]?.length ?? 0;
if (
count > bestLikes ||
(count === bestLikes && r.rating > bestRating)
) {
bestId = r.id;
bestLikes = count;
bestRating = r.rating;
}
}
return { id: bestId, likes: bestLikes };
} }
+2 -1
View File
@@ -15,7 +15,7 @@ export default function RoomPage() {
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const { const {
userCount, match, matchType, matchLikes, likeCounts, swipeCounts, restaurants, mutate, userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, restaurants, mutate,
} = useRoomPolling(roomId); } = useRoomPolling(roomId);
useEffect(() => { useEffect(() => {
@@ -57,6 +57,7 @@ export default function RoomPage() {
matchedRestaurantId={match} matchedRestaurantId={match}
matchType={matchType} matchType={matchType}
matchLikes={matchLikes} matchLikes={matchLikes}
runnerUps={runnerUps}
likeCounts={likeCounts} likeCounts={likeCounts}
swipeCounts={swipeCounts} swipeCounts={swipeCounts}
userCount={userCount} userCount={userCount}
+112 -5
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
MapPin, MapPin,
@@ -13,13 +14,16 @@ import {
RotateCcw, RotateCcw,
SearchX, SearchX,
Home, Home,
ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { Restaurant, MatchType } from "@/types"; import { Restaurant, MatchType, RunnerUp } from "@/types";
interface MatchResultProps { interface MatchResultProps {
restaurant: Restaurant; restaurant: Restaurant;
matchType: MatchType; matchType: MatchType;
matchLikes: number; matchLikes: number;
runnerUps: RunnerUp[];
allRestaurants: Restaurant[];
userCount: number; userCount: number;
onReset: () => Promise<void>; onReset: () => Promise<void>;
resetting: boolean; resetting: boolean;
@@ -104,23 +108,81 @@ function NoMatchResult({
); );
} }
function RunnerUpCard({
restaurant,
likes,
userCount,
}: {
restaurant: Restaurant;
likes: number;
userCount: number;
}) {
return (
<a
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-white/10 p-2.5 backdrop-blur-sm transition-colors hover:bg-white/20"
>
<img
src={restaurant.image}
alt={restaurant.name}
className="h-16 w-16 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer"
/>
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-bold text-white">
{restaurant.name}
</p>
<div className="mt-1 flex items-center gap-2 text-xs text-white/70">
<span className="flex items-center gap-0.5">
<Star size={11} className="fill-yellow-300 text-yellow-300" />
{restaurant.rating}
</span>
<span>{restaurant.price}</span>
{restaurant.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={11} />
{restaurant.distance}
</span>
)}
</div>
<p className="mt-0.5 text-[11px] font-medium text-amber-200">
{likes}/{userCount}
</p>
</div>
</a>
);
}
export default function MatchResult({ export default function MatchResult({
restaurant, restaurant,
matchType, matchType,
matchLikes, matchLikes,
runnerUps,
allRestaurants,
userCount, userCount,
onReset, onReset,
resetting, resetting,
}: MatchResultProps) { }: MatchResultProps) {
const [showRunnerUps, setShowRunnerUps] = useState(false);
if (matchType === "no_match") { if (matchType === "no_match") {
return <NoMatchResult onReset={onReset} resetting={resetting} />; return <NoMatchResult onReset={onReset} resetting={resetting} />;
} }
const isUnanimous = matchType === "unanimous"; const isUnanimous = matchType === "unanimous";
const runnerUpRestaurants = runnerUps
.map((ru) => {
const r = allRestaurants.find((rest) => rest.id === ru.id);
return r ? { restaurant: r, likes: ru.likes } : null;
})
.filter((x): x is { restaurant: Restaurant; likes: number } => x !== null);
return ( return (
<motion.div <motion.div
className={`fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto px-6 py-10 ${ className={`fixed inset-0 z-50 flex flex-col items-center overflow-y-auto px-6 py-10 ${
isUnanimous isUnanimous
? "bg-linear-to-b from-emerald-500 to-teal-600" ? "bg-linear-to-b from-emerald-500 to-teal-600"
: "bg-linear-to-b from-amber-500 to-orange-500" : "bg-linear-to-b from-amber-500 to-orange-500"
@@ -129,6 +191,7 @@ export default function MatchResult({
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
> >
<div className="flex w-full max-w-sm flex-1 flex-col items-center justify-center">
<motion.div <motion.div
initial={{ scale: 0, rotate: -20 }} initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }} animate={{ scale: 1, rotate: 0 }}
@@ -162,7 +225,7 @@ export default function MatchResult({
</motion.p> </motion.p>
<motion.div <motion.div
className="mt-6 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl" className="mt-6 w-full overflow-hidden rounded-2xl bg-white shadow-2xl"
initial={{ y: 60, opacity: 0 }} initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }} transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
@@ -233,7 +296,7 @@ export default function MatchResult({
</motion.div> </motion.div>
<motion.div <motion.div
className="mt-5 flex w-full max-w-sm flex-col gap-2.5" className="mt-5 flex w-full flex-col gap-2.5"
initial={{ y: 30, opacity: 0 }} initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.65 }} transition={{ delay: 0.65 }}
@@ -263,6 +326,49 @@ export default function MatchResult({
)} )}
</motion.div> </motion.div>
{!isUnanimous && runnerUpRestaurants.length > 0 && (
<motion.div
className="mt-5 w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.75 }}
>
<button
onClick={() => setShowRunnerUps((v) => !v)}
className="flex w-full items-center justify-center gap-1.5 py-2 text-xs font-semibold text-white/80 transition-colors hover:text-white"
>
{runnerUpRestaurants.length}
<motion.span
animate={{ rotate: showRunnerUps ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="inline-flex"
>
<ChevronDown size={14} />
</motion.span>
</button>
<AnimatePresence>
{showRunnerUps && (
<motion.div
className="flex flex-col gap-2"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
>
{runnerUpRestaurants.map(({ restaurant: r, likes }) => (
<RunnerUpCard
key={r.id}
restaurant={r}
likes={likes}
userCount={userCount}
/>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
<motion.button <motion.button
className={`mt-4 flex items-center gap-1.5 text-sm font-medium 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" isUnanimous ? "text-emerald-200" : "text-amber-200"
@@ -276,6 +382,7 @@ export default function MatchResult({
<RotateCcw size={13} className={resetting ? "animate-spin" : ""} /> <RotateCcw size={13} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"} {resetting ? "重置中..." : "再来一轮"}
</motion.button> </motion.button>
</div>
</motion.div> </motion.div>
); );
} }
+5 -1
View File
@@ -6,7 +6,7 @@ import SwipeableCard from "./SwipeableCard";
import ActionButtons from "./ActionButtons"; import ActionButtons from "./ActionButtons";
import MatchResult from "./MatchResult"; import MatchResult from "./MatchResult";
import SwipeGuide from "./SwipeGuide"; import SwipeGuide from "./SwipeGuide";
import { Restaurant, SwipeDirection, MatchType } from "@/types"; import { Restaurant, SwipeDirection, MatchType, RunnerUp } from "@/types";
import { Heart, Undo2, Check } from "lucide-react"; import { Heart, Undo2, Check } from "lucide-react";
const AVATARS = [ const AVATARS = [
@@ -144,6 +144,7 @@ interface SwipeDeckProps {
matchedRestaurantId: string | null; matchedRestaurantId: string | null;
matchType: MatchType; matchType: MatchType;
matchLikes: number; matchLikes: number;
runnerUps: RunnerUp[];
likeCounts: Record<string, number>; likeCounts: Record<string, number>;
swipeCounts: Record<string, number>; swipeCounts: Record<string, number>;
userCount: number; userCount: number;
@@ -158,6 +159,7 @@ export default function SwipeDeck({
matchedRestaurantId, matchedRestaurantId,
matchType, matchType,
matchLikes, matchLikes,
runnerUps,
likeCounts, likeCounts,
swipeCounts, swipeCounts,
userCount, userCount,
@@ -398,6 +400,8 @@ export default function SwipeDeck({
restaurant={matchRestaurant} restaurant={matchRestaurant}
matchType={matchType ?? "unanimous"} matchType={matchType ?? "unanimous"}
matchLikes={matchLikes} matchLikes={matchLikes}
runnerUps={runnerUps}
allRestaurants={restaurants}
userCount={userCount} userCount={userCount}
onReset={handleReset} onReset={handleReset}
resetting={resetting} resetting={resetting}
+1
View File
@@ -29,6 +29,7 @@ export function useRoomPolling(roomId: string) {
match: data?.match ?? null, match: data?.match ?? null,
matchType: data?.matchType ?? null, matchType: data?.matchType ?? null,
matchLikes: data?.matchLikes ?? 0, matchLikes: data?.matchLikes ?? 0,
runnerUps: data?.runnerUps ?? [],
likeCounts: data?.likeCounts ?? {}, likeCounts: data?.likeCounts ?? {},
swipeCounts: data?.swipeCounts ?? {}, swipeCounts: data?.swipeCounts ?? {},
restaurants: data?.restaurants ?? [], restaurants: data?.restaurants ?? [],
+6
View File
@@ -17,12 +17,18 @@ export type SwipeDirection = "left" | "right";
export type MatchType = "unanimous" | "best" | "no_match" | null; export type MatchType = "unanimous" | "best" | "no_match" | null;
export interface RunnerUp {
id: string;
likes: number;
}
export interface RoomStatus { export interface RoomStatus {
roomId: string; roomId: string;
userCount: number; userCount: number;
match: string | null; match: string | null;
matchType: MatchType; matchType: MatchType;
matchLikes: number; matchLikes: number;
runnerUps: RunnerUp[];
likeCounts: Record<string, number>; likeCounts: Record<string, number>;
swipeCounts: Record<string, number>; swipeCounts: Record<string, number>;
restaurants: Restaurant[]; restaurants: Restaurant[];