feat: 商家卡片支持多图展示,点击左右切换带 crossfade 过渡

- Restaurant.image 改为 images: string[],API 层从高德取最多 5 张图
- RestaurantCard 新增 ImageGallery:点击图片左右区域切换、顶部圆点指示器、
  左右箭头提示、首次查看时文字引导气泡(2.5s 自动消失)
- 图片切换使用 crossfade 动画(旧图渐隐 280ms),过渡平滑
- MatchResult / Profile 页面兼容新旧数据格式,无图时条件渲染
This commit is contained in:
2026-02-25 11:51:42 +08:00
parent c86a6c0909
commit 079feddf0e
6 changed files with 153 additions and 33 deletions
+16 -12
View File
@@ -134,12 +134,14 @@ function RunnerUpCard({
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"
/>
{restaurant.images?.[0] && (
<img
src={restaurant.images[0]}
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}
@@ -348,12 +350,14 @@ export default function MatchResult({
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
>
<img
src={restaurant.image}
alt={restaurant.name}
className="h-44 w-full object-cover"
referrerPolicy="no-referrer"
/>
{restaurant.images?.[0] && (
<img
src={restaurant.images[0]}
alt={restaurant.name}
className="h-44 w-full object-cover"
referrerPolicy="no-referrer"
/>
)}
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold leading-tight text-zinc-900">
+111 -11
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark } from "lucide-react";
import { useCallback, useState, useEffect } from "react";
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } from "lucide-react";
import { Restaurant } from "@/types";
import { getUserId, isRegistered } from "@/lib/userId";
@@ -14,9 +14,115 @@ function stopAll(e: React.SyntheticEvent) {
e.stopPropagation();
}
function ImageGallery({ images, name }: { images: string[]; name: string }) {
const [idx, setIdx] = useState(0);
const [fadingOut, setFadingOut] = useState<number | null>(null);
const [showHint, setShowHint] = useState(true);
const count = images.length;
const hasMultiple = count > 1;
useEffect(() => {
if (!hasMultiple || !showHint) return;
const t = setTimeout(() => setShowHint(false), 2500);
return () => clearTimeout(t);
}, [hasMultiple, showHint]);
const goTo = useCallback(
(newIdx: number) => {
if (newIdx === idx) return;
setFadingOut(idx);
setIdx(newIdx);
},
[idx],
);
const handleTap = useCallback(
(e: React.MouseEvent) => {
if (!hasMultiple) return;
e.stopPropagation();
e.preventDefault();
setShowHint(false);
const rect = e.currentTarget.getBoundingClientRect();
const tapX = e.clientX - rect.left;
const isRight = tapX > rect.width / 2;
const newIdx = isRight
? Math.min(idx + 1, count - 1)
: Math.max(idx - 1, 0);
goTo(newIdx);
},
[hasMultiple, count, idx, goTo],
);
return (
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
<img
src={images[idx]}
alt={name}
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
referrerPolicy="no-referrer"
/>
{fadingOut !== null && (
<img
key={fadingOut}
src={images[fadingOut]}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
onAnimationEnd={() => setFadingOut(null)}
draggable={false}
referrerPolicy="no-referrer"
/>
)}
{hasMultiple && (
<>
<div className="absolute left-0 right-0 top-2 flex justify-center gap-1">
{images.map((_, i) => (
<span
key={i}
className={`h-[3px] rounded-full transition-all duration-200 ${
i === idx
? "w-4 bg-white"
: "w-1.5 bg-white/40"
}`}
/>
))}
</div>
{idx > 0 && (
<div className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/25 p-1 backdrop-blur-sm">
<ChevronLeft size={20} className="text-white/90" />
</div>
)}
{idx < count - 1 && (
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/25 p-1 backdrop-blur-sm">
<ChevronRight size={20} className="text-white/90" />
</div>
)}
{showHint && idx === 0 && (
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 animate-pulse text-center">
<span className="rounded-full bg-black/40 px-3 py-1 text-[11px] font-medium text-white/90 backdrop-blur-sm">
· {count}
</span>
</div>
)}
</>
)}
</div>
);
}
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
const [favorited, setFavorited] = useState(false);
const images = restaurant.images?.filter(Boolean);
const hasImage = images && images.length > 0;
const handleFavorite = useCallback(async (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
e.preventDefault();
@@ -45,15 +151,9 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<div className="relative h-[58%] w-full shrink-0 overflow-hidden">
<img
src={restaurant.image}
alt={restaurant.name}
className="h-full w-full object-cover"
draggable={false}
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" />
<div className="relative h-[58%] w-full shrink-0 overflow-hidden bg-zinc-100">
{hasImage && <ImageGallery images={images} name={restaurant.name} />}
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" />
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
{restaurant.category && (