feat(调度任务): 实现调度任务管理功能

新增调度任务模块,支持任务的增删改查、启停及手动执行
- 后端添加 schedule_job 模型、服务、路由及调度器工具类
- 前端新增调度任务管理页面
- 修改 flow 相关接口将 id 类型从 String 改为 i64
- 添加 tokio-cron-scheduler 依赖实现定时任务调度
- 初始化时加载已启用任务并注册到调度器
This commit is contained in:
2025-09-24 00:21:30 +08:00
parent cadd336dee
commit 8c06849254
29 changed files with 1253 additions and 103 deletions

View File

@ -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>

View File

@ -651,4 +651,5 @@ export default function MainLayout() {
</ConfigProvider>
</PermissionProvider>
)
}
}
// 说明:菜单完全依赖后端返回的路径,若需要本地添加“调度任务管理”菜单,请在后端创建菜单项 path: '/schedule-jobs',前端会自动展示。

View 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>
)
}