|
|
@@ -1,9 +1,7 @@
|
|
|
'use client';
|
|
|
|
|
|
-import { useState, useCallback } from 'react';
|
|
|
-import { NarrativeHook } from '@/app/lib/novel-types';
|
|
|
-import { parseHooksFromJson, deleteHook, addHooks } from '@/app/lib/novel-utils';
|
|
|
-import { useWorldState } from '@/app/hooks/useWorldState';
|
|
|
+import { useState, useEffect } from 'react';
|
|
|
+import { FileText, Loader2 } from 'lucide-react';
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
|
// Types
|
|
|
@@ -14,52 +12,9 @@ interface NarrativeHookPanelProps {
|
|
|
latestChapterIndex: number;
|
|
|
}
|
|
|
|
|
|
-type HookType = NarrativeHook['type'];
|
|
|
-
|
|
|
-const HOOK_TYPE_OPTIONS: { value: HookType; label: string }[] = [
|
|
|
- { value: 'suspense', label: '悬念' },
|
|
|
- { value: 'foreshadowing', label: '伏笔' },
|
|
|
- { value: 'author_reader_gap', label: '作者读者信息差' },
|
|
|
- { value: 'reader_protagonist_gap', label: '读者主角信息差' },
|
|
|
-];
|
|
|
-
|
|
|
-const HOOK_TYPE_LABELS: Record<HookType, string> = {
|
|
|
- suspense: '悬念',
|
|
|
- foreshadowing: '伏笔',
|
|
|
- author_reader_gap: '作者读者信息差',
|
|
|
- reader_protagonist_gap: '读者主角信息差',
|
|
|
-};
|
|
|
-
|
|
|
-// ─────────────────────────────────────────────
|
|
|
-// Sub-component: NarrativeHookCard
|
|
|
-// ─────────────────────────────────────────────
|
|
|
-
|
|
|
-interface NarrativeHookCardProps {
|
|
|
- hook: NarrativeHook;
|
|
|
- onDelete: (hookId: string) => void;
|
|
|
-}
|
|
|
-
|
|
|
-function NarrativeHookCard({ hook, onDelete }: NarrativeHookCardProps) {
|
|
|
- return (
|
|
|
- <div className="rounded-lg bg-orange-50 border border-orange-200 px-3 py-2 space-y-1">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-xs font-medium text-orange-700 bg-orange-100 px-1.5 py-0.5 rounded">
|
|
|
- {HOOK_TYPE_LABELS[hook.type]}
|
|
|
- </span>
|
|
|
- <span className="text-xs text-orange-500">第 {hook.introducedInChapter} 章埋入</span>
|
|
|
- <button
|
|
|
- onClick={() => onDelete(hook.id)}
|
|
|
- className="ml-auto text-orange-400 hover:text-red-500 transition-colors"
|
|
|
- aria-label="删除钩子"
|
|
|
- >
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <p className="text-xs text-orange-800 leading-relaxed">{hook.description}</p>
|
|
|
- </div>
|
|
|
- );
|
|
|
+interface ImmersionFile {
|
|
|
+ chapterIndex: number;
|
|
|
+ content: string;
|
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
|
@@ -67,201 +22,139 @@ function NarrativeHookCard({ hook, onDelete }: NarrativeHookCardProps) {
|
|
|
// ─────────────────────────────────────────────
|
|
|
|
|
|
export default function NarrativeHookPanel({ projectPath, latestChapterIndex }: NarrativeHookPanelProps) {
|
|
|
- const {
|
|
|
- worldStateFiles,
|
|
|
- updateFile,
|
|
|
- refreshFiles,
|
|
|
- } = useWorldState(projectPath);
|
|
|
-
|
|
|
- // Add hook form state
|
|
|
- const [showAddHookForm, setShowAddHookForm] = useState(false);
|
|
|
- const [newHookType, setNewHookType] = useState<HookType>('suspense');
|
|
|
- const [newHookDescription, setNewHookDescription] = useState('');
|
|
|
- const [newHookChapter, setNewHookChapter] = useState<number>(latestChapterIndex);
|
|
|
- const [isAddingHook, setIsAddingHook] = useState(false);
|
|
|
- const [addHookError, setAddHookError] = useState<string | null>(null);
|
|
|
-
|
|
|
- // Parse narrative hooks from narrative_hooks.json
|
|
|
- const narrativeHooksFile = worldStateFiles.find((f) => f.fileName === 'narrative_hooks.json');
|
|
|
- const allHooks = narrativeHooksFile ? parseHooksFromJson(narrativeHooksFile.content) : [];
|
|
|
- const openHooks = allHooks.filter((h) => h.status === 'open');
|
|
|
- const resolvedHooks = allHooks.filter((h) => h.status === 'resolved');
|
|
|
+ const [immersionFiles, setImmersionFiles] = useState<ImmersionFile[]>([]);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
- // ── Handlers ──
|
|
|
-
|
|
|
- const handleAddHook = async () => {
|
|
|
- if (!newHookDescription.trim()) {
|
|
|
- setAddHookError('请输入钩子描述');
|
|
|
- return;
|
|
|
- }
|
|
|
+ useEffect(() => {
|
|
|
+ loadImmersionFiles();
|
|
|
+ }, [projectPath, latestChapterIndex]);
|
|
|
|
|
|
- setIsAddingHook(true);
|
|
|
- setAddHookError(null);
|
|
|
+ const loadImmersionFiles = async () => {
|
|
|
+ setLoading(true);
|
|
|
+ setError(null);
|
|
|
|
|
|
try {
|
|
|
- const id = `hook-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
-
|
|
|
- const newHook: NarrativeHook = {
|
|
|
- id,
|
|
|
- type: newHookType,
|
|
|
- description: newHookDescription.trim(),
|
|
|
- introducedInChapter: newHookChapter,
|
|
|
- status: 'open',
|
|
|
- };
|
|
|
-
|
|
|
- const existingContent = narrativeHooksFile?.content ?? '';
|
|
|
- const updatedContent = addHooks(existingContent, [newHook]);
|
|
|
-
|
|
|
- await updateFile('narrative_hooks.json', updatedContent, false);
|
|
|
- await refreshFiles();
|
|
|
-
|
|
|
- setNewHookDescription('');
|
|
|
- setNewHookType('suspense');
|
|
|
- setNewHookChapter(latestChapterIndex);
|
|
|
- setShowAddHookForm(false);
|
|
|
+ // List files in world_state/immersion-hooks/
|
|
|
+ const listRes = await fetch('/api/novel/files', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ action: 'list',
|
|
|
+ projectPath,
|
|
|
+ directory: 'world_state/immersion-hooks',
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ const listData = await listRes.json();
|
|
|
+
|
|
|
+ if (!listData.success) {
|
|
|
+ // Directory may not exist yet
|
|
|
+ setImmersionFiles([]);
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const mdFiles: string[] = (listData.files || []).filter((f: string) => f.endsWith('.md'));
|
|
|
+ // Sort by chapter index (newest first), keep last 3
|
|
|
+ const sorted = mdFiles.sort((a: string, b: string) => {
|
|
|
+ const ai = parseInt(a.match(/chapter-(\d+)/)?.[1] || '0');
|
|
|
+ const bi = parseInt(b.match(/chapter-(\d+)/)?.[1] || '0');
|
|
|
+ return bi - ai;
|
|
|
+ });
|
|
|
+
|
|
|
+ const recentFiles = sorted.slice(0, 3);
|
|
|
+ const files: ImmersionFile[] = [];
|
|
|
+
|
|
|
+ for (const fileName of recentFiles) {
|
|
|
+ const ci = parseInt(fileName.match(/chapter-(\d+)/)?.[1] || '0');
|
|
|
+ const readRes = await fetch('/api/novel/files', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ action: 'read',
|
|
|
+ projectPath,
|
|
|
+ filePath: `world_state/immersion-hooks/${fileName}`,
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ const readData = await readRes.json();
|
|
|
+ if (readData.success && readData.content) {
|
|
|
+ files.push({ chapterIndex: ci, content: readData.content as string });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sort by chapter index ascending for display
|
|
|
+ files.sort((a, b) => a.chapterIndex - b.chapterIndex);
|
|
|
+ setImmersionFiles(files);
|
|
|
} catch (err) {
|
|
|
- setAddHookError(err instanceof Error ? err.message : '添加钩子失败');
|
|
|
+ setError(err instanceof Error ? err.message : '加载代入感文件失败');
|
|
|
} finally {
|
|
|
- setIsAddingHook(false);
|
|
|
+ setLoading(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const handleDeleteHook = useCallback(
|
|
|
- async (hookId: string) => {
|
|
|
- if (!narrativeHooksFile) return;
|
|
|
-
|
|
|
- const updatedContent = deleteHook(narrativeHooksFile.content, hookId);
|
|
|
- await updateFile('narrative_hooks.json', updatedContent, false);
|
|
|
- await refreshFiles();
|
|
|
- },
|
|
|
- [narrativeHooksFile, updateFile, refreshFiles]
|
|
|
- );
|
|
|
-
|
|
|
return (
|
|
|
<div className="flex-1 overflow-y-auto p-5">
|
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
|
{/* Header */}
|
|
|
<div className="flex items-center justify-between">
|
|
|
<div>
|
|
|
- <h2 className="text-base font-semibold text-gray-800">叙事钩子</h2>
|
|
|
+ <h2 className="text-base font-semibold text-gray-800">代入感系统</h2>
|
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
|
- 管理故事中的悬念、伏笔和信息差,追踪哪些钩子已回收
|
|
|
+ 查看最近 3 章的代入感设计记录(悬念/信息差),由创作管线自动生成
|
|
|
</p>
|
|
|
</div>
|
|
|
- {openHooks.length > 0 && (
|
|
|
- <span className="text-xs font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full">
|
|
|
- {openHooks.length} 个未解决
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Open hooks list */}
|
|
|
- <div>
|
|
|
- <h3 className="text-sm font-medium text-gray-700 mb-2">未解决的钩子</h3>
|
|
|
- {openHooks.length === 0 ? (
|
|
|
- <p className="text-xs text-gray-400 text-center py-4 bg-gray-50 rounded-lg border border-gray-100">
|
|
|
- 暂无未解决钩子
|
|
|
- </p>
|
|
|
- ) : (
|
|
|
- <div className="space-y-2">
|
|
|
- {openHooks.map((hook) => (
|
|
|
- <NarrativeHookCard key={hook.id} hook={hook} onDelete={handleDeleteHook} />
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ <button
|
|
|
+ onClick={loadImmersionFiles}
|
|
|
+ disabled={loading}
|
|
|
+ className="flex items-center gap-1 px-3 py-1.5 text-xs text-gray-500 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
|
|
+ >
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
|
+ </svg>
|
|
|
+ 刷新
|
|
|
+ </button>
|
|
|
</div>
|
|
|
|
|
|
- {/* Add hook form */}
|
|
|
- <div className="border border-orange-200 rounded-xl p-4 bg-orange-50">
|
|
|
- {!showAddHookForm ? (
|
|
|
- <button
|
|
|
- onClick={() => { setShowAddHookForm(true); setAddHookError(null); setNewHookChapter(latestChapterIndex); }}
|
|
|
- className="w-full flex items-center justify-center gap-1.5 py-2 text-sm text-gray-500 border border-dashed border-orange-300 rounded-lg hover:border-orange-400 hover:text-orange-500 transition-colors"
|
|
|
- >
|
|
|
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
- </svg>
|
|
|
- 手动添加钩子
|
|
|
- </button>
|
|
|
- ) : (
|
|
|
- <div className="space-y-3">
|
|
|
- <p className="text-sm font-medium text-orange-700">添加叙事钩子</p>
|
|
|
-
|
|
|
- {/* Hook type select */}
|
|
|
- <select
|
|
|
- value={newHookType}
|
|
|
- onChange={(e) => setNewHookType(e.target.value as HookType)}
|
|
|
- className="w-full text-sm border border-orange-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:border-orange-400"
|
|
|
- >
|
|
|
- {HOOK_TYPE_OPTIONS.map((opt) => (
|
|
|
- <option key={opt.value} value={opt.value}>
|
|
|
- {opt.label}
|
|
|
- </option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
-
|
|
|
- {/* Chapter input */}
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-sm text-gray-600 shrink-0">引入章节:</span>
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- min="0"
|
|
|
- value={newHookChapter}
|
|
|
- onChange={(e) => setNewHookChapter(parseInt(e.target.value) || 0)}
|
|
|
- className="flex-1 text-sm border border-orange-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:border-orange-400"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Description input */}
|
|
|
- <textarea
|
|
|
- value={newHookDescription}
|
|
|
- onChange={(e) => setNewHookDescription(e.target.value)}
|
|
|
- placeholder="描述这个钩子的内容..."
|
|
|
- className="w-full text-sm border border-orange-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:border-orange-400 resize-none h-24"
|
|
|
- />
|
|
|
+ {/* Loading state */}
|
|
|
+ {loading && (
|
|
|
+ <div className="flex items-center justify-center py-12">
|
|
|
+ <Loader2 className="h-6 w-6 animate-spin text-indigo-500" />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- {addHookError && (
|
|
|
- <p className="text-xs text-red-500">{addHookError}</p>
|
|
|
- )}
|
|
|
+ {/* Error state */}
|
|
|
+ {error && (
|
|
|
+ <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
|
|
+ {error}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- <div className="flex gap-2">
|
|
|
- <button
|
|
|
- onClick={() => { setShowAddHookForm(false); setAddHookError(null); setNewHookDescription(''); setNewHookChapter(latestChapterIndex); }}
|
|
|
- className="flex-1 text-sm py-2 bg-white border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"
|
|
|
- >
|
|
|
- 取消
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={handleAddHook}
|
|
|
- disabled={isAddingHook}
|
|
|
- className="flex-1 text-sm py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50"
|
|
|
- >
|
|
|
- {isAddingHook ? '添加中...' : '添加'}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ {/* Empty state */}
|
|
|
+ {!loading && !error && immersionFiles.length === 0 && (
|
|
|
+ <div className="text-center py-12 text-gray-400">
|
|
|
+ <FileText className="h-10 w-10 mx-auto mb-3 text-gray-300" />
|
|
|
+ <p className="text-sm">暂无代入感设计记录</p>
|
|
|
+ <p className="text-xs mt-1">开始创作章节后,管线中的代入感设计会自动保存到这里</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- {/* Resolved hooks */}
|
|
|
- {resolvedHooks.length > 0 && (
|
|
|
- <div>
|
|
|
- <h3 className="text-sm font-medium text-gray-700 mb-2">已解决的钩子</h3>
|
|
|
- <div className="space-y-2 opacity-60">
|
|
|
- {resolvedHooks.map((hook) => (
|
|
|
- <div key={hook.id} className="rounded-lg bg-green-50 border border-green-200 px-3 py-2 space-y-1">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-xs font-medium text-green-700 bg-green-100 px-1.5 py-0.5 rounded">
|
|
|
- {HOOK_TYPE_LABELS[hook.type]}
|
|
|
- </span>
|
|
|
- <span className="text-xs text-green-500">
|
|
|
- 第 {hook.introducedInChapter} 章埋入 / 第 {hook.resolvedInChapter} 章解决
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <p className="text-xs text-green-800 leading-relaxed">{hook.description}</p>
|
|
|
+ {/* Immersion files display */}
|
|
|
+ {!loading && immersionFiles.length > 0 && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {immersionFiles.map((file) => (
|
|
|
+ <div key={file.chapterIndex} className="border border-indigo-100 rounded-xl overflow-hidden">
|
|
|
+ <div className="bg-indigo-50 px-4 py-2 border-b border-indigo-100">
|
|
|
+ <h3 className="text-sm font-medium text-indigo-800">
|
|
|
+ 第 {file.chapterIndex} 章 - 代入感设计
|
|
|
+ </h3>
|
|
|
</div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
+ <div className="p-4">
|
|
|
+ <pre className="text-sm text-gray-700 whitespace-pre-wrap font-sans leading-relaxed">
|
|
|
+ {file.content}
|
|
|
+ </pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|