|
@@ -0,0 +1,353 @@
|
|
|
|
|
+'use client';
|
|
|
|
|
+
|
|
|
|
|
+import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
+import type { EventNode, EventEdge, ChapterAllocation } from '@/app/lib/novel-types';
|
|
|
|
|
+import { createEventNode, reorderNarrative } from '@/app/lib/event-graph-utils';
|
|
|
|
|
+import { Plus, Trash2, ChevronRight, GripVertical, Search, CheckCircle, ChevronUp, ChevronDown } from 'lucide-react';
|
|
|
|
|
+
|
|
|
|
|
+interface EventGraphEditorProps {
|
|
|
|
|
+ projectPath: string;
|
|
|
|
|
+ apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function EventGraphEditor({ projectPath, apiSettings }: EventGraphEditorProps) {
|
|
|
|
|
+ const [events, setEvents] = useState<EventNode[]>([]);
|
|
|
|
|
+ const [edges, setEdges] = useState<EventEdge[]>([]);
|
|
|
|
|
+ const [allocations, setAllocations] = useState<ChapterAllocation[]>([]);
|
|
|
|
|
+ const [selectedEvent, setSelectedEvent] = useState<EventNode | null>(null);
|
|
|
|
|
+ const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
+ const [validationResult, setValidationResult] = useState<{ errors: string[]; warnings: string[] } | null>(null);
|
|
|
|
|
+ const [aiFeedback, setAiFeedback] = useState<string | null>(null);
|
|
|
|
|
+ const [isAllocating, setIsAllocating] = useState(false);
|
|
|
|
|
+ const [isValidating, setIsValidating] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ // Load event graph
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`)
|
|
|
|
|
+ .then(r => r.json())
|
|
|
|
|
+ .then(data => {
|
|
|
|
|
+ if (data.success) {
|
|
|
|
|
+ setEvents(data.events || []);
|
|
|
|
|
+ setEdges(data.edges || []);
|
|
|
|
|
+ setAllocations(data.chapterAllocations || []);
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {});
|
|
|
|
|
+ }, [projectPath]);
|
|
|
|
|
+
|
|
|
|
|
+ // Save events to server
|
|
|
|
|
+ const saveEvents = useCallback(async (updatedEvents: EventNode[]) => {
|
|
|
|
|
+ setEvents(updatedEvents);
|
|
|
|
|
+ await fetch('/api/novel/event-graph', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ projectPath, events: updatedEvents }),
|
|
|
|
|
+ }).catch(() => {});
|
|
|
|
|
+ }, [projectPath]);
|
|
|
|
|
+
|
|
|
|
|
+ // Save edges to server
|
|
|
|
|
+ const saveEdges = useCallback(async (updatedEdges: EventEdge[]) => {
|
|
|
|
|
+ setEdges(updatedEdges);
|
|
|
|
|
+ await fetch('/api/novel/event-graph', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ projectPath, edges: updatedEdges }),
|
|
|
|
|
+ }).catch(() => {});
|
|
|
|
|
+ }, [projectPath]);
|
|
|
|
|
+
|
|
|
|
|
+ // Add new event
|
|
|
|
|
+ const handleAddEvent = () => {
|
|
|
|
|
+ const node = createEventNode({
|
|
|
|
|
+ title: '新事件',
|
|
|
|
|
+ chronologicalOrder: events.length + 1,
|
|
|
|
|
+ narrativeOrder: events.length + 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ const updated = [...events, node];
|
|
|
|
|
+ saveEvents(updated);
|
|
|
|
|
+ setSelectedEvent(node);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Delete event
|
|
|
|
|
+ const handleDeleteEvent = (id: string) => {
|
|
|
|
|
+ const updatedEvents = events.filter(e => e.id !== id);
|
|
|
|
|
+ const updatedEdges = edges.filter(e => e.sourceId !== id && e.targetId !== id);
|
|
|
|
|
+ setEvents(updatedEvents);
|
|
|
|
|
+ setEdges(updatedEdges);
|
|
|
|
|
+ saveEvents(updatedEvents);
|
|
|
|
|
+ saveEdges(updatedEdges);
|
|
|
|
|
+ if (selectedEvent?.id === id) setSelectedEvent(null);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Narrative reorder
|
|
|
|
|
+ const moveNarrativeUp = (eventId: string) => {
|
|
|
|
|
+ const sorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
|
|
|
|
|
+ const idx = sorted.findIndex(e => e.id === eventId);
|
|
|
|
|
+ if (idx <= 0) return;
|
|
|
|
|
+ const newOrder = [
|
|
|
|
|
+ ...sorted.slice(0, idx - 1).map(e => e.id),
|
|
|
|
|
+ sorted[idx].id,
|
|
|
|
|
+ sorted[idx - 1].id,
|
|
|
|
|
+ ...sorted.slice(idx + 1).map(e => e.id),
|
|
|
|
|
+ ];
|
|
|
|
|
+ const reordered = reorderNarrative(events, newOrder);
|
|
|
|
|
+ saveEvents(reordered);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const moveNarrativeDown = (eventId: string) => {
|
|
|
|
|
+ const sorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
|
|
|
|
|
+ const idx = sorted.findIndex(e => e.id === eventId);
|
|
|
|
|
+ if (idx < 0 || idx >= sorted.length - 1) return;
|
|
|
|
|
+ const newOrder = [
|
|
|
|
|
+ ...sorted.slice(0, idx).map(e => e.id),
|
|
|
|
|
+ sorted[idx + 1].id,
|
|
|
|
|
+ sorted[idx].id,
|
|
|
|
|
+ ...sorted.slice(idx + 2).map(e => e.id),
|
|
|
|
|
+ ];
|
|
|
|
|
+ const reordered = reorderNarrative(events, newOrder);
|
|
|
|
|
+ saveEvents(reordered);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Update selected event field
|
|
|
|
|
+ const updateSelectedEvent = (field: keyof EventNode, value: unknown) => {
|
|
|
|
|
+ if (!selectedEvent) return;
|
|
|
|
|
+ const updated = events.map(e => e.id === selectedEvent.id ? { ...e, [field]: value } : e);
|
|
|
|
|
+ setEvents(updated);
|
|
|
|
|
+ setSelectedEvent({ ...selectedEvent, [field]: value as never });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Auto-save on blur
|
|
|
|
|
+ const handleBlur = () => saveEvents(events);
|
|
|
|
|
+
|
|
|
|
|
+ // AI chapter allocation
|
|
|
|
|
+ const handleAIAllocate = async () => {
|
|
|
|
|
+ if (!apiSettings) return;
|
|
|
|
|
+ setIsAllocating(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/novel/event-graph/allocate', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ projectPath, apiSettings }),
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ if (data.success) setAllocations(data.allocations);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsAllocating(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // AI validation
|
|
|
|
|
+ const handleValidate = async () => {
|
|
|
|
|
+ setIsValidating(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/novel/event-graph/validate', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ projectPath, useAI: !!apiSettings, apiSettings }),
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ if (data.success) {
|
|
|
|
|
+ setValidationResult(data.structural);
|
|
|
|
|
+ setAiFeedback(data.aiFeedback);
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsValidating(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Filter events by search
|
|
|
|
|
+ const filteredEvents = searchQuery
|
|
|
|
|
+ ? events.filter(e => e.title.includes(searchQuery) || e.description.includes(searchQuery))
|
|
|
|
|
+ : events;
|
|
|
|
|
+
|
|
|
|
|
+ const chronologicalSorted = [...events].sort((a, b) => a.chronologicalOrder - b.chronologicalOrder);
|
|
|
|
|
+ const narrativeSorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex h-full bg-white">
|
|
|
|
|
+ {/* Left panel: Event list */}
|
|
|
|
|
+ <div className="w-64 border-r border-gray-200 flex flex-col shrink-0">
|
|
|
|
|
+ <div className="p-3 border-b border-gray-100">
|
|
|
|
|
+ <div className="relative">
|
|
|
|
|
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={searchQuery}
|
|
|
|
|
+ onChange={e => setSearchQuery(e.target.value)}
|
|
|
|
|
+ placeholder="搜索事件..."
|
|
|
|
|
+ className="w-full pl-7 pr-2 py-1.5 text-xs border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto">
|
|
|
|
|
+ {filteredEvents.map(event => {
|
|
|
|
|
+ const alloc = allocations.find(a => a.eventIds.includes(event.id));
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={event.id}
|
|
|
|
|
+ onClick={() => setSelectedEvent(event)}
|
|
|
|
|
+ className={`w-full text-left px-3 py-2 text-xs border-b border-gray-50 hover:bg-gray-50 transition-colors ${
|
|
|
|
|
+ selectedEvent?.id === event.id ? 'bg-indigo-50 border-l-2 border-l-indigo-500' : ''
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="font-medium text-gray-800 truncate">{event.title}</div>
|
|
|
|
|
+ <div className="text-gray-400 mt-0.5 flex items-center gap-1">
|
|
|
|
|
+ <span className={`inline-block w-1.5 h-1.5 rounded-full ${
|
|
|
|
|
+ event.status === 'written' ? 'bg-green-500' : event.status === 'revised' ? 'bg-yellow-500' : 'bg-gray-300'
|
|
|
|
|
+ }`} />
|
|
|
|
|
+ {event.eventType} · {event.timeLabel || '未设置时间'}
|
|
|
|
|
+ {alloc && <span className="text-indigo-400">· 第{alloc.chapterIndex}章</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="p-2 border-t border-gray-200">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleAddEvent}
|
|
|
|
|
+ className="flex items-center gap-1 w-full px-3 py-2 text-xs text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Plus className="h-3.5 w-3.5" />
|
|
|
|
|
+ 添加事件
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Center panel: Dual timeline */}
|
|
|
|
|
+ <div className="flex-1 flex">
|
|
|
|
|
+ <div className="flex-1 border-r border-gray-100 p-4 overflow-y-auto">
|
|
|
|
|
+ <h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">时间顺序</h3>
|
|
|
|
|
+ <div className="space-y-1">
|
|
|
|
|
+ {chronologicalSorted.map(event => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={event.id}
|
|
|
|
|
+ className={`p-2 rounded-lg text-xs cursor-pointer transition-colors ${
|
|
|
|
|
+ selectedEvent?.id === event.id ? 'bg-indigo-100 ring-1 ring-indigo-300' : 'hover:bg-gray-50'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ onClick={() => setSelectedEvent(event)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="font-medium">{event.title}</span>
|
|
|
|
|
+ <span className="text-gray-400 ml-2">{event.timeLabel}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 p-4 overflow-y-auto">
|
|
|
|
|
+ <h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">叙事顺序(可调整)</h3>
|
|
|
|
|
+ <div className="space-y-1">
|
|
|
|
|
+ {narrativeSorted.map(event => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={event.id}
|
|
|
|
|
+ className={`flex items-center gap-1 p-2 rounded-lg text-xs cursor-pointer transition-colors ${
|
|
|
|
|
+ selectedEvent?.id === event.id ? 'bg-indigo-100 ring-1 ring-indigo-300' : 'hover:bg-gray-50'
|
|
|
|
|
+ } ${event.eventType === 'flashback' ? 'border-l-2 border-l-blue-400 pl-1.5' : ''}`}
|
|
|
|
|
+ onClick={() => setSelectedEvent(event)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex flex-col shrink-0 mr-1">
|
|
|
|
|
+ <button onClick={(e) => { e.stopPropagation(); moveNarrativeUp(event.id); }} className="text-gray-300 hover:text-gray-600 leading-none">
|
|
|
|
|
+ <ChevronUp className="h-3 w-3" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button onClick={(e) => { e.stopPropagation(); moveNarrativeDown(event.id); }} className="text-gray-300 hover:text-gray-600 leading-none">
|
|
|
|
|
+ <ChevronDown className="h-3 w-3" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <GripVertical className="h-3 w-3 text-gray-300 shrink-0" />
|
|
|
|
|
+ <span className="font-medium">{event.title}</span>
|
|
|
|
|
+ {event.eventType === 'flashback' && <span className="text-blue-500 ml-1">↓倒叙</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Right panel: Event details */}
|
|
|
|
|
+ <div className="w-80 border-l border-gray-200 p-4 overflow-y-auto shrink-0">
|
|
|
|
|
+ {selectedEvent ? (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h3 className="text-sm font-semibold text-gray-800">事件详情</h3>
|
|
|
|
|
+ <button onClick={() => handleDeleteEvent(selectedEvent.id)} className="text-red-400 hover:text-red-600 transition-colors" title="删除事件">
|
|
|
|
|
+ <Trash2 className="h-4 w-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs text-gray-500">标题</label>
|
|
|
|
|
+ <input type="text" value={selectedEvent.title}
|
|
|
|
|
+ onChange={e => updateSelectedEvent('title', e.target.value)} onBlur={handleBlur}
|
|
|
|
|
+ className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs text-gray-500">描述</label>
|
|
|
|
|
+ <textarea value={selectedEvent.description}
|
|
|
|
|
+ onChange={e => updateSelectedEvent('description', e.target.value)} onBlur={handleBlur} rows={3}
|
|
|
|
|
+ className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300 resize-none" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-2">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs text-gray-500">时间标签</label>
|
|
|
|
|
+ <input type="text" value={selectedEvent.timeLabel}
|
|
|
|
|
+ onChange={e => updateSelectedEvent('timeLabel', e.target.value)} onBlur={handleBlur}
|
|
|
|
|
+ className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs text-gray-500">地点</label>
|
|
|
|
|
+ <input type="text" value={selectedEvent.location}
|
|
|
|
|
+ onChange={e => updateSelectedEvent('location', e.target.value)} onBlur={handleBlur}
|
|
|
|
|
+ className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs text-gray-500">事件类型</label>
|
|
|
|
|
+ <select value={selectedEvent.eventType}
|
|
|
|
|
+ onChange={e => updateSelectedEvent('eventType', e.target.value)} onBlur={handleBlur}
|
|
|
|
|
+ className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300">
|
|
|
|
|
+ <option value="main">主线</option>
|
|
|
|
|
+ <option value="sub">支线</option>
|
|
|
|
|
+ <option value="milestone">里程碑</option>
|
|
|
|
|
+ <option value="flashback">倒叙</option>
|
|
|
|
|
+ <option value="foreshadow">伏笔</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-xs">
|
|
|
|
|
+ <span className="text-gray-500">章节目录:</span>
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const alloc = allocations.find(a => a.eventIds.includes(selectedEvent.id));
|
|
|
|
|
+ return alloc
|
|
|
|
|
+ ? <span className="text-green-600 font-medium">已分配到第 {alloc.chapterIndex} 章</span>
|
|
|
|
|
+ : <span className="text-gray-400">未分配</span>;
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="text-center text-gray-400 text-sm mt-12">选择一个事件查看和编辑详情</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="mt-6 pt-4 border-t border-gray-200 space-y-2">
|
|
|
|
|
+ <button onClick={handleAIAllocate} disabled={isAllocating || !apiSettings}
|
|
|
|
|
+ className="w-full px-3 py-2 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
|
|
|
|
+ {isAllocating ? '分配中...' : 'AI 自动分配章节'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button onClick={handleValidate} disabled={isValidating}
|
|
|
|
|
+ className="w-full px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors">
|
|
|
|
|
+ {isValidating ? '验证中...' : '验证事件图'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {validationResult && (
|
|
|
|
|
+ <div className={`p-3 rounded-lg text-xs ${
|
|
|
|
|
+ validationResult.errors.length > 0 ? 'bg-red-50 text-red-700' :
|
|
|
|
|
+ validationResult.warnings.length > 0 ? 'bg-yellow-50 text-yellow-700' :
|
|
|
|
|
+ 'bg-green-50 text-green-700'
|
|
|
|
|
+ }`}>
|
|
|
|
|
+ {validationResult.errors.map((e, i) => <div key={i}>• {e}</div>)}
|
|
|
|
|
+ {validationResult.warnings.map((w, i) => <div key={i}>• {w}</div>)}
|
|
|
|
|
+ {validationResult.errors.length === 0 && validationResult.warnings.length === 0 && (
|
|
|
|
|
+ <div className="flex items-center gap-1"><CheckCircle className="h-3.5 w-3.5" /> 通过验证</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {aiFeedback && (
|
|
|
|
|
+ <div className="p-3 rounded-lg text-xs bg-blue-50 text-blue-700">{aiFeedback}</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|