2026-07-21 12:09:51 +00:00
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
2026-09-20 10:06:20 +00:00
|
|
|
import { Button, Form, Tag, Tooltip } from 'antd';
|
2026-09-20 05:48:43 +00:00
|
|
|
import { ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
2025-12-11 08:31:26 +00:00
|
|
|
import type { ColumnsType } from 'antd/es/table';
|
2026-07-21 12:09:51 +00:00
|
|
|
|
2025-12-11 08:31:26 +00:00
|
|
|
import { DataTable } from '../../components/admin/DataTable';
|
2026-09-20 05:48:43 +00:00
|
|
|
import { AdminPage } from '../../components/admin/AdminPage';
|
|
|
|
|
import { useListPageSize } from './useListPageSize';
|
2025-12-11 08:31:26 +00:00
|
|
|
import { useToast } from '../../contexts/ToastContext';
|
2026-07-21 12:09:51 +00:00
|
|
|
import { request } from '../../utils/request';
|
|
|
|
|
import { ScheduledJobModal } from './scheduled-jobs/ScheduledJobModal';
|
|
|
|
|
import type { AvailableTask, ScheduledJob } from './scheduled-jobs/types';
|
|
|
|
|
|
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
|
|
|
if (typeof error !== 'object' || error === null || !('response' in error)) return null;
|
|
|
|
|
const detail = (error as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
|
|
|
|
|
if (typeof detail === 'string') return detail;
|
|
|
|
|
if (typeof detail === 'object' && detail !== null && 'message' in detail) return String(detail.message);
|
|
|
|
|
return null;
|
2025-12-11 08:31:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ScheduledJobs() {
|
2026-09-20 05:48:43 +00:00
|
|
|
// 每页数量由系统参数 page_size 控制
|
|
|
|
|
const systemPageSize = useListPageSize();
|
2025-12-11 08:31:26 +00:00
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [data, setData] = useState<ScheduledJob[]>([]);
|
2026-07-21 12:09:51 +00:00
|
|
|
const [keyword, setKeyword] = useState('');
|
2025-12-11 08:31:26 +00:00
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
|
const [editingRecord, setEditingRecord] = useState<ScheduledJob | null>(null);
|
|
|
|
|
const [activeTabKey, setActiveTabKey] = useState('basic');
|
|
|
|
|
const [availableTasks, setAvailableTasks] = useState<AvailableTask[]>([]);
|
|
|
|
|
const [selectedTask, setSelectedTask] = useState<AvailableTask | null>(null);
|
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
const toast = useToast();
|
|
|
|
|
|
2026-07-21 12:09:51 +00:00
|
|
|
const jobType = Form.useWatch<ScheduledJob['job_type']>('job_type', form);
|
|
|
|
|
const predefinedFunction = Form.useWatch<string>('predefined_function', form);
|
|
|
|
|
const filteredData = useMemo(() => {
|
|
|
|
|
const normalized = keyword.toLowerCase();
|
|
|
|
|
return data.filter((item) => item.name.toLowerCase().includes(normalized) || item.description?.toLowerCase().includes(normalized));
|
|
|
|
|
}, [data, keyword]);
|
2025-12-11 08:31:26 +00:00
|
|
|
|
2026-07-21 12:09:51 +00:00
|
|
|
const loadData = useCallback(async () => {
|
2025-12-11 08:31:26 +00:00
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const { data: result } = await request.get('/scheduled-jobs');
|
|
|
|
|
setData(result || []);
|
2026-07-21 12:09:51 +00:00
|
|
|
} catch {
|
2025-12-11 08:31:26 +00:00
|
|
|
toast.error('加载数据失败');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
2026-07-21 12:09:51 +00:00
|
|
|
}, [toast]);
|
2025-12-11 08:31:26 +00:00
|
|
|
|
2026-07-21 12:09:51 +00:00
|
|
|
const loadAvailableTasks = useCallback(async () => {
|
2025-12-11 08:31:26 +00:00
|
|
|
try {
|
|
|
|
|
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
|
|
|
|
|
setAvailableTasks(result || []);
|
2026-07-21 12:09:51 +00:00
|
|
|
} catch {
|
2025-12-11 08:31:26 +00:00
|
|
|
toast.error('加载可用任务列表失败');
|
|
|
|
|
}
|
2026-07-21 12:09:51 +00:00
|
|
|
}, [toast]);
|
2025-12-11 08:31:26 +00:00
|
|
|
|
2026-07-21 12:09:51 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
void loadData();
|
|
|
|
|
void loadAvailableTasks();
|
|
|
|
|
}, [loadAvailableTasks, loadData]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!predefinedFunction || availableTasks.length === 0) {
|
|
|
|
|
setSelectedTask(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const task = availableTasks.find((item) => item.name === predefinedFunction) || null;
|
|
|
|
|
setSelectedTask(task);
|
|
|
|
|
if (task && !editingRecord) {
|
|
|
|
|
const defaults: Record<string, unknown> = {};
|
|
|
|
|
task.parameters.forEach((parameter) => {
|
|
|
|
|
if (parameter.default !== null && parameter.default !== undefined) defaults[parameter.name] = parameter.default;
|
|
|
|
|
});
|
|
|
|
|
form.setFieldsValue({ function_params: defaults });
|
|
|
|
|
}
|
|
|
|
|
}, [availableTasks, editingRecord, form, predefinedFunction]);
|
2025-12-11 08:31:26 +00:00
|
|
|
|
|
|
|
|
const handleAdd = () => {
|
|
|
|
|
setEditingRecord(null);
|
|
|
|
|
setSelectedTask(null);
|
|
|
|
|
form.resetFields();
|
2026-07-21 12:09:51 +00:00
|
|
|
form.setFieldsValue({ job_type: 'predefined', is_active: true, function_params: {} });
|
2025-12-11 08:31:26 +00:00
|
|
|
setActiveTabKey('basic');
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEdit = (record: ScheduledJob) => {
|
|
|
|
|
setEditingRecord(record);
|
2026-07-21 12:09:51 +00:00
|
|
|
form.setFieldsValue({ ...record, function_params: record.function_params || {} });
|
2025-12-11 08:31:26 +00:00
|
|
|
setActiveTabKey('basic');
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (record: ScheduledJob) => {
|
|
|
|
|
try {
|
|
|
|
|
await request.delete(`/scheduled-jobs/${record.id}`);
|
|
|
|
|
toast.success('删除成功');
|
2026-07-21 12:09:51 +00:00
|
|
|
await loadData();
|
|
|
|
|
} catch {
|
2025-12-11 08:31:26 +00:00
|
|
|
toast.error('删除失败');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRunNow = async (record: ScheduledJob) => {
|
|
|
|
|
try {
|
|
|
|
|
await request.post(`/scheduled-jobs/${record.id}/run`);
|
|
|
|
|
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
|
2026-07-21 12:09:51 +00:00
|
|
|
window.setTimeout(() => void loadData(), 1000);
|
|
|
|
|
} catch {
|
2025-12-11 08:31:26 +00:00
|
|
|
toast.error('触发失败');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-20 10:06:20 +00:00
|
|
|
/** 与其它列表一致:状态列使用统一的启用/停用开关 */
|
|
|
|
|
const handleStatusChange = async (record: ScheduledJob, checked: boolean) => {
|
|
|
|
|
try {
|
|
|
|
|
await request.put(`/scheduled-jobs/${record.id}`, { is_active: checked });
|
|
|
|
|
setData((current) => current.map((item) => (item.id === record.id ? { ...item, is_active: checked } : item)));
|
|
|
|
|
toast.success(`${record.name} 已${checked ? '启用' : '禁用'}`);
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error('状态更新失败');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 08:31:26 +00:00
|
|
|
const handleModalOk = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const values = await form.validateFields();
|
|
|
|
|
if (values.job_type === 'predefined') {
|
|
|
|
|
delete values.python_code;
|
|
|
|
|
} else {
|
|
|
|
|
delete values.predefined_function;
|
|
|
|
|
delete values.function_params;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (editingRecord) {
|
|
|
|
|
await request.put(`/scheduled-jobs/${editingRecord.id}`, values);
|
|
|
|
|
toast.success('更新成功');
|
|
|
|
|
} else {
|
|
|
|
|
await request.post('/scheduled-jobs', values);
|
|
|
|
|
toast.success('创建成功');
|
|
|
|
|
}
|
|
|
|
|
setIsModalOpen(false);
|
2026-07-21 12:09:51 +00:00
|
|
|
await loadData();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = getErrorMessage(error);
|
|
|
|
|
if (message) toast.error(message);
|
2025-12-11 08:31:26 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const columns: ColumnsType<ScheduledJob> = [
|
2026-07-21 12:09:51 +00:00
|
|
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
2025-12-11 08:31:26 +00:00
|
|
|
{
|
2026-07-21 12:09:51 +00:00
|
|
|
title: '任务名称', dataIndex: 'name', width: 200,
|
2026-09-20 05:48:43 +00:00
|
|
|
render: (text, record) => (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="adm-cell-strong">{text}</div>
|
|
|
|
|
{record.description ? <div className="adm-cell-sub">{record.description}</div> : null}
|
|
|
|
|
</div>
|
|
|
|
|
),
|
2025-12-11 08:31:26 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-07-21 12:09:51 +00:00
|
|
|
title: '类型', dataIndex: 'job_type', width: 120,
|
|
|
|
|
render: (type) => <Tag color={type === 'predefined' ? 'blue' : 'purple'}>{type === 'predefined' ? '内置任务' : '自定义代码'}</Tag>,
|
2025-12-11 08:31:26 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-07-21 12:09:51 +00:00
|
|
|
title: '任务函数', dataIndex: 'predefined_function', width: 200,
|
2026-09-20 05:48:43 +00:00
|
|
|
render: (func, record) => record.job_type === 'predefined' ? <Tag color="cyan">{func}</Tag> : <span className="adm-cell-sub">-</span>,
|
2025-12-11 08:31:26 +00:00
|
|
|
},
|
2026-07-21 12:09:51 +00:00
|
|
|
{ title: 'Cron 表达式', dataIndex: 'cron_expression', width: 130, render: (text) => <Tag color="green">{text}</Tag> },
|
2025-12-11 08:31:26 +00:00
|
|
|
{
|
2026-07-21 12:09:51 +00:00
|
|
|
title: '上次执行', width: 200,
|
|
|
|
|
render: (_, record) => record.last_run_at ? (
|
|
|
|
|
<div><div>{new Date(record.last_run_at).toLocaleString()}</div><Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>{record.last_run_status === 'success' ? '成功' : '失败'}</Tag></div>
|
2026-09-20 05:48:43 +00:00
|
|
|
) : <span className="adm-cell-sub">从未执行</span>,
|
2025-12-11 08:31:26 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-20 05:48:43 +00:00
|
|
|
<AdminPage
|
|
|
|
|
icon={<ClockCircleOutlined />}
|
|
|
|
|
title="定时任务管理" description="按 Cron 表达式周期性执行内置任务或自定义代码">
|
2025-12-11 08:31:26 +00:00
|
|
|
<DataTable
|
|
|
|
|
columns={columns}
|
|
|
|
|
dataSource={filteredData}
|
|
|
|
|
loading={loading}
|
|
|
|
|
total={filteredData.length}
|
2026-09-20 05:48:43 +00:00
|
|
|
onRefresh={() => void loadData()}
|
2026-07-21 12:09:51 +00:00
|
|
|
onSearch={setKeyword}
|
2026-09-20 05:48:43 +00:00
|
|
|
searchPlaceholder="搜索任务名称 / 描述"
|
2025-12-11 08:31:26 +00:00
|
|
|
onAdd={handleAdd}
|
2026-09-20 05:48:43 +00:00
|
|
|
addText="新增定时任务"
|
|
|
|
|
onEdit={handleEdit}
|
|
|
|
|
onDelete={handleDelete}
|
2026-09-20 10:06:20 +00:00
|
|
|
onStatusChange={handleStatusChange}
|
|
|
|
|
statusField="is_active"
|
2026-09-20 05:48:43 +00:00
|
|
|
deleteConfirmTitle="确认删除该定时任务?"
|
|
|
|
|
deleteConfirmDescription="删除后该任务不再自动执行"
|
2025-12-11 08:31:26 +00:00
|
|
|
rowKey="id"
|
2026-09-20 05:48:43 +00:00
|
|
|
pageSize={systemPageSize}
|
|
|
|
|
customActions={(record) => (
|
|
|
|
|
<Tooltip title="立即执行一次">
|
|
|
|
|
<Button type="text" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRunNow(record)} />
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2025-12-11 08:31:26 +00:00
|
|
|
/>
|
2026-07-21 12:09:51 +00:00
|
|
|
<ScheduledJobModal
|
|
|
|
|
form={form}
|
|
|
|
|
record={editingRecord}
|
2025-12-11 08:31:26 +00:00
|
|
|
open={isModalOpen}
|
|
|
|
|
onOk={handleModalOk}
|
|
|
|
|
onCancel={() => setIsModalOpen(false)}
|
2026-07-21 12:09:51 +00:00
|
|
|
activeTab={activeTabKey}
|
|
|
|
|
onActiveTabChange={setActiveTabKey}
|
|
|
|
|
jobType={jobType}
|
|
|
|
|
availableTasks={availableTasks}
|
|
|
|
|
selectedTask={selectedTask}
|
|
|
|
|
onClearSelectedTask={() => setSelectedTask(null)}
|
|
|
|
|
/>
|
2026-09-20 05:48:43 +00:00
|
|
|
</AdminPage>
|
2025-12-11 08:31:26 +00:00
|
|
|
);
|
|
|
|
|
}
|