Files
Kokorone/apps/web/app/canon-review/CanonReviewView.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

281 lines
11 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.
"use client";
import { useEffect, useState } from "react";
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 待裁決畫面:列出隔離區台詞與待人工確認的矛盾,提供逐句裁決/歸屬更正/指紋精煉後重試。
export function CanonReviewView() {
const [works, setWorks] = useState<CanonWork[]>([]);
const [workId, setWorkId] = useState<string>("");
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 {
const result = await reattemptQuarantined(sceneId);
setError(null);
await refreshWork(workId);
// eslint-disable-next-line no-console
console.info(`指紋精煉後重試釋放了 ${result.released} 句`);
} catch {
setError("重試失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
const sceneIdsWithQuarantine = Array.from(new Set(quarantined.map((line) => line.sceneId)));
return (
<div className="flex flex-col gap-4">
<select
value={workId}
onChange={(event) => setWorkId(event.target.value)}
className="rounded-full border bg-transparent px-3 py-2 text-sm"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇作品……</option>
{works.map((work) => (
<option key={work.id} value={work.id}>
{work.title}
</option>
))}
</select>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
{workId && (
<>
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
隔離區台詞(QUARANTINED)
</h2>
{sceneIdsWithQuarantine.map((sceneId) => (
<button
key={sceneId}
type="button"
onClick={() => void handleReattempt(sceneId)}
disabled={pending}
className="rounded-full border px-3 py-1 text-xs disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
指紋精煉後重試此場景
</button>
))}
</div>
{quarantined.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有隔離中的台詞。
</p>
)}
{quarantined.map((line) => (
<div key={line.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>{line.rawText}</p>
<p style={{ color: "var(--ink-soft)" }}>
信心分數:{(line.confidence * 100).toFixed(0)}%.證據來源:
{line.hasExplicitMarker ? `明示標記→${characterLabel(characters, line.markerCharacterId)}` : "推論(無明示標記)"}
</p>
<div className="flex items-center gap-2">
<select
value={selection[line.id] ?? ""}
onChange={(event) => setSelection({ ...selection, [line.id]: event.target.value })}
className="rounded-full border bg-transparent px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">選擇說話者……</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleAdjudicate(line.id)}
disabled={pending || !selection[line.id]}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
裁決
</button>
</div>
</div>
))}
</section>
<section className="flex flex-col gap-2">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
已歸屬台詞(可更正)
</h2>
{confirmed.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有已歸屬的台詞。
</p>
)}
{confirmed.map((line) => (
<div key={line.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>{line.rawText}</p>
<p style={{ color: "var(--ink-soft)" }}>目前歸屬:{characterLabel(characters, line.speakerCharacterId)}</p>
<div className="flex items-center gap-2">
<select
value={selection[line.id] ?? ""}
onChange={(event) => setSelection({ ...selection, [line.id]: event.target.value })}
className="rounded-full border bg-transparent px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<option value="">改為……</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.aliases[0]?.formalName ?? character.id}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleCorrect(line.id)}
disabled={pending || !selection[line.id]}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
更正
</button>
</div>
</div>
))}
</section>
<section className="flex flex-col gap-2">
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
待人工確認的矛盾(PENDING_HUMAN_REVIEW)
</h2>
{contradictions.filter((c) => c.status === "PENDING_HUMAN_REVIEW").length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有待確認的矛盾。
</p>
)}
{contradictions
.filter((c) => c.status === "PENDING_HUMAN_REVIEW")
.map((contradiction) => (
<div key={contradiction.id} className="rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>
{contradiction.eventLabel}.{contradiction.factKey}
</p>
<p style={{ color: "var(--ink-soft)" }}>{contradiction.description}</p>
</div>
))}
</section>
</>
)}
</div>
);
}