fix(blindbox): 修复房间创建后返回大厅不可见 + 大厅房间列表管理

- 修复房主退出房间时误删整间房的问题,改为返回大厅(房间保留)
- 修复大厅页 fetchRooms 时序依赖导致导航回来不刷新的问题
- fetch 添加 cache:no-store + router.refresh() 确保数据始终最新
- 房间列表增加 max-h 滚动 + 底部渐变遮罩防溢出
- 大厅房间卡片支持内联删除/退出(··· 按钮 → 确认栏)
- rooms API 返回 creatorId 以区分房主/成员操作
This commit is contained in:
2026-02-27 18:38:05 +08:00
parent 2d49744dd0
commit 1e39c72a63
3 changed files with 270 additions and 108 deletions
+1
View File
@@ -34,6 +34,7 @@ export const GET = apiHandler(async (req) => {
id: m.room.id, id: m.room.id,
code: m.room.code, code: m.room.code,
name: m.room.name, name: m.room.name,
creatorId: m.room.creatorId,
memberCount: m.room._count.members, memberCount: m.room._count.members,
ideaCount: m.room._count.ideas, ideaCount: m.room._count.ideas,
poolCount: 0, poolCount: 0,
+54 -23
View File
@@ -590,6 +590,12 @@ export default function BlindboxRoomPage() {
const isCreator = profile?.id === room?.creatorId; const isCreator = profile?.id === room?.creatorId;
const handleBackToLobby = useCallback(() => {
router.push("/blindbox");
router.refresh();
}, [router]);
/** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */
const handleLeaveOrDelete = async () => { const handleLeaveOrDelete = async () => {
if (!confirmLeave) { if (!confirmLeave) {
setConfirmLeave(true); setConfirmLeave(true);
@@ -608,7 +614,8 @@ export default function BlindboxRoomPage() {
const data = await res.json(); const data = await res.json();
throw new Error(data.error || "操作失败"); throw new Error(data.error || "操作失败");
} }
router.replace("/blindbox"); router.push("/blindbox");
router.refresh();
} catch (e) { } catch (e) {
toast.show(e instanceof Error ? e.message : "操作失败"); toast.show(e instanceof Error ? e.message : "操作失败");
setConfirmLeave(false); setConfirmLeave(false);
@@ -1130,34 +1137,58 @@ export default function BlindboxRoomPage() {
/> />
)} )}
{/* Leave / Delete — hidden during plan view */} {/* Leave / Back — hidden during plan view. Creator: 返回大厅 (no delete) + optional 删除房间. Non-creator: 退出房间. */}
{isMember && room && phase !== "plan_reveal" && phase !== "planning" && ( {isMember && room && phase !== "plan_reveal" && phase !== "planning" && (
<motion.div <motion.div
className="mt-12 w-full max-w-sm" className="mt-12 flex w-full max-w-sm flex-col items-center gap-2"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
> >
<button {isCreator ? (
onClick={handleLeaveOrDelete} <>
disabled={leaving} <button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium transition-colors ${ onClick={handleBackToLobby}
confirmLeave className="flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium text-muted transition-colors hover:text-foreground active:bg-elevated"
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30" >
: "text-muted hover:text-rose-400/80" <ArrowLeft size={13} />
}`}
> </button>
{leaving ? ( <button
<Loader2 size={13} className="animate-spin" /> onClick={handleLeaveOrDelete}
) : isCreator ? ( disabled={leaving}
<Trash2 size={13} /> className={`flex w-full items-center justify-center gap-2 rounded-xl py-2 text-xs font-medium transition-colors ${
) : ( confirmLeave
<LogOut size={13} /> ? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
)} : "text-dim hover:text-rose-400/80"
{confirmLeave }`}
? isCreator ? "确认删除房间?所有想法将被清除" : "确认退出房间?" >
: isCreator ? "删除房间" : "退出房间"} {leaving ? (
</button> <Loader2 size={12} className="animate-spin" />
) : (
<Trash2 size={12} />
)}
{confirmLeave ? "确认删除房间?所有想法将被清除" : "删除房间"}
</button>
</>
) : (
<button
onClick={handleLeaveOrDelete}
disabled={leaving}
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium transition-colors ${
confirmLeave
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
: "text-muted hover:text-rose-400/80"
}`}
>
{leaving ? (
<Loader2 size={13} className="animate-spin" />
) : (
<LogOut size={13} />
)}
{confirmLeave ? "确认退出房间?" : "退出房间"}
</button>
)}
</motion.div> </motion.div>
)} )}
+215 -85
View File
@@ -11,24 +11,158 @@ import {
Users, Users,
Sparkles, Sparkles,
ChevronRight, ChevronRight,
MoreHorizontal,
Trash2,
LogOut,
Loader2,
X,
} from "lucide-react"; } from "lucide-react";
import { getCachedProfile, isRegistered } from "@/lib/userId"; import { getCachedProfile, isRegistered } from "@/lib/userId";
import AuthModal from "@/components/AuthModal"; import AuthModal from "@/components/AuthModal";
import Button from "@/components/Button"; import Button from "@/components/Button";
import Input from "@/components/Input"; import Input from "@/components/Input";
import { BlindboxListSkeleton } from "@/components/Skeleton"; import { BlindboxListSkeleton } from "@/components/Skeleton";
import { useToast } from "@/hooks/useToast";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
interface RoomSummary { interface RoomSummary {
id: string; id: string;
code: string; code: string;
name: string; name: string;
creatorId: string;
memberCount: number; memberCount: number;
poolCount: number; poolCount: number;
members: { id: string; username: string; avatar: string }[]; members: { id: string; username: string; avatar: string }[];
lastDrawn: { content: string; createdAt: string } | null; lastDrawn: { content: string; createdAt: string } | null;
} }
/* ── Room card with inline delete ────────────────────────── */
function RoomCard({
room,
index,
isCreator,
confirmDelete,
deleting,
onNavigate,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: {
room: RoomSummary;
index: number;
isCreator: boolean;
confirmDelete: boolean;
deleting: boolean;
onNavigate: () => void;
onRequestDelete: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
}) {
return (
<motion.div
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, height: 0, marginTop: 0, transition: { duration: 0.25 } }}
transition={{ delay: index * 0.05 }}
className="shrink-0 rounded-2xl bg-surface ring-1 ring-border"
>
{/* Main card row */}
<button
onClick={onNavigate}
className="flex w-full items-center gap-3 p-4 text-left transition-colors active:bg-elevated"
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 ring-1 ring-purple-500/20">
<Package size={20} className="text-purple-400" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-heading">{room.name}</p>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted">
<span className="flex items-center gap-1">
<Users size={11} />
{room.memberCount}
</span>
<span className="flex items-center gap-1">
<Package size={11} />
{room.poolCount}
</span>
</div>
{room.lastDrawn && (
<p className="mt-1 truncate text-[11px] text-purple-400/60">
{room.lastDrawn.content}
</p>
)}
</div>
<div className="flex shrink-0 -space-x-1.5">
{room.members.slice(0, 3).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-surface"
title={m.username}
>
{m.avatar}
</div>
))}
{room.memberCount > 3 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-surface">
+{room.memberCount - 3}
</div>
)}
</div>
{/* ··· menu trigger — stops propagation so card onClick doesn't fire */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
confirmDelete ? onCancelDelete() : onRequestDelete();
}}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted/40 transition-colors hover:bg-elevated hover:text-muted active:text-foreground"
>
{confirmDelete ? <X size={14} /> : <MoreHorizontal size={16} />}
</div>
</button>
{/* Inline confirm bar */}
<AnimatePresence>
{confirmDelete && (
<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="flex items-center justify-between border-t border-border/60 px-4 py-2.5">
<span className="text-xs text-muted">
{isCreator ? "删除房间?所有想法将被清除" : "确认退出该房间?"}
</span>
<button
onClick={onConfirmDelete}
disabled={deleting}
className="flex items-center gap-1.5 rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-semibold text-rose-400 transition-colors hover:bg-rose-500/25 active:bg-rose-500/30"
>
{deleting ? (
<Loader2 size={12} className="animate-spin" />
) : isCreator ? (
<Trash2 size={12} />
) : (
<LogOut size={12} />
)}
{isCreator ? "删除" : "退出"}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default function BlindboxLobbyPage() { export default function BlindboxLobbyPage() {
const router = useRouter(); const router = useRouter();
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
@@ -38,6 +172,7 @@ export default function BlindboxLobbyPage() {
const [rooms, setRooms] = useState<RoomSummary[]>([]); const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const toast = useToast();
const createNameRef = useRef<HTMLInputElement>(null); const createNameRef = useRef<HTMLInputElement>(null);
const joinCodeRef = useRef<HTMLInputElement>(null); const joinCodeRef = useRef<HTMLInputElement>(null);
const [joinCodeLength, setJoinCodeLength] = useState(0); const [joinCodeLength, setJoinCodeLength] = useState(0);
@@ -46,24 +181,8 @@ export default function BlindboxLobbyPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
useEffect(() => { const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const registered = isRegistered(); const [deletingId, setDeletingId] = useState<string | null>(null);
setLoggedIn(registered);
if (registered) {
setProfile(getCachedProfile());
}
setHydrated(true);
}, []);
useEffect(() => {
const handler = () => {
const registered = isRegistered();
setLoggedIn(registered);
setProfile(registered ? getCachedProfile() : null);
};
window.addEventListener("nowhatever_auth", handler);
return () => window.removeEventListener("nowhatever_auth", handler);
}, []);
const fetchRooms = useCallback(async (silent = false) => { const fetchRooms = useCallback(async (silent = false) => {
const p = getCachedProfile(); const p = getCachedProfile();
@@ -73,7 +192,7 @@ export default function BlindboxLobbyPage() {
setLoadError(false); setLoadError(false);
} }
try { try {
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`); const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`, { cache: "no-store" });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
setRooms(Array.isArray(data.rooms) ? data.rooms : []); setRooms(Array.isArray(data.rooms) ? data.rooms : []);
@@ -86,26 +205,49 @@ export default function BlindboxLobbyPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (loggedIn) fetchRooms(); const registered = isRegistered();
else setLoading(false); setLoggedIn(registered);
}, [loggedIn, fetchRooms]); if (registered) {
setProfile(getCachedProfile());
fetchRooms();
} else {
setLoading(false);
}
setHydrated(true);
}, [fetchRooms]);
useEffect(() => {
const handler = () => {
const registered = isRegistered();
setLoggedIn(registered);
setProfile(registered ? getCachedProfile() : null);
if (registered) fetchRooms();
};
window.addEventListener("nowhatever_auth", handler);
return () => window.removeEventListener("nowhatever_auth", handler);
}, [fetchRooms]);
useEffect(() => { useEffect(() => {
if (!loggedIn) return; if (!loggedIn) return;
const onFocus = () => fetchRooms(true); const refresh = () => fetchRooms(true);
window.addEventListener("focus", onFocus); window.addEventListener("focus", refresh);
return () => window.removeEventListener("focus", onFocus); window.addEventListener("pageshow", refresh);
return () => {
window.removeEventListener("focus", refresh);
window.removeEventListener("pageshow", refresh);
};
}, [loggedIn, fetchRooms]); }, [loggedIn, fetchRooms]);
useEffect(() => { useEffect(() => {
if (rooms.length > 0) setJoinCodeLength(0); if (rooms.length > 0) setJoinCodeLength(0);
}, [rooms.length]); }, [rooms.length]);
const handleAuth = (p: UserProfile) => { const handleAuth = useCallback((p: UserProfile) => {
setProfile(p); setProfile(p);
setLoggedIn(true); setLoggedIn(true);
setShowAuth(false); setShowAuth(false);
}; fetchRooms();
}, [fetchRooms]);
const handleCreate = async () => { const handleCreate = async () => {
if (creating || !profile) return; if (creating || !profile) return;
@@ -149,6 +291,29 @@ export default function BlindboxLobbyPage() {
} }
}; };
const handleDeleteRoom = useCallback(async (room: RoomSummary) => {
if (deletingId || !profile) return;
setDeletingId(room.id);
try {
const res = await fetch(`/api/blindbox/room/${room.code}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "操作失败");
}
setRooms((prev) => prev.filter((r) => r.id !== room.id));
setConfirmDeleteId(null);
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
} catch (e) {
toast.show(e instanceof Error ? e.message : "操作失败");
} finally {
setDeletingId(null);
}
}, [deletingId, profile, toast]);
return ( return (
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6 overflow-y-auto scrollbar-none"> <div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6 overflow-y-auto scrollbar-none">
{/* Ambient */} {/* Ambient */}
@@ -270,7 +435,7 @@ export default function BlindboxLobbyPage() {
<Package size={36} className="text-purple-400/30" strokeWidth={1.5} /> <Package size={36} className="text-purple-400/30" strokeWidth={1.5} />
<p className="text-sm text-muted"></p> <p className="text-sm text-muted"></p>
<button <button
onClick={fetchRooms} onClick={() => fetchRooms()}
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300" className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
> >
@@ -448,63 +613,28 @@ export default function BlindboxLobbyPage() {
</div> </div>
{/* Room list */} {/* Room list */}
<div className="mt-5 flex flex-col gap-3"> <div className="relative mt-5">
{rooms.map((room, i) => ( <div className="max-h-[40dvh] overflow-y-auto scrollbar-none -mx-2 px-2 flex flex-col gap-3 pt-1 pb-5">
<motion.button <AnimatePresence initial={false}>
key={room.id} {rooms.map((room, i) => (
onClick={() => router.push(`/blindbox/${room.code}`)} <RoomCard
className="group flex w-full items-center gap-3 rounded-2xl bg-surface p-4 text-left ring-1 ring-border transition-all hover:bg-elevated hover:ring-purple-500/30" key={room.id}
initial={{ opacity: 0, x: -20 }} room={room}
animate={{ opacity: 1, x: 0 }} index={i}
transition={{ delay: i * 0.06 }} isCreator={room.creatorId === profile?.id}
whileTap={{ scale: 0.98 }} confirmDelete={confirmDeleteId === room.id}
> deleting={deletingId === room.id}
{/* Icon */} onNavigate={() => router.push(`/blindbox/${room.code}`)}
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 ring-1 ring-purple-500/20"> onRequestDelete={() => setConfirmDeleteId(room.id)}
<Package size={20} className="text-purple-400" strokeWidth={1.5} /> onConfirmDelete={() => handleDeleteRoom(room)}
</div> onCancelDelete={() => setConfirmDeleteId(null)}
/>
{/* Info */} ))}
<div className="min-w-0 flex-1"> </AnimatePresence>
<p className="truncate text-sm font-bold text-heading">{room.name}</p> </div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted"> {rooms.length > 3 && (
<span className="flex items-center gap-1"> <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-linear-to-t from-background to-transparent" />
<Users size={11} /> )}
{room.memberCount}
</span>
<span className="flex items-center gap-1">
<Package size={11} />
{room.poolCount}
</span>
</div>
{room.lastDrawn && (
<p className="mt-1 truncate text-[11px] text-purple-400/60">
{room.lastDrawn.content}
</p>
)}
</div>
{/* Members preview */}
<div className="flex shrink-0 -space-x-1.5">
{room.members.slice(0, 3).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-surface"
title={m.username}
>
{m.avatar}
</div>
))}
{room.memberCount > 3 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-surface">
+{room.memberCount - 3}
</div>
)}
</div>
<ChevronRight size={16} className="shrink-0 text-muted/50 transition-colors group-hover:text-purple-400" />
</motion.button>
))}
</div> </div>
</motion.div> </motion.div>
)} )}