新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
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" },
|
||
});
|