update: 重构后台任务展示,采用悬浮窗样式

This commit is contained in:
xiamuceer-j
2026-04-29 17:31:06 +08:00
parent 9a9ae0608e
commit 5f5fd99005
10 changed files with 613 additions and 610 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ from app.models import (
Settings, WritingStyle, ProjectDefaultStyle, Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember, RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask
) )
# Alembic Config 对象 # Alembic Config 对象
+2 -1
View File
@@ -25,7 +25,8 @@ from app.models import (
Settings, WritingStyle, ProjectDefaultStyle, Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember, RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask
) )
# Alembic Config 对象 # Alembic Config 对象
@@ -28,7 +28,7 @@ def upgrade() -> None:
sa.Column('status_message', sa.String(), nullable=True), sa.Column('status_message', sa.String(), nullable=True),
sa.Column('progress_details', sa.Text(), nullable=True), sa.Column('progress_details', sa.Text(), nullable=True),
sa.Column('error_message', 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('task_result', sa.Text(), nullable=True),
sa.Column('cancel_requested', sa.Boolean(), default=False), sa.Column('cancel_requested', sa.Boolean(), default=False),
sa.Column('retry_count', sa.Integer(), default=0), sa.Column('retry_count', sa.Integer(), default=0),
+123 -25
View File
@@ -1,10 +1,12 @@
"""后台任务API - 查询状态、取消任务""" """后台任务API - 查询状态、取消任务"""
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional from typing import Optional
from app.database import get_db from app.database import get_db
from app.models.background_task import BackgroundTask 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.services.background_task_service import background_task_service
from app.logger import get_logger from app.logger import get_logger
@@ -54,31 +56,75 @@ async def get_tasks(
limit: int = 20, limit: int = 20,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""获取项目的后台任务列表""" """获取项目的后台任务列表(合并 BackgroundTask 和 BatchGenerationTask"""
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="未登录") 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 project_id, user_id, db, task_type=task_type, limit=limit
) )
return { items = [
"items": [ {
{ "id": t.id,
"id": t.id, "task_type": t.task_type,
"task_type": t.task_type, "status": t.status,
"status": t.status, "progress": t.progress,
"progress": t.progress, "status_message": t.status_message,
"status_message": t.status_message, "progress_details": t.progress_details,
"progress_details": t.progress_details, "error_message": t.error_message,
"error_message": t.error_message, "created_at": t.created_at.isoformat() if t.created_at else None,
"created_at": t.created_at.isoformat() if t.created_at else None, "completed_at": t.completed_at.isoformat() if t.completed_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None, }
} for t in bg_tasks
for t in 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="取消任务") @router.post("/{task_id}/cancel", summary="取消任务")
@@ -105,18 +151,70 @@ async def delete_task(
request: Request, request: Request,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""删除已完成/失败的任务记录""" """删除已完成/失败的任务记录(支持 BackgroundTask 和 BatchGenerationTask"""
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="未登录") raise HTTPException(status_code=401, detail="未登录")
# 先尝试从 BackgroundTask 查找
task = await background_task_service.get_task(task_id, user_id, db) task = await background_task_service.get_task(task_id, user_id, db)
if not task: if task:
raise HTTPException(status_code=404, detail="任务不存在") 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"): # 再尝试从 BatchGenerationTask 查找
raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消") 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() await db.commit()
return {"message": "任务记录已删除"}
total = (bg_result.rowcount or 0) + (batch_result.rowcount or 0)
return {"message": f"已清理 {total} 条任务记录", "deleted_count": total}
+37 -34
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.4.4", "version": "1.4.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.4.4", "version": "1.4.7",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -2165,9 +2165,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2461,14 +2461,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.6", "version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.15.11",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/babel-plugin-macros": { "node_modules/babel-plugin-macros": {
@@ -2504,9 +2504,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2708,9 +2708,9 @@
} }
}, },
"node_modules/cosmiconfig/node_modules/yaml": { "node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -3313,9 +3313,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -3729,9 +3729,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -3976,9 +3976,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3989,9 +3989,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4045,10 +4045,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=10"
}
}, },
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
@@ -5125,9 +5128,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -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<FloatingTaskPanelProps> = ({
projectId,
autoRefreshInterval = 3000,
}) => {
const [taskList, setTaskList] = useState<TaskStatus[]>([]);
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 <Tag icon={<ClockCircleOutlined />} color="default"></Tag>;
case 'running':
return <Tag icon={<LoadingOutlined />} color="processing"></Tag>;
case 'completed':
return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>;
case 'failed':
return <Tag icon={<CloseCircleOutlined />} color="error"></Tag>;
case 'cancelled':
return <Tag icon={<CloseCircleOutlined />} color="default"></Tag>;
default:
return <Tag>{status}</Tag>;
}
};
// 获取任务类型标签
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 (
<div
style={{
position: 'fixed',
bottom: 10,
right: 23,
width: collapsed ? 260 : 400,
maxHeight: collapsed ? 60 : 500,
zIndex: 1000,
boxShadow: token.boxShadowSecondary,
borderRadius: token.borderRadiusLG,
overflow: 'hidden',
transition: 'all 0.3s ease',
}}
>
<Card
size="small"
title={
<Space>
<ClockCircleOutlined />
<span></span>
{hasActiveTasks && <Badge count={activeTasks.length} />}
</Space>
}
extra={
<Space>
<Tooltip title="刷新">
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={loadTasks}
loading={loading}
/>
</Tooltip>
{taskList.some(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled') && (
<Popconfirm
title="确认清理所有已结束的任务记录?"
onConfirm={handleClearTasks}
okText="确认"
cancelText="取消"
>
<Tooltip title="清理已结束任务">
<Button
type="text"
size="small"
icon={<ClearOutlined />}
/>
</Tooltip>
</Popconfirm>
)}
<Button
type="text"
size="small"
icon={collapsed ? <UpOutlined /> : <DownOutlined />}
onClick={() => {
const newCollapsed = !collapsed;
setCollapsed(newCollapsed);
// 记录用户手动收起,防止自动展开覆盖
userCollapsedRef.current = newCollapsed;
}}
/>
</Space>
}
bodyStyle={{
padding: collapsed ? 0 : 12,
maxHeight: collapsed ? 0 : 400,
overflowY: 'auto',
transition: 'all 0.3s ease',
}}
>
{!collapsed && (
<>
{taskList.length === 0 ? (
<Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={taskList}
renderItem={(task: TaskStatus) => (
<List.Item
key={task.id}
style={{
padding: '8px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<div style={{ width: '100%' }}>
<div style={{ marginBottom: 4 }}>
<Space size={4} wrap>
{getTaskStatusTag(task.status)}
<Tag color="blue">{getTaskTypeLabel(task.task_type)}</Tag>
</Space>
</div>
{task.status_message && (
<div
style={{
fontSize: 12,
color: token.colorTextSecondary,
marginBottom: 4,
}}
>
{task.status_message}
</div>
)}
{(task.status === 'running' || task.status === 'pending') && (
<Progress
percent={task.progress}
size="small"
status={task.status === 'running' ? 'active' : 'normal'}
style={{ marginBottom: 4 }}
/>
)}
{task.error_message && (
<div
style={{
fontSize: 12,
color: token.colorError,
marginBottom: 4,
}}
>
: {task.error_message}
</div>
)}
<div style={{ marginTop: 8 }}>
<Space size={4}>
{(task.status === 'running' || task.status === 'pending') && (
<Popconfirm
title="确认取消任务?"
onConfirm={() => handleCancelTask(task)}
okText="确认"
cancelText="取消"
>
<Button size="small" danger>
</Button>
</Popconfirm>
)}
{(task.status === 'completed' ||
task.status === 'failed' ||
task.status === 'cancelled') && (
<Popconfirm
title="确认删除任务记录?"
onConfirm={() => handleDeleteTask(task.id)}
okText="确认"
cancelText="取消"
>
<Button size="small" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
</div>
</div>
</List.Item>
)}
/>
)}
</>
)}
</Card>
</div>
);
};
export default FloatingTaskPanel;
+22 -346
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; 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 { 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, ClockCircleOutlined, LoadingOutlined } from '@ant-design/icons'; 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 { useStore } from '../store';
import { eventBus } from '../store/eventBus';
import { useChapterSync } from '../store/hooks'; 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 { projectApi, writingStyleApi, chapterApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { TextAreaRef } from 'antd/es/input/TextArea';
@@ -97,111 +98,6 @@ export default function Chapters() {
const [singleChapterProgress, setSingleChapterProgress] = useState(0); const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState(''); 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<BgTaskStatus[]>([]);
const bgPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 后台任务列表 Modal 状态
const [taskListVisible, setTaskListVisible] = useState(false);
const [taskList, setTaskList] = useState<BgTaskStatus[]>([]);
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 <Tag icon={<ClockCircleOutlined />} color="default"></Tag>;
case 'running': return <Tag icon={<LoadingOutlined />} color="processing"></Tag>;
case 'completed': return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>;
case 'failed': return <Tag icon={<CloseCircleOutlined />} color="error"></Tag>;
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default"></Tag>;
default: return <Tag>{status}</Tag>;
}
};
// 获取任务类型标签
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); const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
@@ -643,7 +539,7 @@ export default function Chapters() {
// 启动轮询 // 启动轮询
startBatchPolling(task.batch_id); startBatchPolling(task.batch_id);
message.info('检测到未完成的批量生成任务,已在顶部显示进度'); message.info('检测到未完成的批量生成任务,请查看任务列表');
} }
} catch (error) { } catch (error) {
console.error('检查批量生成任务失败:', error); console.error('检查批量生成任务失败:', error);
@@ -1079,6 +975,7 @@ export default function Chapters() {
// 后台生成章节(关闭浏览器也不影响) // 后台生成章节(关闭浏览器也不影响)
// 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示
const handleBackgroundGenerate = async () => { const handleBackgroundGenerate = async () => {
if (!editingId) return; if (!editingId) return;
if (!selectedStyleId) { if (!selectedStyleId) {
@@ -1087,12 +984,7 @@ export default function Chapters() {
} }
try { try {
setBgTaskVisible(true); await generateChapterBackground(
setBgTaskRunning(true);
setBgTaskProgress(0);
setBgTaskMessage("正在创建后台任务...");
const cancelFn = await generateChapterBackground(
editingId, editingId,
{ {
style_id: selectedStyleId, style_id: selectedStyleId,
@@ -1100,14 +992,10 @@ export default function Chapters() {
model: selectedModel, model: selectedModel,
narrative_perspective: temporaryNarrativePerspective, narrative_perspective: temporaryNarrativePerspective,
}, },
(status) => { () => {
setBgTaskProgress(status.progress || 0); // 进度更新由悬浮任务框处理,无需额外操作
setBgTaskMessage(status.status_message || "处理中...");
}, },
(_) => { (_) => {
setBgTaskProgress(100);
setBgTaskMessage("生成完成!");
setBgTaskRunning(false);
message.success("后台章节生成完成!"); message.success("后台章节生成完成!");
refreshChapters(); refreshChapters();
if (currentProject) { if (currentProject) {
@@ -1116,17 +1004,15 @@ export default function Chapters() {
loadAnalysisTasks(); loadAnalysisTasks();
}, },
(error) => { (error) => {
setBgTaskRunning(false);
setBgTaskMessage("失败: " + error);
message.error("后台生成失败: " + error); message.error("后台生成失败: " + error);
} }
); );
bgTaskCancelRef.current = cancelFn; message.info("章节生成任务已提交,可在右下角任务面板查看进度");
message.info("已提交后台生成任务,可以关闭此页面"); // 通知悬浮任务框刷新
eventBus.emit('background-task-created');
} catch (error) { } catch (error) {
message.error("创建后台任务失败"); message.error("创建后台任务失败");
setBgTaskRunning(false);
} }
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -1243,7 +1129,7 @@ export default function Chapters() {
try { try {
setBatchGenerating(true); setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗 setBatchGenerateVisible(false); // 关闭配置对话框,任务进度在悬浮任务框中显示
const requestBody: { const requestBody: {
start_chapter_number: number; start_chapter_number: number;
@@ -1293,7 +1179,9 @@ export default function Chapters() {
estimated_time_minutes: result.estimated_time_minutes, 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( showBrowserNotification(
@@ -2092,23 +1980,17 @@ export default function Chapters() {
> >
{batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''} {batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''}
</Button> </Button>
<Button
icon={<ClockCircleOutlined />}
onClick={showTaskListModal}
>
{projectBgTasks.length > 0 && <Badge count={projectBgTasks.length} size="small" style={{ marginLeft: 4 }} />}
</Button>
<Button <Button
type="primary" type="primary"
icon={<RocketOutlined />} icon={<RocketOutlined />}
onClick={handleOpenBatchGenerate} onClick={handleOpenBatchGenerate}
disabled={chapters.length === 0} disabled={chapters.length === 0 || batchGenerating}
loading={batchGenerating}
block={isMobile} block={isMobile}
size={isMobile ? 'middle' : 'middle'} size={isMobile ? 'middle' : 'middle'}
style={{ background: token.colorInfo, borderColor: token.colorInfo }} style={batchGenerating ? {} : { background: token.colorInfo, borderColor: token.colorInfo }}
> >
{batchGenerating ? '生成中...' : '批量生成'}
</Button> </Button>
<Button <Button
type="default" type="default"
@@ -2123,102 +2005,6 @@ export default function Chapters() {
</Space> </Space>
</div> </div>
{/* 后台生成任务进度 */}
{(projectBgTasks.length > 0 || (batchGenerating && batchProgress)) && (
<div style={{
marginBottom: 16,
padding: '12px 16px',
background: token.colorInfoBg,
borderRadius: token.borderRadius,
border: `1px solid ${token.colorInfoBorder}`
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<RocketOutlined style={{ color: token.colorInfo }} spin />
<span style={{ fontWeight: 600, color: token.colorInfo }}>
</span>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
</span>
</div>
{/* 批量生成进度 */}
{batchGenerating && batchProgress && (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}>
<Tag color="processing" style={{ minWidth: 60, textAlign: 'center' }}>
</Tag>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, marginBottom: 4, color: token.colorText }}>
{batchProgress.current_chapter_number
? `正在生成第 ${batchProgress.current_chapter_number}`
: '批量生成中...'} ({batchProgress.completed}/{batchProgress.total})
</div>
<div style={{
background: token.colorBgLayout, borderRadius: 4,
height: 8, overflow: 'hidden'
}}>
<div style={{
background: token.colorInfo, height: '100%',
width: (batchProgress.total > 0 ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0) + '%',
transition: 'width 0.3s'
}} />
</div>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: token.colorInfo, minWidth: 40, textAlign: 'right' }}>
{batchProgress.total > 0 ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}%
</span>
<Button size="small" danger onClick={() => {
modal.confirm({
title: '确认取消',
content: '确定要取消批量生成吗?已生成的章节将保留。',
okText: '确定取消',
cancelText: '继续生成',
okButtonProps: { danger: true },
centered: true,
onOk: handleCancelBatchGenerate,
});
}}>
</Button>
</div>
)}
{/* 单章节后台生成进度 */}
{projectBgTasks.map(task => (
<div key={task.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '6px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}>
<Tag color={task.status === 'running' ? 'processing' : 'default'}
style={{ minWidth: 60, textAlign: 'center' }}>
{task.status === 'running' ? '生成中' : '排队中'}
</Tag>
<div style={{ flex: 1 }}>
<div style={{
background: token.colorBgLayout, borderRadius: 4,
height: 6, overflow: 'hidden'
}}>
<div style={{
background: token.colorInfo, height: '100%',
width: (task.progress || 0) + '%',
transition: 'width 0.3s'
}} />
</div>
</div>
<span style={{ fontSize: 12, color: token.colorTextSecondary, minWidth: 40, textAlign: 'right' }}>
{task.progress || 0}%
</span>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
{task.status_message || ''}
</span>
</div>
))}
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}> <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{chapters.length === 0 ? ( {chapters.length === 0 ? (
@@ -2769,7 +2555,7 @@ export default function Chapters() {
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />} icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
onClick={() => currentChapter && showGenerateModal(currentChapter)} onClick={() => currentChapter && showGenerateModal(currentChapter)}
loading={isContinuing} loading={isContinuing}
disabled={!canGenerate || bgTaskRunning} disabled={!canGenerate}
danger={!canGenerate} danger={!canGenerate}
style={{ fontWeight: 'bold' }} style={{ fontWeight: 'bold' }}
title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'} title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'}
@@ -2779,8 +2565,7 @@ export default function Chapters() {
<Button <Button
icon={<RocketOutlined />} icon={<RocketOutlined />}
onClick={handleBackgroundGenerate} onClick={handleBackgroundGenerate}
disabled={!canGenerate || bgTaskRunning || isContinuing} disabled={!canGenerate || isContinuing}
loading={bgTaskRunning}
style={{ fontWeight: 'bold' }} style={{ fontWeight: 'bold' }}
title={!canGenerate ? disabledReason : '后台生成:关闭浏览器也不影响,完成后自动保存'} title={!canGenerate ? disabledReason : '后台生成:关闭浏览器也不影响,完成后自动保存'}
> >
@@ -2792,26 +2577,6 @@ export default function Chapters() {
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
{/* 后台生成进度 */}
{bgTaskVisible && (
<Alert
message={bgTaskRunning ? '后台生成进行中...' : '后台生成完成'}
description={
<div>
<div style={{ marginBottom: 8 }}>{bgTaskMessage}</div>
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 8, overflow: 'hidden' }}>
<div style={{ background: '#1890ff', height: '100%', width: bgTaskProgress + '%', transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{bgTaskProgress}%</div>
</div>
}
type={bgTaskRunning ? 'info' : (bgTaskProgress >= 100 ? 'success' : 'error')}
showIcon
style={{ marginBottom: 12 }}
closable={!bgTaskRunning}
onClose={() => setBgTaskVisible(false)}
/>
)}
{/* 第一行:写作风格 + 叙事角度 */} {/* 第一行:写作风格 + 叙事角度 */}
<div style={{ <div style={{
@@ -3233,95 +2998,6 @@ export default function Chapters() {
message={singleChapterProgressMessage} message={singleChapterProgressMessage}
/> />
{/* 后台任务列表 Modal */}
<Modal
title={
<Space>
<ClockCircleOutlined />
<span></span>
{taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && (
<Badge count={taskList.filter(t => t.status === 'running' || t.status === 'pending').length} />
)}
</Space>
}
open={taskListVisible}
onCancel={() => setTaskListVisible(false)}
width={isMobile ? '95%' : 700}
centered
footer={
<Space>
<Button icon={<SyncOutlined />} onClick={refreshTaskList} loading={taskListLoading}>
</Button>
<Button onClick={() => setTaskListVisible(false)}>
</Button>
</Space>
}
>
{taskListLoading && taskList.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>...</div>
</div>
) : taskList.length === 0 ? (
<Empty description="暂无后台任务" />
) : (
<List
dataSource={taskList}
renderItem={(task) => (
<List.Item
key={task.id}
actions={[
...(task.status === 'running' || task.status === 'pending'
? [<Button key="cancel" size="small" danger onClick={() => handleCancelBgTask(task.id)}></Button>]
: []
),
...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
? [<Button key="delete" size="small" type="link" danger onClick={() => handleDeleteBgTask(task.id)}></Button>]
: []
),
].filter(Boolean)}
>
<List.Item.Meta
title={
<Space>
{getTaskStatusTag(task.status)}
<span>{getTaskTypeLabel(task.task_type)}</span>
{task.status === 'running' || task.status === 'pending' ? (
<Progress percent={task.progress} size="small" style={{ width: 120 }} />
) : null}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{task.status_message || '无状态信息'}
</div>
<div style={{ fontSize: 11, color: token.colorTextTertiary, marginTop: 4 }}>
: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'}
{task.completed_at && ' | 完成: ' + new Date(task.completed_at).toLocaleString()}
</div>
{task.error_message && (
<div style={{ fontSize: 12, color: token.colorError, marginTop: 4 }}>
{'❌ ' + task.error_message}
</div>
)}
{task.task_result && task.status === 'completed' && (
<div style={{ fontSize: 12, color: token.colorSuccess, marginTop: 4 }}>
{'✅ ' + ((task.task_result as Record<string, unknown>).message as string || '任务完成')}
</div>
)}
</div>
}
/>
</List.Item>
)}
/>
)}
</Modal>
{/* 章节阅读器 */} {/* 章节阅读器 */}
{readingChapter && ( {readingChapter && (
<ChapterReader <ChapterReader
+31 -202
View File
@@ -1,11 +1,13 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination, theme, Progress, Badge, Tooltip } from 'antd'; import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination, theme } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined, ClockCircleOutlined, ReloadOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons'; import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import { eventBus } from '../store/eventBus';
import { getProjectTasks, type TaskStatus } from '../services/backgroundTaskService';
import { useOutlineSync } from '../store/hooks'; import { useOutlineSync } from '../store/hooks';
import { SSEPostClient } from '../utils/sseClient'; import { SSEPostClient } from '../utils/sseClient';
import { SSEProgressModal } from '../components/SSEProgressModal'; import { SSEProgressModal } from '../components/SSEProgressModal';
import { generateOutlineBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus } from '../services/backgroundTaskService'; import { generateOutlineBackground } from '../services/backgroundTaskService';
import { outlineApi, chapterApi, projectApi, characterApi } from '../services/api'; import { outlineApi, chapterApi, projectApi, characterApi } from '../services/api';
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse, ChapterPlanItem, ApiError, Character } from '../types'; import type { OutlineExpansionResponse, BatchOutlineExpansionResponse, ChapterPlanItem, ApiError, Character } from '../types';
@@ -155,13 +157,7 @@ export default function Outline() {
const [sseMessage, setSSEMessage] = useState(''); const [sseMessage, setSSEMessage] = useState('');
const [sseModalVisible, setSSEModalVisible] = useState(false); const [sseModalVisible, setSSEModalVisible] = useState(false);
// 后台任务取消函数引用
const cancelGenerateRef = useRef<(() => void) | null>(null);
// 后台任务列表状态
const [taskListVisible, setTaskListVisible] = useState(false);
const [taskList, setTaskList] = useState<TaskStatus[]>([]);
const [taskListLoading, setTaskListLoading] = useState(false);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@@ -190,10 +186,26 @@ export default function Outline() {
refreshOutlines(); refreshOutlines();
// 加载项目角色列表 // 加载项目角色列表
loadProjectCharacters(); loadProjectCharacters();
// 检查是否有活跃的大纲生成任务,恢复按钮禁用状态
checkActiveOutlineTasks();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]); // 只依赖 ID,不依赖函数 }, [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 () => { const loadProjectCharacters = async () => {
if (!currentProject?.id) return; if (!currentProject?.id) return;
@@ -546,11 +558,6 @@ export default function Outline() {
// 关闭生成表单Modal // 关闭生成表单Modal
Modal.destroyAll(); Modal.destroyAll();
// 显示进度Modal
setSSEProgress(0);
setSSEMessage('正在连接AI服务...');
setSSEModalVisible(true);
// 准备请求数据 // 准备请求数据
const requestData: OutlineGenerateRequestData = { const requestData: OutlineGenerateRequestData = {
project_id: currentProject.id, project_id: currentProject.id,
@@ -583,35 +590,30 @@ export default function Outline() {
console.log('========================='); console.log('=========================');
// 使用后台任务生成(不怕断连,关闭浏览器也继续运行) // 使用后台任务生成(不怕断连,关闭浏览器也继续运行)
setSSEMessage('正在创建后台任务...'); // 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示
await generateOutlineBackground(
const cancelFn = await generateOutlineBackground(
requestData, requestData,
(status) => { () => {
setSSEProgress(status.progress); // 进度更新由悬浮任务框处理,无需额外操作
setSSEMessage(status.status_message || '处理中...');
}, },
(result) => { (result) => {
message.success(result.task_result?.message as string || '大纲生成完成!'); message.success(result.task_result?.message as string || '大纲生成完成!');
setSSEModalVisible(false);
setIsGenerating(false); setIsGenerating(false);
cancelGenerateRef.current = null;
refreshOutlines(); refreshOutlines();
}, },
(error) => { (error) => {
message.error(`生成失败: ${error}`); message.error(`生成失败: ${error}`);
setSSEModalVisible(false);
setIsGenerating(false); setIsGenerating(false);
cancelGenerateRef.current = null;
} }
); );
cancelGenerateRef.current = cancelFn; message.info('大纲生成任务已提交,可在右下角任务面板查看进度');
// 通知悬浮任务框刷新
eventBus.emit('background-task-created');
} catch (error) { } catch (error) {
console.error('AI生成失败:', error); console.error('AI生成失败:', error);
message.error('AI生成失败'); message.error('AI生成失败');
setSSEModalVisible(false);
setIsGenerating(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 <Tag icon={<ClockCircleOutlined />} color="default"></Tag>;
case 'running': return <Tag icon={<LoadingOutlined />} color="processing"></Tag>;
case 'completed': return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>;
case 'failed': return <Tag icon={<CloseCircleOutlined />} color="error"></Tag>;
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default"></Tag>;
default: return <Tag>{status}</Tag>;
}
};
// 获取任务类型标签
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 ( return (
<> <>
{/* 后台任务列表 Modal */}
<Modal
title={
<Space>
<ClockCircleOutlined />
<span></span>
{taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && (
<Badge count={taskList.filter(t => t.status === 'running' || t.status === 'pending').length} />
)}
</Space>
}
open={taskListVisible}
onCancel={() => setTaskListVisible(false)}
width={isMobile ? '95%' : 700}
centered
footer={
<Space>
<Button icon={<ReloadOutlined />} onClick={refreshTaskList} loading={taskListLoading}>
</Button>
<Button onClick={() => setTaskListVisible(false)}>
</Button>
</Space>
}
>
{taskListLoading && taskList.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>...</div>
</div>
) : taskList.length === 0 ? (
<Empty description="暂无后台任务" />
) : (
<List
dataSource={taskList}
renderItem={(task) => (
<List.Item
key={task.id}
actions={[
...(task.status === 'running' || task.status === 'pending'
? [<Button key="cancel" size="small" danger onClick={() => handleCancelTask(task.id)}></Button>]
: []
),
...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
? [<Button key="delete" size="small" type="link" danger onClick={() => handleDeleteTask(task.id)}></Button>]
: []
),
].filter(Boolean)}
>
<List.Item.Meta
title={
<Space>
{getTaskStatusTag(task.status)}
<span>{getTaskTypeLabel(task.task_type)}</span>
{task.status === 'running' || task.status === 'pending' ? (
<Progress percent={task.progress} size="small" style={{ width: 120 }} />
) : null}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{task.status_message || '无状态信息'}
</div>
<div style={{ fontSize: 11, color: token.colorTextTertiary, marginTop: 4 }}>
: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'}
{task.completed_at && ` | 完成: ${new Date(task.completed_at).toLocaleString()}`}
</div>
{task.error_message && (
<div style={{ fontSize: 12, color: token.colorError, marginTop: 4 }}>
{task.error_message}
</div>
)}
{task.task_result && task.status === 'completed' && (
<div style={{ fontSize: 12, color: token.colorSuccess, marginTop: 4 }}>
{(task.task_result as Record<string, unknown>).message as string || '任务完成'}
</div>
)}
</div>
}
/>
</List.Item>
)}
/>
)}
</Modal>
{/* 批量展开预览 Modal */} {/* 批量展开预览 Modal */}
<Modal <Modal
title={ title={
@@ -2092,13 +1934,9 @@ export default function Outline() {
message={sseMessage} message={sseMessage}
title="AI生成中(后台运行,可关闭页面)..." title="AI生成中(后台运行,可关闭页面)..."
onCancel={() => { onCancel={() => {
if (cancelGenerateRef.current) {
cancelGenerateRef.current();
cancelGenerateRef.current = null;
}
setSSEModalVisible(false); setSSEModalVisible(false);
setIsGenerating(false); setIsExpanding(false);
message.info('已取消生成任务'); message.info('已取消操作');
}} }}
/> />
@@ -2153,15 +1991,6 @@ export default function Outline() {
> >
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'} {isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
</Button> </Button>
<Tooltip title="查看后台任务进度">
<Button
icon={<ClockCircleOutlined />}
onClick={showTaskListModal}
block={isMobile}
>
{isMobile ? '任务' : '后台任务'}
</Button>
</Tooltip>
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && ( {outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
<Button <Button
icon={<AppstoreAddOutlined />} icon={<AppstoreAddOutlined />}
+4
View File
@@ -26,6 +26,7 @@ import { projectApi } from '../services/api';
import ThemeSwitch from '../components/ThemeSwitch'; import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode'; import { useThemeMode } from '../theme/useThemeMode';
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState'; import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
import FloatingTaskPanel from '../components/FloatingTaskPanel';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -656,6 +657,9 @@ export default function ProjectDetail() {
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
{/* 悬浮任务框 */}
{projectId && <FloatingTaskPanel projectId={projectId} />}
</Layout> </Layout>
); );
} }
@@ -31,6 +31,22 @@ export interface TaskListResponse {
items: TaskStatus[]; items: TaskStatus[];
} }
/**
* 批量生成任务状态
*/
export interface BatchTaskStatus {
id: string;
project_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
total_chapters: number;
completed_chapters: number;
current_chapter_number: number | null;
error_message: string | null;
created_at: string | null;
started_at: string | null;
completed_at: string | null;
}
/** /**
* 查询任务状态 * 查询任务状态
*/ */
@@ -59,6 +75,29 @@ export async function getProjectTasks(
return response.json(); return response.json();
} }
/**
* 获取项目活跃的批量生成任务
*/
export async function getActiveBatchTasks(projectId: string): Promise<BatchTaskStatus[]> {
const response = await fetch(`/api/chapters/project/${projectId}/batch-generate/active`);
if (!response.ok) {
throw new Error(`获取批量生成任务失败: ${response.statusText}`);
}
const data = await response.json();
// API 返回单个任务或空,统一转为数组
return data ? [data] : [];
}
/**
* 取消批量生成任务
*/
export async function cancelBatchTask(batchId: string): Promise<void> {
const response = await fetch(`/api/chapters/batch-generate/${batchId}/cancel`, { method: 'POST' });
if (!response.ok) {
throw new Error(`取消批量生成任务失败: ${response.statusText}`);
}
}
/** /**
* 取消任务 * 取消任务
*/ */
@@ -69,6 +108,17 @@ export async function cancelTask(taskId: string): Promise<void> {
} }
} }
/**
* 清理项目已结束的任务记录
*/
export async function clearProjectTasks(projectId: string): Promise<{ deleted_count: number }> {
const response = await fetch(`${API_BASE}/project/${projectId}/clear`, { method: 'DELETE' });
if (!response.ok) {
throw new Error(`清理任务记录失败: ${response.statusText}`);
}
return response.json();
}
/** /**
* 删除任务记录 * 删除任务记录
*/ */