diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index 953116c..03dc99d 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -115,19 +115,37 @@ async def get_outlines( .order_by(Outline.order_index) ) outlines = result.scalars().all() - + + # 批量查询是否已展开章节(避免前端 N+1 请求) + outline_ids = [outline.id for outline in outlines] + outline_has_chapters_map: Dict[str, bool] = {} + if outline_ids: + chapters_count_result = await db.execute( + select(Chapter.outline_id, func.count(Chapter.id)) + .where(Chapter.outline_id.in_(outline_ids)) + .group_by(Chapter.outline_id) + ) + outline_has_chapters_map = { + str(outline_id): count > 0 + for outline_id, count in chapters_count_result.all() + if outline_id + } + # 🔧 优化:后端完全解析structure,提取所有字段填充到outline对象 for outline in outlines: + # 动态附加是否已有章节展开状态,供前端直接使用 + setattr(outline, "has_chapters", outline_has_chapters_map.get(outline.id, False)) + if outline.structure: try: structure_data = json.loads(outline.structure) - + # 从structure中提取所有字段填充到outline对象 outline.title = structure_data.get("title", f"第{outline.order_index}章") outline.content = structure_data.get("summary") or structure_data.get("content", "") - + # structure字段保持不变,供前端使用其他字段(如characters、scenes等) - + except json.JSONDecodeError: logger.warning(f"解析大纲 {outline.id} 的structure失败") outline.title = f"第{outline.order_index}章" @@ -136,7 +154,7 @@ async def get_outlines( # 没有structure的异常情况 outline.title = f"第{outline.order_index}章" outline.content = "暂无内容" - + return OutlineListResponse(total=total, items=outlines) diff --git a/backend/app/schemas/outline.py b/backend/app/schemas/outline.py index d5a3d87..a935aeb 100644 --- a/backend/app/schemas/outline.py +++ b/backend/app/schemas/outline.py @@ -35,9 +35,10 @@ class OutlineResponse(BaseModel): content: str structure: Optional[str] = None order_index: int + has_chapters: Optional[bool] = None created_at: datetime updated_at: datetime - + model_config = ConfigDict(from_attributes=True) diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index e9accb8..cdbd9a1 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,5 +1,5 @@ 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, FloatButton } from 'antd'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination } 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 { useChapterSync } from '../store/hooks'; @@ -10,7 +10,6 @@ import ChapterAnalysis from '../components/ChapterAnalysis'; import ExpansionPlanEditor from '../components/ExpansionPlanEditor'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; import { SSEProgressModal } from '../components/SSEProgressModal'; -import FloatingIndexPanel from '../components/FloatingIndexPanel'; import ChapterReader from '../components/ChapterReader'; import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar'; import PartialRegenerateModal from '../components/PartialRegenerateModal'; @@ -69,8 +68,13 @@ export default function Chapters() { const [analysisChapterId, setAnalysisChapterId] = useState(null); // 分析任务状态管理 const [analysisTasksMap, setAnalysisTasksMap] = useState>({}); - const pollingIntervalsRef = useRef>({}); - const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false); + 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); @@ -95,6 +99,7 @@ export default function Chapters() { // 批量生成相关状态 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(); @@ -341,94 +346,116 @@ export default function Chapters() { // 清理轮询定时器 useEffect(() => { - const pollingIntervals = pollingIntervalsRef.current; const batchPollingInterval = batchPollingIntervalRef.current; return () => { - Object.values(pollingIntervals).forEach(interval => { - clearInterval(interval); - }); + if (analysisPollingIntervalRef.current) { + clearInterval(analysisPollingIntervalRef.current); + analysisPollingIntervalRef.current = null; + } if (batchPollingInterval) { clearInterval(batchPollingInterval); } }; }, []); - // 加载所有章节的分析任务状态 - // 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题 - const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => { - const targetChapters = chaptersToLoad || chapters; - if (!targetChapters || targetChapters.length === 0) return; + const clearAnalysisPollingIfIdle = useCallback(() => { + if (activeAnalysisPollingIdsRef.current.size === 0 && analysisPollingIntervalRef.current) { + clearInterval(analysisPollingIntervalRef.current); + analysisPollingIntervalRef.current = null; + } + }, []); - const tasksMap: Record = {}; + const pollActiveAnalysisTasks = useCallback(async () => { + if (!currentProject?.id) return; - for (const chapter of targetChapters) { - // 只查询有内容的章节 - 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 { - // 404或其他错误表示没有分析任务,忽略 - console.debug(`章节 ${chapter.id} 暂无分析任务`); - } - } + const activeIds = Array.from(activeAnalysisPollingIdsRef.current); + if (activeIds.length === 0) { + clearAnalysisPollingIfIdle(); + return; } - setAnalysisTasksMap(tasksMap); - }; + try { + const response = await chapterApi.getBatchAnalysisStatuses(currentProject.id, activeIds); + const tasksMap = response.items || {}; - // 启动单个章节的任务轮询 - const startPollingTask = (chapterId: string) => { - // 如果已经在轮询,先清除 - if (pollingIntervalsRef.current[chapterId]) { - clearInterval(pollingIntervalsRef.current[chapterId]); - } + setAnalysisTasksMap(prev => ({ + ...prev, + ...tasksMap, + })); - const interval = window.setInterval(async () => { - try { - const response = await fetch(`/api/chapters/${chapterId}/analysis/status`); - if (!response.ok) return; + activeIds.forEach((chapterId) => { + const task = tasksMap[chapterId]; + if (!task || task.status === 'completed' || task.status === 'failed' || task.status === 'none') { + activeAnalysisPollingIdsRef.current.delete(chapterId); - 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') { + if (task?.status === 'completed') { + message.success('章节分析完成'); + } else if (task?.status === 'failed') { message.error(`章节分析失败: ${task.error_message || '未知错误'}`); } } - } catch (error) { - console.error('轮询分析任务失败:', error); - } + }); + + clearAnalysisPollingIfIdle(); + } catch (error) { + console.error('批量轮询分析任务失败:', error); + } + }, [clearAnalysisPollingIfIdle, currentProject?.id]); + + const ensureAnalysisPolling = useCallback(() => { + if (analysisPollingIntervalRef.current) return; + + analysisPollingIntervalRef.current = window.setInterval(() => { + void pollActiveAnalysisTasks(); }, 2000); - pollingIntervalsRef.current[chapterId] = interval; + // 立即执行一次 + void pollActiveAnalysisTasks(); + }, [pollActiveAnalysisTasks]); - // 5分钟超时 - setTimeout(() => { - if (pollingIntervalsRef.current[chapterId]) { - clearInterval(pollingIntervalsRef.current[chapterId]); - delete pollingIntervalsRef.current[chapterId]; + // 加载所有章节的分析任务状态(批量接口,避免逐章请求风暴) + // 接受可选的 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(); } - }, 300000); + } catch (error) { + console.error('批量加载分析任务状态失败:', error); + } + }; + + // 启动单个章节的任务轮询(内部合并到批量轮询) + const startPollingTask = (chapterId: string) => { + activeAnalysisPollingIdsRef.current.add(chapterId); + ensureAnalysisPolling(); }; const loadWritingStyles = async () => { @@ -559,9 +586,9 @@ export default function Chapters() { }; // 按章节号排序并按大纲分组章节 (必须在早返回之前调用,避免违反 Hooks 规则) - const { sortedChapters, groupedChapters } = useMemo(() => { + const { sortedChapters } = useMemo(() => { const sorted = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number); - + const groups: Record a.outlineOrder - b.outlineOrder); - - return { sortedChapters: sorted, groupedChapters: grouped }; + 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; // 获取人称的中文显示文本(同时支持中英文值) @@ -608,84 +743,11 @@ export default function Chapters() { }; const canGenerateChapter = (chapter: Chapter): boolean => { - if (chapter.chapter_number === 1) { - return true; - } - - const previousChapters = chapters.filter( - c => c.chapter_number < chapter.chapter_number - ); - - // 检查所有前置章节是否有内容 - const allHaveContent = previousChapters.every(c => c.content && c.content.trim() !== ''); - if (!allHaveContent) { - return false; - } - - // 检查所有前置章节是否分析成功 - const allAnalyzed = previousChapters.every(c => { - const task = analysisTasksMap[c.id]; - // 如果没有分析任务或分析失败,则不允许生成 - if (!task || !task.has_task) { - return false; - } - // 只有completed状态才算分析成功 - return task.status === 'completed'; - }); - - return allAnalyzed; + return chapterGenerateGateMap[chapter.id]?.canGenerate ?? true; }; 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} 章`; - } - - // 检查是否有未分析或分析失败的章节 - const unanalyzedChapters = previousChapters.filter(c => { - const task = analysisTasksMap[c.id]; - if (!task || !task.has_task) { - return true; // 没有分析任务 - } - return task.status !== 'completed'; // 分析未完成或失败 - }); - - if (unanalyzedChapters.length > 0) { - const numbers = unanalyzedChapters.map(c => c.chapter_number).join('、'); - const reasons = unanalyzedChapters.map(c => { - const task = analysisTasksMap[c.id]; - if (!task || !task.has_task) { - return '未分析'; - } - if (task.status === 'pending') { - return '等待分析'; - } - if (task.status === 'running') { - return '分析中'; - } - if (task.status === 'failed') { - return '分析失败'; - } - return '状态未知'; - }); - return `需要先分析前置章节:第 ${numbers} 章 (${reasons.join('、')})`; - } - - return ''; + return chapterGenerateGateMap[chapter.id]?.reason || ''; }; const handleOpenModal = (id: string) => { @@ -954,6 +1016,41 @@ export default function Chapters() { 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; @@ -1730,19 +1827,6 @@ export default function Chapters() { } }; - const handleChapterSelect = (chapterId: string) => { - const element = document.getElementById(`chapter-item-${chapterId}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // Optional: add a visual highlight effect - element.style.transition = 'background-color 0.5s ease'; - element.style.backgroundColor = '#e6f7ff'; - setTimeout(() => { - element.style.backgroundColor = ''; - }, 1500); - } - }; - // 打开阅读器 const handleOpenReader = (chapter: Chapter) => { setReadingChapter(chapter); @@ -1801,11 +1885,28 @@ export default function Chapters() { justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center' }}> -

- - 章节管理 -

+
+

+ + 章节管理 +

+ + {currentProject.outline_mode === 'one-to-one' + ? '传统模式:章节由大纲管理,请在大纲页面操作' + : '细化模式:章节可在大纲页面展开'} + +
+ setChapterSearchKeyword(e.target.value)} + style={{ width: isMobile ? '100%' : 280 }} + /> {currentProject.outline_mode === 'one-to-many' && ( )} + - {!isMobile && ( - - {currentProject.outline_mode === 'one-to-one' - ? '传统模式:章节由大纲管理,请在大纲页面操作' - : '细化模式:章节可在大纲页面展开'} - - )}
{chapters.length === 0 ? ( + ) : filteredSortedChapters.length === 0 ? ( + ) : currentProject.outline_mode === 'one-to-one' ? ( // one-to-one 模式:直接显示扁平列表 ( idx.toString())} + defaultActiveKey={pagedGroupedChapters.length > 0 ? ['0'] : []} + destroyInactivePanel expandIcon={({ isActive }) => } style={{ background: 'transparent' }} > - {groupedChapters.map((group, groupIndex) => ( + {pagedGroupedChapters.map((group, groupIndex) => ( + {filteredSortedChapters.length > 0 && ( +
+ { + setChapterPage(page); + if (size !== chapterPageSize) { + setChapterPageSize(size); + setChapterPage(1); + } + }} + showTotal={(total) => `共 ${total} 条`} + size={isMobile ? 'small' : 'default'} + /> +
+ )} + { - fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`) - .then(response => { - if (response.ok) { - return response.json(); - } - throw new Error('获取状态失败'); - }) - .then((task: AnalysisTask) => { - setAnalysisTasksMap(prev => ({ - ...prev, - [chapterIdToRefresh]: task - })); - - // 如果任务正在运行,启动轮询 - if (task.status === 'pending' || task.status === 'running') { - startPollingTask(chapterIdToRefresh); - } - }) - .catch(error => { - console.error('刷新分析状态失败:', error); - // 如果查询失败,再延迟尝试一次 - setTimeout(() => { - fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`) - .then(response => response.ok ? response.json() : null) - .then((task: AnalysisTask | null) => { - if (task) { - setAnalysisTasksMap(prev => ({ - ...prev, - [chapterIdToRefresh]: task - })); - if (task.status === 'pending' || task.status === 'running') { - startPollingTask(chapterIdToRefresh); - } - } - }) - .catch(err => console.error('第二次刷新失败:', err)); - }, 1000); - }); - }, 500); - } + // 延迟500ms后批量刷新分析状态,避免单章接口高频调用 + setTimeout(() => { + loadAnalysisTasks(); + }, 500); setAnalysisChapterId(null); }} @@ -2867,21 +2957,6 @@ export default function Chapters() { cancelButtonText="取消任务" /> - } - type="primary" - tooltip="章节目录" - onClick={() => setIsIndexPanelVisible(true)} - style={{ right: isMobile ? 24 : 48, bottom: isMobile ? 80 : 48 }} - /> - - setIsIndexPanelVisible(false)} - groupedChapters={groupedChapters} - onChapterSelect={handleChapterSelect} - /> - {/* 章节阅读器 */} {readingChapter && ( e.type === 'organization').map(e => e.name); } +interface OutlineStructureData { + key_events?: string[]; + key_points?: string[]; + characters_involved?: string[]; + characters?: unknown[]; + scenes?: string[] | Array<{ + location: string; + characters: string[]; + purpose: string; + }>; + emotion?: string; + goal?: string; + title?: string; + summary?: string; + content?: string; +} + +function parseOutlineStructure(structure?: string): OutlineStructureData { + if (!structure) return {}; + try { + return JSON.parse(structure) as OutlineStructureData; + } catch (e) { + console.error('解析structure失败:', e); + return {}; + } +} + const { TextArea } = Input; export default function Outline() { @@ -93,9 +120,6 @@ export default function Outline() { const [isExpanding, setIsExpanding] = useState(false); const [projectCharacters, setProjectCharacters] = useState>([]); - // ✅ 新增:记录每个大纲的展开状态 - const [outlineExpandStatus, setOutlineExpandStatus] = useState>({}); - // ✅ 新增:记录场景区域的展开/折叠状态 const [scenesExpandStatus, setScenesExpandStatus] = useState>({}); @@ -122,6 +146,11 @@ export default function Outline() { return () => window.removeEventListener('resize', handleResize); }, []); + // 大纲查询与分页状态 + const [outlineSearchKeyword, setOutlineSearchKeyword] = useState(''); + const [outlinePage, setOutlinePage] = useState(1); + const [outlinePageSize, setOutlinePageSize] = useState(20); + // 使用同步 hooks const { refreshOutlines, @@ -155,25 +184,22 @@ export default function Outline() { } }; - // ✅ 新增:加载所有大纲的展开状态 - useEffect(() => { - const loadExpandStatus = async () => { - if (outlines.length === 0) return; + // 从后端返回字段直接构建展开状态,避免前端 N+1 请求 + const outlineExpandStatus = useMemo(() => { + const statusMap: Record = {}; + outlines.forEach((outline) => { + statusMap[outline.id] = Boolean(outline.has_chapters); + }); + return statusMap; + }, [outlines]); - const statusMap: Record = {}; - for (const outline of outlines) { - try { - const chapters = await outlineApi.getOutlineChapters(outline.id); - statusMap[outline.id] = chapters.has_chapters; - } catch (error) { - console.error(`加载大纲 ${outline.id} 状态失败:`, error); - statusMap[outline.id] = false; - } - } - setOutlineExpandStatus(statusMap); - }; - - loadExpandStatus(); + // 统一预解析 structure,避免 render 阶段重复 JSON.parse + const outlineStructureMap = useMemo(() => { + const parsedMap: Record = {}; + outlines.forEach((outline) => { + parsedMap[outline.id] = parseOutlineStructure(outline.structure); + }); + return parsedMap; }, [outlines]); // 当角色确认数据变化时,初始化选中状态(默认全选) @@ -181,34 +207,48 @@ export default function Outline() { // 移除事件监听,避免无限循环 // Hook 内部已经更新了 store,不需要再次刷新 - if (!currentProject) return null; - // 确保大纲按 order_index 排序 const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.order_index); + // 前端查询过滤 + const filteredOutlines = useMemo(() => { + const keyword = outlineSearchKeyword.trim().toLowerCase(); + if (!keyword) return sortedOutlines; + + return sortedOutlines.filter((outline) => { + return ( + String(outline.order_index).includes(keyword) || + outline.title.toLowerCase().includes(keyword) || + outline.content.toLowerCase().includes(keyword) + ); + }); + }, [sortedOutlines, outlineSearchKeyword]); + + // 当前分页数据 + const pagedOutlines = useMemo(() => { + const start = (outlinePage - 1) * outlinePageSize; + return filteredOutlines.slice(start, start + outlinePageSize); + }, [filteredOutlines, outlinePage, outlinePageSize]); + + // 搜索词或页大小变化时,回到第一页 + useEffect(() => { + setOutlinePage(1); + }, [outlineSearchKeyword, outlinePageSize]); + + // 数据变化导致页码越界时自动纠正 + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(filteredOutlines.length / outlinePageSize)); + if (outlinePage > maxPage) { + setOutlinePage(maxPage); + } + }, [filteredOutlines.length, outlinePage, outlinePageSize]); + + if (!currentProject) return null; + const handleOpenEditModal = (id: string) => { const outline = outlines.find(o => o.id === id); if (outline) { - // 解析structure数据 - let structureData: { - characters?: unknown[]; // 兼容新旧格式 - scenes?: string[] | Array<{ - location: string; - characters: string[]; - purpose: string; - }>; - key_points?: string[]; - emotion?: string; - goal?: string; - } = {}; - - if (outline.structure) { - try { - structureData = JSON.parse(outline.structure); - } catch (e) { - console.error('解析structure失败:', e); - } - } + const structureData = outlineStructureMap[outline.id] || {}; // 解析角色/组织条目(兼容新旧格式) const editEntries = parseCharacterEntries(structureData.characters); @@ -357,8 +397,8 @@ export default function Outline() { onOk: async () => { const values = await editForm.validateFields(); try { - // 解析并重构structure数据 - const originalStructure = outline.structure ? JSON.parse(outline.structure) : {}; + // 解析并重构structure数据(使用预解析缓存,避免重复 JSON.parse) + const originalStructure = outlineStructureMap[outline.id] || {}; // 处理角色和组织数据 - 合并为带类型标识的新格式 const charNames = Array.isArray(values.characters) @@ -1059,18 +1099,6 @@ export default function Outline() { const updatedProject = await projectApi.getProject(currentProject.id); setCurrentProject(updatedProject); } - // 更新展开状态 - setOutlineExpandStatus(prev => { - const newStatus = { ...prev }; - // 找到被删除章节对应的大纲ID并更新其状态 - const outlineId = Object.keys(newStatus).find(id => - outlines.find(o => o.id === id && o.title === outlineTitle) - ); - if (outlineId) { - newStatus[outlineId] = false; - } - return newStatus; - }); } catch (error: unknown) { const apiError = error as ApiError; message.error(apiError.response?.data?.detail || '删除章节失败'); @@ -1901,6 +1929,13 @@ export default function Outline() { )}
+ setOutlineSearchKeyword(e.target.value)} + style={{ width: isMobile ? '100%' : 280 }} + />