新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||
import { useRouter } from "expo-router";
|
||
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";
|
||
import { useUnread } from "../lib/unread-context";
|
||
|
||
const USER_ID = "seed-user-primary";
|
||
|
||
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
|
||
BUILDING: "建置中",
|
||
SLEEPING: "睡眠中",
|
||
BUSY: "忙碌中",
|
||
SEMI_BUSY: "略忙",
|
||
IDLE: "空閒",
|
||
IN_SOLO_ROOM: "單獨聊天中",
|
||
IN_GROUP_ROOM: "多人房中",
|
||
};
|
||
|
||
// T20-P2:大廳頁完整版,取代 T20-P1 最小快照(在場名單+房間清單),改用 T4 正式聚合端點,
|
||
// 與 apps/web 的 LobbyView 對應同一版面:進行中房間(含未讀)→ 作品分組角色卡片 → 大廳對話輸入。
|
||
export function LobbyPreview() {
|
||
const router = useRouter();
|
||
const { setRoomUnreadCounts } = useUnread();
|
||
const [snapshot, setSnapshot] = useState<LobbyFullSnapshot | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [text, setText] = useState("");
|
||
const [pending, setPending] = useState(false);
|
||
const [lobbyMessage, setLobbyMessage] = useState<string | null>(null);
|
||
const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState<Record<string, number>>({});
|
||
const [endingDetectionsByWork, setEndingDetectionsByWork] = useState<Record<string, EndingDetectionLog[]>>({});
|
||
const [possession, setPossession] = useState<PossessionStatus | null>(null);
|
||
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
|
||
const [pendingCalls, setPendingCalls] = useState<PendingLobbyCall[]>([]);
|
||
|
||
async function refresh() {
|
||
try {
|
||
const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]);
|
||
setSnapshot(lobby);
|
||
setPossession(possessionStatus);
|
||
setError(null);
|
||
// T20-P2:把各房未讀數餵給 UnreadContext,聊天分頁籤的紅點才能反映「大廳裡有房間未讀」。
|
||
setRoomUnreadCounts(Object.fromEntries(lobby.activeRooms.map((room) => [room.id, room.unreadCount])));
|
||
} catch {
|
||
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// T20-P5:套用中時查詢待決邀請與待決大廳呼叫,與 apps/web 的 LobbyView 對應同一批次查詢方式。
|
||
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);
|
||
setPendingInvitations(await listPendingInvitations(USER_ID));
|
||
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);
|
||
setPendingCalls(await listPendingLobbyCalls(USER_ID));
|
||
if (decision.roomId) {
|
||
router.push(`/room/${decision.roomId}`);
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
// T20-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單,與 apps/web 的
|
||
// LobbyView 對應同一批次查詢方式,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 <Text style={styles.error}>{error}</Text>;
|
||
}
|
||
if (!snapshot) {
|
||
return <Text style={styles.hint}>大廳載入中……</Text>;
|
||
}
|
||
|
||
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 (
|
||
<View style={styles.container}>
|
||
{possessedCharacterId && (
|
||
<View style={styles.possessionBanner}>
|
||
<View style={styles.possessionBannerRow}>
|
||
<Text style={styles.possessionBannerTitle}>你現在是「{possessedCharacterName}」</Text>
|
||
<Pressable onPress={() => void handleExitPossession()} disabled={pending}>
|
||
<Text style={[styles.exitPossessionLink, pending ? styles.sendButtonDisabled : null]}>退出套用</Text>
|
||
</Pressable>
|
||
</View>
|
||
<Text style={styles.possessionBannerHint}>她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定邀請與呼叫。</Text>
|
||
|
||
{pendingCalls.map((call) => (
|
||
<View key={call.id} style={styles.endingCard}>
|
||
<Text style={styles.endingCardTitle}>有人喊了你:「{call.text}」</Text>
|
||
<View style={styles.endingCardActions}>
|
||
<Pressable onPress={() => void handleDecideCall(call.id, true)} disabled={pending}>
|
||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
|
||
</Pressable>
|
||
<Pressable onPress={() => void handleDecideCall(call.id, false)} disabled={pending}>
|
||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
))}
|
||
|
||
{pendingInvitations.map((invitation) => (
|
||
<View key={invitation.id} style={styles.endingCard}>
|
||
<Text style={styles.endingCardTitle}>
|
||
{(invitation.invitedByCharacterId &&
|
||
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
|
||
"有人"}
|
||
邀請你到房間裡聊聊
|
||
</Text>
|
||
<View style={styles.endingCardActions}>
|
||
<Pressable onPress={() => void handleDecideInvitation(invitation.id, true)} disabled={pending}>
|
||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
|
||
</Pressable>
|
||
<Pressable onPress={() => void handleDecideInvitation(invitation.id, false)} disabled={pending}>
|
||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
<Text style={styles.sectionTitle}>進行中房間({snapshot.activeRooms.length})</Text>
|
||
{snapshot.activeRooms.length === 0 ? (
|
||
<Text style={styles.hint}>目前沒有進行中的房間。</Text>
|
||
) : (
|
||
snapshot.activeRooms.map((room) => (
|
||
<Pressable key={room.id} style={styles.roomCard} onPress={() => router.push(`/room/${room.id}`)}>
|
||
<View style={styles.roomCardRow}>
|
||
<Text style={styles.roomCardText}>
|
||
{room.memberCharacterIds
|
||
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
|
||
.join("、") || room.mode}
|
||
</Text>
|
||
{room.unreadCount > 0 && (
|
||
<View style={styles.unreadBadge}>
|
||
<Text style={styles.unreadBadgeText}>{room.unreadCount}</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Pressable>
|
||
))
|
||
)}
|
||
|
||
{workGroups.map(({ work, characters }) => {
|
||
if (characters.length === 0) return null;
|
||
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
|
||
const endings = endingDetectionsByWork[work.id] ?? [];
|
||
return (
|
||
<View key={work.id} style={styles.workSection}>
|
||
<View style={styles.workHeaderRow}>
|
||
<Text style={styles.sectionTitle}>
|
||
{work.title}
|
||
{work.inEpilogue ? "(後日談)" : ""}
|
||
</Text>
|
||
<View style={styles.workHeaderActions}>
|
||
{pendingCount > 0 && (
|
||
<Pressable style={styles.pendingBadge} onPress={() => router.push("/canon-review")}>
|
||
<Text style={styles.pendingBadgeText}>有 {pendingCount} 項待裁決</Text>
|
||
</Pressable>
|
||
)}
|
||
<Pressable style={styles.importChip} onPress={() => router.push(`/canon-import?workId=${work.id}`)}>
|
||
<Text style={styles.importChipText}>匯入章節</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.rosterRow}>
|
||
{characters.map((character) => (
|
||
<View key={character.id} style={styles.chip}>
|
||
<Text style={styles.chipText}>
|
||
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
|
||
</Text>
|
||
{character.id === possessedCharacterId ? (
|
||
<View style={styles.possessedBadge}>
|
||
<Text style={styles.possessedBadgeText}>套用中</Text>
|
||
</View>
|
||
) : (
|
||
!possessedCharacterId && (
|
||
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
|
||
<Text style={styles.possessButtonText}>套用</Text>
|
||
</Pressable>
|
||
)
|
||
)}
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{endings.map((ending) => (
|
||
<View key={ending.id} style={styles.endingCard}>
|
||
<Text style={styles.endingCardTitle}>偵測到可能的結局章節,是否切入後日談?</Text>
|
||
<Text style={styles.endingCardEvidence}>判定理由:{ending.evidence}</Text>
|
||
<View style={styles.endingCardActions}>
|
||
<Pressable onPress={() => void handleConfirmEnding(ending.id)} disabled={pending}>
|
||
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>確認.切入後日談</Text>
|
||
</Pressable>
|
||
<Pressable onPress={() => void handleRejectEnding(ending.id)} disabled={pending}>
|
||
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>拒絕</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
))}
|
||
|
||
{work.milestones.length > 0 && (
|
||
<View style={styles.milestoneList}>
|
||
{work.milestones.map((milestone, index) => (
|
||
<Text key={`${milestone.characterId}-${index}`} style={styles.milestoneText}>
|
||
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}:
|
||
{milestone.label}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
})}
|
||
|
||
{unassigned.length > 0 && (
|
||
<View style={styles.workSection}>
|
||
<Text style={styles.sectionTitle}>未分組角色</Text>
|
||
<View style={styles.rosterRow}>
|
||
{unassigned.map((character) => (
|
||
<View key={character.id} style={styles.chip}>
|
||
<Text style={styles.chipText}>
|
||
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
|
||
</Text>
|
||
{character.id === possessedCharacterId ? (
|
||
<View style={styles.possessedBadge}>
|
||
<Text style={styles.possessedBadgeText}>套用中</Text>
|
||
</View>
|
||
) : (
|
||
!possessedCharacterId && (
|
||
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
|
||
<Text style={styles.possessButtonText}>套用</Text>
|
||
</Pressable>
|
||
)
|
||
)}
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{lobbyMessage && (
|
||
<View style={styles.messageBox}>
|
||
<Text style={styles.messageBoxText}>{lobbyMessage}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{possessedCharacterId ? (
|
||
<Text style={styles.hint}>套用中無法以自己的身分喊人,請先退出套用。</Text>
|
||
) : (
|
||
<View style={styles.sayRow}>
|
||
<TextInput
|
||
value={text}
|
||
onChangeText={setText}
|
||
placeholder="喊一下誰的名字……"
|
||
style={styles.sayInput}
|
||
editable={!pending}
|
||
onSubmitEditing={handleSay}
|
||
/>
|
||
<Pressable onPress={handleSay} disabled={pending || !text.trim()}>
|
||
<Text style={[styles.sendButton, pending || !text.trim() ? styles.sendButtonDisabled : null]}>送出</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, gap: 6 },
|
||
sectionTitle: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f", marginTop: 6 },
|
||
rosterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||
workSection: { gap: 6 },
|
||
workHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 6 },
|
||
workHeaderActions: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||
pendingBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 8, paddingVertical: 3 },
|
||
pendingBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
|
||
importChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 3 },
|
||
importChipText: { fontSize: 10, color: "#8a6b6f" },
|
||
endingCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 4 },
|
||
endingCardTitle: { fontSize: 11, color: "#222" },
|
||
endingCardEvidence: { fontSize: 11, color: "#8a6b6f" },
|
||
endingCardActions: { flexDirection: "row", gap: 12 },
|
||
confirmButton: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D" },
|
||
rejectButton: { fontSize: 11, color: "#8a6b6f" },
|
||
milestoneList: { gap: 2 },
|
||
milestoneText: { fontSize: 11, color: "#8a6b6f" },
|
||
chip: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
borderRadius: 999,
|
||
borderWidth: 1,
|
||
borderColor: "#E5D5D8",
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 4,
|
||
},
|
||
possessedBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 6, paddingVertical: 1 },
|
||
possessedBadgeText: { fontSize: 9, fontWeight: "bold", color: "#fff" },
|
||
possessButton: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 6, paddingVertical: 1 },
|
||
possessButtonText: { fontSize: 9, color: "#8a6b6f" },
|
||
possessionBanner: { borderRadius: 12, borderWidth: 2, borderColor: "#FF7E9D", backgroundColor: "#FFE1E9", padding: 10, gap: 6 },
|
||
possessionBannerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||
possessionBannerTitle: { fontSize: 13, fontWeight: "bold", color: "#FF7E9D" },
|
||
possessionBannerHint: { fontSize: 11, color: "#8a6b6f" },
|
||
exitPossessionLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" },
|
||
chipText: { fontSize: 12, color: "#222" },
|
||
roomCard: {
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: "#E5D5D8",
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
},
|
||
roomCardRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||
roomCardText: { fontSize: 12, color: "#222" },
|
||
unreadBadge: {
|
||
backgroundColor: "#FF7E9D",
|
||
borderRadius: 999,
|
||
paddingHorizontal: 7,
|
||
paddingVertical: 2,
|
||
minWidth: 18,
|
||
alignItems: "center",
|
||
},
|
||
unreadBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
|
||
messageBox: {
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: "#E5D5D8",
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
},
|
||
messageBoxText: { fontSize: 12, color: "#222" },
|
||
sayRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
marginTop: 8,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 8,
|
||
borderRadius: 999,
|
||
borderWidth: 1,
|
||
borderColor: "#E5D5D8",
|
||
},
|
||
sayInput: { flex: 1, fontSize: 14 },
|
||
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
|
||
sendButtonDisabled: { opacity: 0.5 },
|
||
hint: { fontSize: 12, color: "#8a6b6f", paddingHorizontal: 16 },
|
||
error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 },
|
||
});
|