feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { CanonReviewView } from "./CanonReviewView";
|
||||
|
||||
export default function CanonReviewPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
|
||||
待裁決
|
||||
</h1>
|
||||
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
回到聊天
|
||||
</Link>
|
||||
</div>
|
||||
<CanonReviewView />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user