feat: 定时执行工单 — 新建工单+自动任务管理全面重构

后端:
- Ticket 模型新增 scheduled_at 字段 (DateTime, nullable)
- TicketStatus 新增 SCHEDULED 状态
- 状态转换图: approved→scheduled, scheduled→executing/approved
- execute_ticket: scheduled_at 在未来时进入 scheduled 状态
- ticket_scheduler.py: asyncio 后台定时执行器
- GET /tickets/scheduled/list: 列出定时执行中工单
- POST /tickets/{id}/cancel-schedule: 取消定时执行

前端:
- TicketCreatePage: 新增定时执行 checkbox + datetime-local picker (秒级精度)
- AutoTaskPage: 全面重写为真实定时任务管理页
  - 实时倒计时显示 (Xh Xm 后执行)
  - DB 类型图标 (MySQL/PG/Redis/MongoDB)
  - SQL 预览 + 取消定时执行
  - 每 30 秒自动刷新
This commit is contained in:
logan committed 2026-05-25 00:02:21 +08:00
1 parent 478e663313
commit ffb3d24e2a
6 files changed
+418 -262

No files matched your search

+90 -3
View File
@@ -69,6 +69,7 @@ class TicketStatus(str, Enum):
PENDING = "pending" # 待审批
REVIEWING = "reviewing" # 审批中(多级审批)
APPROVED = "approved" # 已通过
SCHEDULED = "scheduled" # 定时执行中
REJECTED = "rejected" # 已拒绝
EXECUTING = "executing" # 执行中
SUCCESS = "success" # 执行成功
@@ -80,7 +81,8 @@ class TicketStatus(str, Enum):
TICKET_STATE_TRANSITIONS: Dict[str, List[str]] = {
TicketStatus.PENDING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value],
TicketStatus.REVIEWING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value],
TicketStatus.APPROVED.value: [TicketStatus.EXECUTING.value, TicketStatus.REJECTED.value],
TicketStatus.APPROVED.value: [TicketStatus.EXECUTING.value, TicketStatus.SCHEDULED.value, TicketStatus.REJECTED.value],
TicketStatus.SCHEDULED.value: [TicketStatus.EXECUTING.value, TicketStatus.APPROVED.value],
TicketStatus.EXECUTING.value: [TicketStatus.SUCCESS.value, TicketStatus.FAILED.value],
TicketStatus.SUCCESS.value: [TicketStatus.ROLLBACK.value],
TicketStatus.FAILED.value: [TicketStatus.ROLLBACK.value, TicketStatus.APPROVED.value],
@@ -404,6 +406,7 @@ async def create_ticket(
applicant_id=current_user.id,
status=initial_status,
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report and v2_report.findings else None,
scheduled_at=data.scheduled_at,
)
db.add(ticket)
await db.flush() # flush to get ticket.id
@@ -896,7 +899,25 @@ async def execute_ticket(
# 5. 权限检查
await check_instance_permission(current_user, instance, "ticket:execute", db)
# 6. 状态转换: approved -> executing
# 6. 定时执行:如果 scheduled_at 在未来,改为 scheduled 状态
if ticket.scheduled_at:
now = datetime.now(timezone.utc)
scheduled_aware = ticket.scheduled_at.replace(tzinfo=timezone.utc) if ticket.scheduled_at.tzinfo is None else ticket.scheduled_at
if scheduled_aware > now:
if not _validate_status_transition(ticket.status, TicketStatus.SCHEDULED.value):
raise HTTPException(status_code=400, detail=f"非法的状态转换: {ticket.status} -> scheduled")
ticket.status = TicketStatus.SCHEDULED.value
await db.commit()
await db.refresh(ticket)
# 启动后台定时执行
from app.services.ticket_scheduler import schedule_ticket_execution
await schedule_ticket_execution(ticket.id, scheduled_aware)
ticket_data = await _enrich_ticket(ticket, db)
return ticket_data
# 7. 状态转换: approved -> executing
if not _validate_status_transition(ticket.status, TicketStatus.EXECUTING.value):
raise HTTPException(
status_code=400,
@@ -907,7 +928,7 @@ async def execute_ticket(
await db.commit()
await db.refresh(ticket)
# 7. 使用多数据库执行引擎
# 8. 使用多数据库执行引擎
from app.services.ticket_executor import TicketExecutor
executor = TicketExecutor()
@@ -1071,3 +1092,69 @@ async def _send_execution_notification(
channels=channels,
ticket_data=notification_data,
)
# ===== 定时任务管理 =====
@router.get("/scheduled/list")
async def list_scheduled_tickets(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""列出所有定时执行中的工单。"""
result = await db.execute(
select(Ticket)
.where(Ticket.status == TicketStatus.SCHEDULED.value)
.order_by(Ticket.scheduled_at.asc())
)
tickets = result.scalars().all()
data = []
for t in tickets:
inst_result = await db.execute(select(Instance).where(Instance.id == t.instance_id))
inst = inst_result.scalar_one_or_none()
data.append({
"id": t.id,
"ticket_no": t.ticket_no,
"title": t.title,
"db_type": t.db_type,
"instance_name": inst.name if inst else "未知",
"instance_db_type": inst.db_type if inst else "",
"target_database": t.target_database,
"sql_content": t.sql_content[:200] + "..." if len(t.sql_content) > 200 else t.sql_content,
"status": t.status,
"scheduled_at": t.scheduled_at.isoformat() if t.scheduled_at else None,
"priority": t.priority,
"create_time": t.create_time.isoformat() if t.create_time else None,
})
return {"items": data, "total": len(data)}
@router.post("/{ticket_id}/cancel-schedule")
async def cancel_scheduled_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""取消定时执行,将工单状态改回 approved。"""
ticket = await _get_ticket(db, ticket_id)
if ticket.status != TicketStatus.SCHEDULED.value:
raise HTTPException(status_code=400, detail="只能取消定时执行中的工单")
if not _validate_status_transition(ticket.status, TicketStatus.APPROVED.value):
raise HTTPException(status_code=400, detail="非法的状态转换")
# 取消后台任务
from app.services.ticket_scheduler import cancel_ticket_execution
cancel_ticket_execution(ticket.id)
ticket.status = TicketStatus.APPROVED.value
await db.commit()
await db.refresh(ticket)
await AuditLogService.log(
db, user_id=current_user.id, action="CANCEL_SCHEDULED_TICKET",
target_type="ticket", target_id=ticket.id,
detail=f"取消定时执行: {ticket.scheduled_at}",
)
return {"message": "已取消定时执行", "ticket_id": ticket_id, "status": ticket.status}
+1
View File
@@ -24,4 +24,5 @@ class Ticket(Base):
create_time = Column(DateTime, server_default=func.now())
review_time = Column(DateTime, nullable=True)
execute_time = Column(DateTime, nullable=True)
scheduled_at = Column(DateTime, nullable=True, comment="定时执行时间,NULL=立即执行")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+2
View File
@@ -12,6 +12,7 @@ class TicketCreate(BaseModel):
rollback_sql: Optional[str] = None
reason: str = Field(..., min_length=5)
priority: Literal["high", "medium", "low"] = "medium"
scheduled_at: Optional[datetime] = None
class TicketReview(BaseModel):
@@ -42,6 +43,7 @@ class TicketResponse(BaseModel):
create_time: datetime
review_time: Optional[datetime] = None
execute_time: Optional[datetime] = None
scheduled_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+147
View File
@@ -0,0 +1,147 @@
"""工单定时执行调度器 — 基于 asyncio 的轻量级定时任务。"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Dict
logger = logging.getLogger(__name__)
# 内存中保存活跃的定时任务句柄
_active_tasks: Dict[int, asyncio.Task] = {}
async def schedule_ticket_execution(ticket_id: int, scheduled_at: datetime) -> None:
"""注册一个定时执行任务。
使用 asyncio.create_task 创建后台等待任务,到达 scheduled_at 后自动执行工单。
Args:
ticket_id: 工单 ID
scheduled_at: 定时执行时间 (UTC)
"""
now = datetime.now(timezone.utc)
delay = (scheduled_at - now).total_seconds()
if delay <= 0:
# 已到时间,立即执行
logger.info("工单 %d 定时时间已到,立即执行", ticket_id)
task = asyncio.create_task(_execute_scheduled_ticket(ticket_id))
else:
logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay)
task = asyncio.create_task(_wait_and_execute(ticket_id, delay))
_active_tasks[ticket_id] = task
async def _wait_and_execute(ticket_id: int, delay: float) -> None:
"""等待 delay 秒后执行工单。"""
try:
await asyncio.sleep(delay)
await _execute_scheduled_ticket(ticket_id)
except asyncio.CancelledError:
logger.info("工单 %d 定时执行已取消", ticket_id)
except Exception:
logger.exception("工单 %d 定时执行异常", ticket_id)
finally:
_active_tasks.pop(ticket_id, None)
async def _execute_scheduled_ticket(ticket_id: int) -> None:
"""执行定时工单 — 创建独立数据库会话执行。"""
from app.core.database import async_session
from app.models.ticket import Ticket as TicketModel
from app.models.instance import Instance
from app.services.ticket_executor import TicketExecutor
from app.services.audit_log import AuditLogService
from app.core.security import decrypt_password
from sqlalchemy import select
import json
logger.info("开始执行定时工单 %d", ticket_id)
async with async_session() as db:
try:
# 查询工单
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
logger.error("定时工单 %d 不存在", ticket_id)
return
# 状态检查
if ticket.status != "scheduled":
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
return
# 更新状态为 executing
ticket.status = "executing"
await db.commit()
await db.refresh(ticket)
# 查询实例
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
ticket.status = "failed"
ticket.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
return
# 解密密码
instance.password = decrypt_password(instance.password)
# 执行
executor = TicketExecutor()
report = await executor.execute(ticket, instance)
# 更新结果
ticket.status = "success" if report.status == "success" else "failed"
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
except Exception as e:
logger.exception("定时工单 %d 执行异常", ticket_id)
try:
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if ticket:
ticket.status = "failed"
ticket.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
except Exception:
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
finally:
_active_tasks.pop(ticket_id, None)
def cancel_ticket_execution(ticket_id: int) -> bool:
"""取消定时执行任务。
Args:
ticket_id: 工单 ID
Returns:
是否成功取消
"""
task = _active_tasks.get(ticket_id)
if task and not task.done():
task.cancel()
_active_tasks.pop(ticket_id, None)
logger.info("已取消工单 %d 的定时执行", ticket_id)
return True
return False
def get_active_scheduled_count() -> int:
"""返回当前活跃的定时任务数量。"""
return len(_active_tasks)
def get_active_scheduled_ids() -> list[int]:
"""返回当前活跃的定时工单 ID 列表。"""
return list(_active_tasks.keys())
+154 -259
View File
@@ -1,293 +1,188 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Clock, Plus, Play, Pause, Trash2, AlertTriangle,
Calendar, Zap, Database, ChevronDown, ChevronUp, X, Save, Eye
Clock, XCircle, CheckCircle, AlertTriangle,
Database, Zap, Loader2, Calendar, Ban,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import api from '@/api/request';
type TaskFrequency = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
type TaskStatus = 'running' | 'paused' | 'completed' | 'failed';
interface ScheduledTicket {
id: number;
ticket_no: string;
title: string;
db_type: string;
instance_name: string;
instance_db_type: string;
target_database: string;
sql_content: string;
status: string;
scheduled_at: string | null;
priority: string;
create_time: string | null;
}
type AutoTask = {
id: string;
name: string;
description: string;
enabled: boolean;
frequency: TaskFrequency;
cron: string;
nextRun: string;
lastRun: string;
lastStatus: 'success' | 'failed' | 'none';
dbType: string;
instance: string;
sql: string;
status: TaskStatus;
notifyOnFail: boolean;
createdBy: string;
const DB_TYPE_ICON: Record<string, string> = {
mysql: '🐬', postgres: '🐘', redis: '🔴', mongo: '🍃',
};
const frequencyLabels: Record<TaskFrequency, string> = {
once: '一次性',
hourly: '每小时',
daily: '每天',
weekly: '每周',
monthly: '每月',
const DB_TYPE_LABEL: Record<string, string> = {
mysql: 'MySQL', postgres: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB',
};
const initialTasks: AutoTask[] = [
{ id: 'task-1', name: '每日数据归档', description: '将 30 天前的日志数据归档到历史表', enabled: true, frequency: 'daily', cron: '0 2 * * *', nextRun: '2024-01-16 02:00', lastRun: '2024-01-15 02:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO logs_archive SELECT * FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);\nDELETE FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
{ id: 'task-2', name: '每周统计报表', description: '生成周活跃用户统计报表', enabled: true, frequency: 'weekly', cron: '0 6 * * 1', nextRun: '2024-01-22 06:00', lastRun: '2024-01-08 06:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO report_weekly_active (week_start, week_end, active_users, new_users)\nSELECT \n DATE_SUB(NOW(), INTERVAL 7 DAY) as week_start,\n NOW() as week_end,\n COUNT(DISTINCT user_id) as active_users,\n COUNT(DISTINCT CASE WHEN created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) THEN user_id END) as new_users\nFROM user_activity\nWHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
{ id: 'task-3', name: '索引健康检查', description: '检查慢查询并建议优化索引', enabled: true, frequency: 'daily', cron: '0 3 * * *', nextRun: '2024-01-16 03:00', lastRun: '2024-01-15 03:00', lastStatus: 'failed', dbType: 'mysql', instance: '生产主库', sql: 'SELECT \n table_schema,\n table_name,\n index_name,\n cardinality,\n table_rows,\n ROUND(cardinality / table_rows * 100, 2) as selectivity\nFROM information_schema.statistics\nWHERE table_schema NOT IN ("information_schema", "mysql", "performance_schema")\n AND index_name != "PRIMARY"\n AND cardinality / table_rows < 0.1\nORDER BY table_rows DESC\nLIMIT 100;', status: 'paused', notifyOnFail: true, createdBy: 'zhangsan' },
{ id: 'task-4', name: 'Redis 键过期清理', description: '清理 Redis 中已过期的会话键', enabled: false, frequency: 'hourly', cron: '0 * * * *', nextRun: '-', lastRun: '2024-01-14 12:00', lastStatus: 'success', dbType: 'redis', instance: 'Redis集群-01', sql: 'EVAL "local keys = redis.call(\'keys\', \'session:*\')\nfor i=1,#keys,5000 do\n redis.call(\'del\', unpack(keys, i, math.min(i+4999, #keys)))\nend\nreturn #keys" 0', status: 'paused', notifyOnFail: false, createdBy: 'lisi' },
{ id: 'task-5', name: '月度数据备份', description: '每月全量备份核心数据表', enabled: true, frequency: 'monthly', cron: '0 1 1 * *', nextRun: '2024-02-01 01:00', lastRun: '2024-01-01 01:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: '-- 调用备份脚本\nBACKUP DATABASE user_db, order_db TO S3://backups/monthly/;', status: 'running', notifyOnFail: true, createdBy: 'admin' },
];
export default function AutoTaskPage() {
const [tasks, setTasks] = useState<AutoTask[]>(initialTasks);
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [previewTask, setPreviewTask] = useState<AutoTask | null>(null);
const [tasks, setTasks] = useState<ScheduledTicket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cancelling, setCancelling] = useState<number | null>(null);
// 表单状态
const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState('');
const [formFreq, setFormFreq] = useState<TaskFrequency>('daily');
const [formCron, setFormCron] = useState('0 2 * * *');
const [formDbType, setFormDbType] = useState('mysql');
const [formInstance, setFormInstance] = useState('生产主库');
const [formSql, setFormSql] = useState('');
const [formNotify, setFormNotify] = useState(true);
const fetchTasks = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await api.get('/tickets/scheduled/list') as { items: ScheduledTicket[]; total: number };
setTasks(res.items || []);
} catch {
setError('加载定时任务失败');
} finally {
setLoading(false);
}
}, []);
const toggleTask = (id: string) => {
setTasks(prev => prev.map(t => t.id === id ? { ...t, enabled: !t.enabled, status: !t.enabled ? 'running' : 'paused' } : t));
useEffect(() => { fetchTasks(); }, [fetchTasks]);
// 自动刷新(每 30 秒)
useEffect(() => {
const timer = setInterval(fetchTasks, 30000);
return () => clearInterval(timer);
}, [fetchTasks]);
const handleCancel = useCallback(async (id: number) => {
setCancelling(id);
try {
await api.post(`/tickets/${id}/cancel-schedule`);
setTasks(prev => prev.filter(t => t.id !== id));
} catch {
setError('取消定时任务失败');
} finally {
setCancelling(null);
}
}, []);
const formatTime = (iso: string | null) => {
if (!iso) return '-';
return new Date(iso).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
};
const deleteTask = (id: string) => {
setTasks(prev => prev.filter(t => t.id !== id));
};
const openCreate = () => {
setFormName('');
setFormDesc('');
setFormFreq('daily');
setFormCron('0 2 * * *');
setFormDbType('mysql');
setFormInstance('生产主库');
setFormSql('');
setFormNotify(true);
setShowCreate(true);
};
const saveTask = () => {
if (!formName.trim() || !formSql.trim()) return;
const newTask: AutoTask = {
id: `task-${Date.now()}`,
name: formName,
description: formDesc,
enabled: true,
frequency: formFreq,
cron: formCron,
nextRun: '即将计算...',
lastRun: '-',
lastStatus: 'none',
dbType: formDbType,
instance: formInstance,
sql: formSql,
status: 'running',
notifyOnFail: formNotify,
createdBy: 'admin',
};
setTasks(prev => [...prev, newTask]);
setShowCreate(false);
};
const statusColors: Record<string, string> = {
running: 'bg-green-500/15 text-green-400',
paused: 'bg-yellow-500/15 text-yellow-400',
completed: 'bg-blue-500/15 text-blue-400',
failed: 'bg-red-500/15 text-red-400',
const getCountdown = (iso: string | null) => {
if (!iso) return '';
const diff = new Date(iso).getTime() - Date.now();
if (diff <= 0) return '即将执行';
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
if (h > 0) return `${h}h ${m}m 后执行`;
if (m > 0) return `${m}m ${s}s 后执行`;
return `${s}s 后执行`;
};
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">自动任务</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置定时执行的 SQL 任务,支持 Cron 表达式</p>
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-blue-400" />
<h1 className="text-xl font-semibold">定时执行任务</h1>
<span className="text-sm text-[var(--dg-text-muted)]">
审批通过并设置定时执行的工单,到时间后自动执行 SQL
</span>
</div>
<button onClick={openCreate} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Plus className="w-4 h-4" /> 新建任务
<button onClick={fetchTasks} disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors disabled:opacity-50">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />}
刷新
</button>
</div>
<div className="grid grid-cols-4 gap-4">
{[
{ label: '任务总数', value: tasks.length, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '运行中', value: tasks.filter(t => t.status === 'running').length, icon: Play, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '已暂停', value: tasks.filter(t => t.status === 'paused').length, icon: Pause, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
{ label: '上次失败', value: tasks.filter(t => t.lastStatus === 'failed').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg">
<AlertTriangle className="w-4 h-4 shrink-0" /> {error}
</div>
)}
{/* Empty */}
{!loading && tasks.length === 0 && (
<div className="text-center py-16">
<Clock className="w-12 h-12 text-[var(--dg-text-muted)] mx-auto mb-3 opacity-30" />
<p className="text-[var(--dg-text-muted)]">暂无定时执行中的工单</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">
在「新建数据变更工单」中勾选「定时执行」可创建定时任务
</p>
</div>
)}
{/* Task Cards */}
<div className="space-y-3">
{tasks.map(task => {
const isExpanded = expandedId === task.id;
const countdown = getCountdown(task.scheduled_at);
return (
<Card key={task.id} className={`dg-card ${!task.enabled ? 'opacity-60' : ''}`}>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold">{task.name}</h3>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${statusColors[task.status]}`}>
{task.status === 'running' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status === 'failed' ? '失败' : '完成'}
</span>
<span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
{frequencyLabels[task.frequency]}
</span>
<span className="text-[11px] font-mono bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] px-2 py-0.5 rounded">
{task.cron}
</span>
</div>
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{task.description}</p>
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><Database className="w-3.5 h-3.5" /> {task.instance} ({task.dbType.toUpperCase()})</span>
<span className="flex items-center gap-1"><Calendar className="w-3.5 h-3.5" /> 下次执行: {task.nextRun}</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> 上次执行:
<span className={task.lastStatus === 'success' ? 'text-green-400' : task.lastStatus === 'failed' ? 'text-red-400' : ''}>{task.lastRun}</span>
</span>
<span className="flex items-center gap-1"><Zap className="w-3.5 h-3.5" /> 创建者: {task.createdBy}</span>
</div>
{isExpanded && (
<div className="mt-4 p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
<pre className="text-sm text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap overflow-x-auto">{task.sql}</pre>
</div>
)}
<div key={task.id}
className="bg-[var(--dg-bg-secondary)] rounded-lg p-4 space-y-3">
{/* Row 1: Title + Status + Countdown */}
<div className="flex items-center gap-3">
<span className="text-lg">{DB_TYPE_ICON[task.db_type] || '💾'}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{task.title}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 font-medium shrink-0">
定时执行中
</span>
</div>
<div className="flex items-center gap-1 ml-4">
<button onClick={() => setPreviewTask(task)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
<Eye className="w-4 h-4" />
</button>
<button onClick={() => setExpandedId(isExpanded ? null : task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button onClick={() => toggleTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={task.enabled ? '暂停' : '启动'}>
{task.enabled ? <Pause className="w-4 h-4 text-yellow-400" /> : <Play className="w-4 h-4 text-green-400" />}
</button>
<button onClick={() => deleteTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-2 mt-1 text-xs text-[var(--dg-text-muted)]">
<span className="font-mono">{task.ticket_no}</span>
<span>·</span>
<Database className="w-3 h-3" />
<span>{task.instance_name}</span>
<span>·</span>
<span>{DB_TYPE_LABEL[task.db_type] || task.db_type}</span>
<span>·</span>
<span>{task.target_database}</span>
</div>
</div>
</CardContent>
</Card>
{/* Countdown */}
<div className="text-right shrink-0">
<div className="text-sm font-medium text-blue-400">{countdown}</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">
<Calendar className="w-3 h-3 inline mr-0.5" />
{formatTime(task.scheduled_at)}
</div>
</div>
</div>
{/* Row 2: SQL Preview */}
<div className="bg-[var(--dg-bg-primary)] rounded p-2.5">
<pre className="text-[11px] font-mono text-[var(--dg-text-secondary)] overflow-x-auto whitespace-pre-wrap max-h-[60px] overflow-y-auto">
{task.sql_content}
</pre>
</div>
{/* Row 3: Actions */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-[var(--dg-text-muted)]">
创建于 {formatTime(task.create_time)}
</span>
<button onClick={() => handleCancel(task.id)} disabled={cancelling === task.id}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 rounded-md hover:bg-red-500/20 transition-colors disabled:opacity-50">
{cancelling === task.id
? <><Loader2 className="w-3 h-3 animate-spin" /> 取消中...</>
: <><Ban className="w-3 h-3" /> 取消定时执行</>}
</button>
</div>
</div>
);
})}
</div>
{/* 预览弹窗 */}
{previewTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewTask(null)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold">{previewTask.name}</h3>
<button onClick={() => setPreviewTask(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><span className="text-[var(--dg-text-muted)]">执行频率:</span> {frequencyLabels[previewTask.frequency]} ({previewTask.cron})</div>
<div><span className="text-[var(--dg-text-muted)]">数据库:</span> {previewTask.instance} ({previewTask.dbType.toUpperCase()})</div>
<div><span className="text-[var(--dg-text-muted)]">下次执行:</span> {previewTask.nextRun}</div>
<div><span className="text-[var(--dg-text-muted)]">上次状态:</span> <span className={previewTask.lastStatus === 'success' ? 'text-green-400' : previewTask.lastStatus === 'failed' ? 'text-red-400' : ''}>{previewTask.lastStatus === 'success' ? '成功' : previewTask.lastStatus === 'failed' ? '失败' : '未执行'}</span></div>
</div>
<div className="p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
<pre className="text-sm font-mono whitespace-pre-wrap overflow-x-auto">{previewTask.sql}</pre>
</div>
</div>
<div className="flex justify-end mt-4">
<button onClick={() => setPreviewTask(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">关闭</button>
</div>
</div>
</div>
)}
{/* 创建弹窗 */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold">新建自动任务</h3>
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务名称 <span className="text-red-400">*</span></label>
<input type="text" value={formName} onChange={e => setFormName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="例如:每日数据归档" />
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务描述</label>
<textarea value={formDesc} onChange={e => setFormDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={2} placeholder="描述任务用途" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">执行频率</label>
<select value={formFreq} onChange={e => setFormFreq(e.target.value as TaskFrequency)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
{Object.entries(frequencyLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Cron 表达式</label>
<input type="text" value={formCron} onChange={e => setFormCron(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500" placeholder="0 2 * * *" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">数据库类型</label>
<select value={formDbType} onChange={e => setFormDbType(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
<option value="redis">Redis</option>
<option value="mongo">MongoDB</option>
</select>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">目标实例</label>
<input type="text" value={formInstance} onChange={e => setFormInstance(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="生产主库" />
</div>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">SQL 语句 <span className="text-red-400">*</span></label>
<textarea value={formSql} onChange={e => setFormSql(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500 resize-none" rows={6} placeholder="输入要执行的 SQL 语句..." />
</div>
<div className="flex items-center gap-3">
<Switch checked={formNotify} onCheckedChange={setFormNotify} />
<span className="text-sm">执行失败时发送通知</span>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
<button onClick={() => setShowCreate(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
<button onClick={saveTask} disabled={!formName.trim() || !formSql.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
<Save className="w-4 h-4" /> 创建任务
</button>
</div>
</div>
</div>
)}
</div>
);
}
+24
View File
@@ -98,6 +98,8 @@ export default function TicketCreatePage() {
const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [reason, setReason] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const [enableSchedule, setEnableSchedule] = useState(false);
// -- Data / Loading --
const [instances, setInstances] = useState<InstanceItem[]>([]);
@@ -287,6 +289,7 @@ export default function TicketCreatePage() {
sql_content: sqlContent,
reason: reason.trim(),
priority: 'medium',
scheduled_at: enableSchedule && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
});
navigate('/tickets');
} catch (e: unknown) {
@@ -700,6 +703,27 @@ export default function TicketCreatePage() {
</div>
</div>
{/* 定时执行 */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
className="w-4 h-4 rounded border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-blue-500 focus:ring-blue-500 focus:ring-offset-0" />
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span className="text-sm">定时执行</span>
</label>
{enableSchedule && (
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
step="1"
className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
min={new Date().toISOString().slice(0, 16)} />
)}
{enableSchedule && scheduledAt && (
<span className="text-xs text-[var(--dg-text-muted)]">
将于 {new Date(scheduledAt).toLocaleString('zh-CN')} 执行
</span>
)}
</div>
{/* SQL Editor + Browser Panel */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="lg:col-span-3 space-y-3">