Files
Neo-ZQYY/apps/admin-web/src/components/ScheduleTab.tsx

406 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 调度管理 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;