feat: 接入全站图片资源 + 修复卡片滑动与房间轮询问题

图片资源接入:
- OG/Twitter 社交分享元数据 (og-image.png)
- 错误页插画替换图标 (error-robot.png)
- EmptyState 组件支持 image prop,空状态页面接入插画
- 餐厅图片 fallback 改用 restaurant-fallback.png
- 极速救场/周末契约页面添加 hero 装饰图
- 分享卡片添加背景图层 (share-bg-*.png),通过 base64 预加载
- 更新 App 图标 (apple-touch-icon, icon-192/512)

Bug 修复:
- SwipeDeck: swipe action 从 "nope" 改为 "pass",匹配 API 预期
- SwipeDeck: 用 ref 读取 currentIndex 避免竞态重置(本地滑动后
  被服务端旧 swipeCounts 立即清零)
- SwipeDeck: 卡片 key 加入 isTop 标识,强制 remount 解决
  framer-motion drag 手势在 isTop 切换时不重新初始化的问题
- SwipeableCard: initial 统一为背景位置,确保晋升为顶部卡片时
  有一致的放大动画
- useRoomPolling: roomId 为空时跳过 SWR 和 EventSource
- room page: joinRoom 前 guard roomId,消除退房时 404
- layout: 添加 metadataBase 消除 Next.js OG 图片警告
This commit is contained in:
2026-02-27 16:08:38 +08:00
parent 4073be9066
commit 76349f0dcf
30 changed files with 167 additions and 36 deletions
+18
View File
@@ -10,9 +10,11 @@ export interface PlanShareData {
export default function BlindboxPlanShareCard({
data,
cardRef,
bgDataUrl,
}: {
data: PlanShareData;
cardRef: React.RefObject<HTMLDivElement | null>;
bgDataUrl?: string | null;
}) {
const { days, roomName } = data;
const shareUrl =
@@ -38,6 +40,22 @@ export default function BlindboxPlanShareCard({
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
}}
/>
)}
{/* Decorative glows */}
<div
style={{
+18
View File
@@ -11,9 +11,11 @@ export interface BlindboxShareData {
export default function BlindboxShareCard({
data,
cardRef,
bgDataUrl,
}: {
data: BlindboxShareData;
cardRef: React.RefObject<HTMLDivElement | null>;
bgDataUrl?: string | null;
}) {
const { idea, submitter, drawer, roomName } = data;
const shareUrl =
@@ -39,6 +41,22 @@ export default function BlindboxShareCard({
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
}}
/>
)}
{/* Decorative glows */}
<div
style={{
+20 -8
View File
@@ -5,6 +5,7 @@ import type { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
image?: string;
title: string;
subtitle?: string;
ctaLabel?: string;
@@ -14,6 +15,7 @@ interface EmptyStateProps {
export default function EmptyState({
icon: Icon,
image,
title,
subtitle,
ctaLabel,
@@ -30,14 +32,24 @@ export default function EmptyState({
return (
<div className="flex flex-col items-center py-6">
<motion.div
className="relative flex h-14 w-14 items-center justify-center"
animate={{ y: [0, -3, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
>
<div className={`absolute inset-0 rounded-xl ${c.glow} blur-md`} />
<Icon size={24} className={`relative ${c.icon}`} strokeWidth={1.5} />
</motion.div>
{image ? (
<motion.img
src={image}
alt={title}
className="h-20 w-20"
animate={{ y: [0, -3, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
/>
) : (
<motion.div
className="relative flex h-14 w-14 items-center justify-center"
animate={{ y: [0, -3, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
>
<div className={`absolute inset-0 rounded-xl ${c.glow} blur-md`} />
<Icon size={24} className={`relative ${c.icon}`} strokeWidth={1.5} />
</motion.div>
)}
<p className="mt-3 text-sm font-semibold text-secondary">{title}</p>
{subtitle && (
+1
View File
@@ -71,6 +71,7 @@ export default function ProfileFavoritesCard({
) : favorites.length === 0 ? (
<EmptyState
icon={Heart}
image="/empty-no-record.png"
title="还没有收藏的餐厅"
subtitle="在匹配结果中收藏喜欢的店"
ctaLabel="去创建第一个房间"
+1
View File
@@ -70,6 +70,7 @@ export default function ProfileHistoryCard({
) : history.length === 0 ? (
<EmptyState
icon={ClipboardList}
image="/empty-no-record.png"
title="还没有决策记录"
subtitle="创建房间开始一起选餐厅"
ctaLabel="去创建第一个房间"
+6 -2
View File
@@ -1,7 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { UtensilsCrossed } from "lucide-react";
interface RestaurantImageProps {
src: string;
@@ -30,7 +29,12 @@ export default function RestaurantImage({
className={`flex items-center justify-center bg-elevated ${className}`}
style={style}
>
<UtensilsCrossed className="h-1/3 w-1/3 max-h-8 max-w-8 text-muted/40" strokeWidth={1.5} />
<img
src="/restaurant-fallback.png"
alt={alt}
className="h-1/2 w-1/2 max-h-20 max-w-20 object-contain opacity-60"
draggable={false}
/>
</div>
);
}
+18
View File
@@ -16,10 +16,12 @@ export default function RestaurantShareCard({
data,
cardRef,
imageDataUrl,
bgDataUrl,
}: {
data: RestaurantShareData;
cardRef: React.RefObject<HTMLDivElement | null>;
imageDataUrl: string | null;
bgDataUrl?: string | null;
}) {
const { restaurant, matchType, matchLikes, userCount, scene } = data;
const isUnanimous = matchType === "unanimous";
@@ -53,6 +55,22 @@ export default function RestaurantShareCard({
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
}}
/>
)}
{/* Decorative glows */}
<div
style={{
+30 -8
View File
@@ -41,20 +41,41 @@ export default function ShareCardModal({
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const [bgDataUrl, setBgDataUrl] = useState<string | null>(null);
const imageSrc = data.type === "restaurant" ? data.restaurant.images?.[0] : undefined;
const bgSrc =
data.type === "restaurant"
? "/share-bg-panic.png"
: data.type === "blindbox" || data.type === "plan"
? "/share-bg-blindbox.png"
: undefined;
useEffect(() => {
if (!open) {
setImageDataUrl(null);
setBgDataUrl(null);
return;
}
if (!imageSrc) return;
setImageLoading(true);
loadImageAsDataUrl(imageSrc)
.then(setImageDataUrl)
.finally(() => setImageLoading(false));
}, [open, imageSrc]);
const promises: Promise<void>[] = [];
if (imageSrc) {
promises.push(
loadImageAsDataUrl(imageSrc).then(setImageDataUrl),
);
}
if (bgSrc) {
promises.push(
loadImageAsDataUrl(bgSrc).then(setBgDataUrl),
);
}
if (promises.length > 0) {
setImageLoading(true);
Promise.all(promises).finally(() => setImageLoading(false));
}
}, [open, imageSrc, bgSrc]);
const handleGenerate = useCallback(async (): Promise<string | null> => {
if (!cardRef.current) return null;
@@ -153,11 +174,12 @@ export default function ShareCardModal({
data={data}
cardRef={cardRef}
imageDataUrl={imageDataUrl}
bgDataUrl={bgDataUrl}
/>
) : data.type === "plan" ? (
<BlindboxPlanShareCard data={data} cardRef={cardRef} />
<BlindboxPlanShareCard data={data} cardRef={cardRef} bgDataUrl={bgDataUrl} />
) : (
<BlindboxShareCard data={data} cardRef={cardRef} />
<BlindboxShareCard data={data} cardRef={cardRef} bgDataUrl={bgDataUrl} />
)}
</div>
+9 -5
View File
@@ -216,7 +216,7 @@ export default function SwipeDeck({
[],
);
const sendSwipe = async (restaurantId: string, action: "like" | "nope") => {
const sendSwipe = async (restaurantId: string, action: "like" | "pass") => {
try {
const res = await fetch(`/api/room/${roomId}/swipe`, {
method: "POST",
@@ -240,7 +240,7 @@ export default function SwipeDeck({
swipingRef.current = false;
if (guideVisible) setGuideVisible(false);
const action = direction === "right" ? "like" : "nope";
const action = direction === "right" ? "like" : "pass";
sendSwipe(current.id, action);
setSwipeHistory((h) => [...h, current.id]);
@@ -291,12 +291,16 @@ export default function SwipeDeck({
prevLikeCounts.current = {};
}, []);
const currentIndexRef = useRef(currentIndex);
currentIndexRef.current = currentIndex;
useEffect(() => {
const serverIndex = swipeCounts[userId] ?? 0;
if (serverIndex === 0 && currentIndex > 0 && !resetting) {
if (serverIndex === 0 && currentIndexRef.current > 0 && !resetting) {
clearLocalState();
}
}, [swipeCounts, userId, currentIndex, resetting, clearLocalState]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swipeCounts, userId, resetting, clearLocalState]);
const handleReset = useCallback(async () => {
setResetting(true);
@@ -354,7 +358,7 @@ export default function SwipeDeck({
const isTop = index === currentIndex;
return (
<SwipeableCard
key={restaurant.id}
key={`${restaurant.id}-${isTop ? "top" : "bg"}`}
restaurant={restaurant}
isTop={isTop}
onSwipe={handleSwipe}
+1 -1
View File
@@ -107,7 +107,7 @@ export default function SwipeableCard({
dragElastic={0.9}
onDragEnd={handleDragEnd}
whileDrag={{ cursor: "grabbing" }}
initial={isTop ? { scale: 1 } : { scale: 0.95, y: 16 }}
initial={{ scale: 0.95, y: 16 }}
animate={isTop ? { scale: 1, y: 0 } : { scale: 0.95, y: 16 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>