feat(调度任务): 实现调度任务管理功能
新增调度任务模块,支持任务的增删改查、启停及手动执行 - 后端添加 schedule_job 模型、服务、路由及调度器工具类 - 前端新增调度任务管理页面 - 修改 flow 相关接口将 id 类型从 String 改为 i64 - 添加 tokio-cron-scheduler 依赖实现定时任务调度 - 初始化时加载已启用任务并注册到调度器
This commit is contained in:
@ -16,6 +16,7 @@ import FlowList from './pages/FlowList'
|
||||
// 引入流程编辑器
|
||||
import { Flows } from './flows'
|
||||
import FlowRunLogs from './pages/FlowRunLogs'
|
||||
import ScheduleJobs from './pages/ScheduleJobs'
|
||||
|
||||
function RequireAuth({ children }: { children: any }) {
|
||||
const token = getToken()
|
||||
@ -43,6 +44,8 @@ export default function App() {
|
||||
<Route path="/flows/editor" element={<Flows />} />
|
||||
{/* 流程运行日志 */}
|
||||
<Route path="/flow-run-logs" element={<FlowRunLogs />} />
|
||||
{/* 调度任务管理 */}
|
||||
<Route path="/schedule-jobs" element={<ScheduleJobs />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -651,4 +651,5 @@ export default function MainLayout() {
|
||||
</ConfigProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
// 说明:菜单完全依赖后端返回的路径,若需要本地添加“调度任务管理”菜单,请在后端创建菜单项 path: '/schedule-jobs',前端会自动展示。
|
||||
282
frontend/src/pages/ScheduleJobs.tsx
Normal file
282
frontend/src/pages/ScheduleJobs.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tag, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import api from '../utils/axios'
|
||||
import { formatDateTime } from '../utils/datetime'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, ReloadOutlined, StopOutlined, CheckCircleOutlined, PlayCircleOutlined } from '@ant-design/icons'
|
||||
|
||||
interface ScheduleJobItem {
|
||||
id: string
|
||||
name: string
|
||||
cron_expr: string
|
||||
enabled: boolean
|
||||
flow_code: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface PageResp<T> { items: T[]; total: number; page: number; page_size: number }
|
||||
|
||||
interface FlowOption { label: string; value: string }
|
||||
|
||||
export default function ScheduleJobs() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<ScheduleJobItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'true' | 'false'>('all')
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ScheduleJobItem | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const [flowOptions, setFlowOptions] = useState<FlowOption[]>([])
|
||||
|
||||
const fetchJobs = async (p: number = page, ps: number = pageSize, kw: string = keyword, ef: 'all' | 'true' | 'false' = enabledFilter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { page: p, page_size: ps, keyword: kw }
|
||||
if (ef !== 'all') params.enabled = ef === 'true'
|
||||
const { data } = await api.get('/schedule_jobs', { params })
|
||||
if (data?.code === 0) {
|
||||
const resp = data.data as PageResp<ScheduleJobItem>
|
||||
setData(Array.isArray(resp.items) ? resp.items : [])
|
||||
setTotal(Number(resp.total || 0))
|
||||
setPage(Number(resp.page || p))
|
||||
setPageSize(Number(resp.page_size || ps))
|
||||
} else {
|
||||
throw new Error(data?.message || '获取任务列表失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '获取任务列表失败')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const fetchFlowOptions = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/flows', { params: { page: 1, page_size: 1000 } })
|
||||
if (data?.code === 0) {
|
||||
const items = (data.data?.items || []) as any[]
|
||||
setFlowOptions(items.map(it => ({ label: `${it.name || it.code} (${it.code})`, value: it.code })))
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore silently
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchJobs(1, pageSize, keyword, enabledFilter) }, [])
|
||||
|
||||
const columns: ColumnsType<ScheduleJobItem> = useMemo(() => [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '流程编码', dataIndex: 'flow_code', key: 'flow_code', render: (v: string) => <Tag color="blue">{v}</Tag> },
|
||||
{ title: 'Cron 表达式', dataIndex: 'cron_expr', key: 'cron_expr', render: (v: string) => <code style={{ fontFamily: 'monospace' }}>{v}</code> },
|
||||
{ title: '状态', dataIndex: 'enabled', key: 'enabled', render: (v: boolean, r) => (
|
||||
<Space size={8}>
|
||||
<Tag color={v ? 'green' : undefined}>{v ? '启用' : '禁用'}</Tag>
|
||||
</Space>
|
||||
) },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v?: string) => v ? formatDateTime(v) : '-' },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', render: (v?: string) => v ? formatDateTime(v) : '-' },
|
||||
{ title: '操作', key: 'actions', render: (_: any, record) => (
|
||||
<Space size="small" align="center">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确认删除该任务?" onConfirm={() => handleDelete(record)}>
|
||||
<a className="action-link action-danger">
|
||||
<DeleteOutlined />
|
||||
<span>删除</span>
|
||||
</a>
|
||||
</Popconfirm>
|
||||
<a className="action-link" onClick={() => handleExecute(record)}>
|
||||
<PlayCircleOutlined style={{ color: '#1890ff' }} />
|
||||
<span>执行</span>
|
||||
</a>
|
||||
{record.enabled ? (
|
||||
<a className="action-link" onClick={() => handleToggle(record, false)}>
|
||||
<StopOutlined />
|
||||
<span>禁用</span>
|
||||
</a>
|
||||
) : (
|
||||
<a className="action-link" onClick={() => handleToggle(record, true)}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<span>启用</span>
|
||||
</a>
|
||||
)}
|
||||
</Space>
|
||||
) },
|
||||
], [data])
|
||||
|
||||
const openCreate = async () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
await fetchFlowOptions()
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = async (record: ScheduleJobItem) => {
|
||||
setEditing(record)
|
||||
await fetchFlowOptions()
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
flow_code: record.flow_code,
|
||||
cron_expr: record.cron_expr,
|
||||
enabled: record.enabled,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (editing) {
|
||||
const { data } = await api.put(`/schedule_jobs/${editing.id}`, values)
|
||||
if (data?.code === 0) {
|
||||
message.success('更新成功')
|
||||
setModalOpen(false)
|
||||
fetchJobs()
|
||||
} else {
|
||||
throw new Error(data?.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const { data } = await api.post('/schedule_jobs', values)
|
||||
if (data?.code === 0) {
|
||||
message.success('创建成功')
|
||||
setModalOpen(false)
|
||||
fetchJobs(1, pageSize, keyword, enabledFilter)
|
||||
} else {
|
||||
throw new Error(data?.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return // 表单校验错误
|
||||
message.error(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (record: ScheduleJobItem) => {
|
||||
try {
|
||||
const { data } = await api.delete(`/schedule_jobs/${record.id}`)
|
||||
if (data?.code === 0) {
|
||||
message.success('删除成功')
|
||||
const nextPage = data?.data?.deleted ? (data?.data?.remaining === 0 && page > 1 ? page - 1 : page) : page
|
||||
fetchJobs(nextPage, pageSize, keyword, enabledFilter)
|
||||
} else {
|
||||
throw new Error(data?.message || '删除失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (record: ScheduleJobItem, next: boolean) => {
|
||||
try {
|
||||
const url = next ? `/schedule_jobs/${record.id}/enable` : `/schedule_jobs/${record.id}/disable`
|
||||
const { data } = await api.post(url)
|
||||
if (data?.code === 0) {
|
||||
message.success(next ? '已启用' : '已禁用')
|
||||
// 就地更新行
|
||||
setData(prev => prev.map(it => it.id === record.id ? { ...it, enabled: next } : it))
|
||||
} else {
|
||||
throw new Error(data?.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecute = async (record: ScheduleJobItem) => {
|
||||
try {
|
||||
const { data } = await api.post(`/schedule_jobs/${record.id}/execute`)
|
||||
if (data?.code === 0) {
|
||||
message.success('执行成功')
|
||||
// 可以在这里显示执行结果或跳转到日志页面
|
||||
console.log('执行结果:', data.data)
|
||||
} else {
|
||||
throw new Error(data?.message || '执行失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '执行失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchJobs(1, pageSize, keyword, enabledFilter)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader items={["系统管理", "调度任务管理"]} title="" />
|
||||
<div style={{ background: '#fff', padding: 16, marginBottom: 12 }}>
|
||||
<Space wrap>
|
||||
<Input.Search allowClear placeholder="关键字" value={keyword} onChange={e => setKeyword(e.target.value)} onSearch={handleSearch} style={{ width: 280 }} />
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onChange={(v) => setEnabledFilter(v)}
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '仅启用', value: 'true' },
|
||||
{ label: '仅禁用', value: 'false' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" onClick={() => fetchJobs(1, pageSize, keyword, enabledFilter)}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setEnabledFilter('all'); fetchJobs(1, pageSize, '', 'all') }}>重置</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', padding: 16 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增任务</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchJobs(page, pageSize, keyword, enabledFilter)}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => fetchJobs(p, ps, keyword, enabledFilter),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? '编辑任务' : '新增任务'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSubmit}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false} initialValues={{ enabled: true }}>
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如:每小时同步数据" />
|
||||
</Form.Item>
|
||||
<Form.Item label="流程编码" name="flow_code" rules={[{ required: true, message: '请选择流程编码' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="请选择绑定的流程(使用流程的 code)"
|
||||
options={flowOptions}
|
||||
filterOption={(input, option) => (option?.label as string).toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Cron 表达式" name="cron_expr" rules={[{ required: true, message: '请输入 Cron 表达式' }]}>
|
||||
<Input placeholder="例如:0 * * * * *(每分钟)或 0 0 * * *(每日 0 点)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="是否启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user