"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 = { 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([]); const [workId, setWorkId] = useState(""); const [characters, setCharacters] = useState([]); 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([]); const [lines, setLines] = useState([emptyLine()]); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(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) { 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 (
{error && (

{error}

)} {result && (
{result.pendingAdjudication ? ( <>

匯入成功,但有台詞或矛盾待人工確認,暫不投影。

前往待裁決頁 ) : (

匯入成功,已完成投影{result.projected ? "" : "(場景超前目前錨定點,先留存等待錨定點推進)"}。

)}
)} {workId && ( <>
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)" }} /> 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)" }} /> 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)" }} />