feat: 商家卡片支持多图展示,点击左右切换带 crossfade 过渡
- Restaurant.image 改为 images: string[],API 层从高德取最多 5 张图 - RestaurantCard 新增 ImageGallery:点击图片左右区域切换、顶部圆点指示器、 左右箭头提示、首次查看时文字引导气泡(2.5s 自动消失) - 图片切换使用 crossfade 动画(旧图渐隐 280ms),过渡平滑 - MatchResult / Profile 页面兼容新旧数据格式,无图时条件渲染
This commit is contained in:
@@ -42,10 +42,14 @@ function mapPoiToRestaurant(poi: AmapPoiV5, defaultImage: string): Restaurant {
|
|||||||
const price =
|
const price =
|
||||||
costStr && costStr !== "[]" && costStr !== "0" ? `¥${costStr}` : "未知";
|
costStr && costStr !== "[]" && costStr !== "0" ? `¥${costStr}` : "未知";
|
||||||
|
|
||||||
const image =
|
const images =
|
||||||
poi.photos && poi.photos.length > 0 && poi.photos[0].url
|
poi.photos && poi.photos.length > 0
|
||||||
? poi.photos[0].url
|
? poi.photos
|
||||||
: defaultImage;
|
.map((p) => p.url)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 5)
|
||||||
|
: [];
|
||||||
|
if (images.length === 0) images.push(defaultImage);
|
||||||
|
|
||||||
const openTime =
|
const openTime =
|
||||||
cleanField(poi.business?.opentime_week) ||
|
cleanField(poi.business?.opentime_week) ||
|
||||||
@@ -57,7 +61,7 @@ function mapPoiToRestaurant(poi: AmapPoiV5, defaultImage: string): Restaurant {
|
|||||||
rating,
|
rating,
|
||||||
price,
|
price,
|
||||||
distance: poi.distance ? `${poi.distance}m` : "",
|
distance: poi.distance ? `${poi.distance}m` : "",
|
||||||
image,
|
images,
|
||||||
category: extractCategory(poi.type),
|
category: extractCategory(poi.type),
|
||||||
address: cleanField(poi.address),
|
address: cleanField(poi.address),
|
||||||
openTime,
|
openTime,
|
||||||
|
|||||||
@@ -34,3 +34,8 @@ body {
|
|||||||
.scrollbar-none::-webkit-scrollbar {
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes img-fade-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, lo
|
|||||||
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
||||||
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
|
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
|
||||||
|
|
||||||
|
function firstImage(r: Restaurant): string {
|
||||||
|
if (r.images?.length > 0) return r.images[0];
|
||||||
|
// backward compat: old DB records may have `image` instead of `images`
|
||||||
|
const legacy = (r as Record<string, unknown>).image;
|
||||||
|
return typeof legacy === "string" ? legacy : "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
@@ -534,9 +541,9 @@ export default function ProfilePage() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5 transition-colors active:bg-zinc-100"
|
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5 transition-colors active:bg-zinc-100"
|
||||||
>
|
>
|
||||||
{d.restaurantData.image && (
|
{firstImage(d.restaurantData) && (
|
||||||
<img
|
<img
|
||||||
src={d.restaurantData.image}
|
src={firstImage(d.restaurantData)}
|
||||||
alt={d.restaurantName}
|
alt={d.restaurantName}
|
||||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
@@ -611,9 +618,9 @@ export default function ProfilePage() {
|
|||||||
key={f.id}
|
key={f.id}
|
||||||
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5"
|
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5"
|
||||||
>
|
>
|
||||||
{r.image && (
|
{firstImage(r) && (
|
||||||
<img
|
<img
|
||||||
src={r.image}
|
src={firstImage(r)}
|
||||||
alt={r.name}
|
alt={r.name}
|
||||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
|
|||||||
@@ -134,12 +134,14 @@ function RunnerUpCard({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex gap-3 rounded-xl bg-white/10 p-2.5 backdrop-blur-sm transition-colors hover:bg-white/20"
|
className="flex gap-3 rounded-xl bg-white/10 p-2.5 backdrop-blur-sm transition-colors hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<img
|
{restaurant.images?.[0] && (
|
||||||
src={restaurant.image}
|
<img
|
||||||
alt={restaurant.name}
|
src={restaurant.images[0]}
|
||||||
className="h-16 w-16 shrink-0 rounded-lg object-cover"
|
alt={restaurant.name}
|
||||||
referrerPolicy="no-referrer"
|
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">
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||||
<p className="truncate text-sm font-bold text-white">
|
<p className="truncate text-sm font-bold text-white">
|
||||||
{restaurant.name}
|
{restaurant.name}
|
||||||
@@ -348,12 +350,14 @@ export default function MatchResult({
|
|||||||
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 }}
|
||||||
>
|
>
|
||||||
<img
|
{restaurant.images?.[0] && (
|
||||||
src={restaurant.image}
|
<img
|
||||||
alt={restaurant.name}
|
src={restaurant.images[0]}
|
||||||
className="h-44 w-full object-cover"
|
alt={restaurant.name}
|
||||||
referrerPolicy="no-referrer"
|
className="h-44 w-full object-cover"
|
||||||
/>
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="text-lg font-bold leading-tight text-zinc-900">
|
<h2 className="text-lg font-bold leading-tight text-zinc-900">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark } from "lucide-react";
|
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Restaurant } from "@/types";
|
import { Restaurant } from "@/types";
|
||||||
import { getUserId, isRegistered } from "@/lib/userId";
|
import { getUserId, isRegistered } from "@/lib/userId";
|
||||||
|
|
||||||
@@ -14,9 +14,115 @@ function stopAll(e: React.SyntheticEvent) {
|
|||||||
e.stopPropagation();
|
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) {
|
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
|
||||||
const [favorited, setFavorited] = useState(false);
|
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) => {
|
const handleFavorite = useCallback(async (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -45,15 +151,9 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
<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">
|
<div className="relative h-[58%] w-full shrink-0 overflow-hidden bg-zinc-100">
|
||||||
<img
|
{hasImage && <ImageGallery images={images} name={restaurant.name} />}
|
||||||
src={restaurant.image}
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" />
|
||||||
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="absolute bottom-3 left-4 flex items-center gap-1.5">
|
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
|
||||||
{restaurant.category && (
|
{restaurant.category && (
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ export interface Restaurant {
|
|||||||
rating: number;
|
rating: number;
|
||||||
price: string;
|
price: string;
|
||||||
distance: string;
|
distance: string;
|
||||||
image: string;
|
images: string[];
|
||||||
category: string;
|
category: string;
|
||||||
address: string;
|
address: string;
|
||||||
openTime: string;
|
openTime: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user