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 (
<>