cosmo/frontend/src/pages/admin/ScheduledJobs.tsx

587 lines
18 KiB
TypeScript
Raw Normal View History

2025-12-11 08:31:26 +00:00
/**
* Scheduled Jobs Management Page
*/
import { useState, useEffect } from 'react';
import { Modal, Form, Input, Switch, Button, Space, Popconfirm, Tag, Tooltip, Badge, Tabs, Select, Row, Col, Card, Alert } from 'antd';
import { PlayCircleOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { DataTable } from '../../components/admin/DataTable';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
interface ScheduledJob {
id: number;
name: string;
job_type: 'predefined' | 'custom_code';
predefined_function?: string;
function_params?: Record<string, any>;
cron_expression: string;
python_code?: string;
is_active: boolean;
description: string;
last_run_at: string | null;
last_run_status: 'success' | 'failed' | null;
next_run_at: string | null;
created_at: string;
updated_at: string;
}
interface AvailableTask {
name: string;
description: string;
category: string;
parameters: Array<{
name: string;
type: string;
description: string;
required: boolean;
default: any;
}>;
}
export function ScheduledJobs() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ScheduledJob[]>([]);
const [filteredData, setFilteredData] = useState<ScheduledJob[]>([]);
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();
const jobType = Form.useWatch('job_type', form);
const predefinedFunction = Form.useWatch('predefined_function', form);
useEffect(() => {
loadData();
loadAvailableTasks();
}, []);
useEffect(() => {
// When predefined function changes, update selected task
if (predefinedFunction && availableTasks.length > 0) {
const task = availableTasks.find(t => t.name === predefinedFunction);
setSelectedTask(task || null);
// Set default parameter values only if not editing
if (task && !editingRecord) {
const defaultParams: Record<string, any> = {};
task.parameters.forEach(param => {
if (param.default !== null && param.default !== undefined) {
defaultParams[param.name] = param.default;
}
});
form.setFieldsValue({ function_params: defaultParams });
} else if (task && editingRecord) {
// When editing, just set the selected task, don't override params
setSelectedTask(task);
}
} else {
setSelectedTask(null);
}
}, [predefinedFunction, availableTasks]);
const loadData = async () => {
setLoading(true);
try {
const { data: result } = await request.get('/scheduled-jobs');
setData(result || []);
setFilteredData(result || []);
} catch (error) {
toast.error('加载数据失败');
} finally {
setLoading(false);
}
};
const loadAvailableTasks = async () => {
try {
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
setAvailableTasks(result || []);
} catch (error) {
toast.error('加载可用任务列表失败');
}
};
const handleSearch = (keyword: string) => {
const lowerKeyword = keyword.toLowerCase();
const filtered = data.filter(
(item) =>
item.name.toLowerCase().includes(lowerKeyword) ||
item.description?.toLowerCase().includes(lowerKeyword)
);
setFilteredData(filtered);
};
const handleAdd = () => {
setEditingRecord(null);
setSelectedTask(null);
form.resetFields();
form.setFieldsValue({
job_type: 'predefined',
is_active: true,
function_params: {}
});
setActiveTabKey('basic');
setIsModalOpen(true);
};
const handleEdit = (record: ScheduledJob) => {
setEditingRecord(record);
form.setFieldsValue({
...record,
function_params: record.function_params || {}
});
setActiveTabKey('basic');
setIsModalOpen(true);
};
const handleDelete = async (record: ScheduledJob) => {
try {
await request.delete(`/scheduled-jobs/${record.id}`);
toast.success('删除成功');
loadData();
} catch (error) {
toast.error('删除失败');
}
};
const handleRunNow = async (record: ScheduledJob) => {
try {
await request.post(`/scheduled-jobs/${record.id}/run`);
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
setTimeout(loadData, 1000);
} catch (error) {
toast.error('触发失败');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
// Clean up data based on job_type
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);
loadData();
} catch (error: any) {
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
if (typeof detail === 'object' && detail.message) {
toast.error(detail.message);
} else {
toast.error(detail);
}
}
}
};
const columns: ColumnsType<ScheduledJob> = [
{
title: 'ID',
dataIndex: 'id',
width: 60,
},
{
title: '任务名称',
dataIndex: 'name',
width: 200,
render: (text, record) => (
<div>
<div style={{ fontWeight: 500 }}>{text}</div>
{record.description && (
<div style={{ fontSize: 12, color: '#888' }}>{record.description}</div>
)}
</div>
),
},
{
title: '类型',
dataIndex: 'job_type',
width: 120,
render: (type) => (
<Tag color={type === 'predefined' ? 'blue' : 'purple'}>
{type === 'predefined' ? '内置任务' : '自定义代码'}
</Tag>
),
},
{
title: '任务函数',
dataIndex: 'predefined_function',
width: 200,
render: (func, record) => {
if (record.job_type === 'predefined') {
return <Tag color="cyan">{func}</Tag>;
}
return <span style={{ color: '#ccc' }}>-</span>;
},
},
{
title: 'Cron 表达式',
dataIndex: 'cron_expression',
width: 130,
render: (text) => <Tag color="green">{text}</Tag>,
},
{
title: '状态',
dataIndex: 'is_active',
width: 80,
render: (active) => (
<Badge status={active ? 'success' : 'default'} text={active ? '启用' : '禁用'} />
),
},
{
title: '上次执行',
width: 200,
render: (_, record) => (
<div>
{record.last_run_at ? (
<>
<div>{new Date(record.last_run_at).toLocaleString()}</div>
<Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>
{record.last_run_status === 'success' ? '成功' : '失败'}
</Tag>
</>
) : (
<span style={{ color: '#ccc' }}></span>
)}
</div>
),
},
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => (
<Space size="small">
<Tooltip title="立即执行">
<Button
type="text"
icon={<PlayCircleOutlined />}
onClick={() => handleRunNow(record)}
style={{ color: '#52c41a' }}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
style={{ color: '#1890ff' }}
/>
</Tooltip>
<Popconfirm
title="确认删除该任务?"
onConfirm={() => handleDelete(record)}
okText="删除"
cancelText="取消"
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
),
},
];
return (
<>
<DataTable
title="定时任务管理"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onSearch={handleSearch}
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
rowKey="id"
pageSize={10}
/>
<Modal
title={editingRecord ? '编辑任务' : '新增任务'}
open={isModalOpen}
onOk={handleModalOk}
onCancel={() => setIsModalOpen(false)}
width={900}
destroyOnClose
>
<Form
form={form}
layout="vertical"
>
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
{/* 基础配置 Tab */}
<Tabs.TabPane tab="基础配置" key="basic">
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="任务名称"
rules={[{ required: true, message: '请输入任务名称' }]}
>
<Input placeholder="例如:每日数据同步" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="job_type"
label="任务类型"
rules={[{ required: true, message: '请选择任务类型' }]}
>
<Select
options={[
{ label: '内置任务', value: 'predefined' },
{ label: '自定义代码', value: 'custom_code' }
]}
onChange={() => {
// Clear related fields when type changes
form.setFieldsValue({
predefined_function: undefined,
function_params: {},
python_code: undefined
});
setSelectedTask(null);
}}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="cron_expression"
label={
<Space>
<span>Cron </span>
<Tooltip title="格式:分 时 日 月 周 (例如: 0 0 * * * 表示每天零点)">
<QuestionCircleOutlined style={{ color: '#888' }} />
</Tooltip>
</Space>
}
rules={[{ required: true, message: '请输入 Cron 表达式' }]}
>
<Input placeholder="0 0 * * *" style={{ fontFamily: 'monospace' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="is_active"
label="是否启用"
valuePropName="checked"
>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={3} placeholder="任务描述" />
</Form.Item>
{/* 内置任务配置 */}
{jobType === 'predefined' && (
<>
<Form.Item
name="predefined_function"
label="选择预定义任务"
rules={[{ required: true, message: '请选择预定义任务' }]}
>
<Select
placeholder="请选择任务"
options={availableTasks.map(task => ({
label: `${task.name} - ${task.description}`,
value: task.name
}))}
/>
</Form.Item>
{selectedTask && (
<Card
size="small"
title={
<Space>
<InfoCircleOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 16 }}
>
<Alert
message={selectedTask.description}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
{selectedTask.parameters.map(param => (
<Form.Item
key={param.name}
name={['function_params', param.name]}
label={
<Space>
<span>{param.name}</span>
{!param.required && <Tag color="orange"></Tag>}
</Space>
}
tooltip={param.description}
rules={[
{ required: param.required, message: `请输入${param.name}` }
]}
>
{param.type === 'integer' ? (
<Input type="number" placeholder={`默认: ${param.default}`} />
) : param.type === 'boolean' ? (
<Switch />
) : param.type === 'array' ? (
<Select mode="tags" placeholder="输入后回车添加" />
) : (
<Input placeholder={`默认: ${param.default || '无'}`} />
)}
</Form.Item>
))}
</Card>
)}
</>
)}
</Tabs.TabPane>
{/* Python 代码 Tab - 仅在自定义代码模式下显示 */}
{jobType === 'custom_code' && (
<Tabs.TabPane tab="Python 代码" key="code">
<Alert
message="自定义代码执行环境"
description={
<div>
<p></p>
<ul style={{ marginBottom: 0 }}>
<li><code>db</code>: AsyncSession - </li>
<li><code>logger</code>: Logger - </li>
<li><code>task_id</code>: int - ID</li>
<li><code>asyncio</code>: IO</li>
</ul>
<p style={{ marginTop: 8, marginBottom: 0 }}>
使 <code>await</code>
</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item
name="python_code"
rules={[{ required: jobType === 'custom_code', message: '请输入执行脚本' }]}
>
<CodeEditor
placeholder={`# 动态任务脚本示例
# : db (AsyncSession), logger (Logger), task_id (int)
# 使 await
from datetime import datetime
logger.info(f"任务开始执行: {datetime.now()}")
# ...
return "执行成功"`}
/>
</Form.Item>
</Tabs.TabPane>
)}
</Tabs>
</Form>
</Modal>
</>
);
}
// Simple Code Editor with Line Numbers
const CodeEditor = ({
value = '',
onChange,
placeholder = ''
}: {
value?: string;
onChange?: (e: any) => void;
placeholder?: string;
}) => {
const displayValue = value || placeholder;
const lineCount = displayValue.split('\n').length;
const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1).join('\n');
return (
<div style={{
display: 'flex',
border: '1px solid #d9d9d9',
borderRadius: 6,
overflow: 'hidden',
backgroundColor: '#fafafa'
}}>
<div
style={{
padding: '4px 8px',
backgroundColor: '#f0f0f0',
borderRight: '1px solid #d9d9d9',
color: '#999',
textAlign: 'right',
fontFamily: 'monospace',
lineHeight: '1.5',
fontSize: '14px',
userSelect: 'none',
whiteSpace: 'pre',
overflow: 'hidden'
}}
>
{lineNumbers}
</div>
<Input.TextArea
value={value}
onChange={onChange}
placeholder={placeholder}
style={{
border: 'none',
borderRadius: 0,
resize: 'none',
fontFamily: 'monospace',
lineHeight: '1.5',
fontSize: '14px',
padding: '4px 8px',
flex: 1,
backgroundColor: '#fafafa',
color: value ? '#333' : '#999'
}}
rows={20}
spellCheck={false}
wrap="off"
/>
</div>
);
};