From 7e9781477b50e29ff31d3d8501025f94fbc16981 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Wed, 5 Nov 2025 00:11:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BC=98=E5=8C=96=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=B9=B6=E5=8F=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/chapters.py | 364 +++++++++++--------- frontend/src/components/ChapterAnalysis.tsx | 50 +-- frontend/src/pages/ChapterAnalysis.tsx | 124 +------ frontend/src/pages/Chapters.tsx | 268 ++++++++++++-- frontend/src/store/hooks.ts | 12 +- 5 files changed, 478 insertions(+), 340 deletions(-) diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 01c2775..4b91737 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -7,6 +7,7 @@ import json import asyncio from typing import Optional from datetime import datetime +from asyncio import Queue, Lock from app.database import get_db from app.models.chapter import Chapter @@ -34,6 +35,17 @@ from app.api.settings import get_user_ai_service router = APIRouter(prefix="/chapters", tags=["章节管理"]) logger = get_logger(__name__) +# 全局数据库写入锁(每个用户一个锁,用于保护SQLite写入操作) +db_write_locks: dict[str, Lock] = {} + + +async def get_db_write_lock(user_id: str) -> Lock: + """获取或创建用户的数据库写入锁""" + if user_id not in db_write_locks: + db_write_locks[user_id] = Lock() + logger.debug(f"🔒 为用户 {user_id} 创建数据库写入锁") + return db_write_locks[user_id] + @router.post("", response_model=ChapterResponse, summary="创建章节") async def create_chapter( @@ -318,7 +330,7 @@ async def analyze_chapter_background( ai_service: AIService ): """ - 后台异步分析章节 + 后台异步分析章节(支持并发,使用锁保护数据库写入) Args: chapter_id: 章节ID @@ -328,11 +340,10 @@ async def analyze_chapter_background( ai_service: AI服务实例 """ db_session = None + write_lock = await get_db_write_lock(user_id) + try: - logger.info(f"🔍 开始后台分析章节: {chapter_id}") - - # 等待一小段时间,确保主会话的commit已经持久化到磁盘 - await asyncio.sleep(0.1) + logger.info(f"🔍 开始分析章节: {chapter_id}, 任务ID: {task_id}") # 创建独立数据库会话 from app.database import get_engine @@ -346,43 +357,40 @@ async def analyze_chapter_background( ) db_session = AsyncSessionLocal() - # 1. 获取任务(添加重试逻辑) - task = None - for retry in range(3): - task_result = await db_session.execute( - select(AnalysisTask).where(AnalysisTask.id == task_id) - ) - task = task_result.scalar_one_or_none() - if task: - break - if retry < 2: - logger.warning(f"⚠️ 第{retry+1}次未找到任务 {task_id},等待后重试...") - await asyncio.sleep(0.2) + # 1. 获取任务(读操作) + task_result = await db_session.execute( + select(AnalysisTask).where(AnalysisTask.id == task_id) + ) + task = task_result.scalar_one_or_none() if not task: logger.error(f"❌ 任务不存在: {task_id}") return - task.status = 'running' - task.started_at = datetime.now() - task.progress = 10 - await db_session.commit() + # 更新任务状态(写操作,需要锁) + async with write_lock: + task.status = 'running' + task.started_at = datetime.now() + task.progress = 10 + await db_session.commit() - # 2. 获取章节信息 + # 2. 获取章节信息(读操作) chapter_result = await db_session.execute( select(Chapter).where(Chapter.id == chapter_id) ) chapter = chapter_result.scalar_one_or_none() if not chapter or not chapter.content: - task.status = 'failed' - task.error_message = '章节不存在或内容为空' - task.completed_at = datetime.now() - await db_session.commit() + async with write_lock: + task.status = 'failed' + task.error_message = '章节不存在或内容为空' + task.completed_at = datetime.now() + await db_session.commit() logger.error(f"❌ 章节不存在或内容为空: {chapter_id}") return - task.progress = 20 - await db_session.commit() + async with write_lock: + task.progress = 20 + await db_session.commit() # 3. 使用PlotAnalyzer分析章节 analyzer = PlotAnalyzer(ai_service) @@ -394,84 +402,87 @@ async def analyze_chapter_background( ) if not analysis_result: - task.status = 'failed' - task.error_message = 'AI分析失败,请检查日志' - task.completed_at = datetime.now() - await db_session.commit() + async with write_lock: + task.status = 'failed' + task.error_message = 'AI分析失败,请检查日志' + task.completed_at = datetime.now() + await db_session.commit() logger.error(f"❌ AI分析失败: {chapter_id}") return - task.progress = 60 - await db_session.commit() + async with write_lock: + task.progress = 60 + await db_session.commit() - # 4. 保存分析结果到数据库(先检查是否已存在) - existing_analysis_result = await db_session.execute( - select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id) - ) - existing_analysis = existing_analysis_result.scalar_one_or_none() - - if existing_analysis: - # 更新现有记录 - logger.info(f" 更新现有分析记录: {existing_analysis.id}") - existing_analysis.plot_stage = analysis_result.get('plot_stage', '发展') - existing_analysis.conflict_level = analysis_result.get('conflict', {}).get('level', 0) - existing_analysis.conflict_types = analysis_result.get('conflict', {}).get('types', []) - existing_analysis.emotional_tone = analysis_result.get('emotional_arc', {}).get('primary_emotion', '') - existing_analysis.emotional_intensity = analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10.0 - existing_analysis.hooks = analysis_result.get('hooks', []) - existing_analysis.hooks_count = len(analysis_result.get('hooks', [])) - existing_analysis.foreshadows = analysis_result.get('foreshadows', []) - existing_analysis.foreshadows_planted = sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted') - existing_analysis.foreshadows_resolved = sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved') - existing_analysis.plot_points = analysis_result.get('plot_points', []) - existing_analysis.plot_points_count = len(analysis_result.get('plot_points', [])) - existing_analysis.character_states = analysis_result.get('character_states', []) - existing_analysis.scenes = analysis_result.get('scenes', []) - existing_analysis.pacing = analysis_result.get('pacing', 'moderate') - existing_analysis.overall_quality_score = analysis_result.get('scores', {}).get('overall', 0) - existing_analysis.pacing_score = analysis_result.get('scores', {}).get('pacing', 0) - existing_analysis.engagement_score = analysis_result.get('scores', {}).get('engagement', 0) - existing_analysis.coherence_score = analysis_result.get('scores', {}).get('coherence', 0) - existing_analysis.analysis_report = analyzer.generate_analysis_summary(analysis_result) - existing_analysis.suggestions = analysis_result.get('suggestions', []) - existing_analysis.dialogue_ratio = analysis_result.get('dialogue_ratio', 0) - existing_analysis.description_ratio = analysis_result.get('description_ratio', 0) - else: - # 创建新记录 - logger.info(f" 创建新的分析记录") - plot_analysis = PlotAnalysis( - chapter_id=chapter_id, - project_id=project_id, - plot_stage=analysis_result.get('plot_stage', '发展'), - conflict_level=analysis_result.get('conflict', {}).get('level', 0), - conflict_types=analysis_result.get('conflict', {}).get('types', []), - emotional_tone=analysis_result.get('emotional_arc', {}).get('primary_emotion', ''), - emotional_intensity=analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10.0, - hooks=analysis_result.get('hooks', []), - hooks_count=len(analysis_result.get('hooks', [])), - foreshadows=analysis_result.get('foreshadows', []), - foreshadows_planted=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted'), - foreshadows_resolved=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved'), - plot_points=analysis_result.get('plot_points', []), - plot_points_count=len(analysis_result.get('plot_points', [])), - character_states=analysis_result.get('character_states', []), - scenes=analysis_result.get('scenes', []), - pacing=analysis_result.get('pacing', 'moderate'), - overall_quality_score=analysis_result.get('scores', {}).get('overall', 0), - pacing_score=analysis_result.get('scores', {}).get('pacing', 0), - engagement_score=analysis_result.get('scores', {}).get('engagement', 0), - coherence_score=analysis_result.get('scores', {}).get('coherence', 0), - analysis_report=analyzer.generate_analysis_summary(analysis_result), - suggestions=analysis_result.get('suggestions', []), - dialogue_ratio=analysis_result.get('dialogue_ratio', 0), - description_ratio=analysis_result.get('description_ratio', 0) + # 4. 保存分析结果到数据库(写操作,需要锁) + async with write_lock: + existing_analysis_result = await db_session.execute( + select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id) ) - db_session.add(plot_analysis) - - await db_session.commit() - - task.progress = 80 - await db_session.commit() + existing_analysis = existing_analysis_result.scalar_one_or_none() + + if existing_analysis: + # 更新现有记录 + logger.info(f" 更新现有分析记录: {existing_analysis.id}") + existing_analysis.plot_stage = analysis_result.get('plot_stage', '发展') + existing_analysis.conflict_level = analysis_result.get('conflict', {}).get('level', 0) + existing_analysis.conflict_types = analysis_result.get('conflict', {}).get('types', []) + existing_analysis.emotional_tone = analysis_result.get('emotional_arc', {}).get('primary_emotion', '') + existing_analysis.emotional_intensity = analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10.0 + existing_analysis.hooks = analysis_result.get('hooks', []) + existing_analysis.hooks_count = len(analysis_result.get('hooks', [])) + existing_analysis.foreshadows = analysis_result.get('foreshadows', []) + existing_analysis.foreshadows_planted = sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted') + existing_analysis.foreshadows_resolved = sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved') + existing_analysis.plot_points = analysis_result.get('plot_points', []) + existing_analysis.plot_points_count = len(analysis_result.get('plot_points', [])) + existing_analysis.character_states = analysis_result.get('character_states', []) + existing_analysis.scenes = analysis_result.get('scenes', []) + existing_analysis.pacing = analysis_result.get('pacing', 'moderate') + existing_analysis.overall_quality_score = analysis_result.get('scores', {}).get('overall', 0) + existing_analysis.pacing_score = analysis_result.get('scores', {}).get('pacing', 0) + existing_analysis.engagement_score = analysis_result.get('scores', {}).get('engagement', 0) + existing_analysis.coherence_score = analysis_result.get('scores', {}).get('coherence', 0) + existing_analysis.analysis_report = analyzer.generate_analysis_summary(analysis_result) + existing_analysis.suggestions = analysis_result.get('suggestions', []) + existing_analysis.dialogue_ratio = analysis_result.get('dialogue_ratio', 0) + existing_analysis.description_ratio = analysis_result.get('description_ratio', 0) + else: + # 创建新记录 + logger.info(f" 创建新的分析记录") + plot_analysis = PlotAnalysis( + chapter_id=chapter_id, + project_id=project_id, + plot_stage=analysis_result.get('plot_stage', '发展'), + conflict_level=analysis_result.get('conflict', {}).get('level', 0), + conflict_types=analysis_result.get('conflict', {}).get('types', []), + emotional_tone=analysis_result.get('emotional_arc', {}).get('primary_emotion', ''), + emotional_intensity=analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10.0, + hooks=analysis_result.get('hooks', []), + hooks_count=len(analysis_result.get('hooks', [])), + foreshadows=analysis_result.get('foreshadows', []), + foreshadows_planted=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted'), + foreshadows_resolved=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved'), + plot_points=analysis_result.get('plot_points', []), + plot_points_count=len(analysis_result.get('plot_points', [])), + character_states=analysis_result.get('character_states', []), + scenes=analysis_result.get('scenes', []), + pacing=analysis_result.get('pacing', 'moderate'), + overall_quality_score=analysis_result.get('scores', {}).get('overall', 0), + pacing_score=analysis_result.get('scores', {}).get('pacing', 0), + engagement_score=analysis_result.get('scores', {}).get('engagement', 0), + coherence_score=analysis_result.get('scores', {}).get('coherence', 0), + analysis_report=analyzer.generate_analysis_summary(analysis_result), + suggestions=analysis_result.get('suggestions', []), + dialogue_ratio=analysis_result.get('dialogue_ratio', 0), + description_ratio=analysis_result.get('description_ratio', 0) + ) + db_session.add(plot_analysis) + + await db_session.commit() + + task.progress = 80 + await db_session.commit() # 5. 提取记忆并保存到向量数据库(传入章节内容用于计算位置) memories = analyzer.extract_memories_from_analysis( @@ -481,16 +492,18 @@ async def analyze_chapter_background( chapter_content=chapter.content or "" ) - # 先删除该章节的旧记忆(支持重新分析) - old_memories_result = await db_session.execute( - select(StoryMemory).where(StoryMemory.chapter_id == chapter_id) - ) - old_memories = old_memories_result.scalars().all() - for old_mem in old_memories: - await db_session.delete(old_mem) - logger.info(f" 删除旧记忆: {len(old_memories)}条") + # 先删除该章节的旧记忆(写操作,需要锁) + async with write_lock: + old_memories_result = await db_session.execute( + select(StoryMemory).where(StoryMemory.chapter_id == chapter_id) + ) + old_memories = old_memories_result.scalars().all() + for old_mem in old_memories: + await db_session.delete(old_mem) + await db_session.commit() + logger.info(f" 删除旧记忆: {len(old_memories)}条") - # 准备批量添加的记忆数据 + # 准备批量添加的记忆数据(不需要锁) memory_records = [] for mem in memories: memory_id = f"{chapter_id}_{mem['type']}_{len(memory_records)}" @@ -501,34 +514,35 @@ async def analyze_chapter_background( 'metadata': mem['metadata'] }) - # 从metadata中提取位置信息 - text_position = mem['metadata'].get('text_position', -1) - text_length = mem['metadata'].get('text_length', 0) + # 保存到关系数据库(写操作,需要锁) + async with write_lock: + for mem in memories: + memory_id = memory_records[memories.index(mem)]['id'] + text_position = mem['metadata'].get('text_position', -1) + text_length = mem['metadata'].get('text_length', 0) + + story_memory = StoryMemory( + id=memory_id, + project_id=project_id, + chapter_id=chapter_id, + memory_type=mem['type'], + content=mem['content'], + title=mem['title'], + importance_score=mem['metadata'].get('importance_score', 0.5), + tags=mem['metadata'].get('tags', []), + is_foreshadow=mem['metadata'].get('is_foreshadow', 0), + story_timeline=chapter.chapter_number, + chapter_position=text_position, + text_length=text_length, + related_characters=mem['metadata'].get('related_characters', []), + related_locations=mem['metadata'].get('related_locations', []) + ) + db_session.add(story_memory) + + if text_position >= 0: + logger.debug(f" 保存记忆 {memory_id}: position={text_position}, length={text_length}") - # 同时保存到关系数据库 - story_memory = StoryMemory( - id=memory_id, - project_id=project_id, - chapter_id=chapter_id, - memory_type=mem['type'], - content=mem['content'], - title=mem['title'], - importance_score=mem['metadata'].get('importance_score', 0.5), - tags=mem['metadata'].get('tags', []), - is_foreshadow=mem['metadata'].get('is_foreshadow', 0), - story_timeline=chapter.chapter_number, # 使用章节序号作为时间线 - chapter_position=text_position, # 保存文本位置 - text_length=text_length, # 保存文本长度 - related_characters=mem['metadata'].get('related_characters', []), - related_locations=mem['metadata'].get('related_locations', []) - ) - db_session.add(story_memory) - - # 记录日志便于调试 - if text_position >= 0: - logger.debug(f" 保存记忆 {memory_id}: position={text_position}, length={text_length}") - - await db_session.commit() + await db_session.commit() # 批量添加到向量数据库 if memory_records: @@ -539,32 +553,34 @@ async def analyze_chapter_background( ) logger.info(f"✅ 添加{added_count}条记忆到向量库") - task.progress = 100 - task.status = 'completed' - task.completed_at = datetime.now() - await db_session.commit() + # 最终更新任务状态(写操作,需要锁) + async with write_lock: + task.progress = 100 + task.status = 'completed' + task.completed_at = datetime.now() + await db_session.commit() logger.info(f"✅ 章节分析完成: {chapter_id}, 提取{len(memories)}条记忆") except Exception as e: logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True) - # 确保任务状态被更新为failed,避免前端一直轮询 + # 确保任务状态被更新为failed(写操作,需要锁) if db_session: try: - # 重新获取任务以确保有最新状态 - task_result = await db_session.execute( - select(AnalysisTask).where(AnalysisTask.id == task_id) - ) - task = task_result.scalar_one_or_none() - if task: - task.status = 'failed' - task.error_message = str(e)[:500] - task.completed_at = datetime.now() - task.progress = 0 # 重置进度 - await db_session.commit() - logger.info(f"✅ 任务状态已更新为failed: {task_id}") - else: - logger.error(f"❌ 无法找到任务进行状态更新: {task_id}") + async with write_lock: + task_result = await db_session.execute( + select(AnalysisTask).where(AnalysisTask.id == task_id) + ) + task = task_result.scalar_one_or_none() + if task: + task.status = 'failed' + task.error_message = str(e)[:500] + task.completed_at = datetime.now() + task.progress = 0 + await db_session.commit() + logger.info(f"✅ 任务状态已更新为failed: {task_id}") + else: + logger.error(f"❌ 无法找到任务进行状态更新: {task_id}") except Exception as update_error: logger.error(f"❌ 更新任务状态失败: {str(update_error)}") finally: @@ -835,7 +851,7 @@ async def generate_chapter_content_stream( logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字") - # 创建分析任务并启动后台分析 + # 创建分析任务 analysis_task = AnalysisTask( chapter_id=chapter_id, user_id=current_user_id, @@ -845,11 +861,15 @@ async def generate_chapter_content_stream( ) db_session.add(analysis_task) await db_session.commit() - # 不需要refresh,只需要获取ID + await db_session.refresh(analysis_task) task_id = analysis_task.id + logger.info(f"📋 已创建分析任务: {task_id}") - # 启动后台分析任务 + # 短暂延迟确保SQLite WAL完成写入 + await asyncio.sleep(0.05) + + # 直接启动后台分析(并发执行) background_tasks.add_task( analyze_chapter_background, chapter_id=chapter_id, @@ -859,8 +879,6 @@ async def generate_chapter_content_stream( ai_service=user_ai_service ) - logger.info(f"📋 已创建分析任务: {task_id}") - # 发送完成事件(包含分析任务ID) completion_data = { 'type': 'done', @@ -870,13 +888,13 @@ async def generate_chapter_content_stream( } yield f"data: {json.dumps(completion_data, ensure_ascii=False)}\n\n" - # 发送分析排队事件 - analysis_queued_data = { - 'type': 'analysis_queued', + # 发送分析开始事件 + analysis_started_data = { + 'type': 'analysis_started', 'task_id': task_id, - 'message': '章节分析已加入队列' + 'message': '章节分析已开始' } - yield f"data: {json.dumps(analysis_queued_data, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps(analysis_started_data, ensure_ascii=False)}\n\n" break # 退出async for db_session循环 @@ -1211,11 +1229,17 @@ async def trigger_chapter_analysis( ) db.add(analysis_task) await db.commit() - # 注意:不需要refresh,因为我们只需要id,而id在commit后已经生成 task_id = analysis_task.id + logger.info(f"📋 创建分析任务: {task_id}, 章节: {chapter_id}") - # 启动后台分析任务 + # 刷新数据库会话,确保其他会话可以看到新任务 + await db.refresh(analysis_task) + + # 短暂延迟确保SQLite WAL完成写入(让其他会话可见) + await asyncio.sleep(3) + + # 直接启动后台分析(并发执行) background_tasks.add_task( analyze_chapter_background, chapter_id=chapter_id, @@ -1225,11 +1249,9 @@ async def trigger_chapter_analysis( ai_service=user_ai_service ) - logger.info(f"📋 手动触发分析任务: {task_id}") - return { "task_id": task_id, "chapter_id": chapter_id, "status": "pending", - "message": "分析任务已创建并加入队列" + "message": "分析任务已创建并开始执行" } diff --git a/frontend/src/components/ChapterAnalysis.tsx b/frontend/src/components/ChapterAnalysis.tsx index e2eb082..4346e36 100644 --- a/frontend/src/components/ChapterAnalysis.tsx +++ b/frontend/src/components/ChapterAnalysis.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; import { Modal, Progress, Spin, Alert, Tabs, Card, Tag, List, Empty, Statistic, Row, Col, Button } from 'antd'; -import { - ThunderboltOutlined, - BulbOutlined, - FireOutlined, +import { + ThunderboltOutlined, + BulbOutlined, + FireOutlined, HeartOutlined, TeamOutlined, TrophyOutlined, @@ -30,6 +30,11 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter if (visible && chapterId) { fetchAnalysisStatus(); } + + // 清理函数:组件卸载或关闭时清除轮询 + return () => { + // 清除可能存在的轮询 + }; }, [visible, chapterId]); const fetchAnalysisStatus = async () => { @@ -117,16 +122,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter throw new Error(errorData.detail || '触发分析失败'); } - const result = await response.json(); - setTask({ - task_id: result.task_id, - chapter_id: chapterId, - status: 'pending', - progress: 0 - }); - - // 开始轮询 - startPolling(); + // 触发成功后立即关闭Modal,让父组件的状态管理接管 + onClose(); } catch (err) { setError((err as Error).message); } finally { @@ -134,6 +131,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter } }; + const renderStatusIcon = () => { if (!task) return null; @@ -480,7 +478,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter , - !task && ( + !task && !loading && ( + ), + task && task.status === 'completed' && ( + ) - ]} + ].filter(Boolean)} > {loading && !task && (
@@ -518,11 +527,6 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter description={error} type="error" showIcon - action={ - - } /> )} diff --git a/frontend/src/pages/ChapterAnalysis.tsx b/frontend/src/pages/ChapterAnalysis.tsx index 1ebe07e..6bc845b 100644 --- a/frontend/src/pages/ChapterAnalysis.tsx +++ b/frontend/src/pages/ChapterAnalysis.tsx @@ -1,10 +1,9 @@ import React, { useState, useEffect } from 'react'; -import { Card, List, Button, Space, Empty, Tag, Spin, Alert, Switch, Drawer, message, Progress } from 'antd'; +import { Card, List, Button, Space, Empty, Tag, Spin, Alert, Switch, Drawer, message } from 'antd'; import { EyeOutlined, EyeInvisibleOutlined, MenuOutlined, - ReloadOutlined, LeftOutlined, RightOutlined, } from '@ant-design/icons'; @@ -72,8 +71,6 @@ const ChapterAnalysis: React.FC = () => { const [showAnnotations, setShowAnnotations] = useState(true); const [activeAnnotationId, setActiveAnnotationId] = useState(); const [sidebarVisible, setSidebarVisible] = useState(false); - const [analyzing, setAnalyzing] = useState(false); - const [analysisProgress, setAnalysisProgress] = useState(0); const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState(); const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState(); @@ -165,105 +162,6 @@ const ChapterAnalysis: React.FC = () => { } }; - const handleReanalyze = async () => { - if (!selectedChapter) return; - - let pollInterval: number | null = null; - let timeoutId: number | null = null; - - try { - setAnalyzing(true); - setAnalysisProgress(0); - message.loading({ content: '开始分析章节...', key: 'analyze', duration: 0 }); - - // 触发分析任务 - const triggerRes = await api.post(`/chapters/${selectedChapter.id}/analyze`); - const triggerData = triggerRes.data || triggerRes; - const taskId = triggerData.task_id; - - console.log('分析任务已创建:', taskId); - - // 开始轮询状态 - let pollCount = 0; - const maxPolls = 60; // 最多轮询60次(2分钟) - - pollInterval = setInterval(async () => { - pollCount++; - - if (pollCount > maxPolls) { - if (pollInterval) clearInterval(pollInterval); - if (timeoutId) clearTimeout(timeoutId); - setAnalyzing(false); - message.warning({ content: '分析超时,请稍后刷新页面查看结果', key: 'analyze' }); - return; - } - - try { - const statusRes = await api.get(`/chapters/${selectedChapter.id}/analysis/status`); - const responseData = statusRes.data || statusRes; - - if (!responseData) { - console.warn(`第${pollCount}次轮询:响应数据为空`); - return; - } - - const { status, progress, error_message } = responseData; - console.log(`第${pollCount}次轮询:status=${status}, progress=${progress}`); - - setAnalysisProgress(progress || 0); - - if (status === 'completed') { - if (pollInterval) clearInterval(pollInterval); - if (timeoutId) clearTimeout(timeoutId); - setAnalyzing(false); - message.success({ content: '分析完成!', key: 'analyze' }); - - // 重新加载标注数据 - try { - const annotationsRes = await api.get(`/chapters/${selectedChapter.id}/annotations`); - setAnnotationsData(annotationsRes.data || annotationsRes); - } catch (annotErr) { - console.error('加载标注数据失败:', annotErr); - message.warning('分析完成,但加载标注数据失败,请刷新页面'); - } - } else if (status === 'failed') { - if (pollInterval) clearInterval(pollInterval); - if (timeoutId) clearTimeout(timeoutId); - setAnalyzing(false); - message.error({ - content: `分析失败:${error_message || '未知错误'}`, - key: 'analyze' - }); - } - // pending 或 running 状态继续轮询 - } catch (pollErr) { - console.error(`第${pollCount}次轮询失败:`, pollErr); - // 轮询错误不中断,继续下一次轮询 - } - }, 2000); - - // 设置总超时(2分钟) - timeoutId = setTimeout(() => { - if (pollInterval) clearInterval(pollInterval); - setAnalyzing(false); - message.warning({ content: '分析超时,请稍后刷新页面查看结果', key: 'analyze' }); - }, 120000); - - } catch (err: any) { - // 清理定时器 - if (pollInterval) clearInterval(pollInterval); - if (timeoutId) clearTimeout(timeoutId); - - setAnalyzing(false); - const errorMsg = err.response?.data?.detail || err.message || '触发分析失败'; - console.error('触发分析失败:', errorMsg, err); - message.error({ - content: errorMsg, - key: 'analyze' - }); - } - }; - const hasAnnotations = annotationsData && annotationsData.annotations.length > 0; if (loading) { @@ -352,15 +250,6 @@ const ChapterAnalysis: React.FC = () => { - {hasAnnotations && ( <> {
- {analyzing && ( -
- - - 正在分析章节... - -
- )} - - {!analyzing && hasAnnotations && annotationsData && ( + {hasAnnotations && annotationsData && (
共有 {annotationsData.summary.total_annotations} 个标注: {annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`} diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index afac3c7..08b5707 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,10 +1,10 @@ import { useState, useEffect, useRef } from 'react'; import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd'; -import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined } from '@ant-design/icons'; +import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } 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 } from '../types'; +import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types'; import { cardStyles } from '../components/CardStyles'; import ChapterAnalysis from '../components/ChapterAnalysis'; @@ -26,6 +26,9 @@ export default function Chapters() { const [targetWordCount, setTargetWordCount] = useState(3000); const [analysisVisible, setAnalysisVisible] = useState(false); const [analysisChapterId, setAnalysisChapterId] = useState(null); + // 分析任务状态管理 + const [analysisTasksMap, setAnalysisTasksMap] = useState>({}); + const pollingIntervalsRef = useRef>({}); useEffect(() => { const handleResize = () => { @@ -46,10 +49,96 @@ export default function Chapters() { if (currentProject?.id) { refreshChapters(); loadWritingStyles(); + loadAnalysisTasks(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); + // 清理轮询定时器 + useEffect(() => { + return () => { + Object.values(pollingIntervalsRef.current).forEach(interval => { + clearInterval(interval); + }); + }; + }, []); + + // 加载所有章节的分析任务状态 + 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; @@ -162,7 +251,7 @@ export default function Chapters() { setIsContinuing(true); setIsGenerating(true); - await generateChapterContentStream(editingId, (content) => { + const result = await generateChapterContentStream(editingId, (content) => { editorForm.setFieldsValue({ content }); if (contentTextAreaRef.current) { @@ -173,7 +262,24 @@ export default function Chapters() { } }, selectedStyleId, targetWordCount); - message.success('AI创作成功'); + message.success('AI创作成功,正在分析章节内容...'); + + // 如果返回了分析任务ID,启动轮询 + if (result?.analysis_task_id) { + const taskId = result.analysis_task_id; + setAnalysisTasksMap(prev => ({ + ...prev, + [editingId]: { + 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 || '未知错误')); @@ -330,6 +436,46 @@ export default function Chapters() { setAnalysisVisible(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; + } + }; + return (
编辑内容 , - - - , + (() => { + const task = analysisTasksMap[item.id]; + const isAnalyzing = task && (task.status === 'pending' || task.status === 'running'); + const hasContent = item.content && item.content.trim() !== ''; + + return ( + + + + ); + })(),