feat: 盲盒房间体系重构 — 强制登录、独立房间、用户归属

- 新增 BlindBoxRoom/BlindBoxMember 模型,BlindBoxIdea 增加 userId/drawnById
- 新增房间 API(创建/加入/列表/详情),所有盲盒 API 增加认证和成员校验
- 新建盲盒大厅页面(三层引导式设计:未登录氛围页/首次创建引导/房间列表)
- 新建盲盒房间页面(成员校验/邀请分享/用户归属展示/自动聚焦)
- 首页删除契约画廊和 localStorage 盲盒逻辑,周末契约跳转到 /blindbox
- 清理旧路由 /room/[id]/blindbox
- 提取共享工具 src/lib/blindbox.ts(错误响应/房间号生成/成员校验)
- AuthModal 支持 defaultTab 参数
- 更新项目规范:新项目原则、代码优雅和复用优先
This commit is contained in:
2026-02-26 12:25:32 +08:00
parent 11d872e72a
commit 14b0aaece4
15 changed files with 1502 additions and 557 deletions
+610
View File
@@ -0,0 +1,610 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { motion, AnimatePresence, useAnimation } from "framer-motion";
import {
ArrowLeft,
Send,
Loader2,
Package,
Flame,
Trophy,
Users,
Share2,
LogIn,
Copy,
} from "lucide-react";
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import type { UserProfile } from "@/types";
interface RoomInfo {
id: string;
code: string;
name: string;
creatorId: string;
poolCount: number;
members: { id: string; username: string; avatar: string }[];
}
interface DrawnIdea {
id: string;
content: string;
createdAt: string;
user?: { id: string; username: string; avatar: string };
drawnBy?: { id: string; username: string; avatar: string } | null;
}
type Phase = "pool" | "shaking" | "reveal";
export default function BlindboxRoomPage() {
const { code } = useParams<{ code: string }>();
const router = useRouter();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [room, setRoom] = useState<RoomInfo | null>(null);
const [isMember, setIsMember] = useState(false);
const [joiningRoom, setJoiningRoom] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [poolCount, setPoolCount] = useState(0);
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
const [phase, setPhase] = useState<Phase>("pool");
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
const [submitFlash, setSubmitFlash] = useState(false);
const [error, setError] = useState("");
const [showInvite, setShowInvite] = useState(false);
const [toast, setToast] = useState("");
const boxControls = useAnimation();
const inputRef = useRef<HTMLInputElement>(null);
const confettiCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!isRegistered()) {
router.replace("/blindbox");
return;
}
setProfile(getCachedProfile());
}, [router]);
const fetchRoom = useCallback(async () => {
if (!code) return;
try {
const res = await fetch(`/api/blindbox/room/${code}`);
if (!res.ok) {
router.replace("/blindbox");
return;
}
const data: RoomInfo = await res.json();
setRoom(data);
const p = getCachedProfile();
const memberCheck = data.members.some((m) => m.id === p?.id);
setIsMember(memberCheck);
setPoolCount(data.poolCount);
} catch {
router.replace("/blindbox");
} finally {
setPageLoading(false);
}
}, [code, router]);
useEffect(() => {
fetchRoom();
}, [fetchRoom]);
const fetchIdeas = useCallback(async () => {
const p = getCachedProfile();
if (!room || !p) return;
try {
const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${p.id}`);
if (res.ok) {
const data = await res.json();
setPoolCount(data.poolCount ?? 0);
setDrawnHistory(data.drawn ?? []);
}
} catch { /* ignore */ }
}, [room]);
useEffect(() => {
if (isMember && room) fetchIdeas();
}, [isMember, room, fetchIdeas]);
useEffect(() => {
if (isMember && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 300);
}
}, [isMember]);
const handleJoinRoom = async () => {
if (joiningRoom || !profile || !room) return;
setJoiningRoom(true);
try {
const res = await fetch("/api/blindbox/room/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, code }),
});
if (res.ok) {
setIsMember(true);
fetchRoom();
}
} catch { /* ignore */ }
finally { setJoiningRoom(false); }
};
const handleSubmit = async () => {
const text = input.trim();
if (!text || submitting || !profile || !room) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/blindbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
setInput("");
setPoolCount((c) => c + 1);
setSubmitFlash(true);
setTimeout(() => setSubmitFlash(false), 600);
boxControls.start({
scale: [1, 1.08, 1],
rotate: [0, -3, 3, 0],
transition: { duration: 0.5 },
});
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
};
const handleDraw = async () => {
if (poolCount === 0 || !profile || !room) {
setError("盒子是空的,先往里面塞点想法吧!");
return;
}
setPhase("shaking");
setError("");
await boxControls.start({
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
transition: { duration: 2.5, ease: "easeInOut" },
});
try {
const res = await fetch("/api/blindbox/draw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "抽取失败");
}
const idea = await res.json();
setRevealedIdea(idea);
setPhase("reveal");
setPoolCount((c) => Math.max(0, c - 1));
setDrawnHistory((prev) => [idea, ...prev]);
fireConfetti();
} catch (e) {
setError(e instanceof Error ? e.message : "抽取失败");
setPhase("pool");
}
};
const fireConfetti = () => {
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 });
const end = Date.now() + 3000;
const frame = () => {
if (Date.now() > end) return;
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
requestAnimationFrame(frame);
};
setTimeout(frame, 200);
};
const handleCopyCode = async () => {
if (!room) return;
try {
await navigator.clipboard.writeText(room.code);
setToast("房间号已复制");
setTimeout(() => setToast(""), 2000);
} catch { /* ignore */ }
};
const handleShare = async () => {
if (!room) return;
const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : "";
const shareData = {
title: `周末契约 · ${room.name}`,
text: `来和我一起玩周末盲盒吧!房间号:${room.code}`,
url,
};
try {
if (navigator.share && navigator.canShare?.(shareData)) {
await navigator.share(shareData);
return;
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
handleCopyCode();
};
if (pageLoading) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-purple-400" />
</div>
);
}
if (!room) return null;
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
<canvas ref={confettiCanvasRef} className="pointer-events-none fixed inset-0 z-50" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.push("/blindbox")}
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="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{room.name}</p>
<p className="text-[10px] text-dim"> {room.code}</p>
</div>
{/* Members */}
<div className="flex -space-x-1.5">
{room.members.slice(0, 4).map((m) => (
<div
key={m.id}
className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-xs ring-2 ring-background"
title={m.username}
>
{m.avatar}
</div>
))}
{room.members.length > 4 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-background">
+{room.members.length - 4}
</div>
)}
</div>
<button
onClick={() => setShowInvite(!showInvite)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-purple-400 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/25"
>
<Share2 size={14} />
</button>
</div>
{/* Invite panel */}
<AnimatePresence>
{showInvite && (
<motion.div
className="mt-3 w-full max-w-sm overflow-hidden rounded-xl bg-surface ring-1 ring-border"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<div className="flex items-center gap-3 p-3">
<span className="text-xs text-muted"></span>
<span className="font-mono text-base font-bold tracking-[0.2em] text-purple-400">
{room.code}
</span>
<div className="flex-1" />
<button
onClick={handleCopyCode}
className="flex h-8 items-center gap-1 rounded-lg bg-elevated px-3 text-xs font-medium text-gray-300 ring-1 ring-border transition-colors active:bg-subtle"
>
<Copy size={12} />
</button>
<button
onClick={handleShare}
className="flex h-8 items-center gap-1 rounded-lg bg-purple-600 px-3 text-xs font-medium text-white transition-colors active:bg-purple-500"
>
<Share2 size={12} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Non-member state */}
{!isMember ? (
<motion.div
className="mt-16 flex flex-col items-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Package size={40} className="text-purple-400/50" strokeWidth={1.5} />
<p className="text-sm text-gray-400"></p>
<button
onClick={handleJoinRoom}
disabled={joiningRoom}
className="flex h-11 items-center gap-2 rounded-xl bg-purple-600 px-6 text-sm font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50"
>
{joiningRoom ? <Loader2 size={16} className="animate-spin" /> : <LogIn size={16} />}
</button>
</motion.div>
) : (
<>
{/* Blind Box Visual */}
<div className="mt-8 flex flex-col items-center">
<motion.div
animate={boxControls}
className="relative flex h-36 w-36 items-center justify-center"
>
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
<motion.div
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
transition={{ duration: 0.3 }}
>
<Package size={40} className="relative z-10 text-purple-300/60" strokeWidth={1.5} />
</motion.div>
<motion.div
className="absolute -right-2 -top-2 text-lg"
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
</motion.div>
</div>
</motion.div>
<motion.p
className="mt-4 text-sm font-semibold text-muted"
key={poolCount}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
{" "}
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
</motion.p>
</div>
{/* Pool / Shaking / Reveal phases */}
<AnimatePresence mode="wait">
{phase === "pool" && (
<motion.div
key="pool"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex w-full gap-2">
<input
ref={inputRef}
type="text"
placeholder="塞入一个疯狂的周末想法..."
value={input}
onChange={(e) => { setInput(e.target.value); setError(""); }}
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
maxLength={200}
disabled={submitting}
className="h-12 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600 disabled:opacity-50"
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || submitting}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
>
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
<motion.button
onClick={handleDraw}
disabled={poolCount === 0}
className="relative flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-base font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={20} />
</motion.button>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</motion.div>
)}
{phase === "shaking" && (
<motion.div
key="shaking"
className="mt-8 flex flex-col items-center gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<p className="text-sm font-bold text-purple-300 animate-pulse">
...
</p>
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="h-2 w-2 rounded-full bg-purple-400"
animate={{ y: [0, -8, 0] }}
transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</div>
</motion.div>
)}
{phase === "reveal" && revealedIdea && (
<motion.div
key="reveal"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 15, stiffness: 200 }}
>
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
<div className="relative z-10 text-center">
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
</p>
<motion.p
className="mt-4 text-xl font-black leading-relaxed text-white"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{revealedIdea.content}
</motion.p>
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
{/* Attribution */}
<div className="mt-3 flex items-center justify-center gap-2 text-[11px] text-purple-400/50">
{revealedIdea.user && (
<span>
{revealedIdea.user.avatar} {revealedIdea.user.username}
</span>
)}
{revealedIdea.drawnBy && (
<>
<span>·</span>
<span>
{revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username}
</span>
</>
)}
</div>
<p className="mt-2 text-[10px] font-medium text-purple-400/40">
</p>
</div>
</div>
<motion.button
onClick={() => { setPhase("pool"); setRevealedIdea(null); }}
className="flex h-10 items-center gap-2 rounded-full bg-surface px-5 text-xs font-semibold text-muted ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.96 }}
>
</motion.button>
</motion.div>
)}
</AnimatePresence>
{/* History */}
{drawnHistory.length > 0 && phase !== "shaking" && (
<motion.div
className="mt-10 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<div className="mb-3 flex items-center gap-2">
<Trophy size={13} className="text-amber-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{drawnHistory.map((item, i) => (
<motion.div
key={item.id}
className="flex items-start gap-3 rounded-xl bg-surface/60 px-4 py-3 ring-1 ring-border/80"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.06 }}
>
<span className="mt-0.5 text-sm">🏆</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-300">
{item.content}
</p>
<div className="mt-1 flex items-center gap-2 text-[10px] text-dim">
{item.user && (
<span>{item.user.avatar} {item.user.username} </span>
)}
{item.drawnBy && (
<>
<span>·</span>
<span>{item.drawnBy.avatar} {item.drawnBy.username} </span>
</>
)}
<span>·</span>
<span>
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
weekday: "short",
})}
</span>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
</>
)}
{/* Toast */}
<AnimatePresence>
{toast && (
<motion.div
className="fixed bottom-8 left-1/2 -translate-x-1/2 rounded-full bg-surface px-4 py-2 text-xs font-semibold text-gray-300 shadow-xl ring-1 ring-border"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
{toast}
</motion.div>
)}
</AnimatePresence>
<div className="h-8 shrink-0" />
</div>
);
}