后端:
- 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 秒自动刷新
189 lines
7.2 KiB
TypeScript
189 lines
7.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Clock, XCircle, CheckCircle, AlertTriangle,
|
|
Database, Zap, Loader2, Calendar, Ban,
|
|
} from 'lucide-react';
|
|
import api from '@/api/request';
|
|
|
|
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;
|
|
}
|
|
|
|
const DB_TYPE_ICON: Record<string, string> = {
|
|
mysql: '🐬', postgres: '🐘', redis: '🔴', mongo: '🍃',
|
|
};
|
|
const DB_TYPE_LABEL: Record<string, string> = {
|
|
mysql: 'MySQL', postgres: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB',
|
|
};
|
|
|
|
export default function AutoTaskPage() {
|
|
const [tasks, setTasks] = useState<ScheduledTicket[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [cancelling, setCancelling] = useState<number | null>(null);
|
|
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
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 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 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={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>
|
|
|
|
{/* 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 countdown = getCountdown(task.scheduled_at);
|
|
return (
|
|
<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-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>
|
|
{/* 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>
|
|
</div>
|
|
);
|
|
}
|