diff --git a/backend/alembic/postgres/env.py b/backend/alembic/postgres/env.py index aa55b64..f091d4a 100644 --- a/backend/alembic/postgres/env.py +++ b/backend/alembic/postgres/env.py @@ -25,7 +25,8 @@ from app.models import ( Settings, WritingStyle, ProjectDefaultStyle, RelationshipType, CharacterRelationship, Organization, OrganizationMember, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, - RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate + RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate, + BackgroundTask ) # Alembic Config 对象 diff --git a/backend/alembic/sqlite/env.py b/backend/alembic/sqlite/env.py index 5a32b7d..49e5909 100644 --- a/backend/alembic/sqlite/env.py +++ b/backend/alembic/sqlite/env.py @@ -25,7 +25,8 @@ from app.models import ( Settings, WritingStyle, ProjectDefaultStyle, RelationshipType, CharacterRelationship, Organization, OrganizationMember, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, - RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate + RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate, + BackgroundTask ) # Alembic Config 对象 diff --git a/backend/alembic/sqlite/versions/20260427_1200_def45678ghi9_添加后台任务表.py b/backend/alembic/sqlite/versions/20260427_1200_def45678ghi9_添加后台任务表.py index 372bac1..4dd7ec3 100644 --- a/backend/alembic/sqlite/versions/20260427_1200_def45678ghi9_添加后台任务表.py +++ b/backend/alembic/sqlite/versions/20260427_1200_def45678ghi9_添加后台任务表.py @@ -28,7 +28,7 @@ def upgrade() -> None: sa.Column('status_message', sa.String(), nullable=True), sa.Column('progress_details', sa.Text(), nullable=True), sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('task_params', sa.Text(), nullable=True), + sa.Column('task_input', sa.Text(), nullable=True), sa.Column('task_result', sa.Text(), nullable=True), sa.Column('cancel_requested', sa.Boolean(), default=False), sa.Column('retry_count', sa.Integer(), default=0), diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 6c6f092..bca92b1 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1,10 +1,12 @@ """后台任务API - 查询状态、取消任务""" from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from typing import Optional from app.database import get_db from app.models.background_task import BackgroundTask +from app.models.batch_generation_task import BatchGenerationTask from app.services.background_task_service import background_task_service from app.logger import get_logger @@ -54,31 +56,75 @@ async def get_tasks( limit: int = 20, db: AsyncSession = Depends(get_db) ): - """获取项目的后台任务列表""" + """获取项目的后台任务列表(合并 BackgroundTask 和 BatchGenerationTask)""" user_id = getattr(request.state, 'user_id', None) if not user_id: raise HTTPException(status_code=401, detail="未登录") - tasks = await background_task_service.get_project_tasks( + # 查询 BackgroundTask + bg_tasks = await background_task_service.get_project_tasks( project_id, user_id, db, task_type=task_type, limit=limit ) - return { - "items": [ - { - "id": t.id, - "task_type": t.task_type, - "status": t.status, - "progress": t.progress, - "status_message": t.status_message, - "progress_details": t.progress_details, - "error_message": t.error_message, - "created_at": t.created_at.isoformat() if t.created_at else None, - "completed_at": t.completed_at.isoformat() if t.completed_at else None, - } - for t in tasks - ] - } + items = [ + { + "id": t.id, + "task_type": t.task_type, + "status": t.status, + "progress": t.progress, + "status_message": t.status_message, + "progress_details": t.progress_details, + "error_message": t.error_message, + "created_at": t.created_at.isoformat() if t.created_at else None, + "completed_at": t.completed_at.isoformat() if t.completed_at else None, + } + for t in bg_tasks + ] + + # 查询 BatchGenerationTask(不按 task_type 过滤,或过滤 chapter_batch 时才查) + if not task_type or task_type == 'chapter_batch': + batch_result = await db.execute( + select(BatchGenerationTask) + .where( + BatchGenerationTask.project_id == project_id, + BatchGenerationTask.user_id == user_id + ) + .order_by(BatchGenerationTask.created_at.desc()) + .limit(limit) + ) + batch_tasks = batch_result.scalars().all() + + for bt in batch_tasks: + progress = bt.total_chapters * 100 if bt.total_chapters > 0 else 0 + if bt.total_chapters > 0 and bt.status in ('pending', 'running'): + progress = int((bt.completed_chapters / bt.total_chapters) * 100) + elif bt.status == 'completed': + progress = 100 + + status_message = None + if bt.status == 'running' and bt.current_chapter_number: + status_message = f"正在生成第 {bt.current_chapter_number} 章 ({bt.completed_chapters}/{bt.total_chapters})" + elif bt.status == 'completed': + status_message = f"已完成 {bt.completed_chapters} 章" + elif bt.status == 'pending': + status_message = f"等待中,共 {bt.total_chapters} 章" + + items.append({ + "id": bt.id, + "task_type": "chapter_batch", + "status": bt.status, + "progress": progress, + "status_message": status_message, + "progress_details": None, + "error_message": bt.error_message, + "created_at": bt.created_at.isoformat() if bt.created_at else None, + "completed_at": bt.completed_at.isoformat() if bt.completed_at else None, + }) + + # 按创建时间降序排序 + items.sort(key=lambda x: x.get("created_at") or "", reverse=True) + + return {"items": items[:limit]} @router.post("/{task_id}/cancel", summary="取消任务") @@ -105,18 +151,70 @@ async def delete_task( request: Request, db: AsyncSession = Depends(get_db) ): - """删除已完成/失败的任务记录""" + """删除已完成/失败的任务记录(支持 BackgroundTask 和 BatchGenerationTask)""" user_id = getattr(request.state, 'user_id', None) if not user_id: raise HTTPException(status_code=401, detail="未登录") + # 先尝试从 BackgroundTask 查找 task = await background_task_service.get_task(task_id, user_id, db) - if not task: - raise HTTPException(status_code=404, detail="任务不存在") + if task: + if task.status in ("pending", "running"): + raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消") + await db.delete(task) + await db.commit() + return {"message": "任务记录已删除"} - if task.status in ("pending", "running"): - raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消") + # 再尝试从 BatchGenerationTask 查找 + result = await db.execute( + select(BatchGenerationTask).where( + BatchGenerationTask.id == task_id, + BatchGenerationTask.user_id == user_id + ) + ) + batch_task = result.scalar_one_or_none() + if batch_task: + if batch_task.status in ("pending", "running"): + raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消") + await db.delete(batch_task) + await db.commit() + return {"message": "任务记录已删除"} + + raise HTTPException(status_code=404, detail="任务不存在") + + +@router.delete("/project/{project_id}/clear", summary="清理项目已结束的任务记录") +async def clear_project_tasks( + project_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """清理项目中已完成/失败/已取消的任务记录""" + user_id = getattr(request.state, 'user_id', None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + from sqlalchemy import delete as sql_delete + + # 清理 BackgroundTask + bg_result = await db.execute( + sql_delete(BackgroundTask).where( + BackgroundTask.project_id == project_id, + BackgroundTask.user_id == user_id, + BackgroundTask.status.in_(["completed", "failed", "cancelled"]) + ) + ) + + # 清理 BatchGenerationTask + batch_result = await db.execute( + sql_delete(BatchGenerationTask).where( + BatchGenerationTask.project_id == project_id, + BatchGenerationTask.user_id == user_id, + BatchGenerationTask.status.in_(["completed", "failed", "cancelled"]) + ) + ) - await db.delete(task) await db.commit() - return {"message": "任务记录已删除"} \ No newline at end of file + + total = (bg_result.rowcount or 0) + (batch_result.rowcount or 0) + return {"message": f"已清理 {total} 条任务记录", "deleted_count": total} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d40433..27f5cc9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.4.4", + "version": "1.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.4.4", + "version": "1.4.7", "dependencies": { "@ant-design/icons": "^5.6.1", "@dnd-kit/core": "^6.3.1", @@ -2165,9 +2165,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2461,14 +2461,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -2504,9 +2504,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2708,9 +2708,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -3313,9 +3313,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -3729,9 +3729,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -3976,9 +3976,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3989,9 +3989,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -4045,10 +4045,13 @@ "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -5125,9 +5128,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/src/components/FloatingTaskPanel.tsx b/frontend/src/components/FloatingTaskPanel.tsx new file mode 100644 index 0000000..ad8bcbf --- /dev/null +++ b/frontend/src/components/FloatingTaskPanel.tsx @@ -0,0 +1,341 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Card, List, Button, Space, Badge, Tag, Progress, Popconfirm, Empty, theme, Tooltip, message } from 'antd'; +import { + ClockCircleOutlined, + LoadingOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ReloadOutlined, + DeleteOutlined, + UpOutlined, + DownOutlined, + ClearOutlined, +} from '@ant-design/icons'; +import { getProjectTasks, cancelTask, cancelBatchTask, deleteTask, clearProjectTasks, type TaskStatus } from '../services/backgroundTaskService'; +import { eventBus } from '../store/eventBus'; + +interface FloatingTaskPanelProps { + projectId: string; + autoRefreshInterval?: number; // 自动刷新间隔(毫秒),默认3000 +} + +/** + * 悬浮任务框组件 + * 显示在页面右下角,支持收起/展开 + */ +export const FloatingTaskPanel: React.FC = ({ + projectId, + autoRefreshInterval = 3000, +}) => { + const [taskList, setTaskList] = useState([]); + const [loading, setLoading] = useState(false); + const [collapsed, setCollapsed] = useState(true); // 默认收起 + const userCollapsedRef = useRef(false); // 用户手动收起标记 + const { token } = theme.useToken(); + + // 加载任务列表 + const loadTasks = useCallback(async () => { + if (!projectId) return; + setLoading(true); + try { + const result = await getProjectTasks(projectId); + setTaskList(result.items || []); + } catch (error) { + console.error('加载任务列表失败:', error); + } finally { + setLoading(false); + } + }, [projectId]); + + // 初始加载 + useEffect(() => { + loadTasks(); + }, [loadTasks]); + + // 监听后台任务创建事件,立即刷新列表并展开浮窗 + useEffect(() => { + const handleTaskCreated = () => { + loadTasks(); + // 创建新任务时自动展开(重置用户手动收起标记) + userCollapsedRef.current = false; + setCollapsed(false); + }; + eventBus.on('background-task-created', handleTaskCreated); + return () => { + eventBus.off('background-task-created', handleTaskCreated); + }; + }, [loadTasks]); + + // 有活跃任务时自动展开(仅当用户没有手动收起时) + useEffect(() => { + const hasActiveTasks = taskList.some( + (t) => t.status === 'running' || t.status === 'pending' + ); + if (hasActiveTasks && !userCollapsedRef.current) { + setCollapsed(false); + } + }, [taskList]); + + // 自动刷新(仅当有运行中或等待中的任务时) + useEffect(() => { + const hasActiveTasks = taskList.some( + (t) => t.status === 'running' || t.status === 'pending' + ); + + if (!hasActiveTasks) return; + + const timer = setInterval(loadTasks, autoRefreshInterval); + return () => clearInterval(timer); + }, [taskList, autoRefreshInterval, loadTasks]); + + // 取消任务 + const handleCancelTask = async (task: TaskStatus) => { + try { + if (task.task_type === 'chapter_batch') { + await cancelBatchTask(task.id); + } else { + await cancelTask(task.id); + } + loadTasks(); + } catch (error) { + console.error('取消任务失败:', error); + } + }; + + // 删除任务记录 + const handleDeleteTask = async (taskId: string) => { + try { + await deleteTask(taskId); + loadTasks(); + } catch (error) { + console.error('删除任务记录失败:', error); + } + }; + + // 一键清理已结束的任务记录 + const handleClearTasks = async () => { + try { + const result = await clearProjectTasks(projectId); + message.success(`已清理 ${result.deleted_count} 条任务记录`); + loadTasks(); + } catch (error) { + console.error('清理任务记录失败:', error); + message.error('清理任务记录失败'); + } + }; + + // 获取任务状态标签 + const getTaskStatusTag = (status: TaskStatus['status']) => { + switch (status) { + case 'pending': + return } color="default">等待中; + case 'running': + return } color="processing">运行中; + case 'completed': + return } color="success">已完成; + case 'failed': + return } color="error">失败; + case 'cancelled': + return } color="default">已取消; + default: + return {status}; + } + }; + + // 获取任务类型标签 + const getTaskTypeLabel = (taskType: string) => { + switch (taskType) { + case 'outline_new': + return '大纲生成'; + case 'outline_continue': + return '大纲续写'; + case 'outline_expand': + return '大纲展开'; + case 'chapter_generate': + return '章节生成'; + case 'chapter_batch': + return '批量章节生成'; + case 'wizard': + return '向导创建'; + default: + return taskType; + } + }; + + const activeTasks = taskList.filter((t) => t.status === 'running' || t.status === 'pending'); + const hasActiveTasks = activeTasks.length > 0; + + // 没有任务时不显示浮窗 + if (taskList.length === 0) return null; + + return ( +
+ + + 后台任务 + {hasActiveTasks && } + + } + extra={ + + + + + )} + {(task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled') && ( + handleDeleteTask(task.id)} + okText="确认" + cancelText="取消" + > + + + )} + +
+ + + )} + /> + )} + + )} + + + ); +}; + +export default FloatingTaskPanel; diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index f9ef218..93ce944 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Progress, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd'; -import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined, ClockCircleOutlined, LoadingOutlined } from '@ant-design/icons'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd'; +import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons'; import { useStore } from '../store'; +import { eventBus } from '../store/eventBus'; import { useChapterSync } from '../store/hooks'; -import { generateChapterBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus as BgTaskStatus } from '../services/backgroundTaskService'; +import { generateChapterBackground } from '../services/backgroundTaskService'; import { projectApi, writingStyleApi, chapterApi } from '../services/api'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import type { TextAreaRef } from 'antd/es/input/TextArea'; @@ -97,111 +98,6 @@ export default function Chapters() { const [singleChapterProgress, setSingleChapterProgress] = useState(0); const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState(''); - // 后台生成任务状态 - const [bgTaskVisible, setBgTaskVisible] = useState(false); - const [bgTaskProgress, setBgTaskProgress] = useState(0); - const [bgTaskMessage, setBgTaskMessage] = useState(''); - const [bgTaskRunning, setBgTaskRunning] = useState(false); - const bgTaskCancelRef = useRef<(() => void) | null>(null); - const [projectBgTasks, setProjectBgTasks] = useState([]); - const bgPollTimerRef = useRef | null>(null); - // 后台任务列表 Modal 状态 - const [taskListVisible, setTaskListVisible] = useState(false); - const [taskList, setTaskList] = useState([]); - const [taskListLoading, setTaskListLoading] = useState(false); - - // 轮询项目后台任务 - useEffect(() => { - if (!currentProject) return; - const pollBgTasks = async () => { - try { - const resp = await getProjectTasks(currentProject.id, 'chapter_generate', 10); - const active = resp.items.filter(t => t.status === 'pending' || t.status === 'running'); - setProjectBgTasks(active); - // 如果有活跃任务,继续轮询 - if (active.length > 0) { - bgPollTimerRef.current = setTimeout(pollBgTasks, 3000); - } - } catch {} - }; - pollBgTasks(); - return () => { if (bgPollTimerRef.current) clearTimeout(bgPollTimerRef.current); }; - }, [currentProject]); - - // 加载并显示后台任务列表 - const showTaskListModal = async () => { - if (!currentProject?.id) return; - setTaskListVisible(true); - setTaskListLoading(true); - try { - const result = await getProjectTasks(currentProject.id); - setTaskList(result.items || []); - } catch (error) { - message.error('加载任务列表失败'); - } finally { - setTaskListLoading(false); - } - }; - - // 刷新任务列表 - const refreshTaskList = async () => { - if (!currentProject?.id) return; - setTaskListLoading(true); - try { - const result = await getProjectTasks(currentProject.id); - setTaskList(result.items || []); - const active = (result.items || []).filter(t => t.status === 'pending' || t.status === 'running'); - setProjectBgTasks(active); - } catch (error) { - console.error('刷新任务列表失败:', error); - } finally { - setTaskListLoading(false); - } - }; - - // 获取任务状态标签 - const getTaskStatusTag = (status: BgTaskStatus['status']) => { - switch (status) { - case 'pending': return } color="default">等待中; - case 'running': return } color="processing">运行中; - case 'completed': return } color="success">已完成; - case 'failed': return } color="error">失败; - case 'cancelled': return } color="default">已取消; - default: return {status}; - } - }; - - // 获取任务类型标签 - const getTaskTypeLabel = (taskType: string) => { - switch (taskType) { - case 'chapter_generate': return '章节生成'; - case 'outline_new': return '大纲生成'; - case 'outline_continue': return '大纲续写'; - default: return taskType; - } - }; - - // 处理取消后台任务 - const handleCancelBgTask = async (taskId: string) => { - try { - await cancelTask(taskId); - message.success('任务已取消'); - refreshTaskList(); - } catch (error) { - message.error('取消任务失败'); - } - }; - - // 处理删除任务记录 - const handleDeleteBgTask = async (taskId: string) => { - try { - await deleteTask(taskId); - message.success('任务记录已删除'); - refreshTaskList(); - } catch (error) { - message.error('删除任务记录失败'); - } - }; // 批量生成相关状态 const [batchGenerateVisible, setBatchGenerateVisible] = useState(false); @@ -643,7 +539,7 @@ export default function Chapters() { // 启动轮询 startBatchPolling(task.batch_id); - message.info('检测到未完成的批量生成任务,已在顶部显示进度'); + message.info('检测到未完成的批量生成任务,请查看任务列表'); } } catch (error) { console.error('检查批量生成任务失败:', error); @@ -1079,6 +975,7 @@ export default function Chapters() { // 后台生成章节(关闭浏览器也不影响) + // 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示 const handleBackgroundGenerate = async () => { if (!editingId) return; if (!selectedStyleId) { @@ -1087,12 +984,7 @@ export default function Chapters() { } try { - setBgTaskVisible(true); - setBgTaskRunning(true); - setBgTaskProgress(0); - setBgTaskMessage("正在创建后台任务..."); - - const cancelFn = await generateChapterBackground( + await generateChapterBackground( editingId, { style_id: selectedStyleId, @@ -1100,14 +992,10 @@ export default function Chapters() { model: selectedModel, narrative_perspective: temporaryNarrativePerspective, }, - (status) => { - setBgTaskProgress(status.progress || 0); - setBgTaskMessage(status.status_message || "处理中..."); + () => { + // 进度更新由悬浮任务框处理,无需额外操作 }, (_) => { - setBgTaskProgress(100); - setBgTaskMessage("生成完成!"); - setBgTaskRunning(false); message.success("后台章节生成完成!"); refreshChapters(); if (currentProject) { @@ -1116,17 +1004,15 @@ export default function Chapters() { loadAnalysisTasks(); }, (error) => { - setBgTaskRunning(false); - setBgTaskMessage("失败: " + error); message.error("后台生成失败: " + error); } ); - bgTaskCancelRef.current = cancelFn; - message.info("已提交后台生成任务,可以关闭此页面"); + message.info("章节生成任务已提交,可在右下角任务面板查看进度"); + // 通知悬浮任务框刷新 + eventBus.emit('background-task-created'); } catch (error) { message.error("创建后台任务失败"); - setBgTaskRunning(false); } }; const getStatusColor = (status: string) => { @@ -1243,7 +1129,7 @@ export default function Chapters() { try { setBatchGenerating(true); - setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗 + setBatchGenerateVisible(false); // 关闭配置对话框,任务进度在悬浮任务框中显示 const requestBody: { start_chapter_number: number; @@ -1293,7 +1179,9 @@ export default function Chapters() { estimated_time_minutes: result.estimated_time_minutes, }); - message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`); + message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟,可在右下角任务面板查看进度`); + // 通知悬浮任务框刷新 + eventBus.emit('background-task-created'); // 🔔 触发浏览器通知(任务开始) showBrowserNotification( @@ -2092,23 +1980,17 @@ export default function Chapters() { > 一键分析{batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''} - - - )} - {/* 单章节后台生成进度 */} - {projectBgTasks.map(task => ( -
- - {task.status === 'running' ? '生成中' : '排队中'} - -
-
-
-
-
- - {task.progress || 0}% - - - {task.status_message || ''} - -
- ))} -
- )}
{chapters.length === 0 ? ( @@ -2769,7 +2555,7 @@ export default function Chapters() { icon={canGenerate ? : } onClick={() => currentChapter && showGenerateModal(currentChapter)} loading={isContinuing} - disabled={!canGenerate || bgTaskRunning} + disabled={!canGenerate} danger={!canGenerate} style={{ fontWeight: 'bold' }} title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'} @@ -2779,8 +2565,7 @@ export default function Chapters() { - - - } - > - {taskListLoading && taskList.length === 0 ? ( -
- -
加载中...
-
- ) : taskList.length === 0 ? ( - - ) : ( - ( - handleCancelBgTask(task.id)}>取消] - : [] - ), - ...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled' - ? [] - : [] - ), - ].filter(Boolean)} - > - - {getTaskStatusTag(task.status)} - {getTaskTypeLabel(task.task_type)} - {task.status === 'running' || task.status === 'pending' ? ( - - ) : null} - - } - description={ -
-
- {task.status_message || '无状态信息'} -
-
- 创建: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'} - {task.completed_at && ' | 完成: ' + new Date(task.completed_at).toLocaleString()} -
- {task.error_message && ( -
- {'❌ ' + task.error_message} -
- )} - {task.task_result && task.status === 'completed' && ( -
- {'✅ ' + ((task.task_result as Record).message as string || '任务完成')} -
- )} -
- } - /> -
- )} - /> - )} - - - {/* 章节阅读器 */} {readingChapter && ( void) | null>(null); - // 后台任务列表状态 - const [taskListVisible, setTaskListVisible] = useState(false); - const [taskList, setTaskList] = useState([]); - const [taskListLoading, setTaskListLoading] = useState(false); useEffect(() => { const handleResize = () => { @@ -190,10 +186,26 @@ export default function Outline() { refreshOutlines(); // 加载项目角色列表 loadProjectCharacters(); + // 检查是否有活跃的大纲生成任务,恢复按钮禁用状态 + checkActiveOutlineTasks(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); // 只依赖 ID,不依赖函数 + // 检查是否有活跃的大纲生成任务(页面切换后恢复状态) + const checkActiveOutlineTasks = async () => { + if (!currentProject?.id) return; + try { + const result = await getProjectTasks(currentProject.id, 'outline_new', 5); + const result2 = await getProjectTasks(currentProject.id, 'outline_continue', 5); + const allTasks = [...(result.items || []), ...(result2.items || [])]; + const hasActive = allTasks.some((t: TaskStatus) => t.status === 'running' || t.status === 'pending'); + setIsGenerating(hasActive); + } catch (error) { + console.error('检查活跃大纲任务失败:', error); + } + }; + // 加载项目角色列表 const loadProjectCharacters = async () => { if (!currentProject?.id) return; @@ -546,11 +558,6 @@ export default function Outline() { // 关闭生成表单Modal Modal.destroyAll(); - // 显示进度Modal - setSSEProgress(0); - setSSEMessage('正在连接AI服务...'); - setSSEModalVisible(true); - // 准备请求数据 const requestData: OutlineGenerateRequestData = { project_id: currentProject.id, @@ -583,35 +590,30 @@ export default function Outline() { console.log('========================='); // 使用后台任务生成(不怕断连,关闭浏览器也继续运行) - setSSEMessage('正在创建后台任务...'); - - const cancelFn = await generateOutlineBackground( + // 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示 + await generateOutlineBackground( requestData, - (status) => { - setSSEProgress(status.progress); - setSSEMessage(status.status_message || '处理中...'); + () => { + // 进度更新由悬浮任务框处理,无需额外操作 }, (result) => { message.success(result.task_result?.message as string || '大纲生成完成!'); - setSSEModalVisible(false); setIsGenerating(false); - cancelGenerateRef.current = null; refreshOutlines(); }, (error) => { message.error(`生成失败: ${error}`); - setSSEModalVisible(false); setIsGenerating(false); - cancelGenerateRef.current = null; } ); - cancelGenerateRef.current = cancelFn; + message.info('大纲生成任务已提交,可在右下角任务面板查看进度'); + // 通知悬浮任务框刷新 + eventBus.emit('background-task-created'); } catch (error) { console.error('AI生成失败:', error); message.error('AI生成失败'); - setSSEModalVisible(false); setIsGenerating(false); } }; @@ -1902,168 +1904,8 @@ export default function Outline() { }; - // 加载并显示后台任务列表 - const showTaskListModal = async () => { - if (!currentProject?.id) return; - setTaskListVisible(true); - setTaskListLoading(true); - try { - const result = await getProjectTasks(currentProject.id); - setTaskList(result.items || []); - } catch (error) { - message.error('加载任务列表失败'); - } finally { - setTaskListLoading(false); - } - }; - - // 刷新任务列表 - const refreshTaskList = async () => { - if (!currentProject?.id) return; - setTaskListLoading(true); - try { - const result = await getProjectTasks(currentProject.id); - setTaskList(result.items || []); - } catch (error) { - console.error('刷新任务列表失败:', error); - } finally { - setTaskListLoading(false); - } - }; - - // 获取任务状态标签 - const getTaskStatusTag = (status: TaskStatus['status']) => { - switch (status) { - case 'pending': return } color="default">等待中; - case 'running': return } color="processing">运行中; - case 'completed': return } color="success">已完成; - case 'failed': return } color="error">失败; - case 'cancelled': return } color="default">已取消; - default: return {status}; - } - }; - - // 获取任务类型标签 - const getTaskTypeLabel = (taskType: string) => { - switch (taskType) { - case 'outline_new': return '大纲生成'; - case 'outline_continue': return '大纲续写'; - default: return taskType; - } - }; - - // 处理取消后台任务 - const handleCancelTask = async (taskId: string) => { - try { - await cancelTask(taskId); - message.success('任务已取消'); - refreshTaskList(); - } catch (error) { - message.error('取消任务失败'); - } - }; - - // 处理删除任务记录 - const handleDeleteTask = async (taskId: string) => { - try { - await deleteTask(taskId); - message.success('任务记录已删除'); - refreshTaskList(); - } catch (error) { - message.error('删除任务记录失败'); - } - }; - return ( <> - {/* 后台任务列表 Modal */} - - - 后台任务 - {taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && ( - t.status === 'running' || t.status === 'pending').length} /> - )} - - } - open={taskListVisible} - onCancel={() => setTaskListVisible(false)} - width={isMobile ? '95%' : 700} - centered - footer={ - - - - - } - > - {taskListLoading && taskList.length === 0 ? ( -
- -
加载中...
-
- ) : taskList.length === 0 ? ( - - ) : ( - ( - handleCancelTask(task.id)}>取消] - : [] - ), - ...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled' - ? [] - : [] - ), - ].filter(Boolean)} - > - - {getTaskStatusTag(task.status)} - {getTaskTypeLabel(task.task_type)} - {task.status === 'running' || task.status === 'pending' ? ( - - ) : null} - - } - description={ -
-
- {task.status_message || '无状态信息'} -
-
- 创建: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'} - {task.completed_at && ` | 完成: ${new Date(task.completed_at).toLocaleString()}`} -
- {task.error_message && ( -
- ❌ {task.error_message} -
- )} - {task.task_result && task.status === 'completed' && ( -
- ✅ {(task.task_result as Record).message as string || '任务完成'} -
- )} -
- } - /> -
- )} - /> - )} -
- {/* 批量展开预览 Modal */} { - if (cancelGenerateRef.current) { - cancelGenerateRef.current(); - cancelGenerateRef.current = null; - } setSSEModalVisible(false); - setIsGenerating(false); - message.info('已取消生成任务'); + setIsExpanding(false); + message.info('已取消操作'); }} /> @@ -2153,15 +1991,6 @@ export default function Outline() { > {isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'} - - - {outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (