refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化

批次A:重命名 + 路由拆分
- store.ts → roomRepository.ts,更新全部 import
- blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts

批次B:blindboxPlanGen.ts 拆分(710行 → src/lib/plan/)
- agentPlan.ts:Agent 工具调用与系统提示
- legacyPlan.ts:非 Agent 备用生成逻辑
- ideaSelection.ts:Idea 筛选与 Slot 映射
- transitEnrichment.ts:交通信息查询与填充
- index.ts:runPlanGeneration 主入口

批次C:SSE 连接稳定性
- useRoomPolling.ts 加入指数退避重连(上限60s,含Jitter)
- plan/stream/route.ts 添加30s心跳 + abort信号清理

批次D:无障碍修复
- Modal:role=dialog、aria-modal、aria-labelledby
- AuthModal:aria-label关闭按钮、tablist/tab/aria-selected
- PlanItemEditModal、QrInviteModal:补全aria-label
- BlindboxPlan:图标按钮aria-label

批次E:Zod 引入
- src/lib/schemas/ai.ts:AI返回值 Schema(IdeaTagsSchema等5个)
- src/lib/schemas/requests.ts:请求体 Schema
- ai.ts 手工验证替换为 Zod safeParse

批次F:Server Components
- achievements/page.tsx → Server Component + AchievementsClient.tsx
- profile/page.tsx → Server Component + ProfileClient.tsx

批次G:Room 关系化模型
- prisma/schema.prisma:新增 RoomMember、RoomRestaurant、RoomLike、RoomSwipe 4张表
- migration:20260302010000_room_relational_model
- roomRepository.ts 完整重写(关系查询+应用锁)
- buildRoomStatus.ts 适配关系查询

测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
This commit is contained in:
2026-03-02 20:27:06 +08:00
parent 6bb0e65d4c
commit 4f4220652e
59 changed files with 2369 additions and 1999 deletions
+501
View File
@@ -0,0 +1,501 @@
"use client";
import { useState, useEffect } 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 ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
import { useToast } from "@/hooks/useToast";
import { useFavorites } from "@/hooks/useFavorites";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences } from "@/types";
export default function ProfilePage({ initialUserId }: { initialUserId: string }) {
const router = useRouter();
const [userId] = useState(initialUserId);
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
const [loading, setLoading] = useState(true);
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
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 [showFavorites, setShowFavorites] = useState(true);
const toast = useToast();
useEffect(() => {
fetch(`/api/user?id=${userId}`)
.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((e) => {
console.error("ProfilePage: fetch user failed:", e);
})
.finally(() => setLoading(false));
}, [userId, router]);
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 }),
});
mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
toast.show("已取消收藏");
} catch {
toast.show("操作失败");
}
};
const handleLogout = async () => {
await 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>
<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>
);
}
+2 -2
View File
@@ -33,7 +33,7 @@ vi.mock("@/components/ProfileFavoritesCard", () => ({
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import ProfilePage from "./page";
import ProfilePage from "./ProfileClient";
const toastCtx: ToastContextValue = { show: vi.fn() };
@@ -42,7 +42,7 @@ function renderPage() {
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(ProfilePage),
React.createElement(ProfilePage, { initialUserId: "user-1" }),
),
);
}
+14 -505
View File
@@ -1,511 +1,20 @@
"use client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyToken } from "@/lib/auth";
import ProfileClient from "./ProfileClient";
import { useState, useEffect } 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 ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
import { useToast } from "@/hooks/useToast";
import { useFavorites } from "@/hooks/useFavorites";
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 } from "@/types";
export default async function ProfilePage() {
const cookieStore = await cookies();
const token = cookieStore.get("nw_token")?.value;
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);
if (!token) redirect("/");
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
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 [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((e) => {
console.error("ProfilePage: fetch user failed:", e);
setProfile({ ...cached });
})
.finally(() => setLoading(false));
}, [router]);
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 }),
});
mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
toast.show("已取消收藏");
} catch {
toast.show("操作失败");
}
};
const handleLogout = async () => {
await 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>
);
let userId: string;
try {
userId = await verifyToken(token);
} catch {
redirect("/");
}
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>
<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>
);
return <ProfileClient initialUserId={userId} />;
}