update:1.新增章节内容批量生成功能
This commit is contained in:
+612
-1
@@ -18,12 +18,16 @@ from app.models.generation_history import GenerationHistory
|
|||||||
from app.models.writing_style import WritingStyle
|
from app.models.writing_style import WritingStyle
|
||||||
from app.models.analysis_task import AnalysisTask
|
from app.models.analysis_task import AnalysisTask
|
||||||
from app.models.memory import PlotAnalysis, StoryMemory
|
from app.models.memory import PlotAnalysis, StoryMemory
|
||||||
|
from app.models.batch_generation_task import BatchGenerationTask
|
||||||
from app.schemas.chapter import (
|
from app.schemas.chapter import (
|
||||||
ChapterCreate,
|
ChapterCreate,
|
||||||
ChapterUpdate,
|
ChapterUpdate,
|
||||||
ChapterResponse,
|
ChapterResponse,
|
||||||
ChapterListResponse,
|
ChapterListResponse,
|
||||||
ChapterGenerateRequest
|
ChapterGenerateRequest,
|
||||||
|
BatchGenerateRequest,
|
||||||
|
BatchGenerateResponse,
|
||||||
|
BatchGenerateStatusResponse
|
||||||
)
|
)
|
||||||
from app.services.ai_service import AIService
|
from app.services.ai_service import AIService
|
||||||
from app.services.prompt_service import prompt_service
|
from app.services.prompt_service import prompt_service
|
||||||
@@ -1514,3 +1518,610 @@ async def trigger_chapter_analysis(
|
|||||||
"status": "pending",
|
"status": "pending",
|
||||||
"message": "分析任务已创建并开始执行"
|
"message": "分析任务已创建并开始执行"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_estimated_time(
|
||||||
|
chapter_count: int,
|
||||||
|
target_word_count: int,
|
||||||
|
enable_analysis: bool
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
计算预估耗时(分钟)
|
||||||
|
|
||||||
|
基准:
|
||||||
|
- 生成3000字约需2分钟
|
||||||
|
- 分析约需1分钟
|
||||||
|
"""
|
||||||
|
generation_time_per_chapter = (target_word_count / 3000) * 2
|
||||||
|
analysis_time_per_chapter = 1 if enable_analysis else 0
|
||||||
|
|
||||||
|
total_time = chapter_count * (generation_time_per_chapter + analysis_time_per_chapter)
|
||||||
|
|
||||||
|
return max(1, int(total_time))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/project/{project_id}/batch-generate", response_model=BatchGenerateResponse, summary="批量顺序生成章节内容")
|
||||||
|
async def batch_generate_chapters_in_order(
|
||||||
|
project_id: str,
|
||||||
|
batch_request: BatchGenerateRequest,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
从指定章节开始,按顺序批量生成指定数量的章节
|
||||||
|
|
||||||
|
特性:
|
||||||
|
1. 严格按章节序号顺序生成(不可跳过)
|
||||||
|
2. 自动检测起始章节是否可生成
|
||||||
|
3. 可选同步分析(影响耗时和质量)
|
||||||
|
4. 失败后终止,不继续后续章节
|
||||||
|
"""
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
# 验证项目存在
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(Project.id == project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 获取项目的所有章节,按序号排序
|
||||||
|
result = await db.execute(
|
||||||
|
select(Chapter)
|
||||||
|
.where(Chapter.project_id == project_id)
|
||||||
|
.order_by(Chapter.chapter_number)
|
||||||
|
)
|
||||||
|
all_chapters = result.scalars().all()
|
||||||
|
|
||||||
|
if not all_chapters:
|
||||||
|
raise HTTPException(status_code=404, detail="项目没有章节")
|
||||||
|
|
||||||
|
# 计算要生成的章节范围
|
||||||
|
start_number = batch_request.start_chapter_number
|
||||||
|
end_number = start_number + batch_request.count - 1
|
||||||
|
|
||||||
|
# 筛选出要生成的章节
|
||||||
|
chapters_to_generate = [
|
||||||
|
ch for ch in all_chapters
|
||||||
|
if start_number <= ch.chapter_number <= end_number
|
||||||
|
]
|
||||||
|
|
||||||
|
if not chapters_to_generate:
|
||||||
|
raise HTTPException(status_code=404, detail="指定范围内没有章节")
|
||||||
|
|
||||||
|
# 验证起始章节的前置条件
|
||||||
|
first_chapter = chapters_to_generate[0]
|
||||||
|
can_generate, error_msg, _ = await check_prerequisites(db, first_chapter)
|
||||||
|
if not can_generate:
|
||||||
|
raise HTTPException(status_code=400, detail=f"起始章节无法生成:{error_msg}")
|
||||||
|
|
||||||
|
# 创建批量生成任务
|
||||||
|
batch_task = BatchGenerationTask(
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=user_id,
|
||||||
|
start_chapter_number=start_number,
|
||||||
|
chapter_count=len(chapters_to_generate),
|
||||||
|
chapter_ids=[ch.id for ch in chapters_to_generate],
|
||||||
|
style_id=batch_request.style_id,
|
||||||
|
target_word_count=batch_request.target_word_count,
|
||||||
|
enable_analysis=batch_request.enable_analysis,
|
||||||
|
max_retries=batch_request.max_retries,
|
||||||
|
status='pending',
|
||||||
|
total_chapters=len(chapters_to_generate),
|
||||||
|
completed_chapters=0,
|
||||||
|
failed_chapters=[],
|
||||||
|
current_retry_count=0
|
||||||
|
)
|
||||||
|
db.add(batch_task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(batch_task)
|
||||||
|
|
||||||
|
batch_id = batch_task.id
|
||||||
|
|
||||||
|
# 计算预估耗时
|
||||||
|
estimated_time = calculate_estimated_time(
|
||||||
|
chapter_count=len(chapters_to_generate),
|
||||||
|
target_word_count=batch_request.target_word_count,
|
||||||
|
enable_analysis=batch_request.enable_analysis
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"📦 创建批量生成任务: {batch_id}, 章节: 第{start_number}-{end_number}章, 预估耗时: {estimated_time}分钟")
|
||||||
|
|
||||||
|
# 启动后台批量生成任务
|
||||||
|
background_tasks.add_task(
|
||||||
|
execute_batch_generation_in_order,
|
||||||
|
batch_id=batch_id,
|
||||||
|
user_id=user_id,
|
||||||
|
ai_service=user_ai_service
|
||||||
|
)
|
||||||
|
|
||||||
|
return BatchGenerateResponse(
|
||||||
|
batch_id=batch_id,
|
||||||
|
message=f"批量生成任务已创建,将生成 {len(chapters_to_generate)} 个章节",
|
||||||
|
chapters_to_generate=[
|
||||||
|
{
|
||||||
|
"id": ch.id,
|
||||||
|
"chapter_number": ch.chapter_number,
|
||||||
|
"title": ch.title
|
||||||
|
}
|
||||||
|
for ch in chapters_to_generate
|
||||||
|
],
|
||||||
|
estimated_time_minutes=estimated_time
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batch-generate/{batch_id}/status", response_model=BatchGenerateStatusResponse, summary="查询批量生成任务状态")
|
||||||
|
async def get_batch_generation_status(
|
||||||
|
batch_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""查询批量生成任务的状态和进度"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(BatchGenerationTask).where(BatchGenerationTask.id == batch_id)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="批量生成任务不存在")
|
||||||
|
|
||||||
|
return BatchGenerateStatusResponse(
|
||||||
|
batch_id=task.id,
|
||||||
|
status=task.status,
|
||||||
|
total=task.total_chapters,
|
||||||
|
completed=task.completed_chapters,
|
||||||
|
current_chapter_id=task.current_chapter_id,
|
||||||
|
current_chapter_number=task.current_chapter_number,
|
||||||
|
current_retry_count=task.current_retry_count,
|
||||||
|
max_retries=task.max_retries,
|
||||||
|
failed_chapters=task.failed_chapters or [],
|
||||||
|
created_at=task.created_at.isoformat() if task.created_at else None,
|
||||||
|
started_at=task.started_at.isoformat() if task.started_at else None,
|
||||||
|
completed_at=task.completed_at.isoformat() if task.completed_at else None,
|
||||||
|
error_message=task.error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/project/{project_id}/batch-generate/active", summary="获取项目当前运行中的批量生成任务")
|
||||||
|
async def get_active_batch_generation(
|
||||||
|
project_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取项目当前运行中的批量生成任务
|
||||||
|
用于页面刷新后恢复任务状态
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(BatchGenerationTask)
|
||||||
|
.where(BatchGenerationTask.project_id == project_id)
|
||||||
|
.where(BatchGenerationTask.status.in_(['pending', 'running']))
|
||||||
|
.order_by(BatchGenerationTask.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
return {
|
||||||
|
"has_active_task": False,
|
||||||
|
"task": None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_active_task": True,
|
||||||
|
"task": {
|
||||||
|
"batch_id": task.id,
|
||||||
|
"status": task.status,
|
||||||
|
"total": task.total_chapters,
|
||||||
|
"completed": task.completed_chapters,
|
||||||
|
"current_chapter_id": task.current_chapter_id,
|
||||||
|
"current_chapter_number": task.current_chapter_number,
|
||||||
|
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||||
|
"started_at": task.started_at.isoformat() if task.started_at else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-generate/{batch_id}/cancel", summary="取消批量生成任务")
|
||||||
|
async def cancel_batch_generation(
|
||||||
|
batch_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""取消正在进行的批量生成任务"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(BatchGenerationTask).where(BatchGenerationTask.id == batch_id)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="批量生成任务不存在")
|
||||||
|
|
||||||
|
if task.status in ['completed', 'failed', 'cancelled']:
|
||||||
|
raise HTTPException(status_code=400, detail=f"任务已处于 {task.status} 状态,无法取消")
|
||||||
|
|
||||||
|
task.status = 'cancelled'
|
||||||
|
task.completed_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"🛑 批量生成任务已取消: {batch_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "批量生成任务已取消",
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"completed_chapters": task.completed_chapters,
|
||||||
|
"total_chapters": task.total_chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_batch_generation_in_order(
|
||||||
|
batch_id: str,
|
||||||
|
user_id: str,
|
||||||
|
ai_service: AIService
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按顺序执行批量生成任务(后台任务)
|
||||||
|
- 严格按章节序号顺序
|
||||||
|
- 任一章节失败则终止后续生成
|
||||||
|
- 可选同步分析
|
||||||
|
"""
|
||||||
|
db_session = None
|
||||||
|
write_lock = await get_db_write_lock(user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"📦 开始执行顺序批量生成任务: {batch_id}")
|
||||||
|
|
||||||
|
# 创建独立数据库会话
|
||||||
|
from app.database import get_engine
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||||
|
|
||||||
|
engine = await get_engine(user_id)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
db_session = AsyncSessionLocal()
|
||||||
|
|
||||||
|
# 获取任务
|
||||||
|
task_result = await db_session.execute(
|
||||||
|
select(BatchGenerationTask).where(BatchGenerationTask.id == batch_id)
|
||||||
|
)
|
||||||
|
task = task_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
logger.error(f"❌ 批量生成任务不存在: {batch_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 更新任务状态为运行中
|
||||||
|
async with write_lock:
|
||||||
|
task.status = 'running'
|
||||||
|
task.started_at = datetime.now()
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# 按顺序生成每个章节
|
||||||
|
for idx, chapter_id in enumerate(task.chapter_ids, 1):
|
||||||
|
# 检查任务是否被取消
|
||||||
|
await db_session.refresh(task)
|
||||||
|
if task.status == 'cancelled':
|
||||||
|
logger.info(f"🛑 批量生成任务已被取消: {batch_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 更新当前章节
|
||||||
|
async with write_lock:
|
||||||
|
task.current_chapter_id = chapter_id
|
||||||
|
task.current_retry_count = 0 # 重置重试计数
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# 重试循环
|
||||||
|
retry_count = 0
|
||||||
|
chapter_success = False
|
||||||
|
chapter = None
|
||||||
|
last_error = None
|
||||||
|
|
||||||
|
while retry_count <= task.max_retries and not chapter_success:
|
||||||
|
try:
|
||||||
|
# 获取章节信息
|
||||||
|
chapter_result = await db_session.execute(
|
||||||
|
select(Chapter).where(Chapter.id == chapter_id)
|
||||||
|
)
|
||||||
|
chapter = chapter_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not chapter:
|
||||||
|
raise Exception(f"章节 {chapter_id} 不存在")
|
||||||
|
|
||||||
|
# 更新当前章节序号和重试次数
|
||||||
|
async with write_lock:
|
||||||
|
task.current_chapter_number = chapter.chapter_number
|
||||||
|
task.current_retry_count = retry_count
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
if retry_count > 0:
|
||||||
|
logger.info(f"🔄 [{idx}/{task.total_chapters}] 重试生成章节 (第{retry_count}次): 第{chapter.chapter_number}章 《{chapter.title}》")
|
||||||
|
else:
|
||||||
|
logger.info(f"📝 [{idx}/{task.total_chapters}] 开始生成章节: 第{chapter.chapter_number}章 《{chapter.title}》")
|
||||||
|
|
||||||
|
# 检查前置条件(每次都检查,确保顺序性)
|
||||||
|
can_generate, error_msg, _ = await check_prerequisites(db_session, chapter)
|
||||||
|
if not can_generate:
|
||||||
|
raise Exception(f"前置条件不满足: {error_msg}")
|
||||||
|
|
||||||
|
# 生成章节内容(复用现有流式生成逻辑的核心部分)
|
||||||
|
await generate_single_chapter_for_batch(
|
||||||
|
db_session=db_session,
|
||||||
|
chapter=chapter,
|
||||||
|
user_id=user_id,
|
||||||
|
style_id=task.style_id,
|
||||||
|
target_word_count=task.target_word_count,
|
||||||
|
ai_service=ai_service,
|
||||||
|
write_lock=write_lock
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ 章节生成完成: 第{chapter.chapter_number}章")
|
||||||
|
|
||||||
|
# 如果启用同步分析
|
||||||
|
if task.enable_analysis:
|
||||||
|
logger.info(f"🔍 开始同步分析章节: 第{chapter.chapter_number}章")
|
||||||
|
|
||||||
|
async with write_lock:
|
||||||
|
analysis_task = AnalysisTask(
|
||||||
|
chapter_id=chapter_id,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=task.project_id,
|
||||||
|
status='pending',
|
||||||
|
progress=0
|
||||||
|
)
|
||||||
|
db_session.add(analysis_task)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(analysis_task)
|
||||||
|
|
||||||
|
# 同步执行分析(等待完成)
|
||||||
|
await analyze_chapter_background(
|
||||||
|
chapter_id=chapter_id,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=task.project_id,
|
||||||
|
task_id=analysis_task.id,
|
||||||
|
ai_service=ai_service
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ 章节分析完成: 第{chapter.chapter_number}章")
|
||||||
|
|
||||||
|
# 标记成功
|
||||||
|
chapter_success = True
|
||||||
|
|
||||||
|
# 更新完成数
|
||||||
|
async with write_lock:
|
||||||
|
task.completed_chapters += 1
|
||||||
|
task.current_retry_count = 0 # 重置重试计数
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ 进度: {task.completed_chapters}/{task.total_chapters}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = str(e)
|
||||||
|
logger.error(f"❌ 章节生成失败: 第{chapter.chapter_number if chapter else '?'}章, 错误: {last_error}")
|
||||||
|
|
||||||
|
retry_count += 1
|
||||||
|
|
||||||
|
# 如果还有重试机会,等待一小段时间后重试
|
||||||
|
if retry_count <= task.max_retries:
|
||||||
|
wait_time = min(2 ** retry_count, 10) # 指数退避,最多等待10秒
|
||||||
|
logger.info(f"⏳ 等待 {wait_time} 秒后重试...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
# 达到最大重试次数,记录失败信息
|
||||||
|
logger.error(f"❌ 章节生成失败,已达最大重试次数({task.max_retries}): 第{chapter.chapter_number if chapter else '?'}章")
|
||||||
|
|
||||||
|
failed_info = {
|
||||||
|
'chapter_id': chapter_id,
|
||||||
|
'chapter_number': chapter.chapter_number if chapter else -1,
|
||||||
|
'title': chapter.title if chapter else '未知',
|
||||||
|
'error': last_error,
|
||||||
|
'retry_count': retry_count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async with write_lock:
|
||||||
|
if task.failed_chapters is None:
|
||||||
|
task.failed_chapters = []
|
||||||
|
task.failed_chapters.append(failed_info)
|
||||||
|
|
||||||
|
# 标记任务失败并终止
|
||||||
|
task.status = 'failed'
|
||||||
|
task.error_message = f"第{chapter.chapter_number}章生成失败(重试{retry_count-1}次): {last_error}"[:500]
|
||||||
|
task.completed_at = datetime.now()
|
||||||
|
task.current_retry_count = 0
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}章")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 全部完成
|
||||||
|
async with write_lock:
|
||||||
|
task.status = 'completed'
|
||||||
|
task.completed_at = datetime.now()
|
||||||
|
task.current_chapter_id = None
|
||||||
|
task.current_chapter_number = None
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ 批量生成任务全部完成: {batch_id}, 成功生成 {task.completed_chapters} 章")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 批量生成任务异常: {str(e)}", exc_info=True)
|
||||||
|
if db_session and task:
|
||||||
|
try:
|
||||||
|
async with write_lock:
|
||||||
|
task.status = 'failed'
|
||||||
|
task.error_message = str(e)[:500]
|
||||||
|
task.completed_at = datetime.now()
|
||||||
|
await db_session.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
logger.error(f"❌ 更新任务失败状态失败: {str(commit_error)}")
|
||||||
|
finally:
|
||||||
|
if db_session:
|
||||||
|
await db_session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_single_chapter_for_batch(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
chapter: Chapter,
|
||||||
|
user_id: str,
|
||||||
|
style_id: Optional[int],
|
||||||
|
target_word_count: int,
|
||||||
|
ai_service: AIService,
|
||||||
|
write_lock: Lock
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
为批量生成执行单个章节的生成(非流式)
|
||||||
|
复用现有生成逻辑的核心部分
|
||||||
|
"""
|
||||||
|
# 获取项目信息
|
||||||
|
project_result = await db_session.execute(
|
||||||
|
select(Project).where(Project.id == chapter.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise Exception("项目不存在")
|
||||||
|
|
||||||
|
# 获取对应的大纲
|
||||||
|
outline_result = await db_session.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == chapter.project_id)
|
||||||
|
.where(Outline.order_index == chapter.chapter_number)
|
||||||
|
)
|
||||||
|
outline = outline_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 获取所有大纲用于上下文
|
||||||
|
all_outlines_result = await db_session.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == chapter.project_id)
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
all_outlines = all_outlines_result.scalars().all()
|
||||||
|
outlines_context = "\n".join([
|
||||||
|
f"第{o.order_index}章 {o.title}: {o.content[:100]}..."
|
||||||
|
for o in all_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
# 获取角色信息
|
||||||
|
characters_result = await db_session.execute(
|
||||||
|
select(Character).where(Character.project_id == chapter.project_id)
|
||||||
|
)
|
||||||
|
characters = characters_result.scalars().all()
|
||||||
|
characters_info = "\n".join([
|
||||||
|
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
|
||||||
|
for c in characters
|
||||||
|
])
|
||||||
|
|
||||||
|
# 获取写作风格
|
||||||
|
style_content = ""
|
||||||
|
if style_id:
|
||||||
|
style_result = await db_session.execute(
|
||||||
|
select(WritingStyle).where(WritingStyle.id == style_id)
|
||||||
|
)
|
||||||
|
style = style_result.scalar_one_or_none()
|
||||||
|
if style:
|
||||||
|
if style.project_id is None or style.project_id == chapter.project_id:
|
||||||
|
style_content = style.prompt_content or ""
|
||||||
|
|
||||||
|
# 构建智能上下文
|
||||||
|
smart_context = await build_smart_chapter_context(
|
||||||
|
db=db_session,
|
||||||
|
project_id=project.id,
|
||||||
|
current_chapter_number=chapter.chapter_number,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 组装上下文
|
||||||
|
previous_content = ""
|
||||||
|
if smart_context['story_skeleton']:
|
||||||
|
previous_content += smart_context['story_skeleton'] + "\n\n"
|
||||||
|
if smart_context['relevant_history']:
|
||||||
|
previous_content += smart_context['relevant_history'] + "\n\n"
|
||||||
|
if smart_context['recent_summary']:
|
||||||
|
previous_content += smart_context['recent_summary'] + "\n\n"
|
||||||
|
if smart_context['recent_full']:
|
||||||
|
previous_content += smart_context['recent_full']
|
||||||
|
|
||||||
|
# 构建记忆增强上下文
|
||||||
|
memory_context = await memory_service.build_context_for_generation(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project.id,
|
||||||
|
current_chapter=chapter.chapter_number,
|
||||||
|
chapter_outline=outline.content if outline else chapter.summary or "",
|
||||||
|
character_names=[c.name for c in characters] if characters else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成提示词
|
||||||
|
if previous_content:
|
||||||
|
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||||
|
title=project.title,
|
||||||
|
theme=project.theme or '',
|
||||||
|
genre=project.genre or '',
|
||||||
|
narrative_perspective=project.narrative_perspective or '第三人称',
|
||||||
|
time_period=project.world_time_period or '未设定',
|
||||||
|
location=project.world_location or '未设定',
|
||||||
|
atmosphere=project.world_atmosphere or '未设定',
|
||||||
|
rules=project.world_rules or '未设定',
|
||||||
|
characters_info=characters_info or '暂无角色信息',
|
||||||
|
outlines_context=outlines_context,
|
||||||
|
previous_content=previous_content,
|
||||||
|
chapter_number=chapter.chapter_number,
|
||||||
|
chapter_title=chapter.title,
|
||||||
|
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲',
|
||||||
|
style_content=style_content,
|
||||||
|
target_word_count=target_word_count,
|
||||||
|
memory_context=memory_context
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prompt = prompt_service.get_chapter_generation_prompt(
|
||||||
|
title=project.title,
|
||||||
|
theme=project.theme or '',
|
||||||
|
genre=project.genre or '',
|
||||||
|
narrative_perspective=project.narrative_perspective or '第三人称',
|
||||||
|
time_period=project.world_time_period or '未设定',
|
||||||
|
location=project.world_location or '未设定',
|
||||||
|
atmosphere=project.world_atmosphere or '未设定',
|
||||||
|
rules=project.world_rules or '未设定',
|
||||||
|
characters_info=characters_info or '暂无角色信息',
|
||||||
|
outlines_context=outlines_context,
|
||||||
|
chapter_number=chapter.chapter_number,
|
||||||
|
chapter_title=chapter.title,
|
||||||
|
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲',
|
||||||
|
style_content=style_content,
|
||||||
|
target_word_count=target_word_count,
|
||||||
|
memory_context=memory_context
|
||||||
|
)
|
||||||
|
|
||||||
|
# 非流式生成内容
|
||||||
|
full_content = ""
|
||||||
|
async for chunk in ai_service.generate_text_stream(prompt=prompt):
|
||||||
|
full_content += chunk
|
||||||
|
|
||||||
|
# 更新章节内容到数据库(使用锁保护)
|
||||||
|
async with write_lock:
|
||||||
|
old_word_count = chapter.word_count or 0
|
||||||
|
chapter.content = full_content
|
||||||
|
new_word_count = len(full_content)
|
||||||
|
chapter.word_count = new_word_count
|
||||||
|
chapter.status = "completed"
|
||||||
|
|
||||||
|
# 更新项目字数
|
||||||
|
project.current_words = project.current_words - old_word_count + new_word_count
|
||||||
|
|
||||||
|
# 记录生成历史
|
||||||
|
history = GenerationHistory(
|
||||||
|
project_id=chapter.project_id,
|
||||||
|
chapter_id=chapter.id,
|
||||||
|
prompt=f"批量生成: 第{chapter.chapter_number}章 {chapter.title}",
|
||||||
|
generated_content=full_content[:500] if len(full_content) > 500 else full_content,
|
||||||
|
model="default"
|
||||||
|
)
|
||||||
|
db_session.add(history)
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(chapter)
|
||||||
|
|
||||||
|
logger.info(f"✅ 单章节生成完成: 第{chapter.chapter_number}章,共 {new_word_count} 字")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.models import (
|
|||||||
Project, Outline, Character, Chapter, GenerationHistory,
|
Project, Outline, Character, Chapter, GenerationHistory,
|
||||||
Settings, WritingStyle, ProjectDefaultStyle,
|
Settings, WritingStyle, ProjectDefaultStyle,
|
||||||
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
|
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
|
||||||
StoryMemory, PlotAnalysis, AnalysisTask
|
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask
|
||||||
)
|
)
|
||||||
|
|
||||||
# 引擎缓存:每个用户一个引擎
|
# 引擎缓存:每个用户一个引擎
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.models.relationship import (
|
|||||||
)
|
)
|
||||||
from app.models.memory import StoryMemory, PlotAnalysis
|
from app.models.memory import StoryMemory, PlotAnalysis
|
||||||
from app.models.analysis_task import AnalysisTask
|
from app.models.analysis_task import AnalysisTask
|
||||||
|
from app.models.batch_generation_task import BatchGenerationTask
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Project",
|
"Project",
|
||||||
@@ -32,4 +33,5 @@ __all__ = [
|
|||||||
"StoryMemory",
|
"StoryMemory",
|
||||||
"PlotAnalysis",
|
"PlotAnalysis",
|
||||||
"AnalysisTask",
|
"AnalysisTask",
|
||||||
|
"BatchGenerationTask",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""批量生成任务数据模型"""
|
||||||
|
from sqlalchemy import Column, String, Integer, DateTime, Boolean, JSON
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class BatchGenerationTask(Base):
|
||||||
|
"""批量生成任务表"""
|
||||||
|
__tablename__ = "batch_generation_tasks"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
project_id = Column(String(36), nullable=False, comment="项目ID")
|
||||||
|
user_id = Column(String(100), nullable=False, comment="用户ID")
|
||||||
|
|
||||||
|
# 任务配置
|
||||||
|
start_chapter_number = Column(Integer, nullable=False, comment="起始章节序号")
|
||||||
|
chapter_count = Column(Integer, nullable=False, comment="生成章节数量")
|
||||||
|
chapter_ids = Column(JSON, nullable=False, comment="待生成的章节ID列表")
|
||||||
|
style_id = Column(Integer, comment="使用的写作风格ID")
|
||||||
|
target_word_count = Column(Integer, default=3000, comment="目标字数")
|
||||||
|
enable_analysis = Column(Boolean, default=False, comment="是否启用同步分析")
|
||||||
|
|
||||||
|
# 任务状态
|
||||||
|
status = Column(String(20), default="pending", comment="任务状态: pending/running/completed/failed/cancelled")
|
||||||
|
total_chapters = Column(Integer, default=0, comment="总章节数")
|
||||||
|
completed_chapters = Column(Integer, default=0, comment="已完成章节数")
|
||||||
|
failed_chapters = Column(JSON, default=list, comment="失败的章节信息列表")
|
||||||
|
current_chapter_id = Column(String(36), comment="当前正在生成的章节ID")
|
||||||
|
current_chapter_number = Column(Integer, comment="当前正在生成的章节序号")
|
||||||
|
current_retry_count = Column(Integer, default=0, comment="当前章节重试次数")
|
||||||
|
max_retries = Column(Integer, default=3, comment="最大重试次数")
|
||||||
|
|
||||||
|
# 时间记录
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
started_at = Column(DateTime, comment="开始时间")
|
||||||
|
completed_at = Column(DateTime, comment="完成时间")
|
||||||
|
|
||||||
|
# 错误信息
|
||||||
|
error_message = Column(String(500), comment="错误信息")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<BatchGenerationTask(id={self.id}, status={self.status}, completed={self.completed_chapters}/{self.total_chapters})>"
|
||||||
@@ -65,4 +65,44 @@ class ChapterGenerateRequest(BaseModel):
|
|||||||
description="目标字数,默认3000字",
|
description="目标字数,默认3000字",
|
||||||
ge=500, # 最小500字
|
ge=500, # 最小500字
|
||||||
le=10000 # 最大10000字
|
le=10000 # 最大10000字
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchGenerateRequest(BaseModel):
|
||||||
|
"""批量生成章节的请求模型"""
|
||||||
|
start_chapter_number: int = Field(..., description="起始章节序号")
|
||||||
|
count: int = Field(..., description="生成章节数量", ge=1, le=20)
|
||||||
|
style_id: Optional[int] = Field(None, description="写作风格ID")
|
||||||
|
target_word_count: Optional[int] = Field(
|
||||||
|
3000,
|
||||||
|
description="目标字数,默认3000字",
|
||||||
|
ge=500,
|
||||||
|
le=10000
|
||||||
|
)
|
||||||
|
enable_analysis: bool = Field(False, description="是否启用同步分析")
|
||||||
|
max_retries: int = Field(3, description="每个章节的最大重试次数", ge=0, le=5)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchGenerateResponse(BaseModel):
|
||||||
|
"""批量生成响应模型"""
|
||||||
|
batch_id: str = Field(..., description="批次ID")
|
||||||
|
message: str = Field(..., description="响应消息")
|
||||||
|
chapters_to_generate: list[dict] = Field(..., description="待生成章节列表")
|
||||||
|
estimated_time_minutes: int = Field(..., description="预估耗时(分钟)")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchGenerateStatusResponse(BaseModel):
|
||||||
|
"""批量生成状态响应模型"""
|
||||||
|
batch_id: str
|
||||||
|
status: str
|
||||||
|
total: int
|
||||||
|
completed: int
|
||||||
|
current_chapter_id: Optional[str] = None
|
||||||
|
current_chapter_number: Optional[int] = None
|
||||||
|
current_retry_count: Optional[int] = None
|
||||||
|
max_retries: Optional[int] = None
|
||||||
|
failed_chapters: list[dict] = []
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
started_at: Optional[str] = None
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
@@ -478,7 +478,7 @@ class PromptService:
|
|||||||
3. 符合角色性格设定
|
3. 符合角色性格设定
|
||||||
4. 体现世界观特色
|
4. 体现世界观特色
|
||||||
5. 使用{narrative_perspective}视角
|
5. 使用{narrative_perspective}视角
|
||||||
6. 字数要求:不得低于{target_word_count}字
|
6. **字数要求:目标{target_word_count}字,不得低于{target_word_count}字,建议控制在{target_word_count}至{max_word_count}字之间**
|
||||||
7. 语言自然流畅,避免AI痕迹
|
7. 语言自然流畅,避免AI痕迹
|
||||||
|
|
||||||
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
||||||
@@ -536,7 +536,7 @@ class PromptService:
|
|||||||
|
|
||||||
4. **写作风格**:
|
4. **写作风格**:
|
||||||
- 使用{narrative_perspective}视角
|
- 使用{narrative_perspective}视角
|
||||||
- 字数要求:不得低于{target_word_count}字
|
- **字数要求:目标{target_word_count}字,不得低于{target_word_count}字,建议控制在{target_word_count}至{max_word_count}字之间**
|
||||||
- 语言自然流畅,避免AI痕迹
|
- 语言自然流畅,避免AI痕迹
|
||||||
- 体现世界观特色
|
- 体现世界观特色
|
||||||
|
|
||||||
@@ -871,6 +871,9 @@ class PromptService:
|
|||||||
target_word_count: 目标字数,默认3000字
|
target_word_count: 目标字数,默认3000字
|
||||||
memory_context: 记忆上下文(可选)
|
memory_context: 记忆上下文(可选)
|
||||||
"""
|
"""
|
||||||
|
# 计算最大字数(目标字数+1000)
|
||||||
|
max_word_count = target_word_count + 1000
|
||||||
|
|
||||||
# 格式化记忆上下文
|
# 格式化记忆上下文
|
||||||
memory_text = ""
|
memory_text = ""
|
||||||
if memory_context:
|
if memory_context:
|
||||||
@@ -896,7 +899,8 @@ class PromptService:
|
|||||||
chapter_number=chapter_number,
|
chapter_number=chapter_number,
|
||||||
chapter_title=chapter_title,
|
chapter_title=chapter_title,
|
||||||
chapter_outline=chapter_outline,
|
chapter_outline=chapter_outline,
|
||||||
target_word_count=target_word_count
|
target_word_count=target_word_count,
|
||||||
|
max_word_count=max_word_count
|
||||||
)
|
)
|
||||||
|
|
||||||
# 插入记忆上下文
|
# 插入记忆上下文
|
||||||
@@ -930,6 +934,9 @@ class PromptService:
|
|||||||
target_word_count: 目标字数,默认3000字
|
target_word_count: 目标字数,默认3000字
|
||||||
memory_context: 记忆上下文(可选)
|
memory_context: 记忆上下文(可选)
|
||||||
"""
|
"""
|
||||||
|
# 计算最大字数(目标字数+1000)
|
||||||
|
max_word_count = target_word_count + 1000
|
||||||
|
|
||||||
# 格式化记忆上下文
|
# 格式化记忆上下文
|
||||||
memory_text = ""
|
memory_text = ""
|
||||||
if memory_context:
|
if memory_context:
|
||||||
@@ -958,6 +965,7 @@ class PromptService:
|
|||||||
chapter_title=chapter_title,
|
chapter_title=chapter_title,
|
||||||
chapter_outline=chapter_outline,
|
chapter_outline=chapter_outline,
|
||||||
target_word_count=target_word_count,
|
target_word_count=target_word_count,
|
||||||
|
max_word_count=max_word_count,
|
||||||
memory_context=memory_text
|
memory_context=memory_text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
import { Modal, Button, Space } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface AnnouncementModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDoNotShowToday: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }: AnnouncementModalProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setImageError(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleDoNotShowToday = () => {
|
||||||
|
onDoNotShowToday();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="🎉 欢迎使用 AI小说创作助手"
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
<Button onClick={onClose} size="large">
|
||||||
|
知道了
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={handleDoNotShowToday} size="large">
|
||||||
|
今天内不再提示
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
width={600}
|
||||||
|
centered
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: '24px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#666',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
}}>
|
||||||
|
<p>👋 欢迎加入我们的交流群!</p>
|
||||||
|
<p>在这里你可以:</p>
|
||||||
|
<ul style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
marginLeft: '40px',
|
||||||
|
marginTop: '12px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<li>💬 与其他创作者交流心得</li>
|
||||||
|
<li>💡 获取最新功能更新和使用技巧</li>
|
||||||
|
<li>🐛 反馈问题和建议</li>
|
||||||
|
<li>📚 分享创作经验和灵感</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ fontWeight: 600, color: '#333', marginBottom: '16px' }}>
|
||||||
|
扫描下方二维码加入QQ交流群:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!imageError ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src="/qq.jpg"
|
||||||
|
alt="QQ交流群二维码"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '360px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
}}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
padding: '40px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#999',
|
||||||
|
}}>
|
||||||
|
<p>二维码加载失败</p>
|
||||||
|
<p style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||||
|
请确保 qq.jpg 文件位于 frontend/public/ 目录下
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#fff7e6',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #ffd591',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#ad6800',
|
||||||
|
}}>
|
||||||
|
💡 提示:点击"今天内不再提示"可在今天内不再显示此公告
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Spin, Result, Button } from 'antd';
|
import { Spin, Result, Button } from 'antd';
|
||||||
import { authApi } from '../services/api';
|
import { authApi } from '../services/api';
|
||||||
|
import AnnouncementModal from '../components/AnnouncementModal';
|
||||||
|
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
@@ -21,10 +23,21 @@ export default function AuthCallback() {
|
|||||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||||
sessionStorage.removeItem('login_redirect');
|
sessionStorage.removeItem('login_redirect');
|
||||||
|
|
||||||
// 延迟一下再跳转,让用户看到成功提示
|
// 检查今天是否已经显示过公告
|
||||||
setTimeout(() => {
|
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||||
navigate(redirect);
|
const now = new Date().getTime();
|
||||||
}, 1000);
|
|
||||||
|
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||||
|
// 延迟一下再显示公告,让用户看到成功提示
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowAnnouncement(true);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// 延迟一下再跳转,让用户看到成功提示
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(redirect);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登录失败:', error);
|
console.error('登录失败:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -78,20 +91,41 @@ export default function AuthCallback() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAnnouncementClose = () => {
|
||||||
|
setShowAnnouncement(false);
|
||||||
|
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||||
|
sessionStorage.removeItem('login_redirect');
|
||||||
|
navigate(redirect);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoNotShowToday = () => {
|
||||||
|
// 设置到今天23:59:59不再显示
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setHours(23, 59, 59, 999);
|
||||||
|
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<>
|
||||||
display: 'flex',
|
<AnnouncementModal
|
||||||
justifyContent: 'center',
|
visible={showAnnouncement}
|
||||||
alignItems: 'center',
|
onClose={handleAnnouncementClose}
|
||||||
minHeight: '100vh',
|
onDoNotShowToday={handleDoNotShowToday}
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
}}>
|
|
||||||
<Result
|
|
||||||
status="success"
|
|
||||||
title="登录成功"
|
|
||||||
subTitle="正在跳转..."
|
|
||||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
}}>
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="登录成功"
|
||||||
|
subTitle={showAnnouncement ? "欢迎使用..." : "正在跳转..."}
|
||||||
|
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
|
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio } from 'antd';
|
||||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { useChapterSync } from '../store/hooks';
|
import { useChapterSync } from '../store/hooks';
|
||||||
import { projectApi, writingStyleApi } from '../services/api';
|
import { projectApi, writingStyleApi } from '../services/api';
|
||||||
@@ -29,6 +29,19 @@ export default function Chapters() {
|
|||||||
// 分析任务状态管理
|
// 分析任务状态管理
|
||||||
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
|
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
|
||||||
const pollingIntervalsRef = useRef<Record<string, number>>({});
|
const pollingIntervalsRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
// 批量生成相关状态
|
||||||
|
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
|
||||||
|
const [batchGenerating, setBatchGenerating] = useState(false);
|
||||||
|
const [batchTaskId, setBatchTaskId] = useState<string | null>(null);
|
||||||
|
const [batchProgress, setBatchProgress] = useState<{
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
current_chapter_number: number | null;
|
||||||
|
estimated_time_minutes?: number;
|
||||||
|
} | null>(null);
|
||||||
|
const batchPollingIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
@@ -50,6 +63,7 @@ export default function Chapters() {
|
|||||||
refreshChapters();
|
refreshChapters();
|
||||||
loadWritingStyles();
|
loadWritingStyles();
|
||||||
loadAnalysisTasks();
|
loadAnalysisTasks();
|
||||||
|
checkAndRestoreBatchTask();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentProject?.id]);
|
}, [currentProject?.id]);
|
||||||
@@ -60,6 +74,9 @@ export default function Chapters() {
|
|||||||
Object.values(pollingIntervalsRef.current).forEach(interval => {
|
Object.values(pollingIntervalsRef.current).forEach(interval => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
});
|
});
|
||||||
|
if (batchPollingIntervalRef.current) {
|
||||||
|
clearInterval(batchPollingIntervalRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -157,6 +174,40 @@ export default function Chapters() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 检查并恢复批量生成任务
|
||||||
|
const checkAndRestoreBatchTask = async () => {
|
||||||
|
if (!currentProject?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate/active`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.has_active_task && data.task) {
|
||||||
|
const task = data.task;
|
||||||
|
|
||||||
|
// 恢复任务状态
|
||||||
|
setBatchTaskId(task.batch_id);
|
||||||
|
setBatchProgress({
|
||||||
|
status: task.status,
|
||||||
|
total: task.total,
|
||||||
|
completed: task.completed,
|
||||||
|
current_chapter_number: task.current_chapter_number,
|
||||||
|
});
|
||||||
|
setBatchGenerating(true);
|
||||||
|
setBatchGenerateVisible(true);
|
||||||
|
|
||||||
|
// 启动轮询
|
||||||
|
startBatchPolling(task.batch_id);
|
||||||
|
|
||||||
|
message.info('检测到未完成的批量生成任务,已自动恢复');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查批量生成任务失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!currentProject) return null;
|
if (!currentProject) return null;
|
||||||
|
|
||||||
const canGenerateChapter = (chapter: Chapter): boolean => {
|
const canGenerateChapter = (chapter: Chapter): boolean => {
|
||||||
@@ -436,6 +487,168 @@ export default function Chapters() {
|
|||||||
setAnalysisVisible(true);
|
setAnalysisVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 批量生成函数
|
||||||
|
const handleBatchGenerate = async (values: {
|
||||||
|
startChapterNumber: number;
|
||||||
|
count: number;
|
||||||
|
enableAnalysis: boolean;
|
||||||
|
styleId?: number;
|
||||||
|
targetWordCount?: number;
|
||||||
|
}) => {
|
||||||
|
if (!currentProject?.id) return;
|
||||||
|
|
||||||
|
// 使用批量生成对话框中选择的风格和字数,如果没有选择则使用默认值
|
||||||
|
const styleId = values.styleId || selectedStyleId;
|
||||||
|
const wordCount = values.targetWordCount || targetWordCount;
|
||||||
|
|
||||||
|
if (!styleId) {
|
||||||
|
message.error('请选择写作风格');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBatchGenerating(true);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
start_chapter_number: values.startChapterNumber,
|
||||||
|
count: values.count,
|
||||||
|
enable_analysis: values.enableAnalysis,
|
||||||
|
style_id: styleId,
|
||||||
|
target_word_count: wordCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || '创建批量生成任务失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setBatchTaskId(result.batch_id);
|
||||||
|
setBatchProgress({
|
||||||
|
status: 'running',
|
||||||
|
total: result.chapters_to_generate.length,
|
||||||
|
completed: 0,
|
||||||
|
current_chapter_number: values.startChapterNumber,
|
||||||
|
estimated_time_minutes: result.estimated_time_minutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`);
|
||||||
|
|
||||||
|
// 开始轮询任务状态
|
||||||
|
startBatchPolling(result.batch_id);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error('创建批量生成任务失败:' + (error.message || '未知错误'));
|
||||||
|
setBatchGenerating(false);
|
||||||
|
setBatchGenerateVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 轮询批量生成任务状态
|
||||||
|
const startBatchPolling = (taskId: string) => {
|
||||||
|
if (batchPollingIntervalRef.current) {
|
||||||
|
clearInterval(batchPollingIntervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chapters/batch-generate/${taskId}/status`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const status = await response.json();
|
||||||
|
setBatchProgress({
|
||||||
|
status: status.status,
|
||||||
|
total: status.total,
|
||||||
|
completed: status.completed,
|
||||||
|
current_chapter_number: status.current_chapter_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 任务完成或失败,停止轮询
|
||||||
|
if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') {
|
||||||
|
if (batchPollingIntervalRef.current) {
|
||||||
|
clearInterval(batchPollingIntervalRef.current);
|
||||||
|
batchPollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBatchGenerating(false);
|
||||||
|
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
message.success(`批量生成完成!成功生成 ${status.completed} 章`);
|
||||||
|
// 刷新章节列表
|
||||||
|
refreshChapters();
|
||||||
|
loadAnalysisTasks();
|
||||||
|
} else if (status.status === 'failed') {
|
||||||
|
message.error(`批量生成失败:${status.error_message || '未知错误'}`);
|
||||||
|
} else if (status.status === 'cancelled') {
|
||||||
|
message.warning('批量生成已取消');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟关闭对话框,让用户看到最终状态
|
||||||
|
setTimeout(() => {
|
||||||
|
setBatchGenerateVisible(false);
|
||||||
|
setBatchTaskId(null);
|
||||||
|
setBatchProgress(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('轮询批量生成状态失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即执行一次
|
||||||
|
poll();
|
||||||
|
|
||||||
|
// 每2秒轮询一次
|
||||||
|
batchPollingIntervalRef.current = window.setInterval(poll, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消批量生成
|
||||||
|
const handleCancelBatchGenerate = async () => {
|
||||||
|
if (!batchTaskId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chapters/batch-generate/${batchTaskId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('取消失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('批量生成已取消');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error('取消失败:' + (error.message || '未知错误'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开批量生成对话框
|
||||||
|
const handleOpenBatchGenerate = () => {
|
||||||
|
// 找到第一个未生成的章节
|
||||||
|
const firstIncompleteChapter = sortedChapters.find(
|
||||||
|
ch => !ch.content || ch.content.trim() === ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!firstIncompleteChapter) {
|
||||||
|
message.info('所有章节都已生成内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查该章节是否可以生成
|
||||||
|
if (!canGenerateChapter(firstIncompleteChapter)) {
|
||||||
|
const reason = getGenerateDisabledReason(firstIncompleteChapter);
|
||||||
|
message.warning(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBatchGenerateVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染分析状态标签
|
// 渲染分析状态标签
|
||||||
const renderAnalysisStatus = (chapterId: string) => {
|
const renderAnalysisStatus = (chapterId: string) => {
|
||||||
const task = analysisTasksMap[chapterId];
|
const task = analysisTasksMap[chapterId];
|
||||||
@@ -496,6 +709,17 @@ export default function Chapters() {
|
|||||||
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
icon={<RocketOutlined />}
|
||||||
|
onClick={handleOpenBatchGenerate}
|
||||||
|
disabled={chapters.length === 0}
|
||||||
|
block={isMobile}
|
||||||
|
size={isMobile ? 'middle' : 'middle'}
|
||||||
|
style={{ background: '#722ed1', borderColor: '#722ed1' }}
|
||||||
|
>
|
||||||
|
批量生成
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={chapters.length === 0}
|
disabled={chapters.length === 0}
|
||||||
@@ -914,6 +1138,245 @@ export default function Chapters() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 批量生成对话框 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<RocketOutlined style={{ color: '#722ed1' }} />
|
||||||
|
<span>批量生成章节内容</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={batchGenerateVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
if (batchGenerating) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认取消',
|
||||||
|
content: '批量生成正在进行中,确定要取消吗?',
|
||||||
|
okText: '确定取消',
|
||||||
|
cancelText: '继续生成',
|
||||||
|
onOk: () => {
|
||||||
|
handleCancelBatchGenerate();
|
||||||
|
setBatchGenerateVisible(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setBatchGenerateVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
centered
|
||||||
|
closable={!batchGenerating}
|
||||||
|
maskClosable={!batchGenerating}
|
||||||
|
>
|
||||||
|
{!batchGenerating ? (
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleBatchGenerate}
|
||||||
|
initialValues={{
|
||||||
|
startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1,
|
||||||
|
count: 5,
|
||||||
|
enableAnalysis: false,
|
||||||
|
styleId: selectedStyleId,
|
||||||
|
targetWordCount: 3000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
message="批量生成说明"
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
|
||||||
|
<li>严格按章节序号顺序生成,不可跳过</li>
|
||||||
|
<li>所有章节使用相同的写作风格和目标字数</li>
|
||||||
|
<li>任一章节失败则终止后续生成</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="起始章节"
|
||||||
|
name="startChapterNumber"
|
||||||
|
rules={[{ required: true, message: '请选择起始章节' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择起始章节" size="large">
|
||||||
|
{sortedChapters
|
||||||
|
.filter(ch => !ch.content || ch.content.trim() === '')
|
||||||
|
.filter(ch => canGenerateChapter(ch))
|
||||||
|
.map(ch => (
|
||||||
|
<Select.Option key={ch.id} value={ch.chapter_number}>
|
||||||
|
第{ch.chapter_number}章:{ch.title}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="生成数量"
|
||||||
|
name="count"
|
||||||
|
rules={[{ required: true, message: '请选择生成数量' }]}
|
||||||
|
>
|
||||||
|
<Radio.Group buttonStyle="solid" size="large">
|
||||||
|
<Radio.Button value={5}>5章</Radio.Button>
|
||||||
|
<Radio.Button value={10}>10章</Radio.Button>
|
||||||
|
<Radio.Button value={15}>15章</Radio.Button>
|
||||||
|
<Radio.Button value={20}>20章</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="写作风格"
|
||||||
|
name="styleId"
|
||||||
|
rules={[{ required: true, message: '请选择写作风格' }]}
|
||||||
|
tooltip="批量生成时所有章节使用相同的写作风格"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择写作风格"
|
||||||
|
size="large"
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
>
|
||||||
|
{writingStyles.map(style => (
|
||||||
|
<Select.Option key={style.id} value={style.id}>
|
||||||
|
{style.name}
|
||||||
|
{style.is_default && ' (默认)'}
|
||||||
|
{style.description && ` - ${style.description}`}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="目标字数"
|
||||||
|
tooltip="AI生成章节时的目标字数,实际生成字数可能略有偏差"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="targetWordCount"
|
||||||
|
rules={[{ required: true, message: '请设置目标字数' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={500}
|
||||||
|
max={10000}
|
||||||
|
step={100}
|
||||||
|
size="large"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
formatter={(value) => `${value} 字`}
|
||||||
|
parser={(value) => value?.replace(' 字', '') as any}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
|
||||||
|
建议范围:500-10000字,默认3000字
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="同步分析"
|
||||||
|
name="enableAnalysis"
|
||||||
|
tooltip="开启后每章生成完立即分析,会增加约50%耗时,但能提升后续章节质量"
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value={false}>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<span>不分析(推荐)</span>
|
||||||
|
<span style={{ fontSize: 12, color: '#666' }}>生成更快,后续可手动分析</span>
|
||||||
|
</Space>
|
||||||
|
</Radio>
|
||||||
|
<Radio value={true}>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<span>同步分析</span>
|
||||||
|
<span style={{ fontSize: 12, color: '#ff9800' }}>增加约50%耗时,提升质量</span>
|
||||||
|
</Space>
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => setBatchGenerateVisible(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" htmlType="submit" icon={<RocketOutlined />}>
|
||||||
|
开始批量生成
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span>生成进度:</span>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: '#1890ff', fontSize: 18 }}>
|
||||||
|
{batchProgress?.completed || 0} / {batchProgress?.total || 0}
|
||||||
|
</strong>
|
||||||
|
章
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
|
||||||
|
status={batchProgress?.status === 'failed' ? 'exception' : 'active'}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#722ed1',
|
||||||
|
'100%': '#1890ff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{batchProgress?.current_chapter_number && (
|
||||||
|
<Alert
|
||||||
|
message={`正在生成第 ${batchProgress.current_chapter_number} 章...`}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
icon={<SyncOutlined spin />}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && (
|
||||||
|
<div style={{ marginBottom: 16, color: '#666', fontSize: 13 }}>
|
||||||
|
⏱️ 预计耗时:约 {batchProgress.estimated_time_minutes} 分钟
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message="温馨提示"
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
|
||||||
|
<li>批量生成需要一定时间,可以切换到其他页面</li>
|
||||||
|
<li>关闭页面后重新打开,会自动恢复任务进度</li>
|
||||||
|
<li>可以随时点击"取消任务"按钮中止生成</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认取消',
|
||||||
|
content: '确定要取消批量生成吗?已生成的章节将保留。',
|
||||||
|
okText: '确定取消',
|
||||||
|
cancelText: '继续生成',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: handleCancelBatchGenerate,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Button, Card, Space, Typography, message, Spin, Form, Input, Tabs } fro
|
|||||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
import { authApi } from '../services/api';
|
import { authApi } from '../services/api';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import AnnouncementModal from '../components/AnnouncementModal';
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default function Login() {
|
|||||||
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
|
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
|
||||||
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||||
|
|
||||||
// 检查是否已登录和获取认证配置
|
// 检查是否已登录和获取认证配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,8 +49,17 @@ export default function Login() {
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
message.success('登录成功!');
|
message.success('登录成功!');
|
||||||
const redirect = searchParams.get('redirect') || '/';
|
|
||||||
navigate(redirect);
|
// 检查今天是否已经显示过公告
|
||||||
|
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||||
|
const now = new Date().getTime();
|
||||||
|
|
||||||
|
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||||
|
setShowAnnouncement(true);
|
||||||
|
} else {
|
||||||
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
|
navigate(redirect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('本地登录失败:', error);
|
console.error('本地登录失败:', error);
|
||||||
@@ -185,8 +196,27 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAnnouncementClose = () => {
|
||||||
|
setShowAnnouncement(false);
|
||||||
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
|
navigate(redirect);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoNotShowToday = () => {
|
||||||
|
// 设置到今天23:59:59不再显示
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setHours(23, 59, 59, 999);
|
||||||
|
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<>
|
||||||
|
<AnnouncementModal
|
||||||
|
visible={showAnnouncement}
|
||||||
|
onClose={handleAnnouncementClose}
|
||||||
|
onDoNotShowToday={handleDoNotShowToday}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -325,5 +355,6 @@ export default function Login() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user