update: 重构后台任务展示,采用悬浮窗样式
This commit is contained in:
@@ -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 对象
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
Generated
+37
-34
@@ -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
@@ -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
@@ -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 />}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除任务记录
|
* 删除任务记录
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user