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,279 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
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: "敘述",
|
||||
};
|
||||
const LINE_TYPES = Object.keys(LINE_TYPE_LABELS) as SceneLineType[];
|
||||
|
||||
function emptyLine(): ImportSceneLineInput {
|
||||
return { lineType: "NARRATION", rawText: "" };
|
||||
}
|
||||
|
||||
// T20-P4 匯入章節:與 apps/web 的 app/canon-import 對應同一份契約與流程,改用 RN 元件(chip 取代
|
||||
// <select>)。多人在場且台詞沒有明示說話者時,既有的發言歸屬會把信心不足的句子隔離(QUARANTINED),
|
||||
// 提交後直接依回應導向待裁決頁或顯示已投影。
|
||||
export default function CanonImportScreen() {
|
||||
const router = useRouter();
|
||||
const { workId: presetWorkId } = useLocalSearchParams<{ workId?: string }>();
|
||||
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);
|
||||
if (presetWorkId && all.some((w) => w.id === presetWorkId)) {
|
||||
setWorkId(presetWorkId);
|
||||
}
|
||||
});
|
||||
}, [presetWorkId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = works.find((w) => w.id === workId);
|
||||
setStoryOrder(String((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: Number(volume) || 1,
|
||||
chapterLabel: chapterLabel.trim(),
|
||||
storyOrder: Number(storyOrder) || 1,
|
||||
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 (
|
||||
<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>}
|
||||
|
||||
{result && (
|
||||
<View style={styles.resultBox}>
|
||||
{result.pendingAdjudication ? (
|
||||
<>
|
||||
<Text style={styles.resultText}>匯入成功,但有台詞或矛盾待人工確認,暫不投影。</Text>
|
||||
<Pressable onPress={() => router.push("/canon-review")}>
|
||||
<Text style={styles.resultLink}>前往待裁決頁</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.resultText}>
|
||||
匯入成功,已完成投影{result.projected ? "" : "(場景超前目前錨定點,先留存等待錨定點推進)"}。
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{workId && (
|
||||
<>
|
||||
<TextInput value={chapterLabel} onChangeText={setChapterLabel} placeholder="章節標題(例如:第十章)" style={styles.input} />
|
||||
<View style={styles.row}>
|
||||
<TextInput value={volume} onChangeText={setVolume} placeholder="卷數" keyboardType="numeric" style={[styles.input, styles.rowInput]} />
|
||||
<TextInput
|
||||
value={storyOrder}
|
||||
onChangeText={setStoryOrder}
|
||||
placeholder="storyOrder"
|
||||
keyboardType="numeric"
|
||||
style={[styles.input, styles.rowInput]}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
value={summary}
|
||||
onChangeText={setSummary}
|
||||
placeholder="場景摘要(客觀記錄,供角色記憶與情緒基線使用)"
|
||||
style={[styles.input, styles.textarea]}
|
||||
multiline
|
||||
/>
|
||||
<TextInput value={rawText} onChangeText={setRawText} placeholder="原文(完整章節文本)" style={[styles.input, styles.textarea]} multiline />
|
||||
|
||||
<Text style={styles.label}>在場角色</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{characters.map((character) => (
|
||||
<Pressable
|
||||
key={character.id}
|
||||
onPress={() => toggleParticipant(character.id)}
|
||||
style={[styles.pickChip, participantCharacterIds.includes(character.id) ? styles.pickChipActive : null]}
|
||||
>
|
||||
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.label}>逐句台詞/敘述</Text>
|
||||
<Pressable onPress={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Text style={styles.addLineLink}>新增一行</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{lines.map((line, index) => (
|
||||
<View key={index} style={styles.lineRow}>
|
||||
<View style={styles.chipRow}>
|
||||
{LINE_TYPES.map((type) => (
|
||||
<Pressable
|
||||
key={type}
|
||||
onPress={() => updateLine(index, { lineType: type })}
|
||||
style={[styles.typeChip, line.lineType === type ? styles.pickChipActive : null]}
|
||||
>
|
||||
<Text style={styles.pickChipText}>{LINE_TYPE_LABELS[type]}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<TextInput
|
||||
value={line.rawText}
|
||||
onChangeText={(value) => updateLine(index, { rawText: value })}
|
||||
placeholder="這一句的內容"
|
||||
style={styles.input}
|
||||
/>
|
||||
{line.lineType === "DIALOGUE" && (
|
||||
<View style={styles.chipRow}>
|
||||
<Pressable
|
||||
onPress={() => updateLine(index, { markerCharacterId: undefined, hasExplicitMarker: false })}
|
||||
style={[styles.typeChip, !line.markerCharacterId ? styles.pickChipActive : null]}
|
||||
>
|
||||
<Text style={styles.pickChipText}>(無明示說話者)</Text>
|
||||
</Pressable>
|
||||
{characters.map((character) => (
|
||||
<Pressable
|
||||
key={character.id}
|
||||
onPress={() => updateLine(index, { markerCharacterId: character.id, hasExplicitMarker: true })}
|
||||
style={[styles.typeChip, line.markerCharacterId === character.id ? styles.pickChipActive : null]}
|
||||
>
|
||||
<Text style={styles.pickChipText}>{character.aliases[0]?.formalName ?? character.id}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<Pressable onPress={() => removeLine(index)}>
|
||||
<Text style={styles.removeLineLink}>刪除這一行</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Pressable
|
||||
onPress={() => void handleSubmit()}
|
||||
disabled={pending || !chapterLabel.trim() || !summary.trim() || !rawText.trim()}
|
||||
style={styles.submitButton}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>匯入這一章</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1, backgroundColor: "#FFF5F7" },
|
||||
content: { padding: 16, gap: 10, 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" },
|
||||
resultBox: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 10, gap: 4 },
|
||||
resultText: { fontSize: 12, color: "#222" },
|
||||
resultLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" },
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5D5D8",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
fontSize: 13,
|
||||
color: "#222",
|
||||
},
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
rowInput: { flex: 1 },
|
||||
textarea: { minHeight: 70, textAlignVertical: "top" },
|
||||
label: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f" },
|
||||
addLineLink: { fontSize: 12, color: "#8a6b6f" },
|
||||
lineRow: { gap: 6, borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8 },
|
||||
removeLineLink: { fontSize: 11, color: "#8a6b6f", alignSelf: "flex-start" },
|
||||
submitButton: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingVertical: 10, alignItems: "center" },
|
||||
submitButtonText: { fontSize: 14, fontWeight: "bold", color: "#fff" },
|
||||
});
|
||||
Reference in New Issue
Block a user