import { useState, useEffect, useRef, useMemo } from 'react'; import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio, Descriptions, Collapse } from 'antd'; import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { useChapterSync } from '../store/hooks'; import { projectApi, writingStyleApi } from '../services/api'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import ChapterAnalysis from '../components/ChapterAnalysis'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; const { TextArea } = Input; export default function Chapters() { const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore(); const [isModalOpen, setIsModalOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isContinuing, setIsContinuing] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [editingId, setEditingId] = useState(null); const [form] = Form.useForm(); const [editorForm] = Form.useForm(); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const contentTextAreaRef = useRef(null); const [writingStyles, setWritingStyles] = useState([]); const [selectedStyleId, setSelectedStyleId] = useState(); const [targetWordCount, setTargetWordCount] = useState(3000); const [analysisVisible, setAnalysisVisible] = useState(false); const [analysisChapterId, setAnalysisChapterId] = useState(null); // 分析任务状态管理 const [analysisTasksMap, setAnalysisTasksMap] = useState>({}); const pollingIntervalsRef = useRef>({}); // 单章节生成进度状态 const [singleChapterProgress, setSingleChapterProgress] = useState(0); const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState(''); // 批量生成相关状态 const [batchGenerateVisible, setBatchGenerateVisible] = useState(false); const [batchGenerating, setBatchGenerating] = useState(false); const [batchTaskId, setBatchTaskId] = useState(null); const [batchProgress, setBatchProgress] = useState<{ status: string; total: number; completed: number; current_chapter_number: number | null; estimated_time_minutes?: number; } | null>(null); const batchPollingIntervalRef = useRef(null); useEffect(() => { const handleResize = () => { setIsMobile(window.innerWidth <= 768); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); const { refreshChapters, updateChapter, generateChapterContentStream } = useChapterSync(); useEffect(() => { if (currentProject?.id) { refreshChapters(); loadWritingStyles(); loadAnalysisTasks(); checkAndRestoreBatchTask(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); // 清理轮询定时器 useEffect(() => { return () => { Object.values(pollingIntervalsRef.current).forEach(interval => { clearInterval(interval); }); if (batchPollingIntervalRef.current) { clearInterval(batchPollingIntervalRef.current); } }; }, []); // 加载所有章节的分析任务状态 const loadAnalysisTasks = async () => { if (!chapters || chapters.length === 0) return; const tasksMap: Record = {}; for (const chapter of chapters) { // 只查询有内容的章节 if (chapter.content && chapter.content.trim() !== '') { try { const response = await fetch(`/api/chapters/${chapter.id}/analysis/status`); if (response.ok) { const task: AnalysisTask = await response.json(); tasksMap[chapter.id] = task; // 如果任务正在运行,启动轮询 if (task.status === 'pending' || task.status === 'running') { startPollingTask(chapter.id); } } } catch (error) { // 404或其他错误表示没有分析任务,忽略 console.debug(`章节 ${chapter.id} 暂无分析任务`); } } } setAnalysisTasksMap(tasksMap); }; // 启动单个章节的任务轮询 const startPollingTask = (chapterId: string) => { // 如果已经在轮询,先清除 if (pollingIntervalsRef.current[chapterId]) { clearInterval(pollingIntervalsRef.current[chapterId]); } const interval = window.setInterval(async () => { try { const response = await fetch(`/api/chapters/${chapterId}/analysis/status`); if (!response.ok) return; const task: AnalysisTask = await response.json(); setAnalysisTasksMap(prev => ({ ...prev, [chapterId]: task })); // 任务完成或失败,停止轮询 if (task.status === 'completed' || task.status === 'failed') { clearInterval(pollingIntervalsRef.current[chapterId]); delete pollingIntervalsRef.current[chapterId]; if (task.status === 'completed') { message.success(`章节分析完成`); } else if (task.status === 'failed') { message.error(`章节分析失败: ${task.error_message || '未知错误'}`); } } } catch (error) { console.error('轮询分析任务失败:', error); } }, 2000); pollingIntervalsRef.current[chapterId] = interval; // 5分钟超时 setTimeout(() => { if (pollingIntervalsRef.current[chapterId]) { clearInterval(pollingIntervalsRef.current[chapterId]); delete pollingIntervalsRef.current[chapterId]; } }, 300000); }; const loadWritingStyles = async () => { if (!currentProject?.id) return; try { const response = await writingStyleApi.getProjectStyles(currentProject.id); setWritingStyles(response.styles); // 设置默认风格为初始选中 const defaultStyle = response.styles.find(s => s.is_default); if (defaultStyle) { setSelectedStyleId(defaultStyle.id); } } catch (error) { console.error('加载写作风格失败:', error); message.error('加载写作风格失败'); } }; // 检查并恢复批量生成任务 const checkAndRestoreBatchTask = async () => { if (!currentProject?.id) return; try { const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate/active`); if (!response.ok) return; const data = await response.json(); if (data.has_active_task && data.task) { const task = data.task; // 恢复任务状态 setBatchTaskId(task.batch_id); setBatchProgress({ status: task.status, total: task.total, completed: task.completed, current_chapter_number: task.current_chapter_number, }); setBatchGenerating(true); setBatchGenerateVisible(true); // 启动轮询 startBatchPolling(task.batch_id); message.info('检测到未完成的批量生成任务,已自动恢复'); } } catch (error) { console.error('检查批量生成任务失败:', error); } }; if (!currentProject) return null; const canGenerateChapter = (chapter: Chapter): boolean => { if (chapter.chapter_number === 1) { return true; } const previousChapters = chapters.filter( c => c.chapter_number < chapter.chapter_number ); return previousChapters.every(c => c.content && c.content.trim() !== ''); }; const getGenerateDisabledReason = (chapter: Chapter): string => { if (chapter.chapter_number === 1) { return ''; } const previousChapters = chapters.filter( c => c.chapter_number < chapter.chapter_number ); const incompleteChapters = previousChapters.filter( c => !c.content || c.content.trim() === '' ); if (incompleteChapters.length > 0) { const numbers = incompleteChapters.map(c => c.chapter_number).join('、'); return `需要先完成前置章节:第 ${numbers} 章`; } return ''; }; const handleOpenModal = (id: string) => { const chapter = chapters.find(c => c.id === id); if (chapter) { form.setFieldsValue(chapter); setEditingId(id); setIsModalOpen(true); } }; const handleSubmit = async (values: ChapterUpdate) => { if (!editingId) return; try { await updateChapter(editingId, values); message.success('章节更新成功'); setIsModalOpen(false); form.resetFields(); } catch { message.error('操作失败'); } }; const handleOpenEditor = (id: string) => { const chapter = chapters.find(c => c.id === id); if (chapter) { setCurrentChapter(chapter); editorForm.setFieldsValue({ title: chapter.title, content: chapter.content, }); setEditingId(id); setIsEditorOpen(true); } }; const handleEditorSubmit = async (values: ChapterUpdate) => { if (!editingId || !currentProject) return; try { await updateChapter(editingId, values); // 刷新项目信息以更新总字数统计 const updatedProject = await projectApi.getProject(currentProject.id); setCurrentProject(updatedProject); message.success('章节保存成功'); setIsEditorOpen(false); } catch { message.error('保存失败'); } }; const handleGenerate = async () => { if (!editingId) return; try { setIsContinuing(true); setIsGenerating(true); setSingleChapterProgress(0); setSingleChapterProgressMessage('准备开始生成...'); const result = await generateChapterContentStream( editingId, (content) => { editorForm.setFieldsValue({ content }); if (contentTextAreaRef.current) { const textArea = contentTextAreaRef.current.resizableTextArea?.textArea; if (textArea) { textArea.scrollTop = textArea.scrollHeight; } } }, selectedStyleId, targetWordCount, (progressMsg, progressValue) => { // 进度回调 setSingleChapterProgress(progressValue); setSingleChapterProgressMessage(progressMsg); } ); message.success('AI创作成功,正在分析章节内容...'); // 如果返回了分析任务ID,启动轮询 if (result?.analysis_task_id) { const taskId = result.analysis_task_id; setAnalysisTasksMap(prev => ({ ...prev, [editingId]: { has_task: true, task_id: taskId, chapter_id: editingId, status: 'pending', progress: 0 } })); // 启动轮询 startPollingTask(editingId); } } catch (error) { const apiError = error as ApiError; message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误')); } finally { setIsContinuing(false); setIsGenerating(false); setSingleChapterProgress(0); setSingleChapterProgressMessage(''); } }; const showGenerateModal = (chapter: Chapter) => { const previousChapters = chapters.filter( c => c.chapter_number < chapter.chapter_number ).sort((a, b) => a.chapter_number - b.chapter_number); const selectedStyle = writingStyles.find(s => s.id === selectedStyleId); const modal = Modal.confirm({ title: 'AI创作章节内容', width: 700, centered: true, content: (

AI将根据以下信息创作本章内容:

  • 章节大纲和要求
  • 项目的世界观设定
  • 相关角色信息
  • 前面已完成章节的内容(确保剧情连贯)
  • {selectedStyle && (
  • 写作风格:{selectedStyle.name}
  • )}
  • 目标字数:{targetWordCount}字
{previousChapters.length > 0 && (
📚 将引用的前置章节(共{previousChapters.length}章):
{previousChapters.map(ch => (
✓ 第{ch.chapter_number}章:{ch.title} ({ch.word_count || 0}字)
))}
💡 AI会参考这些章节内容,确保情节连贯、角色状态一致
)}

⚠️ 注意:此操作将覆盖当前章节内容

), okText: '开始创作', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { modal.update({ okButtonProps: { danger: true, loading: true }, cancelButtonProps: { disabled: true }, closable: false, maskClosable: false, keyboard: false, }); try { if (!selectedStyleId) { message.error('请先选择写作风格'); modal.update({ okButtonProps: { danger: true, loading: false }, cancelButtonProps: { disabled: false }, closable: true, maskClosable: true, keyboard: true, }); return; } await handleGenerate(); modal.destroy(); } catch (error) { modal.update({ okButtonProps: { danger: true, loading: false }, cancelButtonProps: { disabled: false }, closable: true, maskClosable: true, keyboard: true, }); } }, onCancel: () => { if (isGenerating) { message.warning('AI正在创作中,请等待完成'); return false; } }, }); }; const getStatusColor = (status: string) => { const colors: Record = { 'draft': 'default', 'writing': 'processing', 'completed': 'success', }; return colors[status] || 'default'; }; const getStatusText = (status: string) => { const texts: Record = { 'draft': '草稿', 'writing': '创作中', 'completed': '已完成', }; return texts[status] || status; }; const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number); // 按大纲分组章节 const groupedChapters = useMemo(() => { const groups: Record = {}; sortedChapters.forEach(chapter => { const key = chapter.outline_id || 'uncategorized'; if (!groups[key]) { groups[key] = { outlineId: chapter.outline_id || null, outlineTitle: chapter.outline_title || '未分类章节', outlineOrder: chapter.outline_order ?? 999, chapters: [] }; } groups[key].chapters.push(chapter); }); // 转换为数组并按大纲顺序排序 return Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder); }, [sortedChapters]); const handleExport = () => { if (chapters.length === 0) { message.warning('当前项目没有章节,无法导出'); return; } Modal.confirm({ title: '导出项目章节', content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`, centered: true, okText: '确定导出', cancelText: '取消', onOk: () => { try { projectApi.exportProject(currentProject.id); message.success('开始下载导出文件'); } catch { message.error('导出失败,请重试'); } }, }); }; const handleShowAnalysis = (chapterId: string) => { setAnalysisChapterId(chapterId); setAnalysisVisible(true); }; // 批量生成函数 const handleBatchGenerate = async (values: { startChapterNumber: number; count: number; enableAnalysis: boolean; styleId?: number; targetWordCount?: number; }) => { if (!currentProject?.id) return; // 使用批量生成对话框中选择的风格和字数,如果没有选择则使用默认值 const styleId = values.styleId || selectedStyleId; const wordCount = values.targetWordCount || targetWordCount; if (!styleId) { message.error('请选择写作风格'); return; } try { setBatchGenerating(true); const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ start_chapter_number: values.startChapterNumber, count: values.count, enable_analysis: values.enableAnalysis, style_id: styleId, target_word_count: wordCount, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || '创建批量生成任务失败'); } const result = await response.json(); setBatchTaskId(result.batch_id); setBatchProgress({ status: 'running', total: result.chapters_to_generate.length, completed: 0, current_chapter_number: values.startChapterNumber, estimated_time_minutes: result.estimated_time_minutes, }); message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`); // 开始轮询任务状态 startBatchPolling(result.batch_id); } catch (error: any) { message.error('创建批量生成任务失败:' + (error.message || '未知错误')); setBatchGenerating(false); setBatchGenerateVisible(false); } }; // 轮询批量生成任务状态 const startBatchPolling = (taskId: string) => { if (batchPollingIntervalRef.current) { clearInterval(batchPollingIntervalRef.current); } const poll = async () => { try { const response = await fetch(`/api/chapters/batch-generate/${taskId}/status`); if (!response.ok) return; const status = await response.json(); setBatchProgress({ status: status.status, total: status.total, completed: status.completed, current_chapter_number: status.current_chapter_number, }); // 任务完成或失败,停止轮询 if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') { if (batchPollingIntervalRef.current) { clearInterval(batchPollingIntervalRef.current); batchPollingIntervalRef.current = null; } setBatchGenerating(false); if (status.status === 'completed') { message.success(`批量生成完成!成功生成 ${status.completed} 章`); // 刷新章节列表 refreshChapters(); loadAnalysisTasks(); } else if (status.status === 'failed') { message.error(`批量生成失败:${status.error_message || '未知错误'}`); } else if (status.status === 'cancelled') { message.warning('批量生成已取消'); } // 延迟关闭对话框,让用户看到最终状态 setTimeout(() => { setBatchGenerateVisible(false); setBatchTaskId(null); setBatchProgress(null); }, 2000); } } catch (error) { console.error('轮询批量生成状态失败:', error); } }; // 立即执行一次 poll(); // 每2秒轮询一次 batchPollingIntervalRef.current = window.setInterval(poll, 2000); }; // 取消批量生成 const handleCancelBatchGenerate = async () => { if (!batchTaskId) return; try { const response = await fetch(`/api/chapters/batch-generate/${batchTaskId}/cancel`, { method: 'POST', }); if (!response.ok) { throw new Error('取消失败'); } message.success('批量生成已取消'); } catch (error: any) { message.error('取消失败:' + (error.message || '未知错误')); } }; // 打开批量生成对话框 const handleOpenBatchGenerate = () => { // 找到第一个未生成的章节 const firstIncompleteChapter = sortedChapters.find( ch => !ch.content || ch.content.trim() === '' ); if (!firstIncompleteChapter) { message.info('所有章节都已生成内容'); return; } // 检查该章节是否可以生成 if (!canGenerateChapter(firstIncompleteChapter)) { const reason = getGenerateDisabledReason(firstIncompleteChapter); message.warning(reason); return; } setBatchGenerateVisible(true); }; // 渲染分析状态标签 const renderAnalysisStatus = (chapterId: string) => { const task = analysisTasksMap[chapterId]; if (!task) { return null; } switch (task.status) { case 'pending': return ( } color="processing"> 等待分析 ); case 'running': return ( } color="processing"> 分析中 {task.progress}% ); case 'completed': return ( } color="success"> 已分析 ); case 'failed': return ( } color="error"> 分析失败 ); default: return null; } }; // 显示展开规划详情 const showExpansionPlanModal = (chapter: Chapter) => { if (!chapter.expansion_plan) return; try { const planData: ExpansionPlanData = JSON.parse(chapter.expansion_plan); Modal.info({ title: ( 第{chapter.chapter_number}章展开规划 ), width: 800, content: (
{chapter.title} {planData.emotional_tone} {planData.conflict_type} {planData.estimated_words}字 {planData.narrative_goal} {planData.key_events.map((event, idx) => (
{idx + 1} {event}
))}
{planData.character_focus.map((char, idx) => ( {char} ))} {planData.scenes && planData.scenes.length > 0 && ( {planData.scenes.map((scene, idx) => (
📍 地点:{scene.location}
👥 角色: {scene.characters.map((char, charIdx) => ( {char} ))}
🎯 目的:{scene.purpose}
))}
)}
), okText: '关闭', }); } catch (error) { console.error('解析展开规划失败:', error); message.error('展开规划数据格式错误'); } }; return (

章节管理

{!isMobile && 章节由大纲管理,请在大纲页面添加/删除}
{chapters.length === 0 ? ( ) : ( idx.toString())} expandIcon={({ isActive }) => } style={{ background: 'transparent' }} > {groupedChapters.map((group, groupIndex) => ( {group.outlineId ? `📖 大纲 ${group.outlineOrder}` : '📝 未分类'} {group.outlineTitle} sum + (ch.word_count || 0), 0)} 字`} style={{ backgroundColor: '#1890ff' }} />
} style={{ marginBottom: 16, background: '#fff', borderRadius: 8, border: '1px solid #f0f0f0', }} > ( } onClick={() => handleOpenEditor(item.id)} > 编辑内容 , (() => { const task = analysisTasksMap[item.id]; const isAnalyzing = task && (task.status === 'pending' || task.status === 'running'); const hasContent = item.content && item.content.trim() !== ''; return ( ); })(), , ]} >
} title={
第{item.chapter_number}章:{item.title} {getStatusText(item.status)} {renderAnalysisStatus(item.id)} {item.expansion_plan && ( } color="blue"> 已展开 )} {!canGenerateChapter(item) && ( } color="warning"> 需前置章节 )} {item.expansion_plan && ( { e.stopPropagation(); showExpansionPlanModal(item); }} /> )}
} description={ item.content ? (
{item.content.substring(0, isMobile ? 80 : 150)} {item.content.length > (isMobile ? 80 : 150) && '...'}
) : ( 暂无内容 ) } /> {isMobile && (
)} /> ))} )}
setIsModalOpen(false)} footer={null} centered={!isMobile} width={isMobile ? 'calc(100% - 32px)' : 520} style={isMobile ? { top: 20, paddingBottom: 0, maxWidth: 'calc(100vw - 32px)', margin: '0 16px' } : undefined} styles={{ body: { maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)', overflowY: 'auto' } }} >
{ if (isGenerating) { message.warning('AI正在创作中,请等待完成后再关闭'); return; } setIsEditorOpen(false); }} closable={!isGenerating} maskClosable={!isGenerating} keyboard={!isGenerating} width={isMobile ? 'calc(100% - 32px)' : '85%'} centered={!isMobile} style={isMobile ? { top: 20, paddingBottom: 0, maxWidth: 'calc(100vw - 32px)', margin: '0 16px' } : undefined} styles={{ body: { maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 110px)', overflowY: 'auto', padding: isMobile ? '16px 12px' : '8px' } }} footer={null} >
{editingId && (() => { const currentChapter = chapters.find(c => c.id === editingId); const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false; const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : ''; return ( ); })()} {!selectedStyleId && (
请选择写作风格
)}
setTargetWordCount(value || 3000)} size="large" disabled={isGenerating} style={{ width: '100%' }} formatter={(value) => `${value} 字`} parser={(value) => value?.replace(' 字', '') as any} />
建议范围:500-10000字,默认3000字