feat: 实现异步任务管理系统和任务中心UI
## 后端变更 ### 任务管理核心 - 新增 tasker 服务,支持异步任务队列和工作线程池 - 添加任务执行时间追踪(started_at, completed_at) - 实现任务状态持久化(JSON文件存储) - 支持任务取消和进度更新 ### 知识库导入优化 - 将知识库文档导入改为异步任务处理 - 逐个处理文档并实时更新进度(5% → 95%) - 支持任务取消检测 ### API接口 - 新增任务列表、详情、取消等REST API - 任务API需要管理员权限 ### 测试 - 添加任务路由集成测试 ## 前端变更 ### 任务中心UI - 新增任务中心抽屉组件,采用简洁的卡片式设计 - 支持任务状态筛选(全部/进行中/已完成/失败) - 进行中任务显示实时进度条 - 已完成任务显示执行时长和完成状态徽章 - 支持相对时间显示(如:5分钟前) ### 状态管理 - 新增 tasker store,管理任务列表和轮询 - 支持任务注册、刷新、取消操作 ### 交互优化 - 在状态栏和侧边栏添加任务中心入口 - 显示活跃任务数量徽章 - 知识库导入自动注册到任务中心
This commit is contained in:
parent
ca3e95f89c
commit
43ec5a4a71
@ -88,8 +88,18 @@ async def process_document(
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Check if the overall request was successful
|
||||
if result.get("status") != "success":
|
||||
# Handle asynchronous ingest response
|
||||
overall_status = result.get("status")
|
||||
if overall_status == "queued":
|
||||
task_id = result.get("task_id")
|
||||
extra = f" (task id: {task_id})" if task_id else ""
|
||||
console.print(
|
||||
f"[bold cyan]Ingestion queued for {server_file_path}{extra}. Track progress in the task center.[/bold cyan]"
|
||||
)
|
||||
return True
|
||||
|
||||
# Check if the overall request was successful for synchronous responses
|
||||
if overall_status != "success":
|
||||
console.print(
|
||||
f"[bold yellow]Processing warning for {server_file_path}: {result.get('message')}[/bold yellow]"
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from server.routers import router
|
||||
from server.services.tasker import tasker
|
||||
from server.utils.auth_middleware import is_public_path
|
||||
from server.utils.common_utils import setup_logging
|
||||
|
||||
@ -59,5 +60,16 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
# 添加鉴权中间件
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def start_tasker() -> None:
|
||||
await tasker.start()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def stop_tasker() -> None:
|
||||
await tasker.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=5050, threads=10, workers=10, reload=True)
|
||||
|
||||
@ -6,6 +6,7 @@ from server.routers.dashboard_router import dashboard
|
||||
from server.routers.graph_router import graph
|
||||
from server.routers.knowledge_router import knowledge
|
||||
from server.routers.system_router import system
|
||||
from server.routers.task_router import tasks
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -16,3 +17,4 @@ router.include_router(chat) # /api/chat/*
|
||||
router.include_router(dashboard) # /api/dashboard/*
|
||||
router.include_router(knowledge) # /api/knowledge/*
|
||||
router.include_router(graph) # /api/graph/*
|
||||
router.include_router(tasks) # /api/tasks/*
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from urllib.parse import quote, unquote
|
||||
@ -8,6 +9,7 @@ from starlette.responses import FileResponse as StarletteFileResponse
|
||||
|
||||
from src.storage.db.models import User
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from server.services.tasker import TaskContext, tasker
|
||||
from src import config, knowledge_base
|
||||
from src.knowledge.indexing import SUPPORTED_FILE_EXTENSIONS, is_supported_file_extension, process_file_to_markdown
|
||||
from src.models.embed import test_embedding_model_status, test_all_embedding_models_status
|
||||
@ -160,15 +162,63 @@ async def add_documents(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
async def run_ingest(context: TaskContext):
|
||||
await context.set_message("任务初始化")
|
||||
await context.set_progress(5.0, "准备处理文档")
|
||||
|
||||
total = len(items)
|
||||
processed_items = []
|
||||
|
||||
try:
|
||||
# 逐个处理文档并更新进度
|
||||
for idx, item in enumerate(items, 1):
|
||||
await context.raise_if_cancelled()
|
||||
|
||||
# 更新进度
|
||||
progress = 5.0 + (idx / total) * 90.0 # 5% ~ 95%
|
||||
await context.set_progress(progress, f"正在处理第 {idx}/{total} 个文档")
|
||||
|
||||
# 处理单个文档
|
||||
result = await knowledge_base.add_content(db_id, [item], params=params)
|
||||
processed_items.extend(result)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await context.set_progress(100.0, "任务已取消")
|
||||
raise
|
||||
|
||||
item_type = "URL" if content_type == "url" else "文件"
|
||||
failed_count = len([_p for _p in processed_items if _p.get("status") == "failed"])
|
||||
summary = {
|
||||
"db_id": db_id,
|
||||
"item_type": item_type,
|
||||
"submitted": len(processed_items),
|
||||
"failed": failed_count,
|
||||
}
|
||||
message = f"{item_type}处理完成,失败 {failed_count} 个" if failed_count else f"{item_type}处理完成"
|
||||
await context.set_result(summary | {"items": processed_items})
|
||||
await context.set_progress(100.0, message)
|
||||
return summary | {"items": processed_items}
|
||||
|
||||
try:
|
||||
processed_items = await knowledge_base.add_content(db_id, items, params=params)
|
||||
item_type = "URLs" if content_type == "url" else "files"
|
||||
processed_failed_count = len([_p for _p in processed_items if _p["status"] == "failed"])
|
||||
processed_info = f"Processed {len(processed_items)} {item_type}, {processed_failed_count} {item_type} failed"
|
||||
return {"message": processed_info, "items": processed_items, "status": "success"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process {content_type}s: {e}, {traceback.format_exc()}")
|
||||
return {"message": f"Failed to process {content_type}s: {e}", "status": "failed"}
|
||||
task = await tasker.enqueue(
|
||||
name=f"知识库文档处理({db_id})",
|
||||
task_type="knowledge_ingest",
|
||||
payload={
|
||||
"db_id": db_id,
|
||||
"items": items,
|
||||
"params": params,
|
||||
"content_type": content_type,
|
||||
},
|
||||
coroutine=run_ingest,
|
||||
)
|
||||
return {
|
||||
"message": "任务已提交,请在任务中心查看进度",
|
||||
"status": "queued",
|
||||
"task_id": task.id,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"Failed to enqueue {content_type}s: {e}, {traceback.format_exc()}")
|
||||
return {"message": f"Failed to enqueue task: {e}", "status": "failed"}
|
||||
|
||||
|
||||
@knowledge.get("/databases/{db_id}/documents/{doc_id}")
|
||||
|
||||
35
server/routers/task_router.py
Normal file
35
server/routers/task_router.py
Normal file
@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from src.storage.db.models import User
|
||||
from server.services.tasker import tasker
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
|
||||
tasks = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@tasks.get("")
|
||||
async def list_tasks(
|
||||
status: str | None = Query(default=None),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""List tasks, optionally filtered by status."""
|
||||
task_list = await tasker.list_tasks(status=status)
|
||||
return {"tasks": task_list}
|
||||
|
||||
|
||||
@tasks.get("/{task_id}")
|
||||
async def get_task(task_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""Retrieve a single task by id."""
|
||||
task = await tasker.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {"task": task}
|
||||
|
||||
|
||||
@tasks.post("/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""Request cancellation of a task."""
|
||||
success = await tasker.cancel_task(task_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Task cannot be cancelled")
|
||||
return {"task_id": task_id, "status": "cancelled"}
|
||||
3
server/services/__init__.py
Normal file
3
server/services/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .tasker import TaskContext, Tasker, tasker
|
||||
|
||||
__all__ = ["TaskContext", "Tasker", "tasker"]
|
||||
301
server/services/tasker.py
Normal file
301
server/services/tasker.py
Normal file
@ -0,0 +1,301 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from src.config import config
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
TaskCoroutine = Callable[["TaskContext"], Awaitable[Any]]
|
||||
TERMINAL_STATUSES = {"success", "failed", "cancelled"}
|
||||
|
||||
|
||||
def _utc_timestamp() -> str:
|
||||
return datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
status: str = "pending"
|
||||
progress: float = 0.0
|
||||
message: str = ""
|
||||
created_at: str = field(default_factory=_utc_timestamp)
|
||||
updated_at: str = field(default_factory=_utc_timestamp)
|
||||
started_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
payload: Dict[str, Any] = field(default_factory=dict)
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
cancel_requested: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
data = asdict(self)
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Task":
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data.get("name", "Unnamed Task"),
|
||||
type=data.get("type", "general"),
|
||||
status=data.get("status", "pending"),
|
||||
progress=data.get("progress", 0.0),
|
||||
message=data.get("message", ""),
|
||||
created_at=data.get("created_at", _utc_timestamp()),
|
||||
updated_at=data.get("updated_at", _utc_timestamp()),
|
||||
started_at=data.get("started_at"),
|
||||
completed_at=data.get("completed_at"),
|
||||
payload=data.get("payload", {}),
|
||||
result=data.get("result"),
|
||||
error=data.get("error"),
|
||||
cancel_requested=data.get("cancel_requested", False),
|
||||
)
|
||||
|
||||
|
||||
class TaskContext:
|
||||
def __init__(self, tasker: "Tasker", task_id: str):
|
||||
self._tasker = tasker
|
||||
self.task_id = task_id
|
||||
|
||||
async def set_progress(self, progress: float, message: Optional[str] = None) -> None:
|
||||
await self._tasker._update_task(
|
||||
self.task_id,
|
||||
progress=max(0.0, min(progress, 100.0)),
|
||||
message=message,
|
||||
)
|
||||
|
||||
async def set_message(self, message: str) -> None:
|
||||
await self._tasker._update_task(self.task_id, message=message)
|
||||
|
||||
async def set_result(self, result: Any) -> None:
|
||||
await self._tasker._update_task(self.task_id, result=result)
|
||||
|
||||
def is_cancel_requested(self) -> bool:
|
||||
return self._tasker._is_cancel_requested(self.task_id)
|
||||
|
||||
async def raise_if_cancelled(self) -> None:
|
||||
if self.is_cancel_requested():
|
||||
raise asyncio.CancelledError("Task was cancelled")
|
||||
|
||||
|
||||
class Tasker:
|
||||
def __init__(self, worker_count: int = 2):
|
||||
self.worker_count = max(1, worker_count)
|
||||
self._queue: "asyncio.Queue[tuple[str, TaskCoroutine]]" = asyncio.Queue()
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._workers: List[asyncio.Task[Any]] = []
|
||||
self._storage_path = Path(config.save_dir) / "tasks" / "tasks.json"
|
||||
os.makedirs(self._storage_path.parent, exist_ok=True)
|
||||
self._started = False
|
||||
|
||||
async def start(self) -> None:
|
||||
async with self._lock:
|
||||
if self._started:
|
||||
return
|
||||
await self._load_state()
|
||||
for _ in range(self.worker_count):
|
||||
worker = asyncio.create_task(self._worker_loop(), name="tasker-worker")
|
||||
self._workers.append(worker)
|
||||
self._started = True
|
||||
logger.info("Tasker started with %s workers", self.worker_count)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
async with self._lock:
|
||||
if not self._started:
|
||||
return
|
||||
for worker in self._workers:
|
||||
worker.cancel()
|
||||
await asyncio.gather(*self._workers, return_exceptions=True)
|
||||
self._workers.clear()
|
||||
await self._persist_state()
|
||||
self._started = False
|
||||
logger.info("Tasker shutdown complete")
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
task_type: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
coroutine: TaskCoroutine,
|
||||
) -> Task:
|
||||
task_id = uuid.uuid4().hex
|
||||
task = Task(id=task_id, name=name, type=task_type, payload=payload or {})
|
||||
async with self._lock:
|
||||
self._tasks[task_id] = task
|
||||
await self._persist_state()
|
||||
await self._queue.put((task_id, coroutine))
|
||||
logger.info("Enqueued task %s (%s)", task_id, name)
|
||||
return task
|
||||
|
||||
async def list_tasks(self, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
tasks = list(self._tasks.values())
|
||||
if status:
|
||||
tasks = [task for task in tasks if task.status == status]
|
||||
tasks.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return [task.to_dict() for task in tasks]
|
||||
|
||||
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
return task.to_dict() if task else None
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
if task.status in {"success", "failed", "cancelled"}:
|
||||
return False
|
||||
task.cancel_requested = True
|
||||
task.updated_at = _utc_timestamp()
|
||||
await self._persist_state()
|
||||
logger.info("Cancellation requested for task %s", task_id)
|
||||
return True
|
||||
|
||||
async def _worker_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
task_id, coroutine = await self._queue.get()
|
||||
try:
|
||||
task = await self._get_task_instance(task_id)
|
||||
if not task:
|
||||
continue
|
||||
if task.cancel_requested:
|
||||
await self._mark_cancelled(task_id, "Task was cancelled before execution")
|
||||
continue
|
||||
await self._update_task(task_id, status="running", progress=0.0, message="任务开始执行", started_at=_utc_timestamp())
|
||||
context = TaskContext(self, task_id)
|
||||
try:
|
||||
result = await coroutine(context)
|
||||
if task.cancel_requested:
|
||||
await self._mark_cancelled(task_id, "Task cancelled during execution")
|
||||
continue
|
||||
await self._update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
progress=100.0,
|
||||
message="任务已完成",
|
||||
result=result,
|
||||
completed_at=_utc_timestamp(),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(task_id, "任务被取消")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Task %s failed: %s", task_id, exc, exc_info=True)
|
||||
await self._update_task(
|
||||
task_id,
|
||||
status="failed",
|
||||
progress=100.0,
|
||||
message="任务执行失败",
|
||||
error=str(exc),
|
||||
completed_at=_utc_timestamp(),
|
||||
)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Tasker worker error: %s", exc, exc_info=True)
|
||||
|
||||
async def _get_task_instance(self, task_id: str) -> Optional[Task]:
|
||||
async with self._lock:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
async def _mark_cancelled(self, task_id: str, message: str) -> None:
|
||||
await self._update_task(
|
||||
task_id,
|
||||
status="cancelled",
|
||||
progress=100.0,
|
||||
message=message,
|
||||
completed_at=_utc_timestamp(),
|
||||
)
|
||||
|
||||
async def _update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
progress: Optional[float] = None,
|
||||
message: Optional[str] = None,
|
||||
result: Any = None,
|
||||
error: Optional[str] = None,
|
||||
started_at: Optional[str] = None,
|
||||
completed_at: Optional[str] = None,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
if status:
|
||||
task.status = status
|
||||
if progress is not None:
|
||||
task.progress = max(0.0, min(progress, 100.0))
|
||||
if message is not None:
|
||||
task.message = message
|
||||
if result is not None:
|
||||
task.result = result
|
||||
if error is not None:
|
||||
task.error = error
|
||||
if started_at is not None:
|
||||
task.started_at = started_at
|
||||
if completed_at is not None:
|
||||
task.completed_at = completed_at
|
||||
task.updated_at = _utc_timestamp()
|
||||
await self._persist_state()
|
||||
|
||||
def _is_cancel_requested(self, task_id: str) -> bool:
|
||||
task = self._tasks.get(task_id)
|
||||
return bool(task and task.cancel_requested)
|
||||
|
||||
async def _load_state(self) -> None:
|
||||
if not self._storage_path.exists():
|
||||
return
|
||||
try:
|
||||
content = await asyncio.to_thread(self._storage_path.read_text, encoding="utf-8")
|
||||
if not content.strip():
|
||||
return
|
||||
data = json.loads(content)
|
||||
tasks = data.get("tasks", [])
|
||||
for item in tasks:
|
||||
task = Task.from_dict(item)
|
||||
if task.status == "running":
|
||||
task.status = "failed"
|
||||
task.message = "服务重启时任务中断"
|
||||
task.updated_at = _utc_timestamp()
|
||||
elif task.status not in TERMINAL_STATUSES:
|
||||
task.status = "failed"
|
||||
task.message = "服务重启时任务未继续执行"
|
||||
task.updated_at = _utc_timestamp()
|
||||
self._tasks[task.id] = task
|
||||
logger.info("Loaded %s task records from storage", len(tasks))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to load task state: %s", exc, exc_info=True)
|
||||
|
||||
async def _persist_state(self) -> None:
|
||||
tasks = [task.to_dict() for task in self._tasks.values()]
|
||||
payload = {"tasks": tasks, "updated_at": _utc_timestamp()}
|
||||
|
||||
async def _write() -> None:
|
||||
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self._storage_path.with_suffix(".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_path, self._storage_path)
|
||||
|
||||
await asyncio.to_thread(_write)
|
||||
|
||||
|
||||
tasker = Tasker()
|
||||
|
||||
|
||||
__all__ = ["tasker", "TaskContext", "Tasker"]
|
||||
93
test/api/test_task_router.py
Normal file
93
test/api/test_task_router.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""
|
||||
Integration tests for the task management router.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def test_task_routes_require_admin(test_client, standard_user):
|
||||
"""Non-admin users should be blocked from accessing task APIs."""
|
||||
headers = standard_user["headers"]
|
||||
|
||||
list_response = await test_client.get("/api/tasks", headers=headers)
|
||||
assert list_response.status_code == 403
|
||||
|
||||
detail_response = await test_client.get("/api/tasks/some-task", headers=headers)
|
||||
assert detail_response.status_code == 403
|
||||
|
||||
cancel_response = await test_client.post("/api/tasks/some-task/cancel", headers=headers)
|
||||
assert cancel_response.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_list_tasks(test_client, admin_headers):
|
||||
"""Admin should receive a well-formed task list payload."""
|
||||
response = await test_client.get("/api/tasks", headers=admin_headers)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
payload = response.json()
|
||||
assert "tasks" in payload
|
||||
assert isinstance(payload["tasks"], list)
|
||||
|
||||
|
||||
async def test_cancel_unknown_task_returns_client_error(test_client, admin_headers):
|
||||
"""Cancelling a non-existent task should surface a 400 response."""
|
||||
response = await test_client.post("/api/tasks/not-real/cancel", headers=admin_headers)
|
||||
assert response.status_code == 400, response.text
|
||||
|
||||
|
||||
async def test_enqueue_document_creates_task(
|
||||
test_client,
|
||||
admin_headers,
|
||||
knowledge_database,
|
||||
):
|
||||
"""Trigger knowledge ingestion to ensure a task record is materialised."""
|
||||
db_id = knowledge_database["db_id"]
|
||||
|
||||
enqueue_response = await test_client.post(
|
||||
f"/api/knowledge/databases/{db_id}/documents",
|
||||
json={
|
||||
"items": [],
|
||||
"params": {"content_type": "file"},
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert enqueue_response.status_code == 200, enqueue_response.text
|
||||
|
||||
enqueue_payload = enqueue_response.json()
|
||||
assert enqueue_payload.get("status") == "queued"
|
||||
task_id = enqueue_payload.get("task_id")
|
||||
assert task_id, "Knowledge ingestion did not return a task_id"
|
||||
|
||||
# The task should be queryable immediately after enqueueing.
|
||||
detail_response = await test_client.get(f"/api/tasks/{task_id}", headers=admin_headers)
|
||||
assert detail_response.status_code == 200, detail_response.text
|
||||
detail_payload = detail_response.json().get("task", {})
|
||||
assert detail_payload.get("id") == task_id
|
||||
assert detail_payload.get("status") in {"queued", "pending", "running", "failed", "success", "cancelled"}
|
||||
|
||||
# Ensure the task surfaces in the list endpoint within a short window.
|
||||
for _ in range(10):
|
||||
list_response = await test_client.get("/api/tasks", headers=admin_headers)
|
||||
assert list_response.status_code == 200, list_response.text
|
||||
all_tasks = list_response.json().get("tasks", [])
|
||||
if any(entry.get("id") == task_id for entry in all_tasks):
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
else:
|
||||
pytest.fail("Task did not appear in list endpoint within timeout window")
|
||||
|
||||
# Poll for terminal state to validate worker bookkeeping.
|
||||
for _ in range(20):
|
||||
detail_response = await test_client.get(f"/api/tasks/{task_id}", headers=admin_headers)
|
||||
task_status = detail_response.json().get("task", {}).get("status")
|
||||
if task_status in {"success", "failed", "cancelled"}:
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
pytest.fail("Task did not reach a terminal status within timeout window")
|
||||
@ -8,6 +8,7 @@ export * from './system_api' // 系统管理API
|
||||
export * from './knowledge_api' // 知识库管理API
|
||||
export * from './graph_api' // 图谱API
|
||||
export * from './agent_api' // 智能体API
|
||||
export * from './tasker' // 任务管理API
|
||||
|
||||
// 导出基础工具函数
|
||||
export { apiGet, apiPost, apiPut, apiDelete,
|
||||
@ -36,4 +37,4 @@ export { apiGet, apiPost, apiPut, apiDelete,
|
||||
* - 智能体管理、聊天、配置等功能
|
||||
*
|
||||
* 注意:API模块已处理权限验证和请求头,使用时无需再手动添加认证头
|
||||
*/
|
||||
*/
|
||||
|
||||
19
web/src/apis/tasker.js
Normal file
19
web/src/apis/tasker.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { apiAdminGet, apiAdminPost } from './base'
|
||||
|
||||
const BASE_URL = '/api/tasks'
|
||||
|
||||
export const taskerApi = {
|
||||
fetchTasks: async (params = {}) => {
|
||||
const query = new URLSearchParams(params).toString()
|
||||
const url = query ? `${BASE_URL}?${query}` : BASE_URL
|
||||
return apiAdminGet(url)
|
||||
},
|
||||
|
||||
fetchTaskDetail: async (taskId) => {
|
||||
return apiAdminGet(`${BASE_URL}/${taskId}`)
|
||||
},
|
||||
|
||||
cancelTask: async (taskId) => {
|
||||
return apiAdminPost(`${BASE_URL}/${taskId}/cancel`, {})
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,18 @@
|
||||
<User class="icon" />
|
||||
<span class="user-greeting">{{ greeting }}</span>
|
||||
</div>
|
||||
<div class="task-center-entry" @click="openTaskCenter">
|
||||
<a-badge
|
||||
:count="activeTaskCount"
|
||||
:overflow-count="99"
|
||||
class="task-center-badge"
|
||||
>
|
||||
<span class="task-center-button">
|
||||
<ListChecks class="icon" />
|
||||
<span class="task-center-label">任务中心</span>
|
||||
</span>
|
||||
</a-badge>
|
||||
</div>
|
||||
<div class="status-indicator" :class="systemStatus">
|
||||
<div class="status-dot"></div>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
@ -34,11 +46,15 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useInfoStore } from '@/stores/info'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { Clock, User } from 'lucide-vue-next'
|
||||
import { Clock, User, ListChecks } from 'lucide-vue-next'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
// 使用 stores
|
||||
const infoStore = useInfoStore()
|
||||
const userStore = useUserStore()
|
||||
const taskerStore = useTaskerStore()
|
||||
const { activeCount: activeCountRef } = storeToRefs(taskerStore)
|
||||
|
||||
// 响应式数据
|
||||
const currentTime = ref('')
|
||||
@ -86,6 +102,12 @@ const statusText = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const activeTaskCount = computed(() => activeCountRef.value || 0)
|
||||
|
||||
const openTaskCenter = () => {
|
||||
taskerStore.openDrawer()
|
||||
}
|
||||
|
||||
// 更新时间
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
@ -174,6 +196,48 @@ onUnmounted(() => {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.task-center-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.task-center-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-center-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.task-center-button .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.task-center-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-center-entry:hover .task-center-button {
|
||||
background-color: var(--main-40, #e5f0ff);
|
||||
color: var(--main-500, #1d4ed8);
|
||||
}
|
||||
|
||||
.task-center-badge :deep(.ant-badge-count) {
|
||||
background-color: var(--main-color, #1d4ed8);
|
||||
}
|
||||
|
||||
.time-info,
|
||||
.user-info {
|
||||
display: flex;
|
||||
|
||||
659
web/src/components/TaskCenterDrawer.vue
Normal file
659
web/src/components/TaskCenterDrawer.vue
Normal file
@ -0,0 +1,659 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="isOpen"
|
||||
:width="620"
|
||||
title="任务中心"
|
||||
placement="right"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="task-center">
|
||||
<div class="task-toolbar">
|
||||
<div class="task-filter-group">
|
||||
<a-button-group>
|
||||
<a-button
|
||||
:type="isActiveFilter('all') ? 'primary' : 'default'"
|
||||
@click="setFilter('all')"
|
||||
>
|
||||
全部
|
||||
<span class="filter-count">{{ totalCount }}</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="isActiveFilter('active') ? 'primary' : 'default'"
|
||||
@click="setFilter('active')"
|
||||
>
|
||||
进行中
|
||||
<span class="filter-count">{{ inProgressCount }}</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="isActiveFilter('success') ? 'primary' : 'default'"
|
||||
@click="setFilter('success')"
|
||||
>
|
||||
已完成
|
||||
<span class="filter-count">{{ completedCount }}</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="isActiveFilter('failed') ? 'primary' : 'default'"
|
||||
@click="setFilter('failed')"
|
||||
>
|
||||
失败
|
||||
<span class="filter-count">{{ failedCount }}</span>
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
</div>
|
||||
<div class="task-toolbar-actions">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="handleRefresh"
|
||||
:loading="loadingState"
|
||||
>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="lastErrorState"
|
||||
type="error"
|
||||
show-icon
|
||||
class="task-alert"
|
||||
:message="lastErrorState.message || '加载任务信息失败'"
|
||||
/>
|
||||
|
||||
<div v-if="hasTasks" class="task-list">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
:class="taskCardClasses(task)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-info">
|
||||
<div class="task-card-title">{{ task.name }}</div>
|
||||
<div class="task-card-subtitle">
|
||||
<span class="task-card-id">#{{ formatTaskId(task.id) }}</span>
|
||||
<span class="task-card-type">{{ taskTypeLabel(task.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-tag :color="statusColor(task.status)" class="task-card-status">
|
||||
{{ statusLabel(task.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="!isTaskCompleted(task)" class="task-card-progress">
|
||||
<a-progress
|
||||
:percent="Math.round(task.progress || 0)"
|
||||
:status="progressStatus(task.status)"
|
||||
stroke-width="6"
|
||||
/>
|
||||
<span class="task-card-progress-value">{{ Math.round(task.progress || 0) }}%</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="task-card-completion">
|
||||
<div class="completion-badge" :class="`completion-badge--${task.status}`">
|
||||
<span class="completion-icon">{{ getCompletionIcon(task.status) }}</span>
|
||||
<span class="completion-text">{{ statusLabel(task.status) }}</span>
|
||||
</div>
|
||||
<div class="task-duration" v-if="getTaskDuration(task)">
|
||||
<span class="duration-label">执行时长:</span>
|
||||
<span class="duration-value">{{ getTaskDuration(task) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="task.message && !isTaskCompleted(task)" class="task-card-message">
|
||||
{{ task.message }}
|
||||
</div>
|
||||
<div v-if="task.error" class="task-card-error">
|
||||
{{ task.error }}
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<div class="task-card-timestamps">
|
||||
<span v-if="task.started_at">开始: {{ formatTime(task.started_at, 'short') }}</span>
|
||||
<span v-if="task.completed_at">完成: {{ formatTime(task.completed_at, 'short') }}</span>
|
||||
<span v-if="!task.started_at">创建: {{ formatTime(task.created_at, 'short') }}</span>
|
||||
</div>
|
||||
<div class="task-card-actions">
|
||||
<a-button type="link" size="small" @click="handleDetail(task.id)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="!canCancel(task)"
|
||||
@click="handleCancel(task.id)"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="task-empty">
|
||||
<div class="task-empty-icon">🗂️</div>
|
||||
<div class="task-empty-title">暂无任务</div>
|
||||
<div class="task-empty-subtitle">当你提交知识库导入或其他后台任务时,会在这里展示实时进度。</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onBeforeUnmount, watch, ref } from 'vue'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const taskerStore = useTaskerStore()
|
||||
const { isDrawerOpen, sortedTasks, loading, lastError } = storeToRefs(taskerStore)
|
||||
const isOpen = isDrawerOpen
|
||||
|
||||
const tasks = computed(() => sortedTasks.value)
|
||||
const loadingState = computed(() => Boolean(loading.value))
|
||||
const lastErrorState = computed(() => lastError.value)
|
||||
const statusFilter = ref('all')
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const list = tasks.value
|
||||
switch (statusFilter.value) {
|
||||
case 'active':
|
||||
return list.filter((task) => ACTIVE_CLASS_STATUSES.has(task.status))
|
||||
case 'success':
|
||||
return list.filter((task) => task.status === 'success')
|
||||
case 'failed':
|
||||
return list.filter((task) => FAILED_STATUSES.has(task.status))
|
||||
default:
|
||||
return list
|
||||
}
|
||||
})
|
||||
|
||||
const hasTasks = computed(() => filteredTasks.value.length > 0)
|
||||
|
||||
const ACTIVE_CLASS_STATUSES = new Set(['pending', 'queued', 'running'])
|
||||
const FAILED_STATUSES = new Set(['failed', 'cancelled'])
|
||||
const TASK_TYPE_LABELS = {
|
||||
knowledge_ingest: '知识库导入',
|
||||
graph_task: '图谱处理',
|
||||
agent_job: '智能体任务'
|
||||
}
|
||||
|
||||
function taskCardClasses(task) {
|
||||
return {
|
||||
'task-card--active': ACTIVE_CLASS_STATUSES.has(task.status),
|
||||
'task-card--success': task.status === 'success',
|
||||
'task-card--failed': task.status === 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
function taskTypeLabel(type) {
|
||||
if (!type) return '后台任务'
|
||||
return TASK_TYPE_LABELS[type] || type
|
||||
}
|
||||
|
||||
function formatTaskId(id) {
|
||||
if (!id) return '--'
|
||||
return id.slice(0, 8)
|
||||
}
|
||||
|
||||
watch(
|
||||
isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
taskerStore.loadTasks()
|
||||
taskerStore.startPolling()
|
||||
} else {
|
||||
taskerStore.stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
taskerStore.stopPolling()
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
taskerStore.closeDrawer()
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
taskerStore.loadTasks()
|
||||
}
|
||||
|
||||
function handleDetail(taskId) {
|
||||
const task = tasks.value.find(item => item.id === taskId)
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
const detail = h('div', { class: 'task-detail' }, [
|
||||
h('p', [h('strong', '状态:'), statusLabel(task.status)]),
|
||||
h('p', [h('strong', '进度:'), `${Math.round(task.progress || 0)}%`]),
|
||||
h('p', [h('strong', '更新时间:'), formatTime(task.updated_at)]),
|
||||
h('p', [h('strong', '描述:'), task.message || '-']),
|
||||
h('p', [h('strong', '错误:'), task.error || '-'])
|
||||
])
|
||||
Modal.info({
|
||||
title: task.name,
|
||||
width: 520,
|
||||
content: detail
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel(taskId) {
|
||||
taskerStore.cancelTask(taskId)
|
||||
}
|
||||
|
||||
function formatTime(value, format = 'full') {
|
||||
if (!value) return '-'
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (format === 'short') {
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskDuration(task) {
|
||||
if (!task.started_at || !task.completed_at) return null
|
||||
try {
|
||||
const start = new Date(task.started_at)
|
||||
const end = new Date(task.completed_at)
|
||||
const diffMs = end - start
|
||||
const seconds = Math.floor(diffMs / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes % 60}分钟`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分钟${seconds % 60}秒`
|
||||
} else {
|
||||
return `${seconds}秒`
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isTaskCompleted(task) {
|
||||
return ['success', 'failed', 'cancelled'].includes(task.status)
|
||||
}
|
||||
|
||||
function getCompletionIcon(status) {
|
||||
const icons = {
|
||||
success: '✓',
|
||||
failed: '✗',
|
||||
cancelled: '○'
|
||||
}
|
||||
return icons[status] || '?'
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const map = {
|
||||
pending: '等待中',
|
||||
queued: '已排队',
|
||||
running: '进行中',
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
const map = {
|
||||
pending: 'blue',
|
||||
queued: 'blue',
|
||||
running: 'processing',
|
||||
success: 'green',
|
||||
failed: 'red',
|
||||
cancelled: 'gray'
|
||||
}
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
function progressStatus(status) {
|
||||
if (status === 'failed') return 'exception'
|
||||
if (status === 'cancelled') return 'normal'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
function canCancel(task) {
|
||||
return ['pending', 'running', 'queued'].includes(task.status) && !task.cancel_requested
|
||||
}
|
||||
|
||||
const inProgressCount = computed(
|
||||
() => tasks.value.filter((task) => ACTIVE_CLASS_STATUSES.has(task.status)).length
|
||||
)
|
||||
const completedCount = computed(() => tasks.value.filter((task) => task.status === 'success').length)
|
||||
const failedCount = computed(
|
||||
() => tasks.value.filter((task) => FAILED_STATUSES.has(task.status)).length
|
||||
)
|
||||
const totalCount = computed(() => tasks.value.length)
|
||||
|
||||
function setFilter(value) {
|
||||
statusFilter.value = value
|
||||
}
|
||||
|
||||
function isActiveFilter(value) {
|
||||
return statusFilter.value === value
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="less">
|
||||
.task-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.task-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-filter-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-filter-group :deep(.ant-btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.task-toolbar-actions :deep(.ant-btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.task-alert {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.task-card--active {
|
||||
background: linear-gradient(to bottom, #ffffff, #fafbff);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.task-card--success {
|
||||
background: linear-gradient(to bottom, #ffffff, #f6fff8);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.task-card--failed {
|
||||
background: linear-gradient(to bottom, #ffffff, #fffbfb);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-card-subtitle {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-card-id {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.task-card-type {
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background-color: rgba(15, 23, 42, 0.06);
|
||||
color: #475569;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.task-card-status {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.task-card-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card-progress :deep(.ant-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-card-progress-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.task-card-message,
|
||||
.task-card-error {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.task-card-message {
|
||||
background: rgba(15, 23, 42, 0.03);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.task-card-error {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.task-card-footer {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-card-timestamps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-card-completion {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.02);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.completion-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.completion-badge--success {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.completion-badge--success .completion-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #dcfce7;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.completion-badge--failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.completion-badge--failed .completion-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #fee2e2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.completion-badge--cancelled {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.completion-badge--cancelled .completion-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.task-empty {
|
||||
margin-top: 32px;
|
||||
padding: 40px 30px;
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.03);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
text-align: center;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-empty-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.task-empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-empty-subtitle {
|
||||
font-size: 13px;
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@ -5,18 +5,23 @@ import {
|
||||
GithubOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Bot, Waypoints, LibraryBig, Settings, BarChart3, BookOpen } from 'lucide-vue-next';
|
||||
import { Bot, Waypoints, LibraryBig, Settings, BarChart3, BookOpen, ListChecks } from 'lucide-vue-next';
|
||||
import { onLongPress } from '@vueuse/core'
|
||||
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useInfoStore } from '@/stores/info'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import UserInfoComponent from '@/components/UserInfoComponent.vue'
|
||||
import DebugComponent from '@/components/DebugComponent.vue'
|
||||
import TaskCenterDrawer from '@/components/TaskCenterDrawer.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const databaseStore = useDatabaseStore()
|
||||
const infoStore = useInfoStore()
|
||||
const taskerStore = useTaskerStore()
|
||||
const { activeCount: activeCountRef, isDrawerOpen } = storeToRefs(taskerStore)
|
||||
|
||||
const layoutSettings = reactive({
|
||||
showDebug: false,
|
||||
@ -87,6 +92,8 @@ onMounted(async () => {
|
||||
const route = useRoute()
|
||||
console.log(route)
|
||||
|
||||
const activeTaskCount = computed(() => activeCountRef.value || 0)
|
||||
|
||||
// 下面是导航菜单部分,添加智能体项
|
||||
const mainList = [{
|
||||
name: '智能体',
|
||||
@ -134,6 +141,23 @@ const mainList = [{
|
||||
<component class="icon" :is="route.path.startsWith(item.path) ? item.activeIcon : item.icon" size="22"/>
|
||||
</a-tooltip>
|
||||
</RouterLink>
|
||||
<div
|
||||
class="nav-item task-center"
|
||||
:class="{ active: isDrawerOpen }"
|
||||
@click="taskerStore.openDrawer()"
|
||||
>
|
||||
<a-tooltip placement="right">
|
||||
<template #title>任务中心</template>
|
||||
<a-badge
|
||||
:count="activeTaskCount"
|
||||
:overflow-count="99"
|
||||
class="task-center-badge"
|
||||
size="small"
|
||||
>
|
||||
<ListChecks class="icon" size="22" />
|
||||
</a-badge>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="htmlRefHook"
|
||||
@ -214,6 +238,7 @@ const mainList = [{
|
||||
>
|
||||
<DebugComponent />
|
||||
</a-modal>
|
||||
<TaskCenterDrawer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -365,6 +390,14 @@ div.header, #app-router-view {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
&.task-center {
|
||||
.task-center-badge {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
text-shadow: 0 0 15px var(--main-300);
|
||||
font-weight: bold;
|
||||
|
||||
@ -3,10 +3,12 @@ import { defineStore } from 'pinia';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { databaseApi, documentApi, queryApi } from '@/apis/knowledge_api';
|
||||
import { useTaskerStore } from '@/stores/tasker';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
export const useDatabaseStore = defineStore('database', () => {
|
||||
const router = useRouter();
|
||||
const taskerStore = useTaskerStore();
|
||||
|
||||
// State
|
||||
const database = ref({});
|
||||
@ -187,9 +189,22 @@ export const useDatabaseStore = defineStore('database', () => {
|
||||
state.chunkLoading = true;
|
||||
try {
|
||||
const data = await documentApi.addDocuments(databaseId.value, items, { ...params, content_type: contentType });
|
||||
if (data.status === 'success') {
|
||||
if (data.status === 'success' || data.status === 'queued') {
|
||||
const itemType = contentType === 'file' ? '文件' : 'URL';
|
||||
message.success(data.message || `${itemType}已提交处理,请稍后在列表刷新查看状态`);
|
||||
message.success(data.message || `${itemType}已提交处理,请在任务中心查看进度`);
|
||||
if (data.task_id) {
|
||||
taskerStore.registerQueuedTask({
|
||||
task_id: data.task_id,
|
||||
name: `知识库导入 (${databaseId.value || ''})`,
|
||||
task_type: 'knowledge_ingest',
|
||||
message: data.message,
|
||||
payload: {
|
||||
db_id: databaseId.value,
|
||||
count: items.length,
|
||||
content_type: contentType,
|
||||
}
|
||||
});
|
||||
}
|
||||
await getDatabaseInfo();
|
||||
return true; // Indicate success
|
||||
} else {
|
||||
|
||||
163
web/src/stores/tasker.js
Normal file
163
web/src/stores/tasker.js
Normal file
@ -0,0 +1,163 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { taskerApi } from '@/apis/tasker'
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['pending', 'running', 'queued'])
|
||||
|
||||
const toTask = (raw = {}) => ({
|
||||
id: raw.id,
|
||||
name: raw.name || '后台任务',
|
||||
type: raw.type || 'general',
|
||||
status: raw.status || 'pending',
|
||||
progress: raw.progress ?? 0,
|
||||
message: raw.message || '',
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
started_at: raw.started_at,
|
||||
completed_at: raw.completed_at,
|
||||
payload: raw.payload || {},
|
||||
result: raw.result,
|
||||
error: raw.error,
|
||||
cancel_requested: raw.cancel_requested || false
|
||||
})
|
||||
|
||||
export const useTaskerStore = defineStore('tasker', () => {
|
||||
const tasks = ref([])
|
||||
const loading = ref(false)
|
||||
const lastError = ref(null)
|
||||
const isPolling = ref(false)
|
||||
const isDrawerOpen = ref(false)
|
||||
let pollingTimer = null
|
||||
|
||||
const sortedTasks = computed(() => {
|
||||
return [...tasks.value].sort((a, b) => {
|
||||
if (!a.created_at || !b.created_at) return 0
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
const activeCount = computed(() => sortedTasks.value.filter(task => ACTIVE_STATUSES.has(task.status)).length)
|
||||
|
||||
function upsertTask(rawTask) {
|
||||
if (!rawTask || !rawTask.id) return
|
||||
const task = toTask(rawTask)
|
||||
const index = tasks.value.findIndex(item => item.id === task.id)
|
||||
if (index >= 0) {
|
||||
tasks.value.splice(index, 1, { ...tasks.value[index], ...task })
|
||||
} else {
|
||||
tasks.value.unshift(task)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks(params = {}) {
|
||||
loading.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const response = await taskerApi.fetchTasks(params)
|
||||
const taskList = response?.tasks || []
|
||||
tasks.value = taskList.map(toTask)
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败', error)
|
||||
lastError.value = error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTask(taskId) {
|
||||
if (!taskId) return
|
||||
try {
|
||||
const response = await taskerApi.fetchTaskDetail(taskId)
|
||||
if (response?.task) {
|
||||
upsertTask(response.task)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`刷新任务 ${taskId} 详情失败`, error)
|
||||
lastError.value = error
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTask(taskId) {
|
||||
if (!taskId) return
|
||||
try {
|
||||
await taskerApi.cancelTask(taskId)
|
||||
message.success('取消任务成功')
|
||||
await refreshTask(taskId)
|
||||
} catch (error) {
|
||||
console.error(`取消任务 ${taskId} 失败`, error)
|
||||
message.error(error?.message || '取消任务失败')
|
||||
}
|
||||
}
|
||||
|
||||
function registerQueuedTask({ task_id, name, task_type, message: msg, payload } = {}) {
|
||||
if (!task_id) return
|
||||
const now = new Date().toISOString()
|
||||
upsertTask({
|
||||
id: task_id,
|
||||
name: name || '后台任务',
|
||||
type: task_type || 'manual',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
message: msg || '任务已排队',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
payload: payload || {}
|
||||
})
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
isDrawerOpen.value = !isDrawerOpen.value
|
||||
}
|
||||
|
||||
function startPolling(interval = 5000) {
|
||||
if (pollingTimer) return
|
||||
isPolling.value = true
|
||||
pollingTimer = setInterval(() => {
|
||||
loadTasks()
|
||||
}, interval)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer)
|
||||
pollingTimer = null
|
||||
}
|
||||
isPolling.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stopPolling()
|
||||
tasks.value = []
|
||||
lastError.value = null
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isDrawerOpen,
|
||||
tasks,
|
||||
sortedTasks,
|
||||
loading,
|
||||
lastError,
|
||||
activeCount,
|
||||
isPolling,
|
||||
loadTasks,
|
||||
refreshTask,
|
||||
cancelTask,
|
||||
registerQueuedTask,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
reset,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
toggleDrawer
|
||||
}
|
||||
})
|
||||
@ -695,5 +695,3 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user