/** * 触发器统一管理页面 — 聚合 biz / ai / etl 三类触发器为 Tab 视图。 * * - 4 个 Tab:all(全部,只读统一视图)、biz(业务)、ai(AI)、etl(ETL) * - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=all|biz|ai|etl * - destroyInactiveTabPane={false} 保持 Tab 状态不丢失 * - "全部"Tab 调用 fetchUnifiedTriggers(),展示统一字段表格 * - "业务"Tab 复用 TriggerJobs 组件 + 编辑 Modal * - "AI"Tab 复用 AITriggers(触发器设置)+ AIOperations + AITriggerJobs 组件 * - "ETL"Tab 展示 scheduled_tasks 数据 * * CHANGE 2026-07-15 | Task 10.1:创建 TriggerManager 页面 */ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { Tabs, Typography, Table, Tag, message, Modal, Form, Input, InputNumber, Space, Button, Card, } from 'antd'; import { AppstoreOutlined, SettingOutlined, RobotOutlined, CloudServerOutlined, EditOutlined, ReloadOutlined, } from '@ant-design/icons'; import { useSearchParams } from 'react-router-dom'; import type { ColumnsType } from 'antd/es/table'; import { fetchUnifiedTriggers, type UnifiedTriggerItem } from '../api/triggers'; import { fetchTriggerJobs, updateTriggerConfig, type TriggerJob, type UpdateTriggerConfigReq, } from '../api/triggerJobs'; import { fetchSchedules } from '../api/schedules'; import type { ScheduledTask } from '../types'; import AIOperations from './AIOperations'; import AITriggerJobs from './AITriggerJobs'; import AITriggers from './AITriggers'; const { Title } = Typography; /* ───────── Tab 常量 ───────── */ const VALID_TABS = ['all', 'biz', 'ai', 'etl'] as const; type TabKey = (typeof VALID_TABS)[number]; const DEFAULT_TAB: TabKey = 'all'; function isValidTab(value: string | null): value is TabKey { return value != null && (VALID_TABS as readonly string[]).includes(value); } /* ───────── 工具函数 ───────── */ const SOURCE_COLOR: Record = { biz: 'blue', ai: 'purple', etl: 'green', }; const SOURCE_LABEL: Record = { biz: '业务', ai: 'AI', etl: 'ETL', }; function formatTime(raw: string | null): string { if (!raw) return '—'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN'); } /* ───────── "全部"Tab:统一视图(只读) ───────── */ const AllTriggersTab: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const load = useCallback(async () => { setLoading(true); try { setData(await fetchUnifiedTriggers()); } catch { message.error('加载统一触发器数据失败'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const columns: ColumnsType = [ { title: '名称', dataIndex: 'name', key: 'name', width: 200 }, { title: '类型', dataIndex: 'source', key: 'source', width: 80, render: (v: string) => ( {SOURCE_LABEL[v] ?? v} ), }, { title: '触发条件', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120 }, { title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => { const color = v === 'running' ? 'processing' : v === 'error' ? 'error' : v === 'disabled' ? 'default' : 'success'; return {v}; }, }, { title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime }, { title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime }, { title: '最近错误', dataIndex: 'last_error', key: 'last_error', ellipsis: true, render: (v: string | null) => v ? {v} : '—', }, ]; return ( } size="small" onClick={load} loading={loading}>刷新} > rowKey={(r) => `${r.source}-${r.id}`} columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }} size="small" scroll={{ x: 1000 }} /> ); }; /* ───────── "业务"Tab:TriggerJobs + 编辑 Modal ───────── */ const BizTriggersTab: React.FC = () => { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [editingJob, setEditingJob] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); const load = useCallback(async () => { setLoading(true); try { setJobs(await fetchTriggerJobs()); } catch { message.error('加载业务触发器失败'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const openEdit = (job: TriggerJob) => { setEditingJob(job); const cfg = job.trigger_config ?? {}; form.setFieldsValue({ cron_expression: (cfg.cron_expression as string) ?? undefined, interval_seconds: (cfg.interval_seconds as number) ?? undefined, }); setEditModalOpen(true); }; const handleSave = async () => { if (!editingJob) return; try { const values = await form.validateFields(); // 只发送有值的字段 const body: UpdateTriggerConfigReq = {}; if (values.cron_expression != null && values.cron_expression !== '') { body.cron_expression = values.cron_expression; } if (values.interval_seconds != null) { body.interval_seconds = values.interval_seconds; } if (!body.cron_expression && body.interval_seconds == null) { message.warning('请至少填写 cron 表达式或间隔秒数'); return; } setSaving(true); await updateTriggerConfig(editingJob.id, body); message.success('触发器配置已更新'); setEditModalOpen(false); setEditingJob(null); form.resetFields(); await load(); } catch (err: unknown) { // 422 错误展示具体信息 if (err && typeof err === 'object' && 'response' in err) { const resp = (err as { response?: { status?: number; data?: { detail?: string } } }).response; if (resp?.status === 422 && resp.data?.detail) { message.error(resp.data.detail); return; } } message.error('保存失败'); } finally { setSaving(false); } }; const TRIGGER_LABEL: Record = { cron: '定时(Cron)', interval: '间隔', event: '事件触发', }; const 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 columns: ColumnsType = [ { title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180, render: (name: string, record) => ( <> {record.description || name}
{name} ), }, { title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120, render: (v: string) => {TRIGGER_LABEL[v] || v}, }, { title: '触发配置', key: 'trigger_config', width: 150, render: (_: unknown, record) => {formatTriggerConfig(record)}, }, { title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (v: string) => ( {v === 'enabled' ? '启用' : '禁用'} ), }, { title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime }, { title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime }, { title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200, render: (v: string | null) => v ? {v} : 正常, }, { title: '操作', key: 'action', width: 80, fixed: 'right', render: (_: unknown, record) => ( ), }, ]; return ( <> } size="small" onClick={load} loading={loading}>刷新} > rowKey="id" columns={columns} dataSource={jobs} loading={loading} pagination={false} size="small" scroll={{ x: 1200 }} /> { setEditModalOpen(false); setEditingJob(null); form.resetFields(); }} onOk={handleSave} confirmLoading={saving} okText="保存" cancelText="取消" destroyOnClose >
= 1' }]} >
); }; /* ───────── "AI"Tab:AIOperations + AITriggerJobs ───────── */ const AITriggersTab: React.FC = () => ( ); /* ───────── "ETL"Tab:scheduled_tasks 数据 ───────── */ const ETLTriggersTab: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const load = useCallback(async () => { setLoading(true); try { setData(await fetchSchedules()); } catch { message.error('加载 ETL 调度任务失败'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const columns: ColumnsType = [ { title: '名称', dataIndex: 'name', key: 'name', width: 200 }, { title: '任务代码', dataIndex: 'task_codes', key: 'task_codes', width: 200, render: (v: string[]) => v?.join(', ') ?? '—', }, { title: '状态', dataIndex: 'enabled', key: 'enabled', width: 80, render: (v: boolean) => {v ? '启用' : '禁用'}, }, { title: '上次状态', dataIndex: 'last_status', key: 'last_status', width: 100, render: (v: string | null) => v ? {v} : '—', }, { title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime }, { title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime }, { title: '执行次数', dataIndex: 'run_count', key: 'run_count', width: 90 }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: formatTime }, ]; return ( } size="small" onClick={load} loading={loading}>刷新} > rowKey="id" columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }} size="small" scroll={{ x: 1000 }} /> ); }; /* ───────── 主组件 ───────── */ const TriggerManager: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const activeTab: TabKey = useMemo(() => { const raw = searchParams.get('tab'); return isValidTab(raw) ? raw : DEFAULT_TAB; }, [searchParams]); const handleTabChange = (key: string) => { setSearchParams({ tab: key }, { replace: true }); }; const items = useMemo( () => [ { key: 'all' as TabKey, label: ( 全部 ), children: , }, { key: 'biz' as TabKey, label: ( 业务 ), children: , }, { key: 'ai' as TabKey, label: ( AI ), children: , }, { key: 'etl' as TabKey, label: ( ETL ), children: , }, ], [], ); return (
<SettingOutlined style={{ marginRight: 8 }} /> 触发器管理
); }; export default TriggerManager;