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:
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
Zap,
|
||||
Gift,
|
||||
ClipboardList,
|
||||
Target,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import RestaurantImage from "@/components/RestaurantImage";
|
||||
import ContractHistoryItem from "@/components/ContractHistoryItem";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||
import { buildNavUrl } from "@/lib/navigation";
|
||||
import { useAchievements } from "@/hooks/useAchievements";
|
||||
import type { Restaurant } from "@/types";
|
||||
|
||||
type Tab = "decisions" | "contracts";
|
||||
|
||||
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 AchievementsPage({ initialUserId }: { initialUserId: string }) {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<Tab>("decisions");
|
||||
const [userId] = useState<string | undefined>(initialUserId || undefined);
|
||||
|
||||
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: "决策记录",
|
||||
value: stats.totalDecisions,
|
||||
icon: Target,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-600/15",
|
||||
},
|
||||
{
|
||||
label: "契约完成",
|
||||
value: stats.completedContracts,
|
||||
icon: Trophy,
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-600/15",
|
||||
},
|
||||
{
|
||||
label: "完成率",
|
||||
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
|
||||
icon: TrendingUp,
|
||||
color: "text-purple-400",
|
||||
bg: "bg-purple-600/15",
|
||||
},
|
||||
];
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
|
||||
{ id: "decisions", label: "极速救场", icon: Zap },
|
||||
{ id: "contracts", label: "周末契约", icon: Gift },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
||||
{/* Ambient glow */}
|
||||
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex w-full max-w-sm items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
aria-label="返回"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-muted" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={18} className="text-amber-400" />
|
||||
<h1 className="text-base font-bold text-heading">成就墙</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{statCards.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
|
||||
>
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
|
||||
<s.icon size={16} className={s.color} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-5 w-10" />
|
||||
) : (
|
||||
<p className="text-lg font-black text-heading">{s.value}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<motion.div
|
||||
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
|
||||
tab === t.id ? "text-heading" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{tab === t.id && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
|
||||
transition={{ type: "spring", damping: 25, stiffness: 350 }}
|
||||
/>
|
||||
)}
|
||||
<t.icon size={13} className="relative z-10" />
|
||||
<span className="relative z-10">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-4 w-full max-w-sm">
|
||||
<AnimatePresence mode="wait">
|
||||
{tab === "decisions" && (
|
||||
<motion.div
|
||||
key="decisions"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
</>
|
||||
) : decisions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
image="/empty-no-record.png"
|
||||
title="还没有决策记录"
|
||||
subtitle="使用极速救场后会在这里记录"
|
||||
color="amber"
|
||||
/>
|
||||
) : (
|
||||
decisions.map((d) => (
|
||||
<a
|
||||
key={d.id}
|
||||
href={buildNavUrl(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>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tab === "contracts" && (
|
||||
<motion.div
|
||||
key="contracts"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
</>
|
||||
) : contracts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
image="/empty-no-room.png"
|
||||
title="还没有契约记录"
|
||||
subtitle="完成或过期的契约会在这里显示"
|
||||
color="purple"
|
||||
/>
|
||||
) : (
|
||||
contracts.map((c) => (
|
||||
<ContractHistoryItem key={c.id} record={c} />
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="h-8 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+16
-248
@@ -1,252 +1,20 @@
|
||||
"use client";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifyToken } from "@/lib/auth";
|
||||
import AchievementsClient from "./AchievementsClient";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
Zap,
|
||||
Gift,
|
||||
ClipboardList,
|
||||
Target,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { isRegistered, getCachedProfile } from "@/lib/userId";
|
||||
import RestaurantImage from "@/components/RestaurantImage";
|
||||
import ContractHistoryItem from "@/components/ContractHistoryItem";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||
import { buildNavUrl } from "@/lib/navigation";
|
||||
import { useAchievements } from "@/hooks/useAchievements";
|
||||
import type { Restaurant } from "@/types";
|
||||
export default async function AchievementsPage() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("nw_token")?.value;
|
||||
|
||||
type Tab = "decisions" | "contracts";
|
||||
if (!token) redirect("/");
|
||||
|
||||
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 AchievementsPage() {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<Tab>("decisions");
|
||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRegistered()) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
const p = getCachedProfile();
|
||||
if (p) setUserId(p.id);
|
||||
}, [router]);
|
||||
|
||||
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: "决策记录",
|
||||
value: stats.totalDecisions,
|
||||
icon: Target,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-600/15",
|
||||
},
|
||||
{
|
||||
label: "契约完成",
|
||||
value: stats.completedContracts,
|
||||
icon: Trophy,
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-600/15",
|
||||
},
|
||||
{
|
||||
label: "完成率",
|
||||
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
|
||||
icon: TrendingUp,
|
||||
color: "text-purple-400",
|
||||
bg: "bg-purple-600/15",
|
||||
},
|
||||
];
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
|
||||
{ id: "decisions", label: "极速救场", icon: Zap },
|
||||
{ id: "contracts", label: "周末契约", icon: Gift },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
||||
{/* Ambient glow */}
|
||||
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex w-full max-w-sm items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
aria-label="返回"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-muted" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={18} className="text-amber-400" />
|
||||
<h1 className="text-base font-bold text-heading">成就墙</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{statCards.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
|
||||
>
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
|
||||
<s.icon size={16} className={s.color} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-5 w-10" />
|
||||
) : (
|
||||
<p className="text-lg font-black text-heading">{s.value}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<motion.div
|
||||
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
|
||||
tab === t.id ? "text-heading" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{tab === t.id && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
|
||||
transition={{ type: "spring", damping: 25, stiffness: 350 }}
|
||||
/>
|
||||
)}
|
||||
<t.icon size={13} className="relative z-10" />
|
||||
<span className="relative z-10">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-4 w-full max-w-sm">
|
||||
<AnimatePresence mode="wait">
|
||||
{tab === "decisions" && (
|
||||
<motion.div
|
||||
key="decisions"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
</>
|
||||
) : decisions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
image="/empty-no-record.png"
|
||||
title="还没有决策记录"
|
||||
subtitle="使用极速救场后会在这里记录"
|
||||
color="amber"
|
||||
/>
|
||||
) : (
|
||||
decisions.map((d) => (
|
||||
<a
|
||||
key={d.id}
|
||||
href={buildNavUrl(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>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tab === "contracts" && (
|
||||
<motion.div
|
||||
key="contracts"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
<RecordItemSkeleton />
|
||||
</>
|
||||
) : contracts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
image="/empty-no-room.png"
|
||||
title="还没有契约记录"
|
||||
subtitle="完成或过期的契约会在这里显示"
|
||||
color="purple"
|
||||
/>
|
||||
) : (
|
||||
contracts.map((c) => (
|
||||
<ContractHistoryItem key={c.id} record={c} />
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="h-8 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await verifyToken(token);
|
||||
} catch {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <AchievementsClient initialUserId={userId} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_USER } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
@@ -36,7 +39,7 @@ describe("POST /api/blindbox/draw", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||
body: { roomId: "bb-room-1" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -62,7 +65,7 @@ describe("POST /api/blindbox/draw", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||
body: { roomId: "bb-room-1" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(404);
|
||||
@@ -82,7 +85,7 @@ describe("POST /api/blindbox/draw", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||
body: { roomId: "bb-room-1" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(409);
|
||||
@@ -91,7 +94,7 @@ describe("POST /api/blindbox/draw", () => {
|
||||
it("returns 400 when roomId is missing", async () => {
|
||||
const req = createRequest("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1" },
|
||||
body: {},
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -29,7 +33,6 @@ describe("POST /api/blindbox/plan", () => {
|
||||
method: "POST",
|
||||
body: {
|
||||
roomId: "bb-room-1",
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||
},
|
||||
});
|
||||
@@ -45,7 +48,6 @@ describe("POST /api/blindbox/plan", () => {
|
||||
method: "POST",
|
||||
body: {
|
||||
roomId: "bb-room-1",
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
|
||||
},
|
||||
});
|
||||
@@ -57,7 +59,6 @@ describe("POST /api/blindbox/plan", () => {
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "POST",
|
||||
body: {
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||
},
|
||||
});
|
||||
@@ -76,7 +77,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
body: { planId: "plan-1", userId: "user-1", action: "accept" },
|
||||
body: { planId: "plan-1", action: "accept" },
|
||||
});
|
||||
const res = await PATCH(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -94,7 +95,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
body: { planId: "plan-1", userId: "user-1", action: "complete" },
|
||||
body: { planId: "plan-1", action: "complete" },
|
||||
});
|
||||
const res = await PATCH(req, mockCtx);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -109,7 +110,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
body: { planId: "plan-1", userId: "user-1", action: "expire" },
|
||||
body: { planId: "plan-1", action: "expire" },
|
||||
});
|
||||
const res = await PATCH(req, mockCtx);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -123,7 +124,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
body: { planId: "plan-1", userId: "user-1", action: "accept" },
|
||||
body: { planId: "plan-1", action: "accept" },
|
||||
});
|
||||
const res = await PATCH(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
@@ -137,7 +138,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
body: { planId: "plan-1", userId: "user-1", action: "accept" },
|
||||
body: { planId: "plan-1", action: "accept" },
|
||||
});
|
||||
const res = await PATCH(req, mockCtx);
|
||||
expect(res.status).toBe(403);
|
||||
@@ -148,7 +149,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
body: { planId: "plan-1", userId: "user-1", action: "invalid" },
|
||||
body: { planId: "plan-1", action: "invalid" },
|
||||
});
|
||||
const res = await PATCH(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
@@ -164,7 +165,7 @@ describe("GET /api/blindbox/plan", () => {
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
|
||||
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
|
||||
@@ -176,7 +177,7 @@ describe("GET /api/blindbox/plan", () => {
|
||||
it("returns null when no plan found", async () => {
|
||||
prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never);
|
||||
|
||||
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
|
||||
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
|
||||
@@ -184,7 +185,7 @@ describe("GET /api/blindbox/plan", () => {
|
||||
});
|
||||
|
||||
it("returns 400 for invalid mode", async () => {
|
||||
const req = createRequest("/api/blindbox/plan?mode=invalid&userId=user-1");
|
||||
const req = createRequest("/api/blindbox/plan?mode=invalid");
|
||||
const res = await GET(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { requireMembership } from "@/lib/blindbox";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
import { handlePlanUpdate } from "@/lib/planActions";
|
||||
import { getLatestPlan, getPendingPlans, getHistoryPlans } from "@/lib/planQueries";
|
||||
|
||||
interface AvailableTime {
|
||||
date: string;
|
||||
@@ -37,97 +39,13 @@ export const POST = apiHandler(async (req) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
|
||||
* Returns a Date at 00:00 of that day.
|
||||
*/
|
||||
function nextWeekday(dayLabel: string, from: Date): Date {
|
||||
const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6
|
||||
const d = new Date(from);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const diff = (targetDow - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
|
||||
return d;
|
||||
}
|
||||
|
||||
function computeEndTime(planData: string, now: Date): Date | null {
|
||||
try {
|
||||
const parsed = JSON.parse(planData);
|
||||
const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[];
|
||||
if (!days?.length) return null;
|
||||
|
||||
const lastDay = days[days.length - 1];
|
||||
const lastItem = lastDay.items[lastDay.items.length - 1];
|
||||
if (!lastItem) return null;
|
||||
|
||||
const base = nextWeekday(lastDay.date, now);
|
||||
const [h, m] = lastItem.time.split(":").map(Number);
|
||||
base.setHours(h, m, 0, 0);
|
||||
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
|
||||
|
||||
if (base.getTime() < now.getTime()) {
|
||||
base.setDate(base.getDate() + 7);
|
||||
}
|
||||
|
||||
return base;
|
||||
} catch (e) {
|
||||
console.error("computeEndTime failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(async (req) => {
|
||||
const userId = await getAuthUserId(req);
|
||||
const { planId, action, days } = await req.json();
|
||||
if (!planId) throw new ApiError("planId 不能为空");
|
||||
|
||||
const { prisma } = await import("@/lib/prisma");
|
||||
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
||||
if (!plan) throw new ApiError("计划不存在", 404);
|
||||
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
||||
|
||||
const act = action || "accept";
|
||||
|
||||
if (act === "accept") {
|
||||
if (plan.status !== "active") throw new ApiError("该计划无法接受", 400);
|
||||
const endTime = computeEndTime(plan.planData, new Date());
|
||||
await prisma.weekendPlan.update({
|
||||
where: { id: planId },
|
||||
data: { status: "accepted", endTime },
|
||||
});
|
||||
return NextResponse.json({ ok: true, endTime });
|
||||
}
|
||||
|
||||
if (act === "complete" || act === "expire") {
|
||||
if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400);
|
||||
await prisma.weekendPlan.update({
|
||||
where: { id: planId },
|
||||
data: { status: act === "complete" ? "completed" : "expired" },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (act === "update_plan") {
|
||||
if (plan.status !== "active" && plan.status !== "accepted") {
|
||||
throw new ApiError("只能编辑进行中的计划", 400);
|
||||
}
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new ApiError("days 数据无效", 400);
|
||||
}
|
||||
const newPlanData = JSON.stringify({ days });
|
||||
await prisma.weekendPlan.update({
|
||||
where: { id: planId },
|
||||
data: {
|
||||
planData: newPlanData,
|
||||
...(plan.status === "accepted"
|
||||
? { endTime: computeEndTime(newPlanData, new Date()) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
throw new ApiError("无效的操作", 400);
|
||||
const result = await handlePlanUpdate(planId, userId, action, days);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
@@ -135,92 +53,18 @@ export const GET = apiHandler(async (req) => {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const mode = searchParams.get("mode") || "latest";
|
||||
|
||||
const { prisma } = await import("@/lib/prisma");
|
||||
|
||||
if (mode === "latest") {
|
||||
const roomId = searchParams.get("roomId");
|
||||
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||
|
||||
const plan = await prisma.weekendPlan.findFirst({
|
||||
where: { roomId, userId, status: "accepted" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, planData: true, endTime: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!plan) return NextResponse.json({ plan: null });
|
||||
const parsed = JSON.parse(plan.planData);
|
||||
return NextResponse.json({
|
||||
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
|
||||
});
|
||||
return NextResponse.json(await getLatestPlan(roomId, userId));
|
||||
}
|
||||
|
||||
if (mode === "pending") {
|
||||
const plans = await prisma.weekendPlan.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: "accepted",
|
||||
endTime: { not: null, lt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, planData: true, roomId: true, createdAt: true },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
const result = await Promise.all(
|
||||
plans.map(async (p) => {
|
||||
const room = await prisma.blindBoxRoom.findUnique({
|
||||
where: { id: p.roomId },
|
||||
select: { name: true, code: true },
|
||||
});
|
||||
const parsed = JSON.parse(p.planData);
|
||||
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
||||
return {
|
||||
id: p.id,
|
||||
roomName: room?.name ?? "未知房间",
|
||||
roomCode: room?.code ?? "",
|
||||
date: days.map((d) => d.date).join(" + "),
|
||||
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ pending: result });
|
||||
return NextResponse.json(await getPendingPlans(userId));
|
||||
}
|
||||
|
||||
if (mode === "history") {
|
||||
const plans = await prisma.weekendPlan.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { in: ["completed", "expired"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, planData: true, status: true, roomId: true, createdAt: true },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const result = await Promise.all(
|
||||
plans.map(async (p) => {
|
||||
const room = await prisma.blindBoxRoom.findUnique({
|
||||
where: { id: p.roomId },
|
||||
select: { name: true, code: true },
|
||||
});
|
||||
const parsed = JSON.parse(p.planData);
|
||||
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
||||
return {
|
||||
id: p.id,
|
||||
status: p.status,
|
||||
roomName: room?.name ?? "未知房间",
|
||||
roomCode: room?.code ?? "",
|
||||
date: days.map((d) => d.date).join(" + "),
|
||||
dayCount: days.length,
|
||||
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ history: result });
|
||||
return NextResponse.json(await getHistoryPlans(userId));
|
||||
}
|
||||
|
||||
throw new ApiError("无效的 mode 参数", 400);
|
||||
|
||||
@@ -2,15 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
requireUserId: vi.fn((v) => {
|
||||
if (!v || typeof v !== "string") throw new Error("请先登录");
|
||||
return v;
|
||||
}),
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindboxPlanGen", () => ({
|
||||
@@ -19,11 +16,14 @@ vi.mock("@/lib/blindboxPlanGen", () => ({
|
||||
|
||||
import { POST } from "./route";
|
||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
const mockRunPlan = vi.mocked(runPlanGeneration);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
async function readStream(response: Response): Promise<string> {
|
||||
@@ -55,12 +55,11 @@ describe("POST /api/blindbox/plan/stream", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roomId: "bb-room-1",
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const res = await POST(req as never);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
|
||||
@@ -79,12 +78,11 @@ describe("POST /api/blindbox/plan/stream", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roomId: "bb-room-1",
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const res = await POST(req as never);
|
||||
const text = await readStream(res);
|
||||
expect(text).toContain("event: error");
|
||||
expect(text).toContain("AI 服务不可用");
|
||||
@@ -95,12 +93,11 @@ describe("POST /api/blindbox/plan/stream", () => {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const res = await POST(req as never);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -110,16 +107,16 @@ describe("POST /api/blindbox/plan/stream", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roomId: "bb-room-1",
|
||||
userId: "user-1",
|
||||
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const res = await POST(req as never);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when userId is missing", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = new Request("http://localhost/api/blindbox/plan/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -129,7 +126,7 @@ describe("POST /api/blindbox/plan/stream", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
const res = await POST(req as never);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,13 +49,33 @@ export async function POST(req: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
const signal = req.signal;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let closed = false;
|
||||
const push = (event: string, data: string) => {
|
||||
if (closed) return;
|
||||
controller.enqueue(encoder.encode(encodeSSE(event, data)));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearInterval(heartbeatId);
|
||||
try { controller.close(); } catch {}
|
||||
};
|
||||
|
||||
// 30s heartbeat to prevent proxy disconnects
|
||||
const heartbeatId = setInterval(() => {
|
||||
push("heartbeat", "ping");
|
||||
}, 30_000);
|
||||
|
||||
// Clean up when client disconnects
|
||||
signal.addEventListener("abort", cleanup);
|
||||
|
||||
try {
|
||||
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
|
||||
push("status", message);
|
||||
@@ -65,7 +85,7 @@ export async function POST(req: NextRequest): Promise<Response> {
|
||||
const message = e instanceof Error ? e.message : "生成计划失败";
|
||||
push("error", message);
|
||||
} finally {
|
||||
controller.close();
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("POST /api/blindbox/retag", () => {
|
||||
@@ -36,7 +43,7 @@ describe("POST /api/blindbox/retag", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/retag", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||
body: { roomId: "bb-room-1" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -51,7 +58,7 @@ describe("POST /api/blindbox/retag", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/retag", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||
body: { roomId: "bb-room-1" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -60,7 +67,8 @@ describe("POST /api/blindbox/retag", () => {
|
||||
expect(data.retagged).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/blindbox/retag", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1" },
|
||||
|
||||
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
getRoomByCode: vi.fn(),
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
@@ -10,12 +14,14 @@ vi.mock("@/lib/blindbox", () => ({
|
||||
|
||||
import { GET, PATCH, DELETE } from "./route";
|
||||
import { getRoomByCode } from "@/lib/blindbox";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockGetRoomByCode = vi.mocked(getRoomByCode);
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("GET /api/blindbox/room/[code]", () => {
|
||||
@@ -61,7 +67,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||
method: "PATCH",
|
||||
body: { userId: "user-1", city: "上海", lat: 31.2, lng: 121.4 },
|
||||
body: { city: "上海", lat: 31.2, lng: 121.4 },
|
||||
});
|
||||
const ctx = createRouteContext({ code: "ABC123" });
|
||||
const res = await PATCH(req, ctx);
|
||||
@@ -76,7 +82,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||
method: "PATCH",
|
||||
body: { userId: "user-1", lat: 999, lng: 121.4 },
|
||||
body: { lat: 999, lng: 121.4 },
|
||||
});
|
||||
const ctx = createRouteContext({ code: "ABC123" });
|
||||
const res = await PATCH(req, ctx);
|
||||
@@ -86,13 +92,11 @@ describe("PATCH /api/blindbox/room/[code]", () => {
|
||||
|
||||
describe("DELETE /api/blindbox/room/[code]", () => {
|
||||
it("deletes room when creator", async () => {
|
||||
// user-1 is creator (TEST_BLINDBOX_ROOM.creatorId = "user-1")
|
||||
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
||||
prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never);
|
||||
|
||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||
method: "DELETE",
|
||||
body: { userId: "user-1" },
|
||||
});
|
||||
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
|
||||
const ctx = createRouteContext({ code: "ABC123" });
|
||||
const res = await DELETE(req, ctx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -101,14 +105,13 @@ describe("DELETE /api/blindbox/room/[code]", () => {
|
||||
});
|
||||
|
||||
it("leaves room when not creator", async () => {
|
||||
// Authenticate as user-2 (not creator)
|
||||
vi.mocked(getAuthUserId).mockResolvedValueOnce("user-2");
|
||||
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
||||
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never);
|
||||
prismaMock.blindBoxMember.delete.mockResolvedValue({} as never);
|
||||
|
||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||
method: "DELETE",
|
||||
body: { userId: "user-2" },
|
||||
});
|
||||
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
|
||||
const ctx = createRouteContext({ code: "ABC123" });
|
||||
const res = await DELETE(req, ctx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -117,13 +120,12 @@ describe("DELETE /api/blindbox/room/[code]", () => {
|
||||
});
|
||||
|
||||
it("returns 403 when not a member and not creator", async () => {
|
||||
// Authenticate as stranger (not creator, not member)
|
||||
vi.mocked(getAuthUserId).mockResolvedValueOnce("stranger");
|
||||
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
||||
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
|
||||
|
||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||
method: "DELETE",
|
||||
body: { userId: "stranger" },
|
||||
});
|
||||
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
|
||||
const ctx = createRouteContext({ code: "ABC123" });
|
||||
const res = await DELETE(req, ctx);
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
@@ -19,7 +23,7 @@ describe("POST /api/blindbox/room/join", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
body: { userId: "user-2", code: "ABC123" },
|
||||
body: { code: "ABC123" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -34,7 +38,7 @@ describe("POST /api/blindbox/room/join", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1", code: "ABC123" },
|
||||
body: { code: "ABC123" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -47,7 +51,7 @@ describe("POST /api/blindbox/room/join", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1", code: "BADCODE" },
|
||||
body: { code: "BADCODE" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(404);
|
||||
@@ -56,7 +60,7 @@ describe("POST /api/blindbox/room/join", () => {
|
||||
it("returns 400 when code is missing", async () => {
|
||||
const req = createRequest("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1" },
|
||||
body: {},
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
@@ -2,17 +2,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("POST /api/blindbox/room", () => {
|
||||
@@ -25,7 +32,7 @@ describe("POST /api/blindbox/room", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1", name: "周末计划" },
|
||||
body: { name: "周末计划" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -40,13 +47,14 @@ describe("POST /api/blindbox/room", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox/room", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1" },
|
||||
body: {},
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/blindbox/room", {
|
||||
method: "POST",
|
||||
body: { name: "test" },
|
||||
@@ -58,7 +66,7 @@ describe("POST /api/blindbox/room", () => {
|
||||
it("returns 400 when room name too long", async () => {
|
||||
const req = createRequest("/api/blindbox/room", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1", name: "a".repeat(31) },
|
||||
body: { name: "a".repeat(31) },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("GET /api/blindbox/rooms", () => {
|
||||
@@ -33,7 +40,7 @@ describe("GET /api/blindbox/rooms", () => {
|
||||
{ roomId: "bb-room-1", _count: 3 },
|
||||
] as never);
|
||||
|
||||
const req = createRequest("/api/blindbox/rooms?userId=user-1");
|
||||
const req = createRequest("/api/blindbox/rooms");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
|
||||
@@ -43,7 +50,8 @@ describe("GET /api/blindbox/rooms", () => {
|
||||
expect(data.rooms[0].poolCount).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/blindbox/rooms");
|
||||
const res = await GET(req, mockCtx);
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
|
||||
}));
|
||||
|
||||
import { POST, GET, PUT, DELETE } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("POST /api/blindbox (create idea)", () => {
|
||||
@@ -35,7 +42,7 @@ describe("POST /api/blindbox (create idea)", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" },
|
||||
body: { roomId: "bb-room-1", content: "去公园野餐" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -44,7 +51,8 @@ describe("POST /api/blindbox (create idea)", () => {
|
||||
expect(data.id).toBe("idea-1");
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", content: "test" },
|
||||
@@ -56,7 +64,7 @@ describe("POST /api/blindbox (create idea)", () => {
|
||||
it("returns 400 when content is empty", async () => {
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1", content: "" },
|
||||
body: { roomId: "bb-room-1", content: "" },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
@@ -65,7 +73,7 @@ describe("POST /api/blindbox (create idea)", () => {
|
||||
it("returns 400 when content over 200 chars", async () => {
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "POST",
|
||||
body: { roomId: "bb-room-1", userId: "user-1", content: "a".repeat(201) },
|
||||
body: { roomId: "bb-room-1", content: "a".repeat(201) },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
@@ -79,7 +87,7 @@ describe("GET /api/blindbox (get pool data)", () => {
|
||||
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const req = createRequest("/api/blindbox?userId=user-1&roomId=bb-room-1");
|
||||
const req = createRequest("/api/blindbox?roomId=bb-room-1");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
|
||||
@@ -89,7 +97,8 @@ describe("GET /api/blindbox (get pool data)", () => {
|
||||
expect(data.drawn).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/blindbox?roomId=bb-room-1");
|
||||
const res = await GET(req, mockCtx);
|
||||
expect(res.status).toBe(401);
|
||||
@@ -102,7 +111,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "PUT",
|
||||
body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" },
|
||||
body: { ideaId: "idea-1", content: "去公园散步" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -116,7 +125,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "PUT",
|
||||
body: { ideaId: "nonexistent", userId: "user-1", content: "test" },
|
||||
body: { ideaId: "nonexistent", content: "test" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(404);
|
||||
@@ -129,7 +138,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "DELETE",
|
||||
body: { ideaId: "idea-1", userId: "user-1" },
|
||||
body: { ideaId: "idea-1" },
|
||||
});
|
||||
const res = await DELETE(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -142,7 +151,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
|
||||
|
||||
const req = createRequest("/api/blindbox", {
|
||||
method: "DELETE",
|
||||
body: { ideaId: "nonexistent", userId: "user-1" },
|
||||
body: { ideaId: "nonexistent" },
|
||||
});
|
||||
const res = await DELETE(req, mockCtx);
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blindbox", () => ({
|
||||
requireMembership: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||
import { getRoomData } from "@/lib/store";
|
||||
import { getRoomData } from "@/lib/roomRepository";
|
||||
import { subscribe } from "@/lib/roomEvents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
atomicUpdateRoom: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
|
||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
atomicUpdateRoom: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
|
||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
atomicUpdateRoom: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
|
||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
atomicUpdateRoom: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
|
||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
atomicUpdateRoom: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
|
||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { atomicUpdateRoom } from "@/lib/store";
|
||||
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||
import { notify } from "@/lib/roomEvents";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/store", () => ({
|
||||
vi.mock("@/lib/roomRepository", () => ({
|
||||
createRoom: vi.fn().mockResolvedValue("ROOM01"),
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,7 @@ const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
import { POST } from "./route";
|
||||
import { createRoom } from "@/lib/store";
|
||||
import { createRoom } from "@/lib/roomRepository";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createRoom } from "@/lib/store";
|
||||
import { createRoom } from "@/lib/roomRepository";
|
||||
import { Restaurant, SceneType } from "@/types";
|
||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
|
||||
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("GET /api/user/achievements", () => {
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/user/achievements");
|
||||
const res = await GET(req, mockCtx);
|
||||
expect(res.status).toBe(401);
|
||||
@@ -48,7 +56,7 @@ describe("GET /api/user/achievements", () => {
|
||||
{ id: "bb-room-1", name: "周末", code: "ABC123" },
|
||||
] as never);
|
||||
|
||||
const req = createRequest("/api/user/achievements?userId=user-1");
|
||||
const req = createRequest("/api/user/achievements");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
|
||||
|
||||
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
import { GET, POST, DELETE } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("GET /api/user/favorite", () => {
|
||||
it("returns empty array when no userId", async () => {
|
||||
it("returns empty array when no favorites", async () => {
|
||||
prismaMock.favorite.findMany.mockResolvedValue([] as never);
|
||||
const req = createRequest("/api/user/favorite");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -30,7 +38,7 @@ describe("GET /api/user/favorite", () => {
|
||||
},
|
||||
] as never);
|
||||
|
||||
const req = createRequest("/api/user/favorite?userId=user-1");
|
||||
const req = createRequest("/api/user/favorite");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
|
||||
@@ -48,7 +56,7 @@ describe("POST /api/user/favorite", () => {
|
||||
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1", restaurant: TEST_RESTAURANT },
|
||||
body: { restaurant: TEST_RESTAURANT },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -70,7 +78,7 @@ describe("POST /api/user/favorite", () => {
|
||||
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1", restaurant: TEST_RESTAURANT },
|
||||
body: { restaurant: TEST_RESTAURANT },
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -79,7 +87,8 @@ describe("POST /api/user/favorite", () => {
|
||||
expect(data.id).toBe("fav-existing");
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "POST",
|
||||
body: { restaurant: TEST_RESTAURANT },
|
||||
@@ -91,7 +100,7 @@ describe("POST /api/user/favorite", () => {
|
||||
it("returns 400 when no restaurant", async () => {
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1" },
|
||||
body: {},
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
@@ -108,7 +117,7 @@ describe("DELETE /api/user/favorite", () => {
|
||||
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "DELETE",
|
||||
body: { userId: "user-1", favoriteId: "fav-1" },
|
||||
body: { favoriteId: "fav-1" },
|
||||
});
|
||||
const res = await DELETE(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -120,7 +129,7 @@ describe("DELETE /api/user/favorite", () => {
|
||||
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "DELETE",
|
||||
body: { userId: "user-1", favoriteId: "nonexistent" },
|
||||
body: { favoriteId: "nonexistent" },
|
||||
});
|
||||
const res = await DELETE(req, mockCtx);
|
||||
expect(res.status).toBe(404);
|
||||
@@ -134,7 +143,7 @@ describe("DELETE /api/user/favorite", () => {
|
||||
|
||||
const req = createRequest("/api/user/favorite", {
|
||||
method: "DELETE",
|
||||
body: { userId: "user-1", favoriteId: "fav-1" },
|
||||
body: { favoriteId: "fav-1" },
|
||||
});
|
||||
const res = await DELETE(req, mockCtx);
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("GET /api/user/history", () => {
|
||||
it("returns empty array when no userId", async () => {
|
||||
it("returns empty array when no history", async () => {
|
||||
prismaMock.decision.findMany.mockResolvedValue([] as never);
|
||||
const req = createRequest("/api/user/history");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -33,7 +41,7 @@ describe("GET /api/user/history", () => {
|
||||
},
|
||||
] as never);
|
||||
|
||||
const req = createRequest("/api/user/history?userId=user-1");
|
||||
const req = createRequest("/api/user/history");
|
||||
const res = await GET(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
|
||||
@@ -53,7 +61,6 @@ describe("POST /api/user/history", () => {
|
||||
const req = createRequest("/api/user/history", {
|
||||
method: "POST",
|
||||
body: {
|
||||
userId: "user-1",
|
||||
roomId: "room-1",
|
||||
restaurant: TEST_RESTAURANT,
|
||||
matchType: "unanimous",
|
||||
@@ -74,7 +81,6 @@ describe("POST /api/user/history", () => {
|
||||
const req = createRequest("/api/user/history", {
|
||||
method: "POST",
|
||||
body: {
|
||||
userId: "user-1",
|
||||
roomId: "room-1",
|
||||
restaurant: TEST_RESTAURANT,
|
||||
matchType: "unanimous",
|
||||
@@ -97,7 +103,6 @@ describe("POST /api/user/history", () => {
|
||||
const req = createRequest("/api/user/history", {
|
||||
method: "POST",
|
||||
body: {
|
||||
userId: "user-1",
|
||||
roomId: "room-1",
|
||||
restaurant: TEST_RESTAURANT,
|
||||
matchType: "best",
|
||||
@@ -111,13 +116,14 @@ describe("POST /api/user/history", () => {
|
||||
it("returns 400 when missing required fields", async () => {
|
||||
const req = createRequest("/api/user/history", {
|
||||
method: "POST",
|
||||
body: { userId: "user-1" },
|
||||
body: {},
|
||||
});
|
||||
const res = await POST(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/user/history", {
|
||||
method: "POST",
|
||||
body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" },
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||
import { TEST_USER } from "@/__tests__/helpers/fixtures";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||
}));
|
||||
|
||||
vi.mock("bcryptjs", () => ({
|
||||
default: {
|
||||
@@ -12,12 +17,14 @@ vi.mock("bcryptjs", () => ({
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { GET, PUT } from "./route";
|
||||
import { getAuthUserId } from "@/lib/auth";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
|
||||
beforeEach(() => {
|
||||
resetPrismaMock();
|
||||
vi.mocked(bcrypt.compare).mockReset();
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
describe("GET /api/user", () => {
|
||||
@@ -65,7 +72,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", username: "newname" },
|
||||
body: { username: "newname" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
const { status, data } = await parseJsonResponse(res);
|
||||
@@ -81,7 +88,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", username: "takenname" },
|
||||
body: { username: "takenname" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(409);
|
||||
@@ -94,7 +101,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", currentPassword: "old", newPassword: "newpass123" },
|
||||
body: { currentPassword: "old", newPassword: "newpass123" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -106,7 +113,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", currentPassword: "wrong", newPassword: "newpass123" },
|
||||
body: { currentPassword: "wrong", newPassword: "newpass123" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(403);
|
||||
@@ -117,13 +124,14 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", newPassword: "newpass123" },
|
||||
body: { newPassword: "newpass123" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 when no userId", async () => {
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { username: "test" },
|
||||
@@ -138,7 +146,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", avatar: "🦊" },
|
||||
body: { avatar: "🦊" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
const { data } = await parseJsonResponse(res);
|
||||
@@ -155,7 +163,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", email: "new@example.com" },
|
||||
body: { email: "new@example.com" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -166,7 +174,7 @@ describe("PUT /api/user", () => {
|
||||
|
||||
const req = createRequest("/api/user", {
|
||||
method: "PUT",
|
||||
body: { userId: "user-1", email: "notanemail" },
|
||||
body: { email: "notanemail" },
|
||||
});
|
||||
const res = await PUT(req, mockCtx);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user