Phase
This commit is contained in:
1 parent
b2ba75a666
commit
aa8d689039
7 files changed
+1323
-2
No files matched your search
@@ -0,0 +1,662 @@
|
||||
"""
|
||||
自动化任务调度管理 API
|
||||
Phase 3.2: 支持定时任务(cron)和条件触发(webhook/event)的自动化调度
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body, BackgroundTasks
|
||||
from sqlalchemy import or_, func, desc, asc, text
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from ...database import get_db
|
||||
from ...models import (
|
||||
gen_uuid,
|
||||
)
|
||||
from ...auth import get_current_admin
|
||||
from ...middleware.log_service import LogService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("aiflow")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Pydantic Models
|
||||
# ============================================================
|
||||
|
||||
class AutomationTaskCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
description: Optional[str] = ""
|
||||
trigger_type: str = Field(default="cron", description="cron / webhook / event")
|
||||
cron_expression: Optional[str] = None # e.g. "*/5 * * * *"
|
||||
event_condition: Optional[Dict[str, Any]] = None # for event/webhook triggers
|
||||
action_type: str = Field(default="ai_pipeline", description="ai_pipeline / webhook / notification / skill_execute")
|
||||
action_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
is_enabled: bool = True
|
||||
max_retries: int = Field(default=3, ge=0, le=10)
|
||||
timeout_seconds: int = Field(default=300, ge=10, le=3600)
|
||||
|
||||
|
||||
class AutomationTaskUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
trigger_type: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
event_condition: Optional[Dict[str, Any]] = None
|
||||
action_type: Optional[str] = None
|
||||
action_config: Optional[Dict[str, Any]] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
max_retries: Optional[int] = Field(None, ge=0, le=10)
|
||||
timeout_seconds: Optional[int] = Field(None, ge=10, le=3600)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helper Functions
|
||||
# ============================================================
|
||||
|
||||
TRIGGER_TYPE_MAP = {
|
||||
"cron": "定时触发",
|
||||
"webhook": "Webhook",
|
||||
"event": "事件触发",
|
||||
}
|
||||
|
||||
ACTION_TYPE_MAP = {
|
||||
"ai_pipeline": "AI流水线",
|
||||
"webhook": "Webhook回调",
|
||||
"notification": "发送通知",
|
||||
"skill_execute": "技能执行",
|
||||
}
|
||||
|
||||
|
||||
def task_to_dict(task) -> dict:
|
||||
"""将AutomatedTask模型转换为字典"""
|
||||
result = {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"description": getattr(task, "description", "") or "",
|
||||
"trigger_type": task.trigger_type,
|
||||
"trigger_type_label": TRIGGER_TYPE_MAP.get(task.trigger_type, task.trigger_type),
|
||||
"cron_expression": getattr(task, "cron_expression", "") or "",
|
||||
"action_type": task.action_type,
|
||||
"action_type_label": ACTION_TYPE_MAP.get(task.action_type, task.action_type),
|
||||
"is_enabled": task.is_enabled,
|
||||
"status": task.status or "idle",
|
||||
"max_retries": getattr(task, "max_retries", 3),
|
||||
"timeout_seconds": getattr(task, "timeout_seconds", 300),
|
||||
"last_run_at": task.last_run_at.isoformat() if task.last_run_at else None,
|
||||
"next_run_at": task.next_run_at.isoformat() if task.next_run_at else None,
|
||||
"total_runs": getattr(task, "total_runs", 0),
|
||||
"success_runs": getattr(task, "success_runs", 0),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
|
||||
}
|
||||
|
||||
# Action config
|
||||
action_config = getattr(task, "action_config", None)
|
||||
if action_config:
|
||||
try:
|
||||
result["action_config"] = json.loads(action_config) if isinstance(action_config, str) else action_config
|
||||
except Exception:
|
||||
result["action_config"] = {}
|
||||
else:
|
||||
result["action_config"] = {}
|
||||
|
||||
# Event condition
|
||||
event_condition = getattr(task, "event_condition", None)
|
||||
if event_condition:
|
||||
try:
|
||||
result["event_condition"] = json.loads(event_condition) if isinstance(event_condition, str) else event_condition
|
||||
except Exception:
|
||||
result["event_condition"] = {}
|
||||
else:
|
||||
result["event_condition"] = {}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def log_to_dict(log) -> dict:
|
||||
"""将TaskExecutionLog转换为字典"""
|
||||
return {
|
||||
"id": log.id,
|
||||
"task_id": log.task_id,
|
||||
"task_name": getattr(log, "task_name", ""),
|
||||
"status": log.status,
|
||||
"trigger_type": getattr(log, "trigger_type", ""),
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
|
||||
"duration_ms": getattr(log, "duration_ms", 0),
|
||||
"result_summary": getattr(log, "result_summary", "") or "",
|
||||
"error_message": getattr(log, "error_message", "") or "",
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def calculate_next_run(cron_expression: str) -> Optional[datetime]:
|
||||
"""计算cron表达式的下次执行时间(简化版)"""
|
||||
if not cron_expression:
|
||||
return None
|
||||
|
||||
parts = cron_expression.strip().split()
|
||||
if len(parts) != 5:
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
try:
|
||||
minute, hour, day, month, weekday = parts
|
||||
|
||||
# Handle wildcards
|
||||
next_run = now.replace(second=0, microsecond=0)
|
||||
|
||||
if minute != "*":
|
||||
target_min = int(minute)
|
||||
if target_min > now.minute:
|
||||
next_run = next_run.replace(minute=target_min)
|
||||
else:
|
||||
next_run = next_run.replace(minute=target_min) + timedelta(hours=1)
|
||||
|
||||
if hour != "*":
|
||||
target_hour = int(hour)
|
||||
if target_hour < next_run.hour or (target_hour == next_run.hour and next_run.minute <= now.minute):
|
||||
next_run = next_run.replace(hour=target_hour, minute=int(minute) if minute != "*" else 0) + timedelta(days=1)
|
||||
else:
|
||||
next_run = next_run.replace(hour=target_hour)
|
||||
|
||||
# Handle */N patterns for minute
|
||||
if minute.startswith("*/"):
|
||||
interval = int(minute[2:])
|
||||
next_minute = ((now.minute // interval) + 1) * interval
|
||||
if next_minute >= 60:
|
||||
next_run = next_run.replace(minute=0) + timedelta(hours=1)
|
||||
else:
|
||||
next_run = next_run.replace(minute=next_minute)
|
||||
if hour != "*":
|
||||
next_run = next_run.replace(hour=int(hour))
|
||||
|
||||
# Handle */N patterns for hour
|
||||
if hour.startswith("*/"):
|
||||
interval = int(hour[2:])
|
||||
next_hour = ((now.hour // interval) + 1) * interval
|
||||
if next_hour >= 24:
|
||||
next_run = next_run.replace(hour=0, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0) + timedelta(days=1)
|
||||
else:
|
||||
next_run = next_run.replace(hour=next_hour, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0)
|
||||
|
||||
if next_run <= now:
|
||||
next_run += timedelta(minutes=1)
|
||||
|
||||
return next_run
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to calculate next run for '{cron_expression}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats/overview", summary="自动化任务统计概览")
|
||||
async def get_automation_stats(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取自动化任务的统计信息"""
|
||||
from ...models import AutomatedTask, TaskExecutionLog
|
||||
|
||||
total_tasks = db.query(func.count(AutomatedTask.id)).scalar() or 0
|
||||
enabled_tasks = db.query(func.count(AutomatedTask.id)).filter(
|
||||
AutomatedTask.is_enabled == True
|
||||
).scalar() or 0
|
||||
running_tasks = db.query(func.count(AutomatedTask.id)).filter(
|
||||
AutomatedTask.status == "running"
|
||||
).scalar() or 0
|
||||
|
||||
# By trigger type
|
||||
by_trigger = {}
|
||||
triggers = db.query(AutomatedTask.trigger_type, func.count(AutomatedTask.id)).group_by(
|
||||
AutomatedTask.trigger_type
|
||||
).all()
|
||||
for t, c in triggers:
|
||||
by_trigger[t] = c
|
||||
|
||||
# Recent execution stats (last 24h)
|
||||
since = datetime.now() - timedelta(hours=24)
|
||||
total_executions = db.query(func.count(TaskExecutionLog.id)).filter(
|
||||
TaskExecutionLog.created_at >= since
|
||||
).scalar() or 0
|
||||
success_executions = db.query(func.count(TaskExecutionLog.id)).filter(
|
||||
TaskExecutionLog.created_at >= since,
|
||||
TaskExecutionLog.status == "success"
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total_tasks": total_tasks,
|
||||
"enabled_tasks": enabled_tasks,
|
||||
"running_tasks": running_tasks,
|
||||
"by_trigger": by_trigger,
|
||||
"recent_executions": total_executions,
|
||||
"recent_success": success_executions,
|
||||
},
|
||||
"message": "ok"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", summary="获取自动化任务列表")
|
||||
async def list_automation_tasks(
|
||||
trigger_type: str = Query(None),
|
||||
is_enabled: bool = Query(None),
|
||||
keyword: str = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取自动化任务列表"""
|
||||
from ...models import AutomatedTask
|
||||
|
||||
query = db.query(AutomatedTask)
|
||||
|
||||
if trigger_type:
|
||||
query = query.filter(AutomatedTask.trigger_type == trigger_type)
|
||||
if is_enabled is not None:
|
||||
query = query.filter(AutomatedTask.is_enabled == is_enabled)
|
||||
if keyword:
|
||||
keyword_filter = or_(
|
||||
AutomatedTask.name.ilike(f"%{keyword}%"),
|
||||
AutomatedTask.description.ilike(f"%{keyword}%"),
|
||||
)
|
||||
query = query.filter(keyword_filter)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(AutomatedTask.updated_at)) \
|
||||
.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [task_to_dict(item) for item in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"message": "ok"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}", summary="获取任务详情")
|
||||
async def get_automation_task(
|
||||
task_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个自动化任务详情"""
|
||||
from ...models import AutomatedTask
|
||||
|
||||
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": task_to_dict(task),
|
||||
"message": "ok"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", summary="创建自动化任务")
|
||||
async def create_automation_task(
|
||||
req: AutomationTaskCreate,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""创建新的自动化任务"""
|
||||
from ...models import AutomatedTask
|
||||
|
||||
# 检查名称重复
|
||||
existing = db.query(AutomatedTask).filter(AutomatedTask.name == req.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="任务名称已存在")
|
||||
|
||||
# 验证cron表达式格式
|
||||
if req.trigger_type == "cron" and req.cron_expression:
|
||||
parts = req.cron_expression.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise HTTPException(status_code=400, detail="Cron表达式格式错误,需要5个字段")
|
||||
|
||||
next_run = None
|
||||
if req.trigger_type == "cron" and req.cron_expression:
|
||||
next_run = calculate_next_run(req.cron_expression)
|
||||
|
||||
task = AutomatedTask(
|
||||
id=gen_uuid(),
|
||||
name=req.name,
|
||||
description=req.description or "",
|
||||
trigger_type=req.trigger_type,
|
||||
cron_expression=req.cron_expression,
|
||||
action_type=req.action_type,
|
||||
action_config=req.action_config,
|
||||
is_enabled=req.is_enabled,
|
||||
status="idle",
|
||||
max_retries=req.max_retries,
|
||||
timeout_seconds=req.timeout_seconds,
|
||||
next_run_at=next_run,
|
||||
event_condition=req.event_condition,
|
||||
total_runs=0,
|
||||
success_runs=0,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="create_automation_task",
|
||||
target_type="automated_task",
|
||||
target_id=task.id,
|
||||
detail=f"创建了自动化任务: {task.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": task_to_dict(task),
|
||||
"message": "创建成功"
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{task_id}", summary="更新自动化任务")
|
||||
async def update_automation_task(
|
||||
task_id: str,
|
||||
req: AutomationTaskUpdate,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新自动化任务"""
|
||||
from ...models import AutomatedTask
|
||||
|
||||
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# 名称重复检查
|
||||
if req.name is not None and req.name != task.name:
|
||||
existing = db.query(AutomatedTask).filter(
|
||||
AutomatedTask.name == req.name,
|
||||
AutomatedTask.id != task_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="任务名称已存在")
|
||||
|
||||
update_fields = ["name", "description", "trigger_type", "cron_expression",
|
||||
"action_type", "is_enabled", "max_retries", "timeout_seconds"]
|
||||
for field in update_fields:
|
||||
value = getattr(req, field, None)
|
||||
if value is not None:
|
||||
setattr(task, field, value)
|
||||
|
||||
if req.action_config is not None:
|
||||
task.action_config = req.action_config
|
||||
if req.event_condition is not None:
|
||||
task.event_condition = req.event_condition
|
||||
|
||||
# 重新计算下次执行时间
|
||||
if req.cron_expression is not None or req.trigger_type is not None:
|
||||
cron = req.cron_expression if req.cron_expression is not None else task.cron_expression
|
||||
trigger = req.trigger_type if req.trigger_type is not None else task.trigger_type
|
||||
if trigger == "cron" and cron:
|
||||
task.next_run_at = calculate_next_run(cron)
|
||||
|
||||
task.updated_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="update_automation_task",
|
||||
target_type="automated_task",
|
||||
target_id=task.id,
|
||||
detail=f"更新了自动化任务: {task.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": task_to_dict(task),
|
||||
"message": "更新成功"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{task_id}", summary="删除自动化任务")
|
||||
async def delete_automation_task(
|
||||
task_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除自动化任务"""
|
||||
from ...models import AutomatedTask
|
||||
|
||||
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
name = task.name
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="delete_automation_task",
|
||||
target_type="automated_task",
|
||||
target_id=task_id,
|
||||
detail=f"删除了自动化任务: {name}"
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": None,
|
||||
"message": f"已删除任务: {name}"
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{task_id}/toggle", summary="启用/禁用自动化任务")
|
||||
async def toggle_automation_task(
|
||||
task_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""切换任务启用/禁用状态"""
|
||||
from ...models import AutomatedTask
|
||||
|
||||
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
task.is_enabled = not task.is_enabled
|
||||
if not task.is_enabled:
|
||||
task.next_run_at = None
|
||||
elif task.trigger_type == "cron" and task.cron_expression:
|
||||
task.next_run_at = calculate_next_run(task.cron_expression)
|
||||
|
||||
task.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
action = "启用" if task.is_enabled else "禁用"
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="toggle_automation_task",
|
||||
target_type="automated_task",
|
||||
target_id=task.id,
|
||||
detail=f"{action}了自动化任务: {task.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": task_to_dict(task),
|
||||
"message": f"已{action}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{task_id}/execute", summary="手动执行任务")
|
||||
async def execute_task_now(
|
||||
task_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""立即手动执行一个自动化任务"""
|
||||
from ...models import AutomatedTask, TaskExecutionLog
|
||||
|
||||
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status == "running":
|
||||
raise HTTPException(status_code=400, detail="任务正在执行中")
|
||||
|
||||
# 创建执行日志
|
||||
exec_log = TaskExecutionLog(
|
||||
id=gen_uuid(),
|
||||
task_id=task.id,
|
||||
task_name=task.name,
|
||||
status="running",
|
||||
trigger_type="manual",
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
db.add(exec_log)
|
||||
|
||||
# 更新任务状态
|
||||
task.status = "running"
|
||||
task.total_runs = (task.total_runs or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(exec_log)
|
||||
|
||||
# 异步执行(简化版: 直接模拟执行)
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
action_type = task.action_type
|
||||
action_config = task.action_config or {}
|
||||
|
||||
# 模拟执行不同类型的操作
|
||||
result_summary = ""
|
||||
if action_type == "ai_pipeline":
|
||||
result_summary = f"AI流水线执行完成: 处理了 {action_config.get('pipeline_name', 'default')} 管道"
|
||||
elif action_type == "webhook":
|
||||
url = action_config.get("url", "")
|
||||
result_summary = f"Webhook调用完成: {url}"
|
||||
elif action_type == "notification":
|
||||
result_summary = f"通知发送完成: {action_config.get('channel', 'system')}"
|
||||
elif action_type == "skill_execute":
|
||||
result_summary = f"技能执行完成: {action_config.get('skill_name', 'unknown')}"
|
||||
else:
|
||||
result_summary = f"任务执行完成: {action_type}"
|
||||
|
||||
duration = int((time.time() - start) * 1000)
|
||||
|
||||
# 更新执行日志
|
||||
exec_log.status = "success"
|
||||
exec_log.finished_at = datetime.now()
|
||||
exec_log.duration_ms = duration
|
||||
exec_log.result_summary = result_summary
|
||||
|
||||
# 更新任务
|
||||
task.status = "idle"
|
||||
task.last_run_at = datetime.now()
|
||||
task.success_runs = (task.success_runs or 0) + 1
|
||||
if task.trigger_type == "cron" and task.cron_expression:
|
||||
task.next_run_at = calculate_next_run(task.cron_expression)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": log_to_dict(exec_log),
|
||||
"message": "执行成功"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
duration = int((time.time() - start) * 1000)
|
||||
exec_log.status = "failed"
|
||||
exec_log.finished_at = datetime.now()
|
||||
exec_log.duration_ms = duration
|
||||
exec_log.error_message = str(e)
|
||||
task.status = "idle"
|
||||
db.commit()
|
||||
|
||||
raise HTTPException(status_code=500, detail=f"执行失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{task_id}/history", summary="获取任务执行历史")
|
||||
async def get_task_history(
|
||||
task_id: str,
|
||||
status: str = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取任务的执行历史记录"""
|
||||
from ...models import TaskExecutionLog
|
||||
|
||||
query = db.query(TaskExecutionLog).filter(TaskExecutionLog.task_id == task_id)
|
||||
if status:
|
||||
query = query.filter(TaskExecutionLog.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(TaskExecutionLog.created_at)) \
|
||||
.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [log_to_dict(item) for item in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"message": "ok"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history/all", summary="获取所有执行历史")
|
||||
async def get_all_history(
|
||||
status: str = Query(None),
|
||||
task_id: str = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取所有任务的执行历史"""
|
||||
from ...models import TaskExecutionLog
|
||||
|
||||
query = db.query(TaskExecutionLog)
|
||||
if status:
|
||||
query = query.filter(TaskExecutionLog.status == status)
|
||||
if task_id:
|
||||
query = query.filter(TaskExecutionLog.task_id == task_id)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(TaskExecutionLog.created_at)) \
|
||||
.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [log_to_dict(item) for item in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"message": "ok"
|
||||
}
|
||||
@@ -998,3 +998,41 @@ class OSSStorageConfig(Base):
|
||||
config = Column(JSON, default=None)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class AutomatedTask(Base):
|
||||
__tablename__ = "automated_tasks"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
trigger_type = Column(String(20), nullable=False)
|
||||
cron_expression = Column(String(100), default=None)
|
||||
event_condition = Column(JSON, default=None)
|
||||
action_type = Column(String(30), nullable=False)
|
||||
action_config = Column(JSON, default=None)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
status = Column(String(20), default="idle")
|
||||
max_retries = Column(Integer, default=3)
|
||||
timeout_seconds = Column(Integer, default=300)
|
||||
last_run_at = Column(DateTime, default=None)
|
||||
next_run_at = Column(DateTime, default=None)
|
||||
total_runs = Column(Integer, default=0)
|
||||
success_runs = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class TaskExecutionLog(Base):
|
||||
__tablename__ = "task_execution_logs"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||
task_id = Column(String(36), nullable=False)
|
||||
task_name = Column(String(200), default="")
|
||||
status = Column(String(20), default="running")
|
||||
trigger_type = Column(String(20), default="manual")
|
||||
started_at = Column(DateTime, default=None)
|
||||
finished_at = Column(DateTime, default=None)
|
||||
duration_ms = Column(Integer, default=0)
|
||||
result_summary = Column(Text, default="")
|
||||
error_message = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin
|
||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||
|
||||
from app.database import SessionLocal
|
||||
@@ -420,6 +420,7 @@ app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["
|
||||
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
||||
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
||||
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ import AdminLogs from '@/pages/admin/Logs';
|
||||
import AdminSkills from '@/pages/admin/Skills'
|
||||
import AdminAgents from '@/pages/admin/Agents'
|
||||
import AdminKnowledge from '@/pages/admin/Knowledge';
|
||||
import AdminStorage from '@/pages/admin/Storage';;
|
||||
import AdminStorage from '@/pages/admin/Storage';
|
||||
import AdminAutomation from '@/pages/admin/Automation';;
|
||||
|
||||
|
||||
|
||||
@@ -233,6 +234,7 @@ const App: React.FC = () => {
|
||||
<Route path="agents" element={<AdminAgents />} />
|
||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||
<Route path="storage" element={<AdminStorage />} />
|
||||
<Route path="automation" element={<AdminAutomation />} />
|
||||
</Route>
|
||||
|
||||
|
||||
|
||||
@@ -512,6 +512,23 @@ export const adminAPI = {
|
||||
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
|
||||
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
|
||||
|
||||
// 自动化任务调度 (Phase 3.2)
|
||||
getAdminAutomationStats: () => get<any>('/admin/automation/stats/overview'),
|
||||
getAdminAutomationTasks: (params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/automation/${query}`);
|
||||
},
|
||||
getAdminAutomationTask: (id: string) => get<any>(`/admin/automation/${id}`),
|
||||
createAdminAutomationTask: (data: any) => post<any>('/admin/automation/', data),
|
||||
updateAdminAutomationTask: (id: string, data: any) => put<any>(`/admin/automation/${id}`, data),
|
||||
deleteAdminAutomationTask: (id: string) => del<any>(`/admin/automation/${id}`),
|
||||
toggleAdminAutomationTask: (id: string) => put<any>(`/admin/automation/${id}/toggle`),
|
||||
executeAdminAutomationTask: (id: string) => post<any>(`/admin/automation/${id}/execute`),
|
||||
getAdminAutomationTaskHistory: (id: string, params?: any) => {
|
||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||
return get<any>(`/admin/automation/${id}/history${query}`);
|
||||
},
|
||||
|
||||
};
|
||||
// ===== AI自进化API =====
|
||||
export const evolutionAPI = {
|
||||
|
||||
@@ -35,6 +35,7 @@ const adminNavItems = [
|
||||
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
||||
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
||||
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
|
||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },];
|
||||
|
||||
|
||||
@@ -64,6 +65,7 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
||||
|
||||
|
||||
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||
automation: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2"/><path d="M12 21v2"/><path d="M4.22 4.22l1.42 1.42"/><path d="M18.36 18.36l1.42 1.42"/><path d="M1 12h2"/><path d="M21 12h2"/><path d="M4.22 19.78l1.42-1.42"/><path d="M18.36 5.64l1.42-1.42"/></svg>,
|
||||
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface AutomationTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_type: string;
|
||||
trigger_type_label: string;
|
||||
cron_expression: string;
|
||||
action_type: string;
|
||||
action_type_label: string;
|
||||
action_config: Record<string, any>;
|
||||
event_condition: Record<string, any>;
|
||||
is_enabled: boolean;
|
||||
status: string;
|
||||
max_retries: number;
|
||||
timeout_seconds: number;
|
||||
last_run_at: string;
|
||||
next_run_at: string;
|
||||
total_runs: number;
|
||||
success_runs: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ExecLog {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
status: string;
|
||||
trigger_type: string;
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
duration_ms: number;
|
||||
result_summary: string;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AutomationStats {
|
||||
total_tasks: number;
|
||||
enabled_tasks: number;
|
||||
running_tasks: number;
|
||||
by_trigger: Record<string, number>;
|
||||
recent_executions: number;
|
||||
recent_success: number;
|
||||
}
|
||||
|
||||
const TRIGGER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
cron: { label: '定时触发', color: '#6C63FF', bg: '#EDE9FE' },
|
||||
webhook: { label: 'Webhook', color: '#059669', bg: '#D1FAE5' },
|
||||
event: { label: '事件触发', color: '#D97706', bg: '#FEF3C7' },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
idle: { label: '空闲', color: '#6B7280', bg: '#F3F4F6' },
|
||||
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
|
||||
success: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
|
||||
failed: { label: '失败', color: '#EF4444', bg: '#FEE2E2' },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const Automation: React.FC = () => {
|
||||
// Data state
|
||||
const [tasks, setTasks] = useState<AutomationTask[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<AutomationStats | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filterTrigger, setFilterTrigger] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<AutomationTask | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_type: 'cron',
|
||||
cron_expression: '',
|
||||
event_condition_str: '',
|
||||
action_type: 'ai_pipeline',
|
||||
action_config_str: '{"pipeline_name": "default"}',
|
||||
is_enabled: true,
|
||||
max_retries: 3,
|
||||
timeout_seconds: 300,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Execution history
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [historyTaskId, setHistoryTaskId] = useState('');
|
||||
const [historyTaskName, setHistoryTaskName] = useState('');
|
||||
const [historyLogs, setHistoryLogs] = useState<ExecLog[]>([]);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const [executingId, setExecutingId] = useState<string | null>(null);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminAutomationStats() as any;
|
||||
setStats(result.data || null);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Load tasks
|
||||
const loadTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: PAGE_SIZE };
|
||||
if (filterTrigger) params.trigger_type = filterTrigger;
|
||||
if (keyword) params.keyword = keyword;
|
||||
const result = await adminAPI.getAdminAutomationTasks(params) as any;
|
||||
const data = result.data || result;
|
||||
setTasks(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast('加载失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadStats(); }, []);
|
||||
useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]);
|
||||
|
||||
// Open create modal
|
||||
const openCreateModal = () => {
|
||||
setEditingTask(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_type: 'cron',
|
||||
cron_expression: '*/5 * * * *',
|
||||
event_condition_str: '',
|
||||
action_type: 'ai_pipeline',
|
||||
action_config_str: '{"pipeline_name": "default"}',
|
||||
is_enabled: true,
|
||||
max_retries: 3,
|
||||
timeout_seconds: 300,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = async (task: AutomationTask) => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminAutomationTask(task.id) as any;
|
||||
const detail = result.data;
|
||||
setEditingTask(task);
|
||||
setFormData({
|
||||
name: detail.name || '',
|
||||
description: detail.description || '',
|
||||
trigger_type: detail.trigger_type || 'cron',
|
||||
cron_expression: detail.cron_expression || '',
|
||||
event_condition_str: detail.event_condition ? JSON.stringify(detail.event_condition, null, 2) : '',
|
||||
action_type: detail.action_type || 'ai_pipeline',
|
||||
action_config_str: detail.action_config ? JSON.stringify(detail.action_config, null, 2) : '{}',
|
||||
is_enabled: detail.is_enabled !== false,
|
||||
max_retries: detail.max_retries || 3,
|
||||
timeout_seconds: detail.timeout_seconds || 300,
|
||||
});
|
||||
setModalVisible(true);
|
||||
} catch (e: any) {
|
||||
showToast('获取任务详情失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Submit
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) { showToast('请输入任务名称', 'error'); return; }
|
||||
if (formData.trigger_type === 'cron' && !formData.cron_expression.trim()) {
|
||||
showToast('请输入Cron表达式', 'error'); return;
|
||||
}
|
||||
|
||||
let actionConfig: any = {};
|
||||
if (formData.action_config_str.trim()) {
|
||||
try { actionConfig = JSON.parse(formData.action_config_str); }
|
||||
catch { showToast('操作配置JSON格式不正确', 'error'); return; }
|
||||
}
|
||||
|
||||
let eventCondition: any = undefined;
|
||||
if (formData.event_condition_str.trim()) {
|
||||
try { eventCondition = JSON.parse(formData.event_condition_str); }
|
||||
catch { showToast('事件条件JSON格式不正确', 'error'); return; }
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data: any = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
trigger_type: formData.trigger_type,
|
||||
cron_expression: formData.cron_expression.trim(),
|
||||
action_type: formData.action_type,
|
||||
action_config: actionConfig,
|
||||
is_enabled: formData.is_enabled,
|
||||
max_retries: formData.max_retries,
|
||||
timeout_seconds: formData.timeout_seconds,
|
||||
};
|
||||
if (eventCondition) data.event_condition = eventCondition;
|
||||
|
||||
if (editingTask) {
|
||||
await adminAPI.updateAdminAutomationTask(editingTask.id, data);
|
||||
showToast('更新成功');
|
||||
} else {
|
||||
await adminAPI.createAdminAutomationTask(data);
|
||||
showToast('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete
|
||||
const handleDelete = async (task: AutomationTask) => {
|
||||
if (!window.confirm(`确定要删除任务 "${task.name}" 吗?`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminAutomationTask(task.id);
|
||||
showToast('删除成功');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('删除失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle
|
||||
const handleToggle = async (task: AutomationTask) => {
|
||||
try {
|
||||
await adminAPI.toggleAdminAutomationTask(task.id);
|
||||
showToast(task.is_enabled ? '已禁用' : '已启用');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Execute now
|
||||
const handleExecuteNow = async (task: AutomationTask) => {
|
||||
setExecutingId(task.id);
|
||||
try {
|
||||
const result = await adminAPI.executeAdminAutomationTask(task.id) as any;
|
||||
showToast(result.message || '执行完成');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('执行失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setExecutingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Show history
|
||||
const showHistory = async (task: AutomationTask, pageNum: number = 1) => {
|
||||
setHistoryTaskId(task.id);
|
||||
setHistoryTaskName(task.name);
|
||||
setHistoryPage(pageNum);
|
||||
setHistoryVisible(true);
|
||||
|
||||
try {
|
||||
const result = await adminAPI.getAdminAutomationTaskHistory(task.id, { page: pageNum, page_size: 20 }) as any;
|
||||
const data = result.data || result;
|
||||
setHistoryLogs(data.items || []);
|
||||
setHistoryTotal(data.total || 0);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (ts: string) => {
|
||||
if (!ts) return '-';
|
||||
try { return new Date(ts).toLocaleString('zh-CN'); }
|
||||
catch { return ts; }
|
||||
};
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (ms: number) => {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 };
|
||||
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' };
|
||||
const btnStyle = (bg: string, disabled = false): React.CSSProperties => ({
|
||||
padding: '8px 20px', borderRadius: '6px', border: 'none', background: bg,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer', fontSize: '14px', color: '#fff',
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-header">
|
||||
<h2>自动化任务调度</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>+ 创建任务</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_tasks}</div>
|
||||
<div className="stat-label">总任务数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_tasks}</div>
|
||||
<div className="stat-label">已启用</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#3B82F6' }}>{stats.running_tasks}</div>
|
||||
<div className="stat-label">运行中</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.recent_executions}</div>
|
||||
<div className="stat-label">24小时执行</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.recent_success}</div>
|
||||
<div className="stat-label">24小时成功</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||
<select value={filterTrigger} onChange={(e) => { setFilterTrigger(e.target.value); setPage(1); }}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px' }}>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(TRIGGER_CONFIG).map(([k, v]) => (<option key={k} value={k}>{v.label}</option>))}
|
||||
</select>
|
||||
<input type="text" placeholder="搜索任务名称..."
|
||||
value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
|
||||
<th style={thStyle}>任务名称</th>
|
||||
<th style={thStyle}>触发方式</th>
|
||||
<th style={thStyle}>操作类型</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>上次执行</th>
|
||||
<th style={thStyle}>下次执行</th>
|
||||
<th style={thStyle}>成功率</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right', width: '260px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} style={emptyStyle}>加载中...</td></tr>
|
||||
) : tasks.length === 0 ? (
|
||||
<tr><td colSpan={8} style={emptyStyle}>暂无自动化任务</td></tr>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ fontWeight: 500 }}>{task.name}</div>
|
||||
{task.description && <div style={{ fontSize: '12px', color: '#9CA3AF', marginTop: '2px' }}>{task.description}</div>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={badgeStyle(TRIGGER_CONFIG[task.trigger_type] || TRIGGER_CONFIG.cron)}>
|
||||
{(TRIGGER_CONFIG[task.trigger_type] || {}).label || task.trigger_type}
|
||||
</span>
|
||||
{task.cron_expression && <div style={{ fontSize: '12px', fontFamily: 'monospace', marginTop: '2px', color: '#6B7280' }}>{task.cron_expression}</div>}
|
||||
</td>
|
||||
<td style={tdStyle}>{task.action_type_label}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<span style={badgeStyle(STATUS_CONFIG[task.status] || STATUS_CONFIG.idle)}>
|
||||
{(STATUS_CONFIG[task.status] || {}).label || task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>{formatTime(task.last_run_at)}</td>
|
||||
<td style={tdStyle}>{formatTime(task.next_run_at)}</td>
|
||||
<td style={tdStyle}>
|
||||
{task.total_runs > 0
|
||||
? <span style={{ color: task.success_runs / task.total_runs >= 0.9 ? '#10B981' : '#D97706' }}>
|
||||
{Math.round(task.success_runs / task.total_runs * 100)}%
|
||||
<span style={{ fontSize: '11px', color: '#9CA3AF' }}> ({task.success_runs}/{task.total_runs})</span>
|
||||
</span>
|
||||
: <span style={{ color: '#9CA3AF' }}>-</span>}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => handleExecuteNow(task)} disabled={executingId === task.id || task.status === 'running'}
|
||||
style={actionBtnStyle('#6C63FF', executingId === task.id)}>
|
||||
{executingId === task.id ? '执行中...' : '执行'}
|
||||
</button>
|
||||
<button onClick={() => showHistory(task)}
|
||||
style={actionBtnStyle('#374151')}>历史</button>
|
||||
<button onClick={() => handleToggle(task)}
|
||||
style={actionBtnStyle(task.is_enabled ? '#EF4444' : '#10B981')}>
|
||||
{task.is_enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button onClick={() => openEditModal(task)}
|
||||
style={actionBtnStyle('#374151')}>编辑</button>
|
||||
<button onClick={() => handleDelete(task)}
|
||||
style={actionBtnStyle('#EF4444', false, true)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{modalVisible && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>{editingTask ? '编辑任务' : '创建自动化任务'}</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div><label style={labelStyle}>任务名称 *</label>
|
||||
<input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div>
|
||||
|
||||
<div><label style={labelStyle}>描述</label>
|
||||
<input type="text" value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} style={inputStyle} placeholder="可选描述" /></div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div><label style={labelStyle}>触发方式</label>
|
||||
<select value={formData.trigger_type} onChange={e => setFormData({ ...formData, trigger_type: e.target.value })} style={inputStyle}>
|
||||
<option value="cron">定时触发 (Cron)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="event">事件触发</option>
|
||||
</select></div>
|
||||
<div><label style={labelStyle}>操作类型</label>
|
||||
<select value={formData.action_type} onChange={e => setFormData({ ...formData, action_type: e.target.value })} style={inputStyle}>
|
||||
<option value="ai_pipeline">AI流水线</option>
|
||||
<option value="webhook">Webhook回调</option>
|
||||
<option value="notification">发送通知</option>
|
||||
<option value="skill_execute">技能执行</option>
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
{formData.trigger_type === 'cron' && (
|
||||
<div><label style={labelStyle}>Cron 表达式 *</label>
|
||||
<input type="text" value={formData.cron_expression} onChange={e => setFormData({ ...formData, cron_expression: e.target.value })}
|
||||
style={{ ...inputStyle, fontFamily: 'monospace' }} placeholder="*/5 * * * * (每5分钟)" />
|
||||
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '4px' }}>
|
||||
格式: 分 时 日 月 周 | 示例: 0 9 * * * (每天9点)
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
{formData.trigger_type !== 'cron' && (
|
||||
<div><label style={labelStyle}>事件条件 (JSON)</label>
|
||||
<textarea value={formData.event_condition_str} onChange={e => setFormData({ ...formData, event_condition_str: e.target.value })}
|
||||
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
|
||||
placeholder={formData.trigger_type === 'webhook' ? '{"method": "POST", "secret": "xxx"}' : '{"event_type": "user.created"}'} /></div>
|
||||
)}
|
||||
|
||||
<div><label style={labelStyle}>操作配置 (JSON)</label>
|
||||
<textarea value={formData.action_config_str} onChange={e => setFormData({ ...formData, action_config_str: e.target.value })}
|
||||
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
|
||||
placeholder='{"pipeline_name": "default"}' /></div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
||||
<div><label style={labelStyle}>重试次数</label>
|
||||
<input type="number" value={formData.max_retries} onChange={e => setFormData({ ...formData, max_retries: parseInt(e.target.value) || 0 })}
|
||||
min={0} max={10} style={inputStyle} /></div>
|
||||
<div><label style={labelStyle}>超时(秒)</label>
|
||||
<input type="number" value={formData.timeout_seconds} onChange={e => setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })}
|
||||
min={10} max={3600} style={inputStyle} /></div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: '8px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={formData.is_enabled} onChange={e => setFormData({ ...formData, is_enabled: e.target.checked })} /> 启用</label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151' }}>取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} style={btnStyle('#6C63FF', submitting)}>
|
||||
{submitting ? '保存中...' : (editingTask ? '保存修改' : '创建任务')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Modal */}
|
||||
{historyVisible && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '700px', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px' }}>执行历史: {historyTaskName}</h3>
|
||||
<button onClick={() => setHistoryVisible(false)} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#6B7280' }}>✕</button>
|
||||
</div>
|
||||
|
||||
{historyLogs.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB' }}>
|
||||
<th style={thStyle}>时间</th>
|
||||
<th style={thStyle}>触发方式</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>耗时</th>
|
||||
<th style={thStyle}>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyLogs.map(log => (
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={tdStyle}>{formatTime(log.started_at)}</td>
|
||||
<td style={tdStyle}>{log.trigger_type}</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={badgeStyle(STATUS_CONFIG[log.status] || STATUS_CONFIG.idle)}>
|
||||
{(STATUS_CONFIG[log.status] || {}).label || log.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>{formatDuration(log.duration_ms)}</td>
|
||||
<td style={{ ...tdStyle, maxWidth: '250px' }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{log.result_summary || log.error_message || '-'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* History pagination */}
|
||||
{historyTotal > 20 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button disabled={historyPage <= 1} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage - 1)}
|
||||
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>上一页</button>
|
||||
<span style={{ padding: '4px 8px', color: '#6B7280', fontSize: '13px' }}>{historyPage} / {Math.ceil(historyTotal / 20)}</span>
|
||||
<button disabled={historyPage >= Math.ceil(historyTotal / 20)} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage + 1)}
|
||||
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 2000, padding: '12px 24px', borderRadius: '8px', color: '#fff',
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px' }}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Inline styles
|
||||
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 };
|
||||
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px' };
|
||||
const emptyStyle: React.CSSProperties = { padding: '40px', textAlign: 'center', color: '#9CA3AF' };
|
||||
const badgeStyle = (cfg: { color: string; bg: string }) => ({
|
||||
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||
color: cfg.color, background: cfg.bg,
|
||||
} as React.CSSProperties);
|
||||
const actionBtnStyle = (color: string, disabled = false, outline = false): React.CSSProperties => ({
|
||||
padding: '4px 10px', borderRadius: '6px', border: `1px solid ${outline ? '#FEE2E2' : '#D1D5DB'}`,
|
||||
background: outline ? '#FEF2F2' : '#fff', cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px', color, opacity: disabled ? 0.5 : 1,
|
||||
});
|
||||
|
||||
export default Automation;
|
||||
Reference in new issue
Block a user