feat: 房间创建者管理权限——锁定房间、踢人、结束投票

This commit is contained in:
2026-02-24 21:01:24 +08:00
parent fc0a2a018b
commit 07ffe42176
11 changed files with 507 additions and 9 deletions
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
X,
Lock,
Unlock,
UserX,
Flag,
Crown,
Loader2,
} from "lucide-react";
const AVATARS = [
{ emoji: "🐱", bg: "bg-amber-100" },
{ emoji: "🐶", bg: "bg-orange-100" },
{ emoji: "🦊", bg: "bg-red-100" },
{ emoji: "🐰", bg: "bg-pink-100" },
{ emoji: "🐼", bg: "bg-zinc-100" },
{ emoji: "🐨", bg: "bg-sky-100" },
{ emoji: "🦁", bg: "bg-yellow-100" },
{ emoji: "🐸", bg: "bg-lime-100" },
{ emoji: "🐵", bg: "bg-stone-100" },
{ emoji: "🐷", bg: "bg-rose-100" },
{ emoji: "🐙", bg: "bg-purple-100" },
{ emoji: "🦄", bg: "bg-violet-100" },
] as const;
function getAvatar(uid: string) {
let hash = 0;
for (let i = 0; i < uid.length; i++) {
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
}
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
}
interface RoomManageModalProps {
open: boolean;
onClose: () => void;
roomId: string;
userId: string;
users: string[];
locked: boolean;
swipeCounts: Record<string, number>;
totalCards: number;
onToast: (msg: string) => void;
}
export default function RoomManageModal({
open,
onClose,
roomId,
userId,
users,
locked,
swipeCounts,
totalCards,
onToast,
}: RoomManageModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState<string | null>(null);
const [confirmKick, setConfirmKick] = useState<string | null>(null);
const [confirmEnd, setConfirmEnd] = useState(false);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
const manage = useCallback(
async (action: string, targetUserId?: string) => {
setLoading(action + (targetUserId ?? ""));
try {
const res = await fetch(`/api/room/${roomId}/manage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, action, targetUserId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
onToast(data.error ?? "操作失败");
return;
}
switch (action) {
case "lock":
onToast("房间已锁定,其他人无法加入");
break;
case "unlock":
onToast("房间已解锁");
break;
case "kick":
onToast("已将该用户移出房间");
setConfirmKick(null);
break;
case "end_voting":
onToast("已结束投票,正在结算结果");
setConfirmEnd(false);
onClose();
break;
}
} catch {
onToast("操作失败,请重试");
} finally {
setLoading(null);
}
},
[roomId, userId, onToast, onClose],
);
const otherUsers = users.filter((u) => u !== userId);
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 350 }}
>
<button
onClick={onClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
>
<X size={16} />
</button>
<div className="flex items-center gap-2">
<Crown size={18} className="text-amber-500" />
<h2 className="text-lg font-bold text-zinc-900"></h2>
</div>
<p className="mt-1 text-xs text-zinc-400">
{roomId}
</p>
{/* Lock/Unlock */}
<div className="mt-5">
<button
onClick={() => manage(locked ? "unlock" : "lock")}
disabled={loading !== null}
className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${
locked
? "border border-emerald-200 bg-emerald-50 text-emerald-700 active:bg-emerald-100"
: "border border-zinc-200 bg-white text-zinc-700 active:bg-zinc-50"
}`}
>
{loading === "lock" || loading === "unlock" ? (
<Loader2 size={15} className="animate-spin" />
) : locked ? (
<Unlock size={15} />
) : (
<Lock size={15} />
)}
{locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
</button>
</div>
{/* User list with kick */}
<div className="mt-5">
<h3 className="text-xs font-semibold text-zinc-500">
{users.length}
</h3>
<div className="mt-2 flex flex-col gap-1.5">
{users.map((uid) => {
const avatar = getAvatar(uid);
const isCreator = uid === userId;
const swiped = swipeCounts[uid] ?? 0;
const finished = swiped >= totalCards;
return (
<div
key={uid}
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${avatar.bg}`}
>
{avatar.emoji}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1.5">
{isCreator && (
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-500">
<Crown size={10} />
</span>
)}
<span className="truncate text-xs font-medium text-zinc-500">
{uid.slice(0, 8)}
</span>
</div>
<span
className={`text-[11px] ${finished ? "text-emerald-500" : "text-zinc-400"}`}
>
{swiped}/{totalCards}
{finished ? " 已完成" : " 进行中"}
</span>
</div>
{!isCreator && (
<>
{confirmKick === uid ? (
<div className="flex items-center gap-1">
<button
onClick={() => manage("kick", uid)}
disabled={loading !== null}
className="rounded-lg bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white transition-colors active:bg-rose-600 disabled:opacity-50"
>
{loading === "kick" + uid ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
"确认"
)}
</button>
<button
onClick={() => setConfirmKick(null)}
className="rounded-lg bg-zinc-200 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 transition-colors active:bg-zinc-300"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmKick(uid)}
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-400 transition-colors active:bg-zinc-100 active:text-rose-500"
>
<UserX size={13} />
</button>
)}
</>
)}
</div>
);
})}
</div>
</div>
{/* End voting */}
<div className="mt-5">
{confirmEnd ? (
<div className="flex flex-col gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-800">
</p>
<div className="flex gap-2">
<button
onClick={() => manage("end_voting")}
disabled={loading !== null}
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-xs font-semibold text-white transition-colors active:bg-amber-600 disabled:opacity-50"
>
{loading === "end_voting" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Flag size={13} />
)}
</button>
<button
onClick={() => setConfirmEnd(false)}
className="flex h-9 flex-1 items-center justify-center rounded-lg bg-white text-xs font-semibold text-zinc-600 transition-colors active:bg-zinc-50"
>
</button>
</div>
</div>
) : (
<button
onClick={() => setConfirmEnd(true)}
disabled={loading !== null}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-amber-200 bg-amber-50 text-sm font-semibold text-amber-700 transition-colors active:bg-amber-100 disabled:opacity-50"
>
<Flag size={15} />
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}