406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
/**
|
||
* 调度管理 Tab 组件。
|
||
*
|
||
* 功能:
|
||
* - 调度任务列表(名称、调度类型、启用 Switch、下次执行、执行次数、最近状态、操作)
|
||
* - 创建/编辑调度任务 Modal(名称 + 调度配置)
|
||
* - 删除确认
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Table, Tag, Button, Switch, Popconfirm, Space, Modal, Form,
|
||
Input, Select, InputNumber, TimePicker, Checkbox, message, Typography,
|
||
} from 'antd';
|
||
import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import dayjs from 'dayjs';
|
||
import type { ScheduledTask, ScheduleConfig } from '../types';
|
||
import {
|
||
fetchSchedules,
|
||
updateSchedule,
|
||
deleteSchedule,
|
||
toggleSchedule,
|
||
runScheduleNow,
|
||
} from '../api/schedules';
|
||
import ScheduleHistoryDrawer from './ScheduleHistoryDrawer';
|
||
|
||
const { Text } = Typography;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 常量 & 工具 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const STATUS_COLOR: Record<string, string> = {
|
||
success: 'success',
|
||
failed: 'error',
|
||
running: 'processing',
|
||
cancelled: 'warning',
|
||
};
|
||
|
||
const SCHEDULE_TYPE_LABEL: Record<string, string> = {
|
||
once: '一次性',
|
||
interval: '固定间隔',
|
||
daily: '每日',
|
||
weekly: '每周',
|
||
cron: 'Cron',
|
||
};
|
||
|
||
const INTERVAL_UNIT_LABEL: Record<string, string> = {
|
||
minutes: '分钟',
|
||
hours: '小时',
|
||
days: '天',
|
||
};
|
||
|
||
const WEEKDAY_OPTIONS = [
|
||
{ label: '周一', value: 1 },
|
||
{ label: '周二', value: 2 },
|
||
{ label: '周三', value: 3 },
|
||
{ label: '周四', value: 4 },
|
||
{ label: '周五', value: 5 },
|
||
{ label: '周六', value: 6 },
|
||
{ label: '周日', value: 0 },
|
||
];
|
||
|
||
/** 格式化时间 */
|
||
function fmtTime(iso: string | null | undefined): string {
|
||
if (!iso) return '—';
|
||
return new Date(iso).toLocaleString('zh-CN');
|
||
}
|
||
|
||
/** 根据调度配置生成可读描述 */
|
||
function describeSchedule(cfg: ScheduleConfig): string {
|
||
switch (cfg.schedule_type) {
|
||
case 'once':
|
||
return '一次性';
|
||
case 'interval':
|
||
return `每 ${cfg.interval_value} ${INTERVAL_UNIT_LABEL[cfg.interval_unit] ?? cfg.interval_unit}`;
|
||
case 'daily':
|
||
return `每日 ${cfg.daily_time}`;
|
||
case 'weekly': {
|
||
const days = (cfg.weekly_days ?? [])
|
||
.map((d) => WEEKDAY_OPTIONS.find((o) => o.value === d)?.label ?? `${d}`)
|
||
.join('、');
|
||
return `每周 ${days} ${cfg.weekly_time}`;
|
||
}
|
||
case 'cron':
|
||
return `Cron: ${cfg.cron_expression}`;
|
||
default:
|
||
return cfg.schedule_type;
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 调度配置表单子组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/** 根据调度类型动态渲染配置项 */
|
||
const ScheduleConfigFields: React.FC<{ scheduleType: string }> = ({ scheduleType }) => {
|
||
switch (scheduleType) {
|
||
case 'interval':
|
||
return (
|
||
<Space>
|
||
<Form.Item name={['schedule_config', 'interval_value']} noStyle rules={[{ required: true }]}>
|
||
<InputNumber min={1} placeholder="间隔值" />
|
||
</Form.Item>
|
||
<Form.Item name={['schedule_config', 'interval_unit']} noStyle rules={[{ required: true }]}>
|
||
<Select style={{ width: 100 }} options={[
|
||
{ label: '分钟', value: 'minutes' },
|
||
{ label: '小时', value: 'hours' },
|
||
{ label: '天', value: 'days' },
|
||
]} />
|
||
</Form.Item>
|
||
</Space>
|
||
);
|
||
case 'daily':
|
||
return (
|
||
<Form.Item name={['schedule_config', 'daily_time']} label="执行时间" rules={[{ required: true }]}>
|
||
<TimePicker format="HH:mm" />
|
||
</Form.Item>
|
||
);
|
||
case 'weekly':
|
||
return (
|
||
<>
|
||
<Form.Item name={['schedule_config', 'weekly_days']} label="星期" rules={[{ required: true }]}>
|
||
<Checkbox.Group options={WEEKDAY_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name={['schedule_config', 'weekly_time']} label="执行时间" rules={[{ required: true }]}>
|
||
<TimePicker format="HH:mm" />
|
||
</Form.Item>
|
||
</>
|
||
);
|
||
case 'cron':
|
||
return (
|
||
<Form.Item name={['schedule_config', 'cron_expression']} label="Cron 表达式" rules={[{ required: true }]}>
|
||
<Input placeholder="0 4 * * *" />
|
||
</Form.Item>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 主组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const ScheduleTab: React.FC = () => {
|
||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [scheduleType, setScheduleType] = useState<string>('daily');
|
||
const [form] = Form.useForm();
|
||
|
||
/* 执行历史抽屉状态 */
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [historyScheduleId, setHistoryScheduleId] = useState<string | null>(null);
|
||
const [historyScheduleName, setHistoryScheduleName] = useState('');
|
||
|
||
/* 加载列表 */
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setData(await fetchSchedules());
|
||
} catch {
|
||
message.error('加载调度任务失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
/* 打开编辑 Modal */
|
||
const openEdit = (record: ScheduledTask) => {
|
||
setEditing(record);
|
||
const cfg = record.schedule_config;
|
||
form.setFieldsValue({
|
||
name: record.name,
|
||
schedule_config: {
|
||
...cfg,
|
||
daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined,
|
||
weekly_time: cfg.weekly_time ? dayjs(cfg.weekly_time, 'HH:mm') : undefined,
|
||
},
|
||
});
|
||
setScheduleType(cfg.schedule_type);
|
||
setModalOpen(true);
|
||
};
|
||
|
||
/* 打开执行历史 */
|
||
const openHistory = (record: ScheduledTask) => {
|
||
setHistoryScheduleId(record.id);
|
||
setHistoryScheduleName(record.name);
|
||
setHistoryOpen(true);
|
||
};
|
||
|
||
/* 提交编辑 */
|
||
const handleSubmit = async () => {
|
||
if (!editing) return;
|
||
try {
|
||
const values = await form.validateFields();
|
||
setSubmitting(true);
|
||
|
||
const cfg = { ...values.schedule_config };
|
||
if (cfg.daily_time && typeof cfg.daily_time !== 'string') {
|
||
cfg.daily_time = cfg.daily_time.format('HH:mm');
|
||
}
|
||
if (cfg.weekly_time && typeof cfg.weekly_time !== 'string') {
|
||
cfg.weekly_time = cfg.weekly_time.format('HH:mm');
|
||
}
|
||
|
||
const scheduleConfig: ScheduleConfig = {
|
||
schedule_type: cfg.schedule_type ?? 'daily',
|
||
interval_value: cfg.interval_value ?? 1,
|
||
interval_unit: cfg.interval_unit ?? 'hours',
|
||
daily_time: cfg.daily_time ?? '04:00',
|
||
weekly_days: cfg.weekly_days ?? [1],
|
||
weekly_time: cfg.weekly_time ?? '04:00',
|
||
cron_expression: cfg.cron_expression ?? '0 4 * * *',
|
||
enabled: true,
|
||
start_date: null,
|
||
end_date: null,
|
||
};
|
||
|
||
await updateSchedule(editing.id, {
|
||
name: values.name,
|
||
schedule_config: scheduleConfig,
|
||
});
|
||
message.success('调度任务已更新');
|
||
|
||
setModalOpen(false);
|
||
load();
|
||
} catch {
|
||
// 表单验证失败
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
/* 删除 */
|
||
const handleDelete = async (id: string) => {
|
||
try {
|
||
await deleteSchedule(id);
|
||
message.success('已删除');
|
||
load();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
};
|
||
|
||
/* 启用/禁用 */
|
||
const handleToggle = async (id: string) => {
|
||
try {
|
||
await toggleSchedule(id);
|
||
load();
|
||
} catch {
|
||
message.error('切换状态失败');
|
||
}
|
||
};
|
||
|
||
/* 手动执行一次(不更新调度间隔) */
|
||
const handleRunNow = async (id: string) => {
|
||
try {
|
||
await runScheduleNow(id);
|
||
message.success('已提交到执行队列');
|
||
} catch {
|
||
message.error('执行失败');
|
||
}
|
||
};
|
||
|
||
/* 表格列定义 */
|
||
const columns: ColumnsType<ScheduledTask> = [
|
||
{
|
||
title: '调度 ID',
|
||
dataIndex: 'id',
|
||
key: 'id',
|
||
width: 120,
|
||
render: (id: string) => (
|
||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||
{id.slice(0, 8)}…
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
},
|
||
{
|
||
title: '调度类型',
|
||
key: 'schedule_type',
|
||
render: (_: unknown, record: ScheduledTask) => describeSchedule(record.schedule_config),
|
||
},
|
||
{
|
||
title: '启用',
|
||
dataIndex: 'enabled',
|
||
key: 'enabled',
|
||
width: 80,
|
||
render: (enabled: boolean, record: ScheduledTask) => (
|
||
<Switch checked={enabled} onChange={() => handleToggle(record.id)} size="small" />
|
||
),
|
||
},
|
||
{
|
||
title: '下次执行',
|
||
dataIndex: 'next_run_at',
|
||
key: 'next_run_at',
|
||
render: fmtTime,
|
||
},
|
||
{
|
||
title: '执行次数',
|
||
dataIndex: 'run_count',
|
||
key: 'run_count',
|
||
width: 90,
|
||
},
|
||
{
|
||
title: '最近状态',
|
||
dataIndex: 'last_status',
|
||
key: 'last_status',
|
||
width: 100,
|
||
render: (s: string | null) =>
|
||
s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—',
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 300,
|
||
render: (_: unknown, record: ScheduledTask) => (
|
||
<Space size="small">
|
||
<Popconfirm title="确认立即执行一次?(不影响调度间隔)" onConfirm={() => handleRunNow(record.id)}>
|
||
<Button type="link" icon={<PlayCircleOutlined />} size="small">
|
||
立即执行
|
||
</Button>
|
||
</Popconfirm>
|
||
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => openHistory(record)}>
|
||
执行历史
|
||
</Button>
|
||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => openEdit(record)}>
|
||
编辑
|
||
</Button>
|
||
<Popconfirm title="确认删除该调度任务?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button type="link" danger icon={<DeleteOutlined />} size="small">
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Text type="secondary">共 {data.length} 个调度任务</Text>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
<Table<ScheduledTask>
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
pagination={false}
|
||
size="middle"
|
||
/>
|
||
|
||
{/* 编辑 Modal */}
|
||
<Modal
|
||
title="编辑调度任务"
|
||
open={modalOpen}
|
||
onOk={handleSubmit}
|
||
onCancel={() => setModalOpen(false)}
|
||
confirmLoading={submitting}
|
||
destroyOnClose
|
||
>
|
||
<Form form={form} layout="vertical" preserve={false}>
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入调度任务名称' }]}>
|
||
<Input placeholder="例如:每日全量同步" />
|
||
</Form.Item>
|
||
|
||
<Form.Item name={['schedule_config', 'schedule_type']} label="调度类型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={Object.entries(SCHEDULE_TYPE_LABEL).map(([value, label]) => ({ value, label }))}
|
||
onChange={(v: string) => setScheduleType(v)}
|
||
/>
|
||
</Form.Item>
|
||
|
||
<ScheduleConfigFields scheduleType={scheduleType} />
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* 执行历史抽屉 */}
|
||
<ScheduleHistoryDrawer
|
||
open={historyOpen}
|
||
scheduleId={historyScheduleId}
|
||
scheduleName={historyScheduleName}
|
||
onClose={() => setHistoryOpen(false)}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ScheduleTab;
|