"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { confirmEndingDetection, decideInvitation, decideLobbyCall, enterPossession, exitPossession, getFullLobby, getPossessionStatus, listEndingDetections, listPendingAdjudication, listPendingInvitations, listPendingLobbyCalls, rejectEndingDetection, sayToLobby, type EndingDetectionLog, type LobbyFullSnapshot, type LobbyPresenceState, type PendingInvitation, type PendingLobbyCall, type PossessionStatus, } from "../../lib/api"; const USER_ID = "seed-user-primary"; const PRESENCE_LABELS: Record = { BUILDING: "建置中", SLEEPING: "睡眠中", BUSY: "忙碌中", SEMI_BUSY: "略忙", IDLE: "空閒", IN_SOLO_ROOM: "單獨聊天中", IN_GROUP_ROOM: "多人房中", }; const UNAVAILABLE_STATES: LobbyPresenceState[] = ["BUILDING", "SLEEPING", "IN_SOLO_ROOM", "IN_GROUP_ROOM"]; // T19-P2:大廳頁完整版——取代 T19-P1 的最小快照(在場名單+房間清單),改用 T4 正式聚合端點 // 一次取得作品分組、角色狀態、各進行中房間未讀數,並提供大廳對話式呼叫(T5)輸入框:答應者開房 // 後直接導向該房,全部婉拒或找代答時把角色台詞就地顯示在輸入框上方(不跳轉)。 export function LobbyView() { const router = useRouter(); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const [text, setText] = useState(""); const [pending, setPending] = useState(false); const [lobbyMessage, setLobbyMessage] = useState(null); const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState>({}); const [endingDetectionsByWork, setEndingDetectionsByWork] = useState>({}); const [possession, setPossession] = useState(null); const [pendingInvitations, setPendingInvitations] = useState([]); const [pendingCalls, setPendingCalls] = useState([]); async function refresh() { try { const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]); setSnapshot(lobby); setPossession(possessionStatus); setError(null); } catch { setError("大廳查詢失敗,請確認 api 服務是否已啟動"); } } useEffect(() => { void refresh(); }, []); // T19-P5:套用中時查詢待決邀請與待決大廳呼叫——沿用她的身分決定接受/婉拒,不讓人格系統代答。 useEffect(() => { if (!possession?.possessingCharacterId) { setPendingInvitations([]); setPendingCalls([]); return; } let cancelled = false; void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => { if (cancelled) return; setPendingInvitations(invitations); setPendingCalls(calls); }); return () => { cancelled = true; }; }, [possession]); async function handleEnterPossession(characterId: string) { if (pending) return; setPending(true); setError(null); try { await enterPossession(USER_ID, characterId); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleExitPossession() { if (pending) return; setPending(true); setError(null); try { await exitPossession(USER_ID); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleDecideInvitation(invitationId: string, accept: boolean) { if (pending) return; setPending(true); setError(null); try { await decideInvitation(invitationId, accept); const invitations = await listPendingInvitations(USER_ID); setPendingInvitations(invitations); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleDecideCall(pendingCallId: string, accept: boolean) { if (pending) return; setPending(true); setError(null); try { const decision = await decideLobbyCall(pendingCallId, accept); const calls = await listPendingLobbyCalls(USER_ID); setPendingCalls(calls); if (decision.roomId) { router.push(`/room/${decision.roomId}`); return; } } catch (err) { setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } // T19-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單——依目前大廳snapshot 內的 // 作品清單批次查詢,snapshot 每次刷新(匯入、裁決、確認結局後)都會連動重新取得。 useEffect(() => { if (!snapshot) return; const workIds = snapshot.works.map((w) => w.id); let cancelled = false; void Promise.all( workIds.map(async (workId) => { const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]); return { workId, pendingCount: adjudication.length, endings }; }), ).then((results) => { if (cancelled) return; setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount]))); setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings]))); }); return () => { cancelled = true; }; }, [snapshot]); async function handleConfirmEnding(logId: string) { if (pending) return; setPending(true); setError(null); try { await confirmEndingDetection(logId); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleRejectEnding(logId: string) { if (pending) return; setPending(true); setError(null); try { await rejectEndingDetection(logId); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } async function handleSay() { const trimmed = text.trim(); if (!trimmed || pending || possession?.possessingCharacterId) return; setPending(true); setError(null); setLobbyMessage(null); try { const result = await sayToLobby(USER_ID, trimmed); setText(""); if (result.roomId) { router.push(`/room/${result.roomId}`); return; } if (result.message) { setLobbyMessage(result.message); } await refresh(); } catch { setError("大廳呼叫失敗,請確認 api 服務是否已啟動"); } finally { setPending(false); } } if (error) { return (

{error}

); } if (!snapshot) { return (

載入中……

); } const workGroups = snapshot.works.map((work) => ({ work, characters: snapshot.characters.filter((c) => c.workId === work.id), })); const unassigned = snapshot.characters.filter((c) => c.workId === null); const possessedCharacterId = possession?.possessingCharacterId ?? null; const possessedCharacterName = possessedCharacterId ? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId : null; return (
{possessedCharacterId && (

你現在是「{possessedCharacterName}」

她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定她收到的邀請與呼叫。

{pendingCalls.map((call) => (

有人喊了你:「{call.text}」

))} {pendingInvitations.map((invitation) => (

{(invitation.invitedByCharacterId && snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ?? "有人"} 邀請你到房間裡聊聊

))}
)}

進行中房間({snapshot.activeRooms.length})

{snapshot.activeRooms.length === 0 ? (

目前沒有進行中的房間。

) : (
    {snapshot.activeRooms.map((room) => (
  • ))}
)}
{workGroups.map(({ work, characters }) => { if (characters.length === 0) return null; const pendingCount = pendingAdjudicationByWork[work.id] ?? 0; const endings = endingDetectionsByWork[work.id] ?? []; return (

{work.title} {work.inEpilogue && ( 後日談 )}

{pendingCount > 0 && ( 有 {pendingCount} 項待裁決 )} 匯入章節
{characters.map((character) => ( {character.formalName} /{PRESENCE_LABELS[character.presenceState]} {character.id === possessedCharacterId ? ( 套用中 ) : ( !possessedCharacterId && ( ) )} ))}
{endings.length > 0 && (
{endings.map((ending) => (

偵測到可能的結局章節,是否切入後日談?

判定理由:{ending.evidence}

))}
)} {work.milestones.length > 0 && (
{work.milestones.map((milestone, index) => (

.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId} :{milestone.label}

))}
)}
); })} {unassigned.length > 0 && (

未分組角色

{unassigned.map((character) => ( {character.formalName} /{PRESENCE_LABELS[character.presenceState]} {character.id === possessedCharacterId ? ( 套用中 ) : ( !possessedCharacterId && ( ) )} ))}
)} {lobbyMessage && (

{lobbyMessage}

)} {possessedCharacterId ? (

套用中無法以自己的身分喊人,請先退出套用。

) : (
setText(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void handleSay(); }} placeholder="喊一下誰的名字……" className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none" style={{ borderColor: "var(--line)", color: "var(--ink)" }} disabled={pending} />
)}
); }