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,296 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
importCanonScene,
|
||||
listCanonWorks,
|
||||
listCharacters,
|
||||
type CanonWork,
|
||||
type CharacterSummary,
|
||||
type ImportSceneLineInput,
|
||||
type ImportedSceneResult,
|
||||
type SceneLineType,
|
||||
} from "../../lib/api";
|
||||
|
||||
const LINE_TYPE_LABELS: Record<SceneLineType, string> = {
|
||||
DIALOGUE: "台詞",
|
||||
ACTION: "動作描寫",
|
||||
MONOLOGUE: "內心獨白",
|
||||
NARRATION: "敘述",
|
||||
};
|
||||
|
||||
function emptyLine(): ImportSceneLineInput {
|
||||
return { lineType: "NARRATION", rawText: "" };
|
||||
}
|
||||
|
||||
// T19-P4 匯入章節:場景層級的摘要/原文+逐句台詞。多人在場且台詞沒有明示說話者時,M-3/M-11
|
||||
// 既有的發言歸屬會把信心不足的句子隔離(QUARANTINED),提交後直接依回應導向待裁決頁或顯示已投影。
|
||||
export function CanonImportView() {
|
||||
const [works, setWorks] = useState<CanonWork[]>([]);
|
||||
const [workId, setWorkId] = useState("");
|
||||
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
|
||||
const [chapterLabel, setChapterLabel] = useState("");
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [storyOrder, setStoryOrder] = useState(1);
|
||||
const [summary, setSummary] = useState("");
|
||||
const [rawText, setRawText] = useState("");
|
||||
const [participantCharacterIds, setParticipantCharacterIds] = useState<string[]>([]);
|
||||
const [lines, setLines] = useState<ImportSceneLineInput[]>([emptyLine()]);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ImportedSceneResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void listCanonWorks().then((all) => {
|
||||
setWorks(all);
|
||||
const presetWorkId = new URLSearchParams(window.location.search).get("workId");
|
||||
if (presetWorkId && all.some((w) => w.id === presetWorkId)) {
|
||||
setWorkId(presetWorkId);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = works.find((w) => w.id === workId);
|
||||
setStoryOrder((selected?.anchorStoryOrder ?? 0) + 1);
|
||||
setParticipantCharacterIds([]);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
if (workId) {
|
||||
void listCharacters().then((all) => setCharacters(all.filter((c) => c.workId === workId)));
|
||||
} else {
|
||||
setCharacters([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workId]);
|
||||
|
||||
function toggleParticipant(characterId: string) {
|
||||
setParticipantCharacterIds((prev) =>
|
||||
prev.includes(characterId) ? prev.filter((id) => id !== characterId) : [...prev, characterId],
|
||||
);
|
||||
}
|
||||
|
||||
function updateLine(index: number, patch: Partial<ImportSceneLineInput>) {
|
||||
setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line)));
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
setLines((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!workId || !chapterLabel.trim() || !summary.trim() || !rawText.trim() || pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const imported = await importCanonScene({
|
||||
workId,
|
||||
volume,
|
||||
chapterLabel: chapterLabel.trim(),
|
||||
storyOrder,
|
||||
summary: summary.trim(),
|
||||
rawText: rawText.trim(),
|
||||
participantCharacterIds,
|
||||
lines: lines.filter((line) => line.rawText.trim().length > 0),
|
||||
});
|
||||
setResult(imported);
|
||||
setChapterLabel("");
|
||||
setSummary("");
|
||||
setRawText("");
|
||||
setLines([emptyLine()]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "章節匯入失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
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((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="flex flex-col gap-1 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
|
||||
{result.pendingAdjudication ? (
|
||||
<>
|
||||
<p style={{ color: "var(--ink)" }}>匯入成功,但有台詞或矛盾待人工確認,暫不投影。</p>
|
||||
<Link href="/canon-review" className="font-bold" style={{ color: "var(--accent)" }}>
|
||||
前往待裁決頁
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ color: "var(--ink)" }}>匯入成功,已完成投影{result.projected ? "" : "(場景超前目前錨定點,先留存等待錨定點推進)"}。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workId && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={chapterLabel}
|
||||
onChange={(event) => setChapterLabel(event.target.value)}
|
||||
placeholder="章節標題(例如:第十章)"
|
||||
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={volume}
|
||||
onChange={(event) => setVolume(Number(event.target.value))}
|
||||
placeholder="卷數"
|
||||
className="w-20 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={storyOrder}
|
||||
onChange={(event) => setStoryOrder(Number(event.target.value))}
|
||||
placeholder="storyOrder"
|
||||
className="w-28 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder="場景摘要(客觀記錄,供角色記憶與情緒基線使用)"
|
||||
className="rounded-2xl border bg-transparent p-3 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={rawText}
|
||||
onChange={(event) => setRawText(event.target.value)}
|
||||
placeholder="原文(完整章節文本)"
|
||||
className="rounded-2xl border bg-transparent p-3 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-bold" style={{ color: "var(--ink)" }}>
|
||||
在場角色
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{characters.map((character) => (
|
||||
<button
|
||||
key={character.id}
|
||||
type="button"
|
||||
onClick={() => toggleParticipant(character.id)}
|
||||
className="rounded-full border px-3 py-1"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
background: participantCharacterIds.includes(character.id) ? "var(--chip-bg)" : "transparent",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{character.aliases[0]?.formalName ?? character.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold" style={{ color: "var(--ink)" }}>
|
||||
逐句台詞/敘述
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLines((prev) => [...prev, emptyLine()])}
|
||||
className="rounded-full border px-3 py-1 text-xs"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
新增一行
|
||||
</button>
|
||||
</div>
|
||||
{lines.map((line, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<select
|
||||
value={line.lineType}
|
||||
onChange={(event) => updateLine(index, { lineType: event.target.value as SceneLineType })}
|
||||
className="rounded-full border bg-transparent px-2 py-1"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
{(Object.keys(LINE_TYPE_LABELS) as SceneLineType[]).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{LINE_TYPE_LABELS[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={line.rawText}
|
||||
onChange={(event) => updateLine(index, { rawText: event.target.value })}
|
||||
placeholder="這一句的內容"
|
||||
className="flex-1 rounded-full border bg-transparent px-3 py-1 outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
/>
|
||||
{line.lineType === "DIALOGUE" && (
|
||||
<select
|
||||
value={line.markerCharacterId ?? ""}
|
||||
onChange={(event) =>
|
||||
updateLine(index, {
|
||||
markerCharacterId: event.target.value || undefined,
|
||||
hasExplicitMarker: Boolean(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={() => removeLine(index)}
|
||||
className="rounded-full border px-2 py-1"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={pending || !chapterLabel.trim() || !summary.trim() || !rawText.trim()}
|
||||
className="self-start rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
匯入這一章
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { CanonImportView } from "./CanonImportView";
|
||||
|
||||
export default function CanonImportPage() {
|
||||
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="/lobby" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
回到大廳
|
||||
</Link>
|
||||
</div>
|
||||
<CanonImportView />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user