feat: 添加全局 Error Boundary 和餐厅图片加载失败 fallback
- error.tsx: 路由级错误边界,提供重试和返回首页操作 - global-error.tsx: 根布局级兜底,纯内联样式避免依赖加载 - RestaurantImage: 可复用图片组件,加载失败显示餐具占位图标 - 替换 RestaurantCard、MatchResult、profile 中所有餐厅图片
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { AlertTriangle, RotateCcw, Home } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("[ErrorBoundary]", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6">
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative flex h-20 w-20 items-center justify-center"
|
||||||
|
animate={{ y: [0, -4, 0] }}
|
||||||
|
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-rose-500/15 blur-lg" />
|
||||||
|
<AlertTriangle size={36} className="relative text-rose-400/80" strokeWidth={1.5} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-xl font-bold text-heading">出了点问题</h1>
|
||||||
|
<p className="mt-2 max-w-xs text-sm text-muted">
|
||||||
|
页面遇到了意外错误,请重试或返回首页
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-rose-500"
|
||||||
|
>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-1.5 rounded-xl bg-surface px-5 py-2.5 text-sm font-semibold text-secondary ring-1 ring-border transition-colors hover:bg-elevated"
|
||||||
|
>
|
||||||
|
<Home size={15} />
|
||||||
|
首页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("[GlobalError]", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif", background: "#0a0a0a", color: "#e5e5e5" }}>
|
||||||
|
<div style={{ display: "flex", minHeight: "100dvh", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
|
||||||
|
<div style={{ fontSize: "3rem" }}>⚠️</div>
|
||||||
|
<h1 style={{ marginTop: "1.5rem", fontSize: "1.25rem", fontWeight: 700 }}>应用崩溃了</h1>
|
||||||
|
<p style={{ marginTop: "0.5rem", fontSize: "0.875rem", color: "#a3a3a3", textAlign: "center" }}>
|
||||||
|
发生了严重错误,请尝试刷新页面
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
marginTop: "2rem",
|
||||||
|
padding: "0.625rem 1.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
background: "#e11d48",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import EmptyState from "@/components/EmptyState";
|
import EmptyState from "@/components/EmptyState";
|
||||||
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||||||
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";
|
||||||
@@ -556,11 +557,10 @@ export default function ProfilePage() {
|
|||||||
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
|
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
{firstImage(d.restaurantData) && (
|
{firstImage(d.restaurantData) && (
|
||||||
<img
|
<RestaurantImage
|
||||||
src={firstImage(d.restaurantData)}
|
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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||||
@@ -638,11 +638,10 @@ export default function ProfilePage() {
|
|||||||
className="flex gap-3 rounded-xl bg-elevated p-2.5"
|
className="flex gap-3 rounded-xl bg-elevated p-2.5"
|
||||||
>
|
>
|
||||||
{firstImage(r) && (
|
{firstImage(r) && (
|
||||||
<img
|
<RestaurantImage
|
||||||
src={firstImage(r)}
|
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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { fireCelebration, playChime } from "@/lib/celebrate";
|
import { fireCelebration, playChime } from "@/lib/celebrate";
|
||||||
import { isRegistered } from "@/lib/userId";
|
import { isRegistered } from "@/lib/userId";
|
||||||
import ShareCardModal from "@/components/ShareCardModal";
|
import ShareCardModal from "@/components/ShareCardModal";
|
||||||
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
import AuthModal from "@/components/AuthModal";
|
import AuthModal from "@/components/AuthModal";
|
||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
@@ -145,11 +146,10 @@ function RunnerUpCard({
|
|||||||
className="flex gap-3 rounded-xl bg-surface/80 p-2.5 ring-1 ring-border/50 backdrop-blur-sm transition-colors hover:bg-elevated/80"
|
className="flex gap-3 rounded-xl bg-surface/80 p-2.5 ring-1 ring-border/50 backdrop-blur-sm transition-colors hover:bg-elevated/80"
|
||||||
>
|
>
|
||||||
{restaurant.images?.[0] && (
|
{restaurant.images?.[0] && (
|
||||||
<img
|
<RestaurantImage
|
||||||
src={restaurant.images[0]}
|
src={restaurant.images[0]}
|
||||||
alt={restaurant.name}
|
alt={restaurant.name}
|
||||||
className="h-16 w-16 shrink-0 rounded-lg object-cover"
|
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">
|
||||||
@@ -397,11 +397,10 @@ export default function MatchResult({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
{restaurant.images?.[0] && (
|
{restaurant.images?.[0] && (
|
||||||
<img
|
<RestaurantImage
|
||||||
src={restaurant.images[0]}
|
src={restaurant.images[0]}
|
||||||
alt={restaurant.name}
|
alt={restaurant.name}
|
||||||
className="h-44 w-full object-cover"
|
className="h-44 w-full object-cover"
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useState, useEffect } from "react";
|
|||||||
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } 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";
|
||||||
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
|
|
||||||
interface RestaurantCardProps {
|
interface RestaurantCardProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -57,16 +58,15 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
|
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
|
||||||
<img
|
<RestaurantImage
|
||||||
src={images[idx]}
|
src={images[idx]}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fadingOut !== null && (
|
{fadingOut !== null && (
|
||||||
<img
|
<RestaurantImage
|
||||||
key={fadingOut}
|
key={fadingOut}
|
||||||
src={images[fadingOut]}
|
src={images[fadingOut]}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -74,7 +74,6 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
|
|||||||
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
|
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
|
||||||
onAnimationEnd={() => setFadingOut(null)}
|
onAnimationEnd={() => setFadingOut(null)}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { UtensilsCrossed } from "lucide-react";
|
||||||
|
|
||||||
|
interface RestaurantImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
draggable?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onAnimationEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RestaurantImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className = "",
|
||||||
|
draggable,
|
||||||
|
style,
|
||||||
|
onAnimationEnd,
|
||||||
|
}: RestaurantImageProps) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const handleError = useCallback(() => setFailed(true), []);
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
draggable={draggable}
|
||||||
|
style={style}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={handleError}
|
||||||
|
onAnimationEnd={onAnimationEnd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user