feat: 定时任务调度功能
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 platform_scheduled_tasks 和 platform_task_logs 数据表
- 实现 APScheduler 调度器服务(支持简单模式和CRON表达式)
- 添加定时任务 CRUD API
- 支持手动触发执行和查看执行日志
- 前端任务管理页面
This commit is contained in:
2026-01-28 11:27:42 +08:00
parent e45fe8128c
commit ed88099cf0
7 changed files with 1319 additions and 1 deletions

View File

@@ -15,8 +15,10 @@ from .routers.alerts import router as alerts_router
from .routers.cost import router as cost_router
from .routers.quota import router as quota_router
from .routers.tool_configs import router as tool_configs_router
from .routers.tasks import router as tasks_router
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
from .middleware.trace import setup_logging
from .services.scheduler import start_scheduler, shutdown_scheduler
# 配置日志(包含 TraceID
setup_logging(level=logging.INFO, include_trace=True)
@@ -68,6 +70,27 @@ app.include_router(alerts_router, prefix="/api")
app.include_router(cost_router, prefix="/api")
app.include_router(quota_router, prefix="/api")
app.include_router(tool_configs_router, prefix="/api")
app.include_router(tasks_router, prefix="/api")
@app.on_event("startup")
async def startup_event():
"""应用启动时初始化调度器"""
try:
start_scheduler()
logging.info("Scheduler started successfully")
except Exception as e:
logging.error(f"Failed to start scheduler: {e}")
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时停止调度器"""
try:
shutdown_scheduler()
logging.info("Scheduler shutdown successfully")
except Exception as e:
logging.error(f"Failed to shutdown scheduler: {e}")
@app.get("/")

View File

@@ -0,0 +1,356 @@
"""定时任务管理路由"""
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..database import get_db
from .auth import get_current_user, require_operator
from ..models.user import User
from ..services.scheduler import (
add_task_to_scheduler,
remove_task_from_scheduler,
reload_task,
execute_task
)
router = APIRouter(prefix="/scheduled-tasks", tags=["定时任务"])
# Schemas
class TaskCreate(BaseModel):
tenant_id: str
task_name: str
task_desc: Optional[str] = None
schedule_type: str = "simple" # simple | cron
time_points: Optional[List[str]] = None # ["09:00", "14:00"]
cron_expression: Optional[str] = None # "0 9,14 * * *"
webhook_url: str
input_params: Optional[dict] = None
is_enabled: bool = True
class TaskUpdate(BaseModel):
task_name: Optional[str] = None
task_desc: Optional[str] = None
schedule_type: Optional[str] = None
time_points: Optional[List[str]] = None
cron_expression: Optional[str] = None
webhook_url: Optional[str] = None
input_params: Optional[dict] = None
# API Endpoints
@router.get("")
async def list_tasks(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取定时任务列表"""
# 构建查询
where_clauses = []
params = {}
if tenant_id:
where_clauses.append("tenant_id = :tenant_id")
params["tenant_id"] = tenant_id
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# 查询总数
count_result = db.execute(
text(f"SELECT COUNT(*) FROM platform_scheduled_tasks WHERE {where_sql}"),
params
)
total = count_result.scalar()
# 查询列表
params["offset"] = (page - 1) * size
params["limit"] = size
result = db.execute(
text(f"""
SELECT t.*, tn.name as tenant_name
FROM platform_scheduled_tasks t
LEFT JOIN platform_tenants tn ON t.tenant_id = tn.code
WHERE {where_sql}
ORDER BY t.id DESC
LIMIT :limit OFFSET :offset
"""),
params
)
tasks = [dict(row) for row in result.mappings().all()]
return {
"total": total,
"page": page,
"size": size,
"items": tasks
}
@router.post("")
async def create_task(
data: TaskCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建定时任务"""
# 验证调度配置
if data.schedule_type == "simple":
if not data.time_points or len(data.time_points) == 0:
raise HTTPException(status_code=400, detail="简单模式需要至少一个时间点")
elif data.schedule_type == "cron":
if not data.cron_expression:
raise HTTPException(status_code=400, detail="CRON模式需要提供表达式")
# 插入数据库
import json
time_points_json = json.dumps(data.time_points) if data.time_points else None
input_params_json = json.dumps(data.input_params) if data.input_params else None
db.execute(
text("""
INSERT INTO platform_scheduled_tasks
(tenant_id, task_name, task_desc, schedule_type, time_points,
cron_expression, webhook_url, input_params, is_enabled)
VALUES (:tenant_id, :task_name, :task_desc, :schedule_type, :time_points,
:cron_expression, :webhook_url, :input_params, :is_enabled)
"""),
{
"tenant_id": data.tenant_id,
"task_name": data.task_name,
"task_desc": data.task_desc,
"schedule_type": data.schedule_type,
"time_points": time_points_json,
"cron_expression": data.cron_expression,
"webhook_url": data.webhook_url,
"input_params": input_params_json,
"is_enabled": 1 if data.is_enabled else 0
}
)
db.commit()
# 获取新插入的ID
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
task_id = result.scalar()
# 如果启用,添加到调度器
if data.is_enabled:
reload_task(task_id)
return {"id": task_id, "message": "创建成功"}
@router.get("/{task_id}")
async def get_task(
task_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务详情"""
result = db.execute(
text("""
SELECT t.*, tn.name as tenant_name
FROM platform_scheduled_tasks t
LEFT JOIN platform_tenants tn ON t.tenant_id = tn.code
WHERE t.id = :id
"""),
{"id": task_id}
)
task = result.mappings().first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return dict(task)
@router.put("/{task_id}")
async def update_task(
task_id: int,
data: TaskUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新定时任务"""
# 检查任务是否存在
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
task = result.mappings().first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 构建更新语句
import json
updates = []
params = {"id": task_id}
if data.task_name is not None:
updates.append("task_name = :task_name")
params["task_name"] = data.task_name
if data.task_desc is not None:
updates.append("task_desc = :task_desc")
params["task_desc"] = data.task_desc
if data.schedule_type is not None:
updates.append("schedule_type = :schedule_type")
params["schedule_type"] = data.schedule_type
if data.time_points is not None:
updates.append("time_points = :time_points")
params["time_points"] = json.dumps(data.time_points)
if data.cron_expression is not None:
updates.append("cron_expression = :cron_expression")
params["cron_expression"] = data.cron_expression
if data.webhook_url is not None:
updates.append("webhook_url = :webhook_url")
params["webhook_url"] = data.webhook_url
if data.input_params is not None:
updates.append("input_params = :input_params")
params["input_params"] = json.dumps(data.input_params)
if updates:
db.execute(
text(f"UPDATE platform_scheduled_tasks SET {', '.join(updates)} WHERE id = :id"),
params
)
db.commit()
# 重新加载调度器中的任务
reload_task(task_id)
return {"message": "更新成功"}
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除定时任务"""
# 检查任务是否存在
result = db.execute(
text("SELECT id FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
if not result.scalar():
raise HTTPException(status_code=404, detail="任务不存在")
# 从调度器移除
remove_task_from_scheduler(task_id)
# 删除日志
db.execute(
text("DELETE FROM platform_task_logs WHERE task_id = :id"),
{"id": task_id}
)
# 删除任务
db.execute(
text("DELETE FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
db.commit()
return {"message": "删除成功"}
@router.post("/{task_id}/toggle")
async def toggle_task(
task_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""启用/禁用任务"""
# 获取当前状态
result = db.execute(
text("SELECT is_enabled FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="任务不存在")
current_enabled = row[0]
new_enabled = 0 if current_enabled else 1
# 更新状态
db.execute(
text("UPDATE platform_scheduled_tasks SET is_enabled = :enabled WHERE id = :id"),
{"id": task_id, "enabled": new_enabled}
)
db.commit()
# 更新调度器
reload_task(task_id)
return {
"is_enabled": bool(new_enabled),
"message": "已启用" if new_enabled else "已禁用"
}
@router.post("/{task_id}/run")
async def run_task_now(
task_id: int,
background_tasks: BackgroundTasks,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""手动执行任务"""
# 检查任务是否存在
result = db.execute(
text("SELECT id FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
if not result.scalar():
raise HTTPException(status_code=404, detail="任务不存在")
# 在后台执行任务
background_tasks.add_task(asyncio.create_task, execute_task(task_id))
return {"message": "任务已触发执行"}
@router.get("/{task_id}/logs")
async def get_task_logs(
task_id: int,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务执行日志"""
# 查询总数
count_result = db.execute(
text("SELECT COUNT(*) FROM platform_task_logs WHERE task_id = :task_id"),
{"task_id": task_id}
)
total = count_result.scalar()
# 查询日志
result = db.execute(
text("""
SELECT * FROM platform_task_logs
WHERE task_id = :task_id
ORDER BY id DESC
LIMIT :limit OFFSET :offset
"""),
{"task_id": task_id, "limit": size, "offset": (page - 1) * size}
)
logs = [dict(row) for row in result.mappings().all()]
return {
"total": total,
"page": page,
"size": size,
"items": logs
}

View File

@@ -0,0 +1,286 @@
"""定时任务调度器服务"""
import asyncio
import logging
from datetime import datetime
from typing import Optional, List, Dict, Any
import httpx
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..database import SessionLocal
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler: Optional[AsyncIOScheduler] = None
def get_scheduler() -> AsyncIOScheduler:
"""获取调度器实例"""
global scheduler
if scheduler is None:
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
return scheduler
def get_db_session() -> Session:
"""获取数据库会话"""
return SessionLocal()
async def execute_task(task_id: int):
"""执行定时任务"""
db = get_db_session()
log_id = None
try:
# 1. 查询任务配置
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id AND is_enabled = 1"),
{"id": task_id}
)
task = result.mappings().first()
if not task:
logger.warning(f"Task {task_id} not found or disabled")
return
# 2. 更新任务状态为运行中
db.execute(
text("UPDATE platform_scheduled_tasks SET last_run_status = 'running', last_run_at = NOW() WHERE id = :id"),
{"id": task_id}
)
db.commit()
# 3. 创建执行日志
db.execute(
text("""
INSERT INTO platform_task_logs (task_id, tenant_id, started_at, status)
VALUES (:task_id, :tenant_id, NOW(), 'running')
"""),
{"task_id": task_id, "tenant_id": task["tenant_id"]}
)
db.commit()
# 获取刚插入的日志ID
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
log_id = result.scalar()
# 4. 调用 webhook
webhook_url = task["webhook_url"]
input_params = task["input_params"] or {}
async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(
webhook_url,
json=input_params,
headers={"Content-Type": "application/json"}
)
response_code = response.status_code
response_body = response.text[:5000] if response.text else "" # 限制存储长度
if response.is_success:
status = "success"
error_message = None
else:
status = "failed"
error_message = f"HTTP {response_code}"
# 5. 更新执行日志
db.execute(
text("""
UPDATE platform_task_logs
SET finished_at = NOW(), status = :status, response_code = :code,
response_body = :body, error_message = :error
WHERE id = :id
"""),
{
"id": log_id,
"status": status,
"code": response_code,
"body": response_body,
"error": error_message
}
)
# 6. 更新任务状态
db.execute(
text("""
UPDATE platform_scheduled_tasks
SET last_run_status = :status, last_run_message = :message
WHERE id = :id
"""),
{
"id": task_id,
"status": status,
"message": error_message or "执行成功"
}
)
db.commit()
logger.info(f"Task {task_id} executed with status: {status}")
except Exception as e:
logger.error(f"Task {task_id} execution error: {str(e)}")
# 更新失败状态
try:
if log_id:
db.execute(
text("""
UPDATE platform_task_logs
SET finished_at = NOW(), status = 'failed', error_message = :error
WHERE id = :id
"""),
{"id": log_id, "error": str(e)[:1000]}
)
db.execute(
text("""
UPDATE platform_scheduled_tasks
SET last_run_status = 'failed', last_run_message = :message
WHERE id = :id
"""),
{"id": task_id, "message": str(e)[:500]}
)
db.commit()
except Exception:
pass
finally:
db.close()
def add_task_to_scheduler(task: Dict[str, Any]):
"""将任务添加到调度器"""
global scheduler
if scheduler is None:
return
task_id = task["id"]
schedule_type = task["schedule_type"]
# 先移除已有的任务(如果存在)
remove_task_from_scheduler(task_id)
if schedule_type == "cron":
# CRON 模式
cron_expr = task["cron_expression"]
if cron_expr:
try:
trigger = CronTrigger.from_crontab(cron_expr, timezone="Asia/Shanghai")
scheduler.add_job(
execute_task,
trigger,
args=[task_id],
id=f"task_{task_id}_cron",
replace_existing=True
)
logger.info(f"Added cron task {task_id}: {cron_expr}")
except Exception as e:
logger.error(f"Failed to add cron task {task_id}: {e}")
else:
# 简单模式 - 多个时间点
time_points = task.get("time_points") or []
if isinstance(time_points, str):
import json
time_points = json.loads(time_points)
for i, time_point in enumerate(time_points):
try:
hour, minute = time_point.split(":")
trigger = CronTrigger(
hour=int(hour),
minute=int(minute),
timezone="Asia/Shanghai"
)
scheduler.add_job(
execute_task,
trigger,
args=[task_id],
id=f"task_{task_id}_time_{i}",
replace_existing=True
)
logger.info(f"Added simple task {task_id} at {time_point}")
except Exception as e:
logger.error(f"Failed to add time point {time_point} for task {task_id}: {e}")
def remove_task_from_scheduler(task_id: int):
"""从调度器移除任务"""
global scheduler
if scheduler is None:
return
# 移除所有相关的 job
jobs_to_remove = []
for job in scheduler.get_jobs():
if job.id.startswith(f"task_{task_id}_"):
jobs_to_remove.append(job.id)
for job_id in jobs_to_remove:
try:
scheduler.remove_job(job_id)
logger.info(f"Removed job {job_id}")
except Exception as e:
logger.warning(f"Failed to remove job {job_id}: {e}")
def load_all_tasks():
"""从数据库加载所有启用的任务"""
db = get_db_session()
try:
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE is_enabled = 1")
)
tasks = result.mappings().all()
for task in tasks:
add_task_to_scheduler(dict(task))
logger.info(f"Loaded {len(tasks)} scheduled tasks")
finally:
db.close()
def start_scheduler():
"""启动调度器"""
global scheduler
scheduler = get_scheduler()
# 加载所有任务
load_all_tasks()
# 启动调度器
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started")
def shutdown_scheduler():
"""关闭调度器"""
global scheduler
if scheduler and scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler shutdown")
def reload_task(task_id: int):
"""重新加载单个任务"""
db = get_db_session()
try:
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
task = result.mappings().first()
if task and task["is_enabled"]:
add_task_to_scheduler(dict(task))
else:
remove_task_from_scheduler(task_id)
finally:
db.close()

View File

@@ -12,3 +12,4 @@ python-multipart>=0.0.6
httpx>=0.26.0
redis>=5.0.0
openpyxl>=3.1.0
apscheduler>=3.10.0

View File

@@ -16,6 +16,7 @@ const menuItems = computed(() => {
{ path: '/apps', title: '应用管理', icon: 'Grid' },
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' }
]

View File

@@ -53,7 +53,13 @@ const routes = [
path: 'app-config',
name: 'AppConfig',
component: () => import('@/views/app-config/index.vue'),
meta: { title: '租户应用配置', icon: 'Setting' }
meta: { title: '租户订阅', icon: 'Setting' }
},
{
path: 'scheduled-tasks',
name: 'ScheduledTasks',
component: () => import('@/views/scheduled-tasks/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
},
{
path: 'stats',

View File

@@ -0,0 +1,645 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus, VideoPlay, Clock, Document } from '@element-plus/icons-vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: ''
})
// 租户列表
const tenantList = ref([])
const tenantMap = ref({})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
task_name: '',
task_desc: '',
schedule_type: 'simple',
time_points: [],
cron_expression: '',
webhook_url: '',
input_params: '',
is_enabled: true
})
// 时间选择器
const newTimePoint = ref('')
const rules = {
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
webhook_url: [{ required: true, message: '请输入 Webhook URL', trigger: 'blur' }]
}
// 日志对话框
const logsDialogVisible = ref(false)
const logsLoading = ref(false)
const logsData = ref([])
const logsTotal = ref(0)
const logsTaskId = ref(null)
const logsQuery = reactive({
page: 1,
size: 10
})
// 获取租户名称
function getTenantName(code) {
return tenantMap.value[code] || code
}
// 获取调度描述
function getScheduleDesc(row) {
if (row.schedule_type === 'cron') {
return `CRON: ${row.cron_expression}`
}
const points = row.time_points || []
if (points.length === 0) return '-'
if (points.length <= 3) return points.join(', ')
return `${points.slice(0, 3).join(', ')}${points.length}个时间点`
}
// 获取状态标签类型
function getStatusType(status) {
const map = {
success: 'success',
failed: 'danger',
running: 'warning'
}
return map[status] || 'info'
}
// 格式化时间
function formatTime(time) {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
const map = {}
tenantList.value.forEach(t => {
map[t.code] = t.name
})
tenantMap.value = map
} catch (e) {
console.error('获取租户列表失败:', e)
}
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/scheduled-tasks', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建定时任务'
Object.assign(form, {
tenant_id: '',
task_name: '',
task_desc: '',
schedule_type: 'simple',
time_points: [],
cron_expression: '',
webhook_url: '',
input_params: '',
is_enabled: true
})
newTimePoint.value = ''
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑定时任务'
Object.assign(form, {
tenant_id: row.tenant_id,
task_name: row.task_name,
task_desc: row.task_desc || '',
schedule_type: row.schedule_type || 'simple',
time_points: row.time_points || [],
cron_expression: row.cron_expression || '',
webhook_url: row.webhook_url,
input_params: row.input_params ? JSON.stringify(row.input_params, null, 2) : '',
is_enabled: row.is_enabled
})
newTimePoint.value = ''
dialogVisible.value = true
}
// 添加时间点
function addTimePoint() {
if (!newTimePoint.value) return
if (!form.time_points.includes(newTimePoint.value)) {
form.time_points.push(newTimePoint.value)
form.time_points.sort()
}
newTimePoint.value = ''
}
// 移除时间点
function removeTimePoint(index) {
form.time_points.splice(index, 1)
}
async function handleSubmit() {
await formRef.value.validate()
// 验证调度配置
if (form.schedule_type === 'simple' && form.time_points.length === 0) {
ElMessage.error('请至少添加一个执行时间点')
return
}
if (form.schedule_type === 'cron' && !form.cron_expression) {
ElMessage.error('请输入 CRON 表达式')
return
}
// 解析输入参数
let inputParams = null
if (form.input_params) {
try {
inputParams = JSON.parse(form.input_params)
} catch (e) {
ElMessage.error('输入参数格式错误,请输入有效的 JSON')
return
}
}
const data = {
tenant_id: form.tenant_id,
task_name: form.task_name,
task_desc: form.task_desc,
schedule_type: form.schedule_type,
time_points: form.schedule_type === 'simple' ? form.time_points : null,
cron_expression: form.schedule_type === 'cron' ? form.cron_expression : null,
webhook_url: form.webhook_url,
input_params: inputParams,
is_enabled: form.is_enabled
}
try {
if (editingId.value) {
await api.put(`/api/scheduled-tasks/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/scheduled-tasks', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除任务「${row.task_name}」吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/scheduled-tasks/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleToggle(row) {
try {
const res = await api.post(`/api/scheduled-tasks/${row.id}/toggle`)
ElMessage.success(res.data.message)
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleRunNow(row) {
await ElMessageBox.confirm(`确定立即执行任务「${row.task_name}」吗?`, '手动执行', {
type: 'info'
})
try {
await api.post(`/api/scheduled-tasks/${row.id}/run`)
ElMessage.success('任务已触发执行')
// 延迟刷新以获取最新状态
setTimeout(() => fetchList(), 2000)
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleViewLogs(row) {
logsTaskId.value = row.id
logsQuery.page = 1
logsDialogVisible.value = true
await fetchLogs()
}
async function fetchLogs() {
logsLoading.value = true
try {
const res = await api.get(`/api/scheduled-tasks/${logsTaskId.value}/logs`, {
params: logsQuery
})
logsData.value = res.data.items || []
logsTotal.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
logsLoading.value = false
}
}
function handleLogsPageChange(page) {
logsQuery.page = page
fetchLogs()
}
// 快速选择租户
function selectTenant(code) {
if (query.tenant_id === code) {
query.tenant_id = ''
} else {
query.tenant_id = code
}
handleSearch()
}
onMounted(() => {
fetchTenants()
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">定时任务</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
<div class="page-tip">
<el-alert type="info" :closable="false">
管理定时任务支持简单时间点和 CRON 表达式两种调度方式可自动调用 n8n 工作流
</el-alert>
</div>
<!-- 租户快速筛选标签 -->
<div class="tenant-tags">
<span class="tag-label">租户筛选</span>
<el-tag
v-for="tenant in tenantList"
:key="tenant.code"
:type="query.tenant_id === tenant.code ? '' : 'info'"
:effect="query.tenant_id === tenant.code ? 'dark' : 'plain'"
class="tenant-tag"
@click="selectTenant(tenant.code)"
>
{{ tenant.name }}
</el-tag>
<el-tag
v-if="query.tenant_id"
type="danger"
effect="plain"
class="tenant-tag clear-tag"
@click="selectTenant('')"
>
清除筛选
</el-tag>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="租户" width="100">
<template #default="{ row }">
{{ getTenantName(row.tenant_id) }}
</template>
</el-table-column>
<el-table-column prop="task_name" label="任务名称" width="150" />
<el-table-column label="调度配置" width="200">
<template #default="{ row }">
<el-tag size="small" :type="row.schedule_type === 'cron' ? 'warning' : ''">
{{ row.schedule_type === 'cron' ? 'CRON' : '简单' }}
</el-tag>
<span style="margin-left: 8px; color: #606266; font-size: 12px">
{{ getScheduleDesc(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上次执行" width="180">
<template #default="{ row }">
<div v-if="row.last_run_at" style="font-size: 12px">
<div>{{ formatTime(row.last_run_at) }}</div>
<el-tag :type="getStatusType(row.last_run_status)" size="small">
{{ row.last_run_status || '-' }}
</el-tag>
</div>
<span v-else style="color: #909399">未执行</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link size="small" @click="handleRunNow(row)">
<el-icon><VideoPlay /></el-icon>
执行
</el-button>
<el-button
:type="row.is_enabled ? 'warning' : 'success'"
link
size="small"
@click="handleToggle(row)"
>
{{ row.is_enabled ? '禁用' : '启用' }}
</el-button>
<el-button type="info" link size="small" @click="handleViewLogs(row)">
<el-icon><Document /></el-icon>
日志
</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
:lock-scroll="true"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户" prop="tenant_id">
<el-select
v-model="form.tenant_id"
:disabled="!!editingId"
placeholder="请选择租户"
filterable
style="width: 100%"
>
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
</el-form-item>
<el-form-item label="任务名称" prop="task_name">
<el-input v-model="form.task_name" placeholder="如:每日数据同步" />
</el-form-item>
<el-form-item label="任务描述">
<el-input v-model="form.task_desc" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
<el-form-item label="调度模式">
<el-radio-group v-model="form.schedule_type">
<el-radio value="simple">简单模式选择时间点</el-radio>
<el-radio value="cron">CRON 表达式</el-radio>
</el-radio-group>
</el-form-item>
<!-- 简单模式 -->
<el-form-item v-if="form.schedule_type === 'simple'" label="执行时间">
<div class="time-points-editor">
<div class="time-points-list">
<el-tag
v-for="(time, index) in form.time_points"
:key="time"
closable
@close="removeTimePoint(index)"
style="margin-right: 8px; margin-bottom: 8px"
>
{{ time }}
</el-tag>
</div>
<div class="time-points-add">
<el-time-select
v-model="newTimePoint"
start="00:00"
step="00:30"
end="23:30"
placeholder="选择时间"
style="width: 140px"
/>
<el-button type="primary" @click="addTimePoint" :disabled="!newTimePoint">
添加
</el-button>
</div>
</div>
</el-form-item>
<!-- CRON 模式 -->
<el-form-item v-if="form.schedule_type === 'cron'" label="CRON 表达式">
<el-input v-model="form.cron_expression" placeholder="如0 9,18 * * *每天9点和18点" />
<div class="form-tip">
格式 例如0 9 * * * 表示每天9点执行
</div>
</el-form-item>
<el-form-item label="Webhook URL" prop="webhook_url">
<el-input v-model="form.webhook_url" placeholder="如https://n8n.ireborn.com.cn/webhook/xxx" />
</el-form-item>
<el-form-item label="输入参数">
<el-input
v-model="form.input_params"
type="textarea"
:rows="4"
placeholder='可选JSON 格式,如:{"key": "value"}'
/>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.is_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 日志对话框 -->
<el-dialog
v-model="logsDialogVisible"
title="执行日志"
width="800px"
:lock-scroll="true"
>
<el-table v-loading="logsLoading" :data="logsData" style="width: 100%">
<el-table-column label="开始时间" width="170">
<template #default="{ row }">
{{ formatTime(row.started_at) }}
</template>
</el-table-column>
<el-table-column label="结束时间" width="170">
<template #default="{ row }">
{{ formatTime(row.finished_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="响应码" width="80">
<template #default="{ row }">
{{ row.response_code || '-' }}
</template>
</el-table-column>
<el-table-column label="错误信息">
<template #default="{ row }">
<span v-if="row.error_message" style="color: #f56c6c">{{ row.error_message }}</span>
<span v-else style="color: #67c23a">成功</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="logsQuery.page"
:page-size="logsQuery.size"
:total="logsTotal"
layout="total, prev, pager, next"
@current-change="handleLogsPageChange"
/>
</div>
<template #footer>
<el-button @click="logsDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
.tenant-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
}
.tag-label {
color: #606266;
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.tenant-tag {
cursor: pointer;
transition: all 0.2s;
}
.tenant-tag:hover {
transform: translateY(-1px);
}
.clear-tag {
margin-left: 8px;
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.time-points-editor {
width: 100%;
}
.time-points-list {
min-height: 32px;
margin-bottom: 8px;
}
.time-points-add {
display: flex;
gap: 8px;
}
.form-tip {
color: #909399;
font-size: 12px;
margin-top: 4px;
}
</style>