|
|
@@ -0,0 +1,270 @@
|
|
|
+'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';
|
|
|
+
|
|
|
+// ─────────────────────────────────────────────
|
|
|
+// Types
|
|
|
+// ─────────────────────────────────────────────
|
|
|
+
|
|
|
+interface NarrativeHookPanelProps {
|
|
|
+ projectPath: string;
|
|
|
+ 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>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────────────────────────────────
|
|
|
+// Main component: NarrativeHookPanel
|
|
|
+// ─────────────────────────────────────────────
|
|
|
+
|
|
|
+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');
|
|
|
+
|
|
|
+ // ── Handlers ──
|
|
|
+
|
|
|
+ const handleAddHook = async () => {
|
|
|
+ if (!newHookDescription.trim()) {
|
|
|
+ setAddHookError('请输入钩子描述');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsAddingHook(true);
|
|
|
+ setAddHookError(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);
|
|
|
+ } catch (err) {
|
|
|
+ setAddHookError(err instanceof Error ? err.message : '添加钩子失败');
|
|
|
+ } finally {
|
|
|
+ setIsAddingHook(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>
|
|
|
+ <p className="text-xs text-gray-500 mt-0.5">
|
|
|
+ 管理故事中的悬念、伏笔和信息差,追踪哪些钩子已回收
|
|
|
+ </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>
|
|
|
+ )}
|
|
|
+ </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"
|
|
|
+ />
|
|
|
+
|
|
|
+ {addHookError && (
|
|
|
+ <p className="text-xs text-red-500">{addHookError}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <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>
|
|
|
+
|
|
|
+ {/* 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>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|