import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd'; import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { eventBus } from '../store/eventBus'; import { useChapterSync } from '../store/hooks'; import { generateChapterBackground } from '../services/backgroundTaskService'; import { projectApi, writingStyleApi, chapterApi } from '../services/api'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import ChapterAnalysis from '../components/ChapterAnalysis'; import ExpansionPlanEditor from '../components/ExpansionPlanEditor'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; import ChapterReader from '../components/ChapterReader'; import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar'; import PartialRegenerateModal from '../components/PartialRegenerateModal'; const { TextArea } = Input; // localStorage 缓存键名 const WORD_COUNT_CACHE_KEY = 'chapter_default_word_count'; const DEFAULT_WORD_COUNT = 3000; // 从 localStorage 读取缓存的字数 const getCachedWordCount = (): number => { try { const cached = localStorage.getItem(WORD_COUNT_CACHE_KEY); if (cached) { const value = parseInt(cached, 10); if (!isNaN(value) && value >= 500 && value <= 10000) { return value; } } } catch (error) { console.warn('读取字数缓存失败:', error); } return DEFAULT_WORD_COUNT; }; // 保存字数到 localStorage const setCachedWordCount = (value: number): void => { try { localStorage.setItem(WORD_COUNT_CACHE_KEY, String(value)); } catch (error) { console.warn('保存字数缓存失败:', error); } }; export default function Chapters() { const { currentProject, chapters, outlines, setCurrentChapter, setCurrentProject } = useStore(); const [modal, contextHolder] = Modal.useModal(); const { token } = theme.useToken(); 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(getCachedWordCount); const [availableModels, setAvailableModels] = useState>([]); const [selectedModel, setSelectedModel] = useState(); const [batchSelectedModel, setBatchSelectedModel] = useState(); // 批量生成的模型选择 const [temporaryNarrativePerspective, setTemporaryNarrativePerspective] = useState(); // 临时人称选择 const [analysisVisible, setAnalysisVisible] = useState(false); const [analysisChapterId, setAnalysisChapterId] = useState(null); // 分析任务状态管理 const [analysisTasksMap, setAnalysisTasksMap] = useState>({}); const analysisPollingIntervalRef = useRef(null); const activeAnalysisPollingIdsRef = useRef>(new Set()); // 列表查询与分页状态 const [chapterSearchKeyword, setChapterSearchKeyword] = useState(''); const [chapterPage, setChapterPage] = useState(1); const [chapterPageSize, setChapterPageSize] = useState(20); // 阅读器状态 const [readerVisible, setReaderVisible] = useState(false); const [readingChapter, setReadingChapter] = useState(null); // 规划编辑状态 const [planEditorVisible, setPlanEditorVisible] = useState(false); const [editingPlanChapter, setEditingPlanChapter] = useState(null); // 局部重写状态 const [partialRegenerateToolbarVisible, setPartialRegenerateToolbarVisible] = useState(false); const [partialRegenerateToolbarPosition, setPartialRegenerateToolbarPosition] = useState({ top: 0, left: 0 }); const [selectedTextForRegenerate, setSelectedTextForRegenerate] = useState(''); const [selectionStartPosition, setSelectionStartPosition] = useState(0); const [selectionEndPosition, setSelectionEndPosition] = useState(0); const [partialRegenerateModalVisible, setPartialRegenerateModalVisible] = useState(false); // 单章节生成进度状态 const [singleChapterProgress, setSingleChapterProgress] = useState(0); const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState(''); // 批量生成相关状态 const [batchGenerateVisible, setBatchGenerateVisible] = useState(false); const [batchGenerating, setBatchGenerating] = useState(false); const [batchAnalyzingUnanalyzed, setBatchAnalyzingUnanalyzed] = useState(false); const [batchTaskId, setBatchTaskId] = useState(null); const [batchForm] = Form.useForm(); const [manualCreateForm] = Form.useForm(); 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 handleTextSelection = useCallback(() => { // 只在编辑器打开时处理选中 if (!isEditorOpen || isGenerating) { setPartialRegenerateToolbarVisible(false); return; } const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { setPartialRegenerateToolbarVisible(false); return; } const selectedText = selection.toString().trim(); // 至少选中10个字符才显示工具栏 if (selectedText.length < 10) { setPartialRegenerateToolbarVisible(false); return; } // 检查选中是否在 TextArea 内 const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea; if (!textArea) { setPartialRegenerateToolbarVisible(false); return; } // 检查选中是否在 textarea 内(需要特殊处理,因为 textarea 的选中不会创建 range) if (document.activeElement !== textArea) { setPartialRegenerateToolbarVisible(false); return; } // 获取 textarea 中的选中位置 const start = textArea.selectionStart; const end = textArea.selectionEnd; const textContent = textArea.value; const selectedInTextArea = textContent.substring(start, end); if (selectedInTextArea.trim().length < 10) { setPartialRegenerateToolbarVisible(false); return; } // 计算浮动工具栏位置 const rect = textArea.getBoundingClientRect(); const computedStyle = window.getComputedStyle(textArea); const lineHeight = parseFloat(computedStyle.lineHeight) || 24; const paddingTop = parseFloat(computedStyle.paddingTop) || 0; // 计算选中文本起始位置所在的行号 const textBeforeSelection = textContent.substring(0, start); const startLine = textBeforeSelection.split('\n').length - 1; // 计算选中文本在 textarea 中的视觉位置 // 需要考虑 scrollTop(textarea 内部滚动偏移) const scrollTop = textArea.scrollTop; const visualTop = (startLine * lineHeight) + paddingTop - scrollTop; // 工具栏位置:textarea 顶部 + 选中文本的视觉位置 - 工具栏高度偏移 const toolbarTop = rect.top + visualTop - 45; // 水平位置:放在 textarea 的右侧区域,避免遮挡文本 const toolbarLeft = rect.right - 180; setSelectedTextForRegenerate(selectedInTextArea); setSelectionStartPosition(start); setSelectionEndPosition(end); // 计算工具栏位置,如果选中位置不在可视区域内,固定在边缘 let finalTop = toolbarTop; if (visualTop < 0) { finalTop = rect.top + 10; } else if (visualTop > textArea.clientHeight) { finalTop = rect.bottom - 50; } setPartialRegenerateToolbarPosition({ top: Math.max(rect.top + 10, Math.min(finalTop, rect.bottom - 50)), left: Math.min(Math.max(rect.left + 20, toolbarLeft), window.innerWidth - 200), }); setPartialRegenerateToolbarVisible(true); }, [isEditorOpen, isGenerating]); // 更新工具栏位置的函数(不检测选中,只更新位置) const updateToolbarPosition = useCallback(() => { if (!partialRegenerateToolbarVisible || !selectedTextForRegenerate) return; const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea; if (!textArea) return; const rect = textArea.getBoundingClientRect(); const computedStyle = window.getComputedStyle(textArea); const lineHeight = parseFloat(computedStyle.lineHeight) || 24; const paddingTop = parseFloat(computedStyle.paddingTop) || 0; const textContent = textArea.value; const textBeforeSelection = textContent.substring(0, selectionStartPosition); const startLine = textBeforeSelection.split('\n').length - 1; const scrollTop = textArea.scrollTop; const visualTop = (startLine * lineHeight) + paddingTop - scrollTop; const toolbarTop = rect.top + visualTop - 45; // 固定在 textarea 右上角,不随选中位置变化 const toolbarLeft = rect.right - 180; // 工具栏固定在 textarea 可视区域内,即使选中文本滚出视野也保持显示 // 如果选中位置在可视区域内,跟随选中位置 // 如果滚出视野,固定在顶部或底部边缘 let finalTop = toolbarTop; if (visualTop < 0) { // 选中位置在上方视野外,工具栏固定在顶部 finalTop = rect.top + 10; } else if (visualTop > textArea.clientHeight) { // 选中位置在下方视野外,工具栏固定在底部 finalTop = rect.bottom - 50; } setPartialRegenerateToolbarPosition({ top: Math.max(rect.top + 10, Math.min(finalTop, rect.bottom - 50)), left: Math.min(Math.max(rect.left + 20, toolbarLeft), window.innerWidth - 200), }); }, [partialRegenerateToolbarVisible, selectedTextForRegenerate, selectionStartPosition]); // 监听选中事件 useEffect(() => { if (!isEditorOpen) return; const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea; if (!textArea) return; const handleMouseUp = () => { // 鼠标释放时检查选中 setTimeout(handleTextSelection, 50); }; const handleKeyUp = (e: KeyboardEvent) => { // Shift + 方向键选中时检查 if (e.shiftKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { setTimeout(handleTextSelection, 50); } }; const handleScroll = () => { // 滚动时更新位置(使用 requestAnimationFrame 优化性能) requestAnimationFrame(updateToolbarPosition); }; // 监听 textarea 滚动 textArea.addEventListener('mouseup', handleMouseUp); textArea.addEventListener('keyup', handleKeyUp); textArea.addEventListener('scroll', handleScroll); // 同时监听 Modal body 滚动(Modal 内容可能在外层容器滚动) const modalBody = textArea.closest('.ant-modal-body'); if (modalBody) { modalBody.addEventListener('scroll', handleScroll); } // 监听窗口大小变化 window.addEventListener('resize', handleScroll); return () => { textArea.removeEventListener('mouseup', handleMouseUp); textArea.removeEventListener('keyup', handleKeyUp); textArea.removeEventListener('scroll', handleScroll); if (modalBody) { modalBody.removeEventListener('scroll', handleScroll); } window.removeEventListener('resize', handleScroll); }; }, [isEditorOpen, handleTextSelection, updateToolbarPosition]); // 点击其他区域时隐藏工具栏 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; // 如果点击的是工具栏,不隐藏 if (target.closest('[data-partial-regenerate-toolbar]')) { return; } // 如果点击的是 textarea,不隐藏 if (target.tagName === 'TEXTAREA') { return; } // 如果点击的是 Modal 内部(包括滚动条),不隐藏 if (target.closest('.ant-modal-content')) { return; } // 点击 Modal 外部才隐藏工具栏 setPartialRegenerateToolbarVisible(false); }; if (partialRegenerateToolbarVisible) { document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); } }, [partialRegenerateToolbarVisible]); const { refreshChapters, updateChapter, deleteChapter, generateChapterContentStream } = useChapterSync(); useEffect(() => { if (currentProject?.id) { refreshChapters(); loadWritingStyles(); loadAnalysisTasks(); checkAndRestoreBatchTask(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); // 清理轮询定时器 useEffect(() => { const batchPollingInterval = batchPollingIntervalRef.current; return () => { if (analysisPollingIntervalRef.current) { clearInterval(analysisPollingIntervalRef.current); analysisPollingIntervalRef.current = null; } if (batchPollingInterval) { clearInterval(batchPollingInterval); } }; }, []); const clearAnalysisPollingIfIdle = useCallback(() => { if (activeAnalysisPollingIdsRef.current.size === 0 && analysisPollingIntervalRef.current) { clearInterval(analysisPollingIntervalRef.current); analysisPollingIntervalRef.current = null; } }, []); const pollActiveAnalysisTasks = useCallback(async () => { if (!currentProject?.id) return; const activeIds = Array.from(activeAnalysisPollingIdsRef.current); if (activeIds.length === 0) { clearAnalysisPollingIfIdle(); return; } try { const response = await chapterApi.getBatchAnalysisStatuses(currentProject.id, activeIds); const tasksMap = response.items || {}; setAnalysisTasksMap(prev => ({ ...prev, ...tasksMap, })); activeIds.forEach((chapterId) => { const task = tasksMap[chapterId]; if (!task || task.status === 'completed' || task.status === 'failed' || task.status === 'none') { activeAnalysisPollingIdsRef.current.delete(chapterId); if (task?.status === 'completed') { message.success('章节分析完成'); } else if (task?.status === 'failed') { message.error(`章节分析失败: ${task.error_message || '未知错误'}`); } } }); clearAnalysisPollingIfIdle(); } catch (error) { console.error('批量轮询分析任务失败:', error); } }, [clearAnalysisPollingIfIdle, currentProject?.id]); const ensureAnalysisPolling = useCallback(() => { if (analysisPollingIntervalRef.current) return; analysisPollingIntervalRef.current = window.setInterval(() => { void pollActiveAnalysisTasks(); }, 2000); // 立即执行一次 void pollActiveAnalysisTasks(); }, [pollActiveAnalysisTasks]); // 加载所有章节的分析任务状态(批量接口,避免逐章请求风暴) // 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题 const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => { const targetChapters = chaptersToLoad || chapters; if (!targetChapters || targetChapters.length === 0 || !currentProject?.id) return; const chapterIds = targetChapters .filter(chapter => chapter.content && chapter.content.trim() !== '') .map(chapter => chapter.id); if (chapterIds.length === 0) { setAnalysisTasksMap({}); activeAnalysisPollingIdsRef.current.clear(); clearAnalysisPollingIfIdle(); return; } try { const response = await chapterApi.getBatchAnalysisStatuses(currentProject.id, chapterIds); const tasksMap = response.items || {}; setAnalysisTasksMap(tasksMap); activeAnalysisPollingIdsRef.current.clear(); Object.entries(tasksMap).forEach(([chapterId, task]) => { if (task?.status === 'pending' || task?.status === 'running') { activeAnalysisPollingIdsRef.current.add(chapterId); } }); if (activeAnalysisPollingIdsRef.current.size > 0) { ensureAnalysisPolling(); } else { clearAnalysisPollingIfIdle(); } } catch (error) { console.error('批量加载分析任务状态失败:', error); } }; // 启动单个章节的任务轮询(内部合并到批量轮询) const startPollingTask = (chapterId: string) => { activeAnalysisPollingIdsRef.current.add(chapterId); ensureAnalysisPolling(); }; 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 loadAvailableModels = async () => { try { // 从设置API获取用户配置的模型列表 const settingsResponse = await fetch('/api/settings'); if (settingsResponse.ok) { const settings = await settingsResponse.json(); const { api_key, api_base_url, api_provider } = settings; if (api_key && api_base_url) { try { const modelsResponse = await fetch( `/api/settings/models?api_key=${encodeURIComponent(api_key)}&api_base_url=${encodeURIComponent(api_base_url)}&provider=${api_provider}` ); if (modelsResponse.ok) { const data = await modelsResponse.json(); if (data.models && data.models.length > 0) { setAvailableModels(data.models); // 设置默认模型为当前配置的模型 setSelectedModel(settings.llm_model); return settings.llm_model; // 返回模型名称 } } } catch { console.log('获取模型列表失败,将使用默认模型'); } } } } catch (error) { console.error('加载可用模型失败:', error); } return null; }; // 检查并恢复批量生成任务 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; // 恢复任务状态(只在顶部进度条显示,不弹出Modal) 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),避免弹出Modal遮挡页面 // 启动轮询 startBatchPolling(task.batch_id); message.info('检测到未完成的批量生成任务,请查看任务列表'); } } catch (error) { console.error('检查批量生成任务失败:', error); } }; // 🔔 显示浏览器通知 const showBrowserNotification = (title: string, body: string, type: 'success' | 'error' | 'info' = 'info') => { // 检查浏览器是否支持通知 if (!('Notification' in window)) { console.log('浏览器不支持通知功能'); return; } // 检查通知权限 if (Notification.permission === 'granted') { // 选择图标 const icon = type === 'success' ? '/logo.svg' : type === 'error' ? '/favicon.ico' : '/logo.svg'; const notification = new Notification(title, { body, icon, badge: '/favicon.ico', tag: 'batch-generation', // 相同tag会替换旧通知 requireInteraction: false, // 自动关闭 silent: false, // 播放提示音 }); // 点击通知时聚焦到窗口 notification.onclick = () => { window.focus(); notification.close(); }; // 5秒后自动关闭 setTimeout(() => { notification.close(); }, 5000); } else if (Notification.permission !== 'denied') { // 如果权限未被明确拒绝,尝试请求权限 Notification.requestPermission().then(permission => { if (permission === 'granted') { showBrowserNotification(title, body, type); } }); } }; // 按章节号排序并按大纲分组章节 (必须在早返回之前调用,避免违反 Hooks 规则) const { sortedChapters } = useMemo(() => { const sorted = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number); const groups: Record = {}; sorted.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 { sortedChapters: sorted }; }, [chapters]); // 章节查询过滤(前端过滤,减少渲染压力) const filteredSortedChapters = useMemo(() => { const keyword = chapterSearchKeyword.trim().toLowerCase(); if (!keyword) return sortedChapters; return sortedChapters.filter((chapter) => { return ( String(chapter.chapter_number).includes(keyword) || chapter.title.toLowerCase().includes(keyword) || (chapter.outline_title || '').toLowerCase().includes(keyword) ); }); }, [sortedChapters, chapterSearchKeyword]); // 分页后的扁平章节 const pagedSortedChapters = useMemo(() => { const start = (chapterPage - 1) * chapterPageSize; return filteredSortedChapters.slice(start, start + chapterPageSize); }, [filteredSortedChapters, chapterPage, chapterPageSize]); // one-to-many 模式分页后再按大纲分组 const pagedGroupedChapters = useMemo(() => { const groups: Record = {}; pagedSortedChapters.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); }, [pagedSortedChapters]); // 搜索词或分页大小变化时重置到第一页 useEffect(() => { setChapterPage(1); }, [chapterSearchKeyword, chapterPageSize, currentProject?.outline_mode]); // 数据变化导致页码越界时自动纠正 useEffect(() => { const maxPage = Math.max(1, Math.ceil(filteredSortedChapters.length / chapterPageSize)); if (chapterPage > maxPage) { setChapterPage(maxPage); } }, [filteredSortedChapters.length, chapterPage, chapterPageSize]); // 预计算每章可生成状态,避免在渲染阶段重复 O(n²) 扫描 const chapterGenerateGateMap = useMemo(() => { const gateMap: Record = {}; const incompleteChapterNumbers: number[] = []; const unanalyzedChapters: Array<{ chapterNumber: number; reason: string }> = []; sortedChapters.forEach((chapter) => { if (incompleteChapterNumbers.length > 0) { gateMap[chapter.id] = { canGenerate: false, reason: `需要先完成前置章节:第 ${incompleteChapterNumbers.join('、')} 章` }; } else if (unanalyzedChapters.length > 0) { gateMap[chapter.id] = { canGenerate: false, reason: `需要先分析前置章节:第 ${unanalyzedChapters.map(c => c.chapterNumber).join('、')} 章 (${unanalyzedChapters.map(c => c.reason).join('、')})` }; } else { gateMap[chapter.id] = { canGenerate: true, reason: '' }; } // 将当前章纳入“后续章节”的前置条件 if (!chapter.content || chapter.content.trim() === '') { incompleteChapterNumbers.push(chapter.chapter_number); } const task = analysisTasksMap[chapter.id]; if (!task || !task.has_task) { unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '未分析' }); } else if (task.status === 'pending') { unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '等待分析' }); } else if (task.status === 'running') { unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '分析中' }); } else if (task.status === 'failed') { unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '分析失败' }); } else if (task.status !== 'completed') { unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '状态未知' }); } }); return gateMap; }, [sortedChapters, analysisTasksMap]); // 当前可被“一键分析”的章节(有内容且未处于完成/进行中) const batchAnalyzableChapterCount = useMemo(() => { return sortedChapters.filter((chapter) => { if (!chapter.content || chapter.content.trim() === '') return false; const task = analysisTasksMap[chapter.id]; if (!task || !task.has_task) return true; return task.status !== 'completed' && task.status !== 'pending' && task.status !== 'running'; }).length; }, [sortedChapters, analysisTasksMap]); if (!currentProject) return null; // 获取人称的中文显示文本(同时支持中英文值) const getNarrativePerspectiveText = (perspective?: string): string => { const texts: Record = { // 英文值映射(向后兼容) 'first_person': '第一人称(我)', 'third_person': '第三人称(他/她)', 'omniscient': '全知视角', // 中文值映射(项目设置使用) '第一人称': '第一人称(我)', '第三人称': '第三人称(他/她)', '全知视角': '全知视角', }; return texts[perspective || ''] || '第三人称(默认)'; }; const canGenerateChapter = (chapter: Chapter): boolean => { return chapterGenerateGateMap[chapter.id]?.canGenerate ?? true; }; const getGenerateDisabledReason = (chapter: Chapter): string => { return chapterGenerateGateMap[chapter.id]?.reason || ''; }; 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); // 刷新章节列表以获取完整的章节数据(包括outline_title等联查字段) await refreshChapters(); 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); setTemporaryNarrativePerspective(undefined); // 重置人称选择 setIsEditorOpen(true); // 打开编辑窗口时加载模型列表 loadAvailableModels(); } }; 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); }, selectedModel, // 传递选中的模型 temporaryNarrativePerspective // 传递临时人称参数 ); 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 instance = 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 () => { instance.update({ okButtonProps: { danger: true, loading: true }, cancelButtonProps: { disabled: true }, closable: false, maskClosable: false, keyboard: false, }); try { if (!selectedStyleId) { message.error('请先选择写作风格'); instance.update({ okButtonProps: { danger: true, loading: false }, cancelButtonProps: { disabled: false }, closable: true, maskClosable: true, keyboard: true, }); return; } await handleGenerate(); instance.destroy(); } catch { instance.update({ okButtonProps: { danger: true, loading: false }, cancelButtonProps: { disabled: false }, closable: true, maskClosable: true, keyboard: true, }); } }, onCancel: () => { if (isGenerating) { message.warning('AI正在创作中,请等待完成'); return false; } }, }); }; // 后台生成章节(关闭浏览器也不影响) // 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示 const handleBackgroundGenerate = async () => { if (!editingId) return; if (!selectedStyleId) { message.error("请先选择写作风格"); return; } try { await generateChapterBackground( editingId, { style_id: selectedStyleId, target_word_count: targetWordCount, model: selectedModel, narrative_perspective: temporaryNarrativePerspective, }, () => { // 进度更新由悬浮任务框处理,无需额外操作 }, (_) => { message.success("后台章节生成完成!"); refreshChapters(); if (currentProject) { projectApi.getProject(currentProject.id).then(setCurrentProject).catch(console.error); } loadAnalysisTasks(); }, (error) => { message.error("后台生成失败: " + error); } ); message.info("章节生成任务已提交,可在右下角任务面板查看进度"); // 通知悬浮任务框刷新 eventBus.emit('background-task-created'); } catch (error) { message.error("创建后台任务失败"); } }; const getStatusColor = (status: string) => { const colors: Record = { 'draft': 'default', 'pending': 'warning', 'writing': 'processing', 'completed': 'success', }; return colors[status] || 'default'; }; const getStatusText = (status: string) => { const texts: Record = { 'draft': '草稿', 'pending': '待处理', 'writing': '创作中', 'completed': '已完成', }; return texts[status] || status; }; 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 handleBatchAnalyzeUnanalyzed = async () => { if (!currentProject?.id) return; try { setBatchAnalyzingUnanalyzed(true); const result = await chapterApi.batchAnalyzeUnanalyzed(currentProject.id); if (result.total_started > 0) { setAnalysisTasksMap((prev) => ({ ...prev, ...result.started_tasks, })); Object.keys(result.started_tasks).forEach((chapterId) => { startPollingTask(chapterId); }); message.success( `已加入 ${result.total_started} 章顺序分析队列(跳过已分析 ${result.total_already_completed} 章,分析中/排队中 ${result.total_skipped_running} 章)` ); } else { message.info('没有可启动分析的章节:当前章节要么无内容、要么已分析完成、要么正在分析中'); } // 刷新一次状态,确保前端与后端一致 await loadAnalysisTasks(); } catch (error: unknown) { const err = error as Error; message.error(`一键分析失败:${err.message || '未知错误'}`); } finally { setBatchAnalyzingUnanalyzed(false); } }; // 批量生成函数 const handleBatchGenerate = async (values: { startChapterNumber: number; count: number; enableAnalysis: boolean; styleId?: number; targetWordCount?: number; model?: string; }) => { if (!currentProject?.id) return; // 调试日志 console.log('[批量生成] 表单values:', values); console.log('[批量生成] batchSelectedModel状态:', batchSelectedModel); // 使用批量生成对话框中选择的风格和字数,如果没有选择则使用默认值 const styleId = values.styleId || selectedStyleId; const wordCount = values.targetWordCount || targetWordCount; // 使用批量生成专用的模型状态 const model = batchSelectedModel; console.log('[批量生成] 最终使用的model:', model); if (!styleId) { message.error('请选择写作风格'); return; } try { setBatchGenerating(true); setBatchGenerateVisible(false); // 关闭配置对话框,任务进度在悬浮任务框中显示 const requestBody: { start_chapter_number: number; count: number; enable_analysis: boolean; style_id: number; target_word_count: number; model?: string; } = { start_chapter_number: values.startChapterNumber, count: values.count, enable_analysis: true, style_id: styleId, target_word_count: wordCount, }; // 如果有模型参数,添加到请求体中 if (model) { requestBody.model = model; console.log('[批量生成] 请求体包含model:', model); } else { console.log('[批量生成] 请求体不包含model,使用后端默认模型'); } console.log('[批量生成] 完整请求体:', JSON.stringify(requestBody, null, 2)); const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); 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} 分钟,可在右下角任务面板查看进度`); // 通知悬浮任务框刷新 eventBus.emit('background-task-created'); // 🔔 触发浏览器通知(任务开始) showBrowserNotification( '批量生成已启动', `开始生成 ${result.chapters_to_generate.length} 章,预计需要 ${result.estimated_time_minutes} 分钟`, 'info' ); // 开始轮询任务状态 startBatchPolling(result.batch_id); } catch (error: unknown) { const err = error as Error; message.error('创建批量生成任务失败:' + (err.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, }); // 每次轮询时刷新章节列表和分析状态,实时显示新生成的章节和分析进度 // 使用 await 确保获取最新章节列表后再加载分析任务状态 if (status.completed > 0) { const latestChapters = await refreshChapters(); await loadAnalysisTasks(latestChapters); // 刷新项目信息以实时更新总字数统计 if (currentProject?.id) { const updatedProject = await projectApi.getProject(currentProject.id); setCurrentProject(updatedProject); } } // 任务完成或失败,停止轮询 if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') { if (batchPollingIntervalRef.current) { clearInterval(batchPollingIntervalRef.current); batchPollingIntervalRef.current = null; } setBatchGenerating(false); // 立即刷新章节列表和分析任务状态(在显示消息前) // 使用 refreshChapters 返回的最新章节列表传递给 loadAnalysisTasks const finalChapters = await refreshChapters(); await loadAnalysisTasks(finalChapters); // 刷新项目信息以更新总字数统计 if (currentProject?.id) { const updatedProject = await projectApi.getProject(currentProject.id); setCurrentProject(updatedProject); } if (status.status === 'completed') { message.success(`批量生成完成!成功生成 ${status.completed} 章`); // 🔔 触发浏览器通知 showBrowserNotification( '批量生成完成', `《${currentProject?.title || '项目'}》成功生成 ${status.completed} 章节`, 'success' ); } else if (status.status === 'failed') { message.error(`批量生成失败:${status.error_message || '未知错误'}`); // 🔔 触发浏览器通知 showBrowserNotification( '批量生成失败', status.error_message || '未知错误', 'error' ); } 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('批量生成已取消'); // 取消后立即刷新章节列表和分析任务,显示已生成的章节 await refreshChapters(); await loadAnalysisTasks(); // 刷新项目信息以更新总字数统计 if (currentProject?.id) { const updatedProject = await projectApi.getProject(currentProject.id); setCurrentProject(updatedProject); } } catch (error: unknown) { const err = error as Error; message.error('取消失败:' + (err.message || '未知错误')); } }; // 打开批量生成对话框 const handleOpenBatchGenerate = async () => { // 找到第一个未生成的章节 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; } // 打开对话框时加载模型列表,等待完成 const defaultModel = await loadAvailableModels(); console.log('[打开批量生成] defaultModel:', defaultModel); console.log('[打开批量生成] selectedStyleId:', selectedStyleId); // 设置批量生成的模型选择状态 setBatchSelectedModel(defaultModel || undefined); // 重置表单并设置初始值(使用缓存的字数) batchForm.setFieldsValue({ startChapterNumber: firstIncompleteChapter.chapter_number, count: 5, enableAnalysis: false, styleId: selectedStyleId, targetWordCount: getCachedWordCount(), }); setBatchGenerateVisible(true); }; // 手动创建章节(仅one-to-many模式) const showManualCreateChapterModal = () => { // 计算下一个章节号 const nextChapterNumber = chapters.length > 0 ? Math.max(...chapters.map(c => c.chapter_number)) + 1 : 1; modal.confirm({ title: '手动创建章节', width: 600, centered: true, content: (