c17b13b6a8
- 扩展 WeekendPlan schema: 新增 endTime 字段与 userId 索引 - PATCH /api/blindbox/plan 支持 accept/complete/expire 操作, 接受时自动计算契约结束时间 - GET /api/blindbox/plan 支持 mode 参数 (latest/pending/history) - 房间页接受契约后自动返回想法池,顶部显示"契约进行中"指示器 - 契约到期时触发浏览器通知 + 页面加载时弹出完成确认弹窗 - 新增 /achievements 成就墙页面:统计数据 + 决策记录/契约记录双标签 - 首页和个人中心新增成就墙入口
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
ArrowLeft,
|
||
Mail,
|
||
Loader2,
|
||
LogOut,
|
||
Lock,
|
||
Edit3,
|
||
Check,
|
||
X,
|
||
Eye,
|
||
EyeOff,
|
||
Zap,
|
||
Trophy,
|
||
ChevronRight,
|
||
} from "lucide-react";
|
||
import Card from "@/components/Card";
|
||
import Input from "@/components/Input";
|
||
import ProfileHistoryCard from "@/components/ProfileHistoryCard";
|
||
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
|
||
import { useToast } from "@/hooks/useToast";
|
||
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
||
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord } from "@/types";
|
||
|
||
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 = useToast();
|
||
|
||
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) => { if (!r.ok) throw new Error(); return r.json(); })
|
||
.then((data) => setHistory(Array.isArray(data) ? data : []))
|
||
.catch(() => {})
|
||
.finally(() => setHistoryLoading(false));
|
||
|
||
setFavLoading(true);
|
||
fetch(`/api/user/favorite?userId=${userId}`)
|
||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||
.then((data) => setFavorites(Array.isArray(data) ? data : []))
|
||
.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);
|
||
toast.show("用户名已更新");
|
||
} 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("");
|
||
toast.show("密码已更新");
|
||
} 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);
|
||
toast.show("头像已更新");
|
||
}
|
||
} catch {
|
||
toast.show("更新失败");
|
||
}
|
||
};
|
||
|
||
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));
|
||
toast.show("已取消收藏");
|
||
} catch {
|
||
toast.show("操作失败");
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
router.push("/");
|
||
};
|
||
|
||
if (loading) {
|
||
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">
|
||
<div className="h-8 w-8" />
|
||
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
||
</nav>
|
||
<div className="mx-auto max-w-sm px-5">
|
||
<ProfileCardSkeleton />
|
||
<Card className="mt-4">
|
||
<div className="flex flex-col gap-2">
|
||
<RecordItemSkeleton />
|
||
<RecordItemSkeleton />
|
||
</div>
|
||
</Card>
|
||
<Card className="mt-4">
|
||
<div className="flex flex-col gap-2">
|
||
<RecordItemSkeleton />
|
||
<RecordItemSkeleton />
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!profile) return null;
|
||
|
||
|
||
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-heading">个人中心</h1>
|
||
</nav>
|
||
|
||
<div className="mx-auto max-w-sm px-5">
|
||
{/* Profile card */}
|
||
<Card animated>
|
||
<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
|
||
size="sm"
|
||
className="flex-1"
|
||
/>
|
||
<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>
|
||
</Card>
|
||
|
||
{/* Change password */}
|
||
<Card animated className="mt-4" 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="pr-9"
|
||
/>
|
||
<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"
|
||
/>
|
||
</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"
|
||
/>
|
||
</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>
|
||
</Card>
|
||
|
||
{/* Email binding */}
|
||
<Card animated className="mt-4" 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="flex-1"
|
||
/>
|
||
<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>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Achievements link */}
|
||
<Card animated className="mt-4" delay={0.15}>
|
||
<button
|
||
onClick={() => router.push("/achievements")}
|
||
className="flex w-full items-center gap-2"
|
||
>
|
||
<Trophy size={15} className="text-amber-400" />
|
||
<h3 className="text-sm font-semibold text-secondary">成就墙</h3>
|
||
<span className="text-[10px] text-dim">决策记录 · 契约成就</span>
|
||
<ChevronRight size={14} className="ml-auto text-muted" />
|
||
</button>
|
||
</Card>
|
||
|
||
<ProfileHistoryCard
|
||
history={history}
|
||
loading={historyLoading}
|
||
open={showHistory}
|
||
onToggle={() => setShowHistory((v) => !v)}
|
||
onEmpty={() => router.push("/blindbox")}
|
||
delay={0.2}
|
||
/>
|
||
|
||
<ProfileFavoritesCard
|
||
favorites={favorites}
|
||
loading={favLoading}
|
||
open={showFavorites}
|
||
onToggle={() => setShowFavorites((v) => !v)}
|
||
onRemove={handleRemoveFavorite}
|
||
onEmpty={() => router.push("/blindbox")}
|
||
delay={0.2}
|
||
/>
|
||
|
||
{/* 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>
|
||
|
||
</div>
|
||
);
|
||
}
|