diff --git a/src/app/api/room/[id]/join/route.ts b/src/app/api/room/[id]/join/route.ts index 17091f0..ea49960 100644 --- a/src/app/api/room/[id]/join/route.ts +++ b/src/app/api/room/[id]/join/route.ts @@ -15,6 +15,12 @@ export async function POST( } const updated = await atomicUpdateRoom(id, (data) => { + if (data.kickedUsers.includes(userId)) { + throw new Error("KICKED"); + } + if (data.locked && !data.users.includes(userId)) { + throw new Error("LOCKED"); + } if (!data.users.includes(userId)) { data.users.push(userId); } @@ -35,6 +41,20 @@ export async function POST( userCount: updated.users.length, }); } catch (e) { + if (e instanceof Error) { + if (e.message === "LOCKED") { + return NextResponse.json( + { error: "房间已锁定,无法加入" }, + { status: 403 }, + ); + } + if (e.message === "KICKED") { + return NextResponse.json( + { error: "你已被移出该房间" }, + { status: 403 }, + ); + } + } console.error("Failed to join room:", e); return NextResponse.json( { error: "加入房间失败" }, diff --git a/src/app/api/room/[id]/manage/route.ts b/src/app/api/room/[id]/manage/route.ts new file mode 100644 index 0000000..1219256 --- /dev/null +++ b/src/app/api/room/[id]/manage/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { atomicUpdateRoom } from "@/lib/store"; +import { notify } from "@/lib/roomEvents"; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const { userId, action, targetUserId } = await req.json(); + if (!userId || !action) { + return NextResponse.json( + { error: "userId and action required" }, + { status: 400 }, + ); + } + + const updated = await atomicUpdateRoom(id, (data) => { + if (data.creatorId !== userId) { + throw new Error("FORBIDDEN"); + } + + switch (action) { + case "lock": + data.locked = true; + break; + + case "unlock": + data.locked = false; + break; + + case "kick": + if (!targetUserId || targetUserId === userId) { + throw new Error("INVALID_TARGET"); + } + data.users = data.users.filter((u) => u !== targetUserId); + if (!data.kickedUsers.includes(targetUserId)) { + data.kickedUsers.push(targetUserId); + } + delete data.swipeCounts[targetUserId]; + for (const rid of Object.keys(data.likes)) { + data.likes[rid] = data.likes[rid].filter( + (u) => u !== targetUserId, + ); + } + if ( + data.match && + data.likes[data.match]?.length !== data.users.length + ) { + data.match = null; + } + break; + + case "end_voting": + for (const u of data.users) { + data.swipeCounts[u] = data.restaurants.length; + } + break; + + default: + throw new Error("UNKNOWN_ACTION"); + } + + return data; + }); + + if (!updated) { + return NextResponse.json( + { error: "房间不存在或已过期" }, + { status: 404 }, + ); + } + + notify(id); + + return NextResponse.json({ ok: true }); + } catch (e) { + if (e instanceof Error) { + if (e.message === "FORBIDDEN") { + return NextResponse.json( + { error: "只有房主可以执行此操作" }, + { status: 403 }, + ); + } + if (e.message === "INVALID_TARGET") { + return NextResponse.json( + { error: "无效的操作对象" }, + { status: 400 }, + ); + } + if (e.message === "UNKNOWN_ACTION") { + return NextResponse.json( + { error: "未知操作" }, + { status: 400 }, + ); + } + } + console.error("Failed to manage room:", e); + return NextResponse.json( + { error: "操作失败" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/room/create/route.ts b/src/app/api/room/create/route.ts index f3afa94..77954c9 100644 --- a/src/app/api/room/create/route.ts +++ b/src/app/api/room/create/route.ts @@ -96,6 +96,7 @@ function filterByPrice( export async function POST(req: Request) { let restaurants: Restaurant[] = fallbackRestaurants; + let creatorId = ""; try { const body = await req.json(); @@ -105,7 +106,9 @@ export async function POST(req: Request) { radius = 3000, priceRange = "any", cuisine = "不限", + userId = "", } = body; + creatorId = userId; if (lat && lng) { const apiKey = process.env.AMAP_API_KEY; @@ -142,7 +145,7 @@ export async function POST(req: Request) { } try { - const roomId = await createRoom(restaurants); + const roomId = await createRoom(restaurants, creatorId); return NextResponse.json({ roomId, restaurants }); } catch (e) { console.error("Failed to create room:", e); diff --git a/src/app/page.tsx b/src/app/page.tsx index c0b57cb..d9f3cbf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -150,7 +150,7 @@ export default function LandingPage() { const res = await fetch("/api/room/create", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...coords, radius, priceRange, cuisine }), + body: JSON.stringify({ ...coords, radius, priceRange, cuisine, userId: getUserId() }), }); if (!res.ok) { diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx index 9b2cc12..c6a141f 100644 --- a/src/app/room/[id]/page.tsx +++ b/src/app/room/[id]/page.tsx @@ -20,7 +20,8 @@ export default function RoomPage() { const leavingRef = useRef(false); const { - userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, restaurants, notFound, mutate, + userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, + restaurants, notFound, mutate, creatorId, locked, users, } = useRoomPolling(roomId); useEffect(() => { @@ -117,7 +118,17 @@ export default function RoomPage() { return (
- + void; + roomId: string; + userId: string; + users: string[]; + locked: boolean; + swipeCounts: Record; + totalCards: number; + onToast: (msg: string) => void; +} + +export default function RoomManageModal({ + open, + onClose, + roomId, + userId, + users, + locked, + swipeCounts, + totalCards, + onToast, +}: RoomManageModalProps) { + const backdropRef = useRef(null); + const [loading, setLoading] = useState(null); + const [confirmKick, setConfirmKick] = useState(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 ( + + {open && ( + + + + +
+ +

房间管理

+
+

+ 房间号 {roomId} +

+ + {/* Lock/Unlock */} +
+ +
+ + {/* User list with kick */} +
+

+ 房间成员({users.length}) +

+
+ {users.map((uid) => { + const avatar = getAvatar(uid); + const isCreator = uid === userId; + const swiped = swipeCounts[uid] ?? 0; + const finished = swiped >= totalCards; + + return ( +
+ + {avatar.emoji} + +
+
+ {isCreator && ( + + + 房主 + + )} + + {uid.slice(0, 8)} + +
+ + {swiped}/{totalCards} + {finished ? " 已完成" : " 进行中"} + +
+ + {!isCreator && ( + <> + {confirmKick === uid ? ( +
+ + +
+ ) : ( + + )} + + )} +
+ ); + })} +
+
+ + {/* End voting */} +
+ {confirmEnd ? ( +
+

+ 确定要结束投票吗?将根据当前已有的投票结果直接结算。 +

+
+ + +
+
+ ) : ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index ee4de36..6a31b07 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -1,19 +1,37 @@ "use client"; import { useState, useCallback } from "react"; -import { Users, QrCode, LogOut } from "lucide-react"; +import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import QrInviteModal from "./QrInviteModal"; +import RoomManageModal from "./RoomManageModal"; interface TopNavProps { roomId: string; userCount: number; onExit?: () => void; + isCreator?: boolean; + userId?: string; + users?: string[]; + locked?: boolean; + swipeCounts?: Record; + totalCards?: number; } -export default function TopNav({ roomId, userCount, onExit }: TopNavProps) { +export default function TopNav({ + roomId, + userCount, + onExit, + isCreator = false, + userId = "", + users = [], + locked = false, + swipeCounts = {}, + totalCards = 0, +}: TopNavProps) { const [toast, setToast] = useState(""); const [showQr, setShowQr] = useState(false); + const [showManage, setShowManage] = useState(false); const showToast = useCallback((msg: string) => { setToast(msg); @@ -23,14 +41,23 @@ export default function TopNav({ roomId, userCount, onExit }: TopNavProps) { return ( <>