包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
6.3 KiB
TypeScript
207 lines
6.3 KiB
TypeScript
/**
|
||
* 定时任务管理页面。
|
||
*
|
||
* 展示 biz.trigger_jobs 表中所有定时任务,支持手动执行。
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import { Table, Tag, Button, message, Modal, Typography, Card, Space, Popconfirm, Tooltip } from 'antd';
|
||
import {
|
||
ReloadOutlined,
|
||
ClockCircleOutlined,
|
||
PlayCircleOutlined,
|
||
CheckCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { fetchTriggerJobs, runTriggerJob, clearAllTasks, type TriggerJob } from '../api/triggerJobs';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const TRIGGER_LABEL: Record<string, string> = {
|
||
cron: '定时(Cron)',
|
||
interval: '间隔',
|
||
event: '事件触发',
|
||
};
|
||
|
||
const STATUS_COLOR: Record<string, string> = {
|
||
enabled: 'green',
|
||
disabled: 'default',
|
||
};
|
||
|
||
function formatTime(raw: string | null): string {
|
||
if (!raw) return '—';
|
||
const d = new Date(raw);
|
||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||
}
|
||
|
||
function formatTriggerConfig(job: TriggerJob): string {
|
||
const cfg = job.trigger_config;
|
||
if (!cfg) return '—';
|
||
if (job.trigger_condition === 'cron') return cfg.cron_expression as string || '—';
|
||
if (job.trigger_condition === 'interval') {
|
||
const sec = cfg.interval_seconds as number;
|
||
if (sec >= 3600) return `每 ${sec / 3600} 小时`;
|
||
if (sec >= 60) return `每 ${sec / 60} 分钟`;
|
||
return `每 ${sec} 秒`;
|
||
}
|
||
if (job.trigger_condition === 'event') return `事件: ${cfg.event_name || '—'}`;
|
||
return JSON.stringify(cfg);
|
||
}
|
||
|
||
const TriggerJobs: React.FC = () => {
|
||
const [jobs, setJobs] = useState<TriggerJob[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [runningId, setRunningId] = useState<number | null>(null);
|
||
const [clearing, setClearing] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await fetchTriggerJobs();
|
||
setJobs(data);
|
||
} catch {
|
||
message.error('加载定时任务失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const handleRun = async (jobId: number) => {
|
||
setRunningId(jobId);
|
||
try {
|
||
const result = await runTriggerJob(jobId);
|
||
if (result.success) {
|
||
message.success(result.message);
|
||
} else {
|
||
message.error(result.message);
|
||
}
|
||
await load();
|
||
} catch {
|
||
message.error('执行失败');
|
||
} finally {
|
||
setRunningId(null);
|
||
}
|
||
};
|
||
|
||
const handleClearAllTasks = async () => {
|
||
setClearing(true);
|
||
try {
|
||
const result = await clearAllTasks();
|
||
if (result.success) {
|
||
Modal.success({
|
||
title: '清空完成',
|
||
content: result.message,
|
||
});
|
||
await load();
|
||
} else {
|
||
message.error(result.message);
|
||
}
|
||
} catch {
|
||
message.error('清空任务失败');
|
||
} finally {
|
||
setClearing(false);
|
||
}
|
||
};
|
||
|
||
const columns: ColumnsType<TriggerJob> = [
|
||
{
|
||
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
|
||
render: (name: string, record) => (
|
||
<Tooltip title={record.description || name}>
|
||
<Text strong>{record.description || name}</Text>
|
||
<br />
|
||
<Text type="secondary" style={{ fontSize: 12 }}>{name}</Text>
|
||
</Tooltip>
|
||
),
|
||
},
|
||
{
|
||
title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120,
|
||
render: (v: string) => <Tag>{TRIGGER_LABEL[v] || v}</Tag>,
|
||
},
|
||
{
|
||
title: '触发配置', key: 'trigger_config', width: 150,
|
||
render: (_: unknown, record) => <code style={{ fontSize: 12 }}>{formatTriggerConfig(record)}</code>,
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
||
render: (v: string) => <Tag color={STATUS_COLOR[v] || 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>,
|
||
},
|
||
{
|
||
title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170,
|
||
render: (v: string | null) => formatTime(v),
|
||
},
|
||
{
|
||
title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170,
|
||
render: (v: string | null) => formatTime(v),
|
||
},
|
||
{
|
||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
|
||
render: (v: string | null) => v
|
||
? <Tooltip title={v}><Text type="danger" ellipsis style={{ maxWidth: 180 }}><ExclamationCircleOutlined /> {v}</Text></Tooltip>
|
||
: <Text type="success"><CheckCircleOutlined /> 正常</Text>,
|
||
},
|
||
{
|
||
title: '操作', key: 'action', width: 100, fixed: 'right',
|
||
render: (_: unknown, record) => (
|
||
<Popconfirm
|
||
title={`确认手动执行「${record.description || record.job_name}」?`}
|
||
onConfirm={() => handleRun(record.id)}
|
||
okText="执行"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlayCircleOutlined />}
|
||
loading={runningId === record.id}
|
||
disabled={record.status !== 'enabled'}
|
||
>
|
||
执行
|
||
</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Title level={4} style={{ margin: 0 }}>
|
||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||
定时任务管理
|
||
</Title>
|
||
<Space>
|
||
<Popconfirm
|
||
title="确认清空所有助教任务?"
|
||
description="将删除 coach_tasks 和 coach_task_history 中的全部数据,此操作不可撤销。"
|
||
onConfirm={handleClearAllTasks}
|
||
okText="确认清空"
|
||
cancelText="取消"
|
||
okButtonProps={{ danger: true }}
|
||
>
|
||
<Button danger loading={clearing}>🧹 清空所有任务</Button>
|
||
</Popconfirm>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<Card size="small">
|
||
<Table<TriggerJob>
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={jobs}
|
||
loading={loading}
|
||
pagination={false}
|
||
size="small"
|
||
scroll={{ x: 1200 }}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TriggerJobs;
|