Files
no-whatever/src/app/profile/page.tsx
T
kurihada e10e3c8230 ui: 全站统一暗色主题设计系统
- globals.css 定义语义化 token (background/surface/elevated/border/muted/dim/accent)
- 所有页面和组件迁移至暗色 token,移除硬编码 bg-white/text-zinc-*/bg-gray-*
- RestaurantCard 和 MatchResult 适配暗色卡片风格
- 按钮颜色分层:系统CTA(accent)/模式强调(橙/紫)/危险(rose)/次级(surface)
- 修复 room 页深色文字在深背景不可见的可访问性问题
2026-02-26 11:27:18 +08:00

693 lines
26 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,
} from "lucide-react";
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 }) | 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="min-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="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-white">{profile.username}</h2>
<button
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
className="text-muted transition-colors active:text-gray-300"
>
<Edit3 size={13} />
</button>
</div>
)}
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</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-gray-300"></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-white 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-white 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-white 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-gray-300"></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-white 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-gray-300">
{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 ? (
<p className="py-6 text-center text-xs text-muted">
</p>
) : (
<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) && (
<img
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
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">
<p className="truncate text-sm font-semibold text-white">{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-gray-300">
{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 ? (
<p className="py-6 text-center text-xs text-muted">
</p>
) : (
<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) && (
<img
src={firstImage(r)}
alt={r.name}
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">
<p className="truncate text-sm font-semibold text-white">{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 gap-1.5 text-xs font-medium text-muted transition-colors hover:text-rose-400"
>
<LogOut size={13} />
退
</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-white 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>
);
}