Files
Kokorone/apps/mobile/app/canon-review.tsx
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

259 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import {
adjudicateLine,
correctLineAttribution,
getCanonScene,
listCanonScenes,
listCanonWorks,
listCharacters,
listContradictions,
reattemptQuarantined,
type CanonWork,
type CharacterSummary,
type SceneContradiction,
type SceneLine,
} from "../lib/api";
interface LineRow extends SceneLine {
sceneId: string;
}
function characterLabel(characters: CharacterSummary[], characterId: string | null): string {
if (!characterId) return "(無)";
const found = characters.find((c) => c.id === characterId);
return found?.aliases[0]?.formalName ?? characterId;
}
// M-11/T20-P4 待裁決畫面:與 apps/web 的 CanonReviewView 對應同一份契約,改用 RN 元件(chip 取代
// <select>)。列出隔離區台詞與待人工確認的矛盾,提供逐句裁決/歸屬更正/指紋精煉後重試。
export default function CanonReviewScreen() {
const router = useRouter();
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState("");
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [quarantined, setQuarantined] = useState<LineRow[]>([]);
const [confirmed, setConfirmed] = useState<LineRow[]>([]);
const [contradictions, setContradictions] = useState<SceneContradiction[]>([]);
const [selection, setSelection] = useState<Record<string, string>>({});
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void listCanonWorks()
.then(setWorks)
.catch(() => setError("無法連線到 api 服務,作品清單暫時無法顯示"));
}, []);
async function refreshWork(targetWorkId: string) {
if (!targetWorkId) return;
setError(null);
try {
const [allCharacters, scenes, contradictionList] = await Promise.all([
listCharacters(),
listCanonScenes(targetWorkId),
listContradictions(targetWorkId),
]);
setCharacters(allCharacters.filter((c) => c.workId === targetWorkId));
setContradictions(contradictionList);
const details = await Promise.all(scenes.map((scene) => getCanonScene(scene.id)));
const nextQuarantined: LineRow[] = [];
const nextConfirmed: LineRow[] = [];
for (const scene of details) {
for (const line of scene.lines) {
const row = { ...line, sceneId: scene.id };
if (line.status === "QUARANTINED") {
nextQuarantined.push(row);
} else if (line.status === "CONFIRMED") {
nextConfirmed.push(row);
}
}
}
setQuarantined(nextQuarantined);
setConfirmed(nextConfirmed);
} catch {
setError("待裁決資料查詢失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
void refreshWork(workId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workId]);
async function handleAdjudicate(lineId: string) {
const speakerCharacterId = selection[lineId];
if (!speakerCharacterId || pending) return;
setPending(true);
setError(null);
try {
await adjudicateLine(lineId, speakerCharacterId);
await refreshWork(workId);
} catch {
setError("裁決失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleCorrect(lineId: string) {
const newSpeakerCharacterId = selection[lineId];
if (!newSpeakerCharacterId || pending) return;
setPending(true);
setError(null);
try {
await correctLineAttribution(lineId, newSpeakerCharacterId);
await refreshWork(workId);
} catch {
setError("歸屬更正失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleReattempt(sceneId: string) {
if (pending) return;
setPending(true);
setError(null);
try {
await reattemptQuarantined(sceneId);
await refreshWork(workId);
} catch {
setError("重試失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
const sceneIdsWithQuarantine = Array.from(new Set(quarantined.map((line) => line.sceneId)));
return (
<ScrollView style={styles.flex} contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<Text style={styles.title}>待裁決</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.backLink}>回到大廳</Text>
</Pressable>
</View>
<View style={styles.chipRow}>
{works.map((w) => (
<Pressable key={w.id} onPress={() => setWorkId(w.id)} style={[styles.pickChip, workId === w.id ? styles.pickChipActive : null]}>
<Text style={styles.pickChipText}>{w.title}</Text>
</Pressable>
))}
</View>
{error && <Text style={styles.error}>{error}</Text>}
{workId && (
<>
<View style={styles.section}>
<View style={styles.headerRow}>
<Text style={styles.label}>隔離區台詞(QUARANTINED)</Text>
</View>
{sceneIdsWithQuarantine.map((sceneId) => (
<Pressable key={sceneId} onPress={() => void handleReattempt(sceneId)} style={styles.pickChip}>
<Text style={styles.pickChipText}>指紋精煉後重試此場景</Text>
</Pressable>
))}
{quarantined.length === 0 && <Text style={styles.hint}>目前沒有隔離中的台詞。</Text>}
{quarantined.map((line) => (
<View key={line.id} style={styles.lineCard}>
<Text style={styles.lineText}>{line.rawText}</Text>
<Text style={styles.hint}>
信心分數:{(line.confidence * 100).toFixed(0)}%.證據來源:
{line.hasExplicitMarker ? `明示標記→${characterLabel(characters, line.markerCharacterId)}` : "推論(無明示標記)"}
</Text>
<View style={styles.chipRow}>
{characters.map((character) => (
<Pressable
key={character.id}
onPress={() => setSelection({ ...selection, [line.id]: character.id })}
style={[styles.typeChip, selection[line.id] === character.id ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
</Pressable>
))}
</View>
<Pressable
onPress={() => void handleAdjudicate(line.id)}
disabled={pending || !selection[line.id]}
style={styles.confirmButton}
>
<Text style={styles.confirmButtonText}>裁決</Text>
</Pressable>
</View>
))}
</View>
<View style={styles.section}>
<Text style={styles.label}>已歸屬台詞(可更正)</Text>
{confirmed.length === 0 && <Text style={styles.hint}>目前沒有已歸屬的台詞。</Text>}
{confirmed.map((line) => (
<View key={line.id} style={styles.lineCard}>
<Text style={styles.lineText}>{line.rawText}</Text>
<Text style={styles.hint}>目前歸屬:{characterLabel(characters, line.speakerCharacterId)}</Text>
<View style={styles.chipRow}>
{characters.map((character) => (
<Pressable
key={character.id}
onPress={() => setSelection({ ...selection, [line.id]: character.id })}
style={[styles.typeChip, selection[line.id] === character.id ? styles.pickChipActive : null]}
>
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
</Pressable>
))}
</View>
<Pressable onPress={() => void handleCorrect(line.id)} disabled={pending || !selection[line.id]} style={styles.pickChip}>
<Text style={styles.pickChipText}>更正</Text>
</Pressable>
</View>
))}
</View>
<View style={styles.section}>
<Text style={styles.label}>待人工確認的矛盾(PENDING_HUMAN_REVIEW)</Text>
{contradictions.filter((c) => c.status === "PENDING_HUMAN_REVIEW").length === 0 && (
<Text style={styles.hint}>目前沒有待確認的矛盾。</Text>
)}
{contradictions
.filter((c) => c.status === "PENDING_HUMAN_REVIEW")
.map((contradiction) => (
<View key={contradiction.id} style={styles.lineCard}>
<Text style={styles.lineText}>
{contradiction.eventLabel}.{contradiction.factKey}
</Text>
<Text style={styles.hint}>{contradiction.description}</Text>
</View>
))}
</View>
</>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 16, gap: 12, paddingBottom: 40 },
headerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
title: { fontSize: 16, fontWeight: "bold", color: "#FF7E9D" },
backLink: { fontSize: 12, color: "#8a6b6f" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
pickChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 10, paddingVertical: 6 },
pickChipActive: { backgroundColor: "#FFE1E9" },
pickChipText: { fontSize: 12, color: "#222" },
typeChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 4 },
error: { fontSize: 12, color: "#FF7E9D" },
section: { gap: 6 },
label: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f" },
hint: { fontSize: 11, color: "#8a6b6f" },
lineCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 6 },
lineText: { fontSize: 12, color: "#222" },
confirmButton: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 12, paddingVertical: 6, alignSelf: "flex-start" },
confirmButtonText: { fontSize: 12, fontWeight: "bold", color: "#fff" },
});