feat: 实现异步任务管理系统和任务中心UI

## 后端变更

  ### 任务管理核心
  - 新增 tasker 服务,支持异步任务队列和工作线程池
  - 添加任务执行时间追踪(started_at, completed_at)
  - 实现任务状态持久化(JSON文件存储)
  - 支持任务取消和进度更新

  ### 知识库导入优化
  - 将知识库文档导入改为异步任务处理
  - 逐个处理文档并实时更新进度(5% → 95%)
  - 支持任务取消检测

  ### API接口
  - 新增任务列表、详情、取消等REST API
  - 任务API需要管理员权限

  ### 测试
  - 添加任务路由集成测试

  ## 前端变更

  ### 任务中心UI
  - 新增任务中心抽屉组件,采用简洁的卡片式设计
  - 支持任务状态筛选(全部/进行中/已完成/失败)
  - 进行中任务显示实时进度条
  - 已完成任务显示执行时长和完成状态徽章
  - 支持相对时间显示(如:5分钟前)

  ### 状态管理
  - 新增 tasker store,管理任务列表和轮询
  - 支持任务注册、刷新、取消操作

  ### 交互优化
  - 在状态栏和侧边栏添加任务中心入口
  - 显示活跃任务数量徽章
  - 知识库导入自动注册到任务中心
This commit is contained in:
Wenjie Zhang 2025-10-11 15:02:24 +08:00
parent ca3e95f89c
commit 43ec5a4a71
16 changed files with 1475 additions and 17 deletions

View File

@ -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]"
)

View File

@ -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)

View File

@ -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/*

View File

@ -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}")

View 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"}

View File

@ -0,0 +1,3 @@
from .tasker import TaskContext, Tasker, tasker
__all__ = ["TaskContext", "Tasker", "tasker"]

301
server/services/tasker.py Normal file
View 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"]

View 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")

View File

@ -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
View 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`, {})
}
}

View File

@ -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;

View 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>

View File

@ -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;

View File

@ -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
View 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
}
})

View File

@ -695,5 +695,3 @@ onUnmounted(() => {
}
}
</style>