Files
no-whatever/src/app/profile/page.tsx
T
kurihada 4ce6ea469c feat: 添加全局 Error Boundary 和餐厅图片加载失败 fallback
- error.tsx: 路由级错误边界,提供重试和返回首页操作
- global-error.tsx: 根布局级兜底,纯内联样式避免依赖加载
- RestaurantImage: 可复用图片组件,加载失败显示餐具占位图标
- 替换 RestaurantCard、MatchResult、profile 中所有餐厅图片
2026-02-26 15:22:29 +08:00

712 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Mail,
Clock,
Star,
MapPin,
Trash2,
Loader2,
ChevronDown,
LogOut,
Lock,
Edit3,
Check,
X,
Eye,
EyeOff,
Zap,
ClipboardList,
Heart,
} from "lucide-react";
import EmptyState from "@/components/EmptyState";
import RestaurantImage from "@/components/RestaurantImage";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
export default function ProfilePage() {
const router = useRouter();
const [userId, setUserId] = useState("");
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
const [loading, setLoading] = useState(true);
const [history, setHistory] = useState<DecisionRecord[]>([]);
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [favLoading, setFavLoading] = useState(false);
const [editingUsername, setEditingUsername] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [usernameSaving, setUsernameSaving] = useState(false);
const [usernameMsg, setUsernameMsg] = useState("");
const [editingPassword, setEditingPassword] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMsg, setPasswordMsg] = useState("");
const [editingAvatar, setEditingAvatar] = useState(false);
const [email, setEmail] = useState("");
const [emailSaving, setEmailSaving] = useState(false);
const [emailMsg, setEmailMsg] = useState("");
const [showHistory, setShowHistory] = useState(true);
const [showFavorites, setShowFavorites] = useState(true);
const [toast, setToast] = useState("");
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
useEffect(() => {
const cached = getCachedProfile();
if (!cached) {
router.push("/");
return;
}
const id = getUserId();
setUserId(id);
fetch(`/api/user?id=${id}`)
.then((r) => r.json())
.then((data) => {
if (data) {
setProfile(data);
setEmail(data.email ?? "");
setCachedProfile({ id: data.id, username: data.username, avatar: data.avatar });
if (data.preferences) setCachedPreferences(data.preferences);
} else {
router.push("/");
}
})
.catch(() => {
setProfile({ ...cached });
})
.finally(() => setLoading(false));
}, [router]);
useEffect(() => {
if (!userId) return;
setHistoryLoading(true);
fetch(`/api/user/history?userId=${userId}`)
.then((r) => r.json())
.then(setHistory)
.catch(() => {})
.finally(() => setHistoryLoading(false));
setFavLoading(true);
fetch(`/api/user/favorite?userId=${userId}`)
.then((r) => r.json())
.then(setFavorites)
.catch(() => {})
.finally(() => setFavLoading(false));
}, [userId]);
const handleSaveUsername = async () => {
const trimmed = newUsername.trim();
if (trimmed.length < 2 || trimmed.length > 16) {
setUsernameMsg("用户名需要 2-16 个字符");
return;
}
setUsernameSaving(true);
setUsernameMsg("");
try {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, username: trimmed }),
});
const data = await res.json();
if (res.ok) {
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
setEditingUsername(false);
showToast("用户名已更新");
} else {
setUsernameMsg(data.error ?? "更新失败");
}
} catch {
setUsernameMsg("网络错误");
} finally {
setUsernameSaving(false);
}
};
const handleSavePassword = async () => {
if (!currentPassword) {
setPasswordMsg("请输入当前密码");
return;
}
if (newPassword.length < 6) {
setPasswordMsg("新密码至少 6 个字符");
return;
}
if (newPassword !== confirmPassword) {
setPasswordMsg("两次密码不一致");
return;
}
setPasswordSaving(true);
setPasswordMsg("");
try {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, currentPassword, newPassword }),
});
const data = await res.json();
if (res.ok) {
setEditingPassword(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
showToast("密码已更新");
} else {
setPasswordMsg(data.error ?? "更新失败");
}
} catch {
setPasswordMsg("网络错误");
} finally {
setPasswordSaving(false);
}
};
const handleSaveAvatar = async (emoji: string) => {
try {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, avatar: emoji }),
});
if (res.ok) {
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
setEditingAvatar(false);
showToast("头像已更新");
}
} catch {
showToast("更新失败");
}
};
const handleSaveEmail = async () => {
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailMsg("邮箱格式不正确");
return;
}
setEmailSaving(true);
setEmailMsg("");
try {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, email: email || null }),
});
if (res.ok) {
setEmailMsg(email ? "邮箱已绑定" : "邮箱已解绑");
} else {
const data = await res.json().catch(() => ({}));
setEmailMsg(data.error ?? "保存失败");
}
} catch {
setEmailMsg("网络错误");
} finally {
setEmailSaving(false);
}
};
const handleRemoveFavorite = async (favId: string) => {
try {
await fetch("/api/user/favorite", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, favoriteId: favId }),
});
setFavorites((f) => f.filter((x) => x.id !== favId));
showToast("已取消收藏");
} catch {
showToast("操作失败");
}
};
const handleLogout = () => {
logout();
router.push("/");
};
if (loading) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-muted" />
</div>
);
}
if (!profile) return null;
const amapNavUrl = (r: Restaurant) =>
r.location
? `https://uri.amap.com/marker?position=${r.location}&name=${encodeURIComponent(r.name)}&callnative=1`
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.name)}`;
return (
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<button
onClick={() => router.push("/")}
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors active:bg-elevated"
>
<ArrowLeft size={20} />
</button>
<h1 className="flex-1 text-base font-bold text-white"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
{/* Profile card */}
<motion.div
className="rounded-2xl bg-surface p-4 ring-1 ring-border"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
>
<div className="flex items-center gap-4">
<button
onClick={() => setEditingAvatar(!editingAvatar)}
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
>
{profile.avatar}
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface text-muted shadow-sm ring-1 ring-border">
<Edit3 size={10} />
</span>
</button>
<div className="flex-1">
{editingUsername ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newUsername}
onChange={(e) => {
setNewUsername(e.target.value.slice(0, 16));
setUsernameMsg("");
}}
maxLength={16}
autoFocus
className="h-8 flex-1 rounded-lg border-none bg-elevated px-2 text-sm text-white outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50"
/>
<button
onClick={handleSaveUsername}
disabled={usernameSaving}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-white disabled:opacity-50"
>
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
<button
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
className="text-muted transition-colors active:text-secondary"
>
<Edit3 size={13} />
</button>
</div>
)}
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
{(profile.decisionCount ?? 0) > 0 && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
<Zap size={11} className="text-amber-400" />
{profile.decisionCount}
</p>
)}
</div>
</div>
{/* Avatar picker */}
<AnimatePresence>
{editingAvatar && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 grid grid-cols-6 gap-2">
{AVATARS.map((a) => (
<button
key={a.emoji}
onClick={() => handleSaveAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
profile.avatar === a.emoji
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
: "bg-elevated hover:bg-subtle"
}`}
>
{a.emoji}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Change password */}
<motion.div
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.05 }}
>
<button
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
className="flex w-full items-center gap-2"
>
<Lock size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
</button>
<AnimatePresence>
{editingPassword && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-3 flex flex-col gap-3">
<div>
<p className="text-xs text-muted"></p>
<div className="relative mt-1">
<input
type={showPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
className="h-9 w-full rounded-lg border-none bg-elevated px-3 pr-9 text-sm text-heading outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<div>
<p className="text-xs text-muted"></p>
<input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
placeholder="至少 6 个字符"
className="mt-1 h-9 w-full rounded-lg border-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
/>
</div>
<div>
<p className="text-xs text-muted"></p>
<input
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
placeholder="再次输入新密码"
className="mt-1 h-9 w-full rounded-lg border-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
/>
</div>
{passwordMsg && (
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
{passwordMsg}
</p>
)}
<button
onClick={handleSavePassword}
disabled={passwordSaving}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Email binding */}
<motion.div
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2">
<Mail size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"></span>
</div>
<div className="mt-3 flex gap-2">
<input
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setEmailMsg("");
}}
className="h-9 flex-1 rounded-lg border-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
/>
<button
onClick={handleSaveEmail}
disabled={emailSaving}
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
</button>
</div>
{emailMsg && (
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-400" : "text-accent"}`}>
{emailMsg}
</p>
)}
</motion.div>
{/* Decision History */}
<motion.div
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.15 }}
>
<button
onClick={() => setShowHistory((v) => !v)}
className="flex w-full items-center justify-between"
>
<div className="flex items-center gap-2">
<Clock size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary">
{history.length > 0 && `(${history.length})`}
</h3>
</div>
<motion.span
animate={{ rotate: showHistory ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-muted"
>
<ChevronDown size={16} />
</motion.span>
</button>
<AnimatePresence>
{showHistory && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{historyLoading ? (
<div className="flex justify-center py-6">
<Loader2 size={18} className="animate-spin text-muted" />
</div>
) : history.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="还没有决策记录"
subtitle="创建房间开始一起选餐厅"
ctaLabel="去创建第一个房间"
onCta={() => router.push("/blindbox")}
color="purple"
/>
) : (
<div className="mt-3 flex flex-col gap-2">
{history.map((d) => (
<a
key={d.id}
href={amapNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
>
{firstImage(d.restaurantData) && (
<RestaurantImage
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">{d.restaurantName}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}</span>
<span>{d.participants} </span>
<span>{new Date(d.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })}</span>
</div>
</div>
</a>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Favorites */}
<motion.div
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<button
onClick={() => setShowFavorites((v) => !v)}
className="flex w-full items-center justify-between"
>
<div className="flex items-center gap-2">
<Star size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary">
{favorites.length > 0 && `(${favorites.length})`}
</h3>
</div>
<motion.span
animate={{ rotate: showFavorites ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-muted"
>
<ChevronDown size={16} />
</motion.span>
</button>
<AnimatePresence>
{showFavorites && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{favLoading ? (
<div className="flex justify-center py-6">
<Loader2 size={18} className="animate-spin text-muted" />
</div>
) : favorites.length === 0 ? (
<EmptyState
icon={Heart}
title="还没有收藏的餐厅"
subtitle="在匹配结果中收藏喜欢的店"
ctaLabel="去创建第一个房间"
onCta={() => router.push("/blindbox")}
color="amber"
/>
) : (
<div className="mt-3 flex flex-col gap-2">
{favorites.map((f) => {
const r = f.restaurantData;
return (
<div
key={f.id}
className="flex gap-3 rounded-xl bg-elevated p-2.5"
>
{firstImage(r) && (
<RestaurantImage
src={firstImage(r)}
alt={r.name}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">{r.name}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span className="flex items-center gap-0.5">
<Star size={10} className="fill-amber-400 text-amber-400" />
{r.rating}
</span>
<span>{r.price}</span>
{r.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={10} />
{r.distance}
</span>
)}
</div>
</div>
<button
onClick={() => handleRemoveFavorite(f.id)}
className="flex h-8 w-8 shrink-0 items-center justify-center self-center rounded-full text-muted transition-colors active:bg-subtle active:text-rose-400"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Logout */}
<motion.div
className="mt-6 flex justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
>
<button
onClick={handleLogout}
className="flex items-center justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
>
<LogOut size={14} />
退
</button>
</motion.div>
</div>
<AnimatePresence>
{toast && (
<motion.div
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle"
initial={{ opacity: 0, y: -12, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -12, x: "-50%" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
</div>
);
}