312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
Table, Button, Space, Typography, message, Card, Modal, Form, Input, Select, Switch, Tag, Popconfirm, Tabs,
|
||
} from 'antd';
|
||
import {
|
||
ClockCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, StopOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||
} from '@ant-design/icons';
|
||
import {
|
||
listScheduledTasks,
|
||
createScheduledTask,
|
||
updateScheduledTask,
|
||
deleteScheduledTask,
|
||
runScheduledTask,
|
||
toggleScheduledTask,
|
||
} from '../api';
|
||
import type { ScheduledTask } from '../types';
|
||
|
||
const { TextArea } = Input;
|
||
|
||
const SCHEDULE_TYPE_OPTIONS = [
|
||
{ value: 'external_api', label: '外部接口调用' },
|
||
{ value: 'internal_method', label: '内部方法执行' },
|
||
];
|
||
|
||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||
external_api: '外部接口',
|
||
internal_method: '内部方法',
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||
success: { label: '成功', color: 'green' },
|
||
error: { label: '失败', color: 'red' },
|
||
};
|
||
|
||
const AdminScheduledTasks: React.FC = () => {
|
||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modal, setModal] = useState(false);
|
||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||
const [form] = Form.useForm();
|
||
const [saving, setSaving] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<'basic' | 'config'>('basic');
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await listScheduledTasks();
|
||
setData(res.items || []);
|
||
} catch (err: any) {
|
||
message.error(err?.message || '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
const openModal = (task?: ScheduledTask) => {
|
||
if (task) {
|
||
setEditing(task);
|
||
let configStr = '';
|
||
if (task.config) {
|
||
try {
|
||
configStr = typeof task.config === 'string' ? JSON.stringify(JSON.parse(task.config), null, 2) : JSON.stringify(task.config, null, 2);
|
||
} catch {
|
||
configStr = task.config;
|
||
}
|
||
}
|
||
form.setFieldsValue({
|
||
name: task.name,
|
||
task_type: task.task_type,
|
||
schedule: task.schedule,
|
||
config: configStr,
|
||
is_active: task.is_active,
|
||
});
|
||
} else {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({ is_active: true, task_type: 'external_api' });
|
||
}
|
||
setActiveTab('basic');
|
||
setModal(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
let configVal = values.config;
|
||
if (configVal && typeof configVal === 'string') {
|
||
try {
|
||
configVal = JSON.stringify(JSON.parse(configVal));
|
||
} catch {
|
||
message.warning('配置 JSON 格式不合法,将按原样保存');
|
||
}
|
||
}
|
||
setSaving(true);
|
||
if (editing) {
|
||
await updateScheduledTask(editing.id, { ...values, config: configVal });
|
||
message.success('更新成功');
|
||
} else {
|
||
await createScheduledTask({ ...values, config: configVal });
|
||
message.success('创建成功');
|
||
}
|
||
setModal(false);
|
||
await fetchData();
|
||
} catch (err: any) {
|
||
message.error(err?.message || '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (taskId: string) => {
|
||
try {
|
||
await deleteScheduledTask(taskId);
|
||
message.success('删除成功');
|
||
await fetchData();
|
||
} catch (err: any) {
|
||
message.error(err?.message || '删除失败');
|
||
}
|
||
};
|
||
|
||
const handleRun = async (taskId: string) => {
|
||
try {
|
||
await runScheduledTask(taskId);
|
||
message.success('任务已提交执行');
|
||
setTimeout(fetchData, 1500);
|
||
} catch (err: any) {
|
||
message.error(err?.message || '执行失败');
|
||
}
|
||
};
|
||
|
||
const handleToggle = async (task: ScheduledTask) => {
|
||
try {
|
||
const res = await toggleScheduledTask(task.id);
|
||
message.success(res.message);
|
||
await fetchData();
|
||
} catch (err: any) {
|
||
message.error(err?.message || '操作失败');
|
||
}
|
||
};
|
||
|
||
const taskType = Form.useWatch('task_type', form);
|
||
|
||
const columns = [
|
||
{ title: '任务名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'task_type',
|
||
width: 100,
|
||
render: (v: string) => <Tag color={v === 'external_api' ? 'blue' : 'purple'}>{TASK_TYPE_LABELS[v] || v}</Tag>,
|
||
},
|
||
{ title: '调度表达式', dataIndex: 'schedule', width: 140, render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code> },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'is_active',
|
||
width: 80,
|
||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||
},
|
||
{
|
||
title: '最后执行',
|
||
dataIndex: 'last_run_at',
|
||
width: 160,
|
||
render: (v: string, r: ScheduledTask) => {
|
||
if (!v) return '-';
|
||
const status = r.last_status ? STATUS_LABELS[r.last_status] : null;
|
||
return (
|
||
<span>
|
||
{v.includes('T') ? v.replace('T', ' ').slice(0, 19) : v}
|
||
{status && <Tag color={status.color} style={{ marginLeft: 6 }}>{status.label}</Tag>}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 220,
|
||
render: (_: any, record: ScheduledTask) => (
|
||
<Space size="small">
|
||
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(record.id)}>执行</Button>
|
||
<Button type="link" size="small" icon={record.is_active ? <StopOutlined /> : <CheckCircleOutlined />} onClick={() => handleToggle(record)}>
|
||
{record.is_active ? '禁用' : '启用'}
|
||
</Button>
|
||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openModal(record)}>编辑</Button>
|
||
<Popconfirm title="确定删除该任务?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
|
||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const scheduleHelp = (
|
||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||
<div>• 纯数字:间隔秒数(如 60 = 每 60 秒执行一次)</div>
|
||
<div>• Cron 表达式(5 字段):分 时 日 月 周</div>
|
||
<div>• 示例:<code>* * * * *</code> = 每分钟 | <code>0 * * * *</code> = 每小时</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{
|
||
width: 44, height: 44, borderRadius: 10,
|
||
background: 'rgba(99,102,241,0.08)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 20, color: '#6366f1',
|
||
}}>
|
||
<ClockCircleOutlined />
|
||
</div>
|
||
<div>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>定时任务管理</Typography.Title>
|
||
<Typography.Text type="secondary">自定义定时执行外部接口调用或内部方法</Typography.Text>
|
||
</div>
|
||
</div>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>
|
||
新增任务
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||
<Table
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 新增/编辑弹窗 */}
|
||
<Modal
|
||
title={editing ? '编辑定时任务' : '新增定时任务'}
|
||
open={modal}
|
||
onOk={handleSave}
|
||
onCancel={() => setModal(false)}
|
||
confirmLoading={saving}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
destroyOnClose
|
||
width={600}
|
||
>
|
||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={(k) => setActiveTab(k as 'basic' | 'config')}
|
||
items={[
|
||
{
|
||
key: 'basic',
|
||
label: '基本设置',
|
||
children: (
|
||
<>
|
||
<Form.Item name="name" label="任务名称" rules={[{ required: true, message: '请输入任务名称' }]}>
|
||
<Input placeholder="例如:每小时同步银行交易" />
|
||
</Form.Item>
|
||
<Form.Item name="task_type" label="任务类型" rules={[{ required: true, message: '请选择任务类型' }]}>
|
||
<Select options={SCHEDULE_TYPE_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="schedule"
|
||
label="调度表达式"
|
||
rules={[{ required: true, message: '请输入调度表达式' }]}
|
||
extra={scheduleHelp}
|
||
>
|
||
<Input placeholder="纯数字(秒)或 Cron 表达式:* * * * *" />
|
||
</Form.Item>
|
||
<Form.Item name="is_active" label="启用" valuePropName="checked">
|
||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||
</Form.Item>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: 'config',
|
||
label: '任务配置',
|
||
children: (
|
||
<Form.Item
|
||
name="config"
|
||
label="配置 JSON"
|
||
extra={
|
||
taskType === 'external_api'
|
||
? '字段:url(地址)、method(GET/POST/PUT/DELETE)、headers(对象)、payload(对象/参数)、timeout(秒,默认 30)'
|
||
: '字段:module(模块路径,如 app.services.xxx)、function(函数名)、args(参数数组)'
|
||
}
|
||
>
|
||
<TextArea
|
||
rows={8}
|
||
placeholder={taskType === 'external_api'
|
||
? '{\n "url": "https://api.example.com/data",\n "method": "POST",\n "headers": {},\n "payload": {}\n}'
|
||
: '{\n "module": "app.services.report",\n "function": "generate_daily_report",\n "args": []\n}'
|
||
}
|
||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||
/>
|
||
</Form.Item>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminScheduledTasks;
|