This commit is contained in:
qyyfw committed 2026-07-03 15:06:25 +08:00
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"
}
+38
View File
@@ -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)
+2 -1
View File
@@ -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=["后台-自动化任务"])
+3 -1
View File
@@ -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>
+17
View File
@@ -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>,
};
+599
View File
@@ -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;