包含多个会话的累积代码变更: - 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>
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
/**
|
||
* 任务管理页面。
|
||
*
|
||
* 三个 Tab:队列、调度、历史
|
||
* 队列 Tab:running 状态的任务可点击查看实时 WebSocket 日志流
|
||
* 历史 Tab:点击记录可查看执行详情和历史日志
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import {
|
||
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
|
||
Typography, Descriptions, Empty, Spin,
|
||
} from 'antd';
|
||
import {
|
||
ReloadOutlined, DeleteOutlined, StopOutlined,
|
||
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
|
||
FileTextOutlined, PlayCircleOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { QueuedTask, ExecutionLog } from '../types';
|
||
import {
|
||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution, rerunExecution,
|
||
} from '../api/execution';
|
||
import { apiClient } from '../api/client';
|
||
import LogStream from '../components/LogStream';
|
||
import ScheduleTab from '../components/ScheduleTab';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 状态颜色映射 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const STATUS_COLOR: Record<string, string> = {
|
||
pending: 'default',
|
||
running: 'processing',
|
||
success: 'success',
|
||
failed: 'error',
|
||
cancelled: 'warning',
|
||
interrupted: 'volcano',
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 工具函数 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function fmtTime(iso: string | null | undefined): string {
|
||
if (!iso) return '—';
|
||
return new Date(iso).toLocaleString('zh-CN');
|
||
}
|
||
|
||
function fmtDuration(ms: number | null | undefined): string {
|
||
if (ms == null) return '—';
|
||
if (ms < 1000) return `${ms}ms`;
|
||
const sec = ms / 1000;
|
||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||
const min = Math.floor(sec / 60);
|
||
const remainSec = Math.round(sec % 60);
|
||
return `${min}m${remainSec}s`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 队列 Tab */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
export const QueueTab: React.FC = () => {
|
||
const [data, setData] = useState<QueuedTask[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
/* WebSocket 日志流状态 */
|
||
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
||
const [logLines, setLogLines] = useState<string[]>([]);
|
||
const [logTaskId, setLogTaskId] = useState<string | null>(null);
|
||
const [wsConnected, setWsConnected] = useState(false);
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try { setData(await fetchQueue()); }
|
||
catch { message.error('加载队列失败'); }
|
||
finally { setLoading(false); }
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
/* 自动轮询队列状态(5 秒间隔),保持状态实时 */
|
||
useEffect(() => {
|
||
const timer = setInterval(load, 5_000);
|
||
return () => clearInterval(timer);
|
||
}, [load]);
|
||
|
||
/* 组件卸载时关闭 WebSocket */
|
||
useEffect(() => {
|
||
return () => { wsRef.current?.close(); };
|
||
}, []);
|
||
|
||
/** 打开日志抽屉并建立 WebSocket 连接 */
|
||
const handleViewLogs = useCallback((taskId: string) => {
|
||
setLogTaskId(taskId);
|
||
setLogLines([]);
|
||
setLogDrawerOpen(true);
|
||
|
||
// 关闭旧连接
|
||
wsRef.current?.close();
|
||
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const host = window.location.host;
|
||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${taskId}`);
|
||
wsRef.current = ws;
|
||
|
||
ws.onopen = () => { setWsConnected(true); };
|
||
ws.onmessage = (event) => {
|
||
setLogLines((prev) => [...prev, event.data]);
|
||
};
|
||
ws.onclose = () => { setWsConnected(false); };
|
||
ws.onerror = () => {
|
||
message.error('WebSocket 连接失败');
|
||
setWsConnected(false);
|
||
};
|
||
}, []);
|
||
|
||
/** 关闭日志抽屉 */
|
||
const handleCloseLogDrawer = useCallback(() => {
|
||
setLogDrawerOpen(false);
|
||
wsRef.current?.close();
|
||
wsRef.current = null;
|
||
setWsConnected(false);
|
||
setLogTaskId(null);
|
||
}, []);
|
||
|
||
const handleDelete = async (id: string) => {
|
||
try { await deleteFromQueue(id); message.success('已删除'); load(); }
|
||
catch { message.error('删除失败'); }
|
||
};
|
||
|
||
const handleCancel = async (id: string) => {
|
||
try { await cancelExecution(id); message.success('已取消'); load(); }
|
||
catch { message.error('取消失败'); }
|
||
};
|
||
|
||
const columns: ColumnsType<QueuedTask> = [
|
||
{
|
||
title: '任务 ID', dataIndex: 'id', key: 'id', width: 120,
|
||
render: (id: string) => (
|
||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||
{id.slice(0, 8)}…
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks',
|
||
render: (tasks: string[]) => (
|
||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: tasks?.join(', ') }}>
|
||
{tasks?.join(', ') ?? '—'}
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: 'Flow', dataIndex: ['config', 'flow'], key: 'flow', width: 120,
|
||
render: (v: string) => <Tag>{v}</Tag>,
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||
},
|
||
{ title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' },
|
||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime },
|
||
{
|
||
title: '操作', key: 'action', width: 160, align: 'center',
|
||
render: (_: unknown, record: QueuedTask) => {
|
||
if (record.status === 'pending') {
|
||
return (
|
||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||
</Popconfirm>
|
||
);
|
||
}
|
||
if (record.status === 'running') {
|
||
return (
|
||
<Space size={4}>
|
||
<Button
|
||
type="link" icon={<FileTextOutlined />} size="small"
|
||
onClick={() => handleViewLogs(record.id)}
|
||
>
|
||
日志
|
||
</Button>
|
||
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
|
||
<Button type="link" danger icon={<StopOutlined />} size="small">取消</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
);
|
||
}
|
||
return null;
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Text type="secondary">共 {data.length} 个任务</Text>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||
</div>
|
||
<Table<QueuedTask>
|
||
rowKey="id" columns={columns} dataSource={data}
|
||
loading={loading} pagination={false} size="small"
|
||
locale={{ emptyText: <Empty description="队列为空" /> }}
|
||
/>
|
||
|
||
{/* 实时日志抽屉 */}
|
||
<Drawer
|
||
title={
|
||
<Space>
|
||
<FileTextOutlined />
|
||
<span>执行日志</span>
|
||
{wsConnected
|
||
? <Tag color="processing">实时连接中</Tag>
|
||
: <Tag>未连接</Tag>}
|
||
</Space>
|
||
}
|
||
open={logDrawerOpen}
|
||
onClose={handleCloseLogDrawer}
|
||
width={720}
|
||
styles={{ body: { padding: 12, display: 'flex', flexDirection: 'column', height: '100%' } }}
|
||
>
|
||
{logTaskId && (
|
||
<div style={{ flex: 1, minHeight: 0 }}>
|
||
<LogStream executionId={logTaskId} lines={logLines} />
|
||
</div>
|
||
)}
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 历史 Tab */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
export const HistoryTab: React.FC = () => {
|
||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||
const [historyLogLines, setHistoryLogLines] = useState<string[]>([]);
|
||
const [logLoading, setLogLoading] = useState(false);
|
||
const [wsConnected, setWsConnected] = useState(false);
|
||
const historyWsRef = useRef<WebSocket | null>(null);
|
||
|
||
/** 关闭 WebSocket 连接并重置状态 */
|
||
const closeHistoryWs = useCallback(() => {
|
||
historyWsRef.current?.close();
|
||
historyWsRef.current = null;
|
||
setWsConnected(false);
|
||
}, []);
|
||
|
||
/* 组件卸载时清理 WebSocket */
|
||
useEffect(() => {
|
||
return () => { historyWsRef.current?.close(); };
|
||
}, []);
|
||
|
||
const handleCancelHistory = useCallback(async (id: string) => {
|
||
try { await cancelExecution(id); message.success('已发送终止信号'); load(); }
|
||
catch { message.error('终止失败'); }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// CHANGE 2026-03-22 | 重新执行历史任务
|
||
const handleRerun = useCallback(async (id: string) => {
|
||
try {
|
||
const { execution_id } = await rerunExecution(id);
|
||
message.success(`已重新执行,新 ID: ${execution_id.slice(0, 8)}…`);
|
||
load();
|
||
} catch { message.error('重新执行失败'); }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try { setData(await fetchHistory()); }
|
||
catch { message.error('加载历史记录失败'); }
|
||
finally { setLoading(false); }
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
/** 点击行时加载详情和日志;running 任务走 WebSocket 实时流 */
|
||
const handleRowClick = useCallback(async (record: ExecutionLog) => {
|
||
setDetail(record);
|
||
setHistoryLogLines([]);
|
||
setLogLoading(true);
|
||
closeHistoryWs();
|
||
|
||
if (record.status === 'running') {
|
||
// running 任务:通过 WebSocket 实时推送日志
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const host = window.location.host;
|
||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${record.id}`);
|
||
historyWsRef.current = ws;
|
||
|
||
ws.onopen = () => { setWsConnected(true); setLogLoading(false); };
|
||
ws.onmessage = (event) => {
|
||
setHistoryLogLines((prev) => [...prev, event.data]);
|
||
};
|
||
ws.onclose = () => {
|
||
setWsConnected(false);
|
||
// 任务结束后刷新历史列表以更新状态
|
||
load();
|
||
};
|
||
ws.onerror = () => {
|
||
message.error('WebSocket 连接失败,回退到静态日志');
|
||
setWsConnected(false);
|
||
// 回退:用 REST API 拉取已有日志
|
||
apiClient.get<{
|
||
execution_id: string;
|
||
output_log: string | null;
|
||
error_log: string | null;
|
||
}>(`/execution/${record.id}/logs`).then(({ data: logData }) => {
|
||
const parts: string[] = [];
|
||
if (logData.output_log) parts.push(logData.output_log);
|
||
if (logData.error_log) parts.push(logData.error_log);
|
||
setHistoryLogLines(parts.join('\n').split('\n').filter(Boolean));
|
||
}).catch(() => {}).finally(() => setLogLoading(false));
|
||
};
|
||
} else {
|
||
// 已完成任务:REST API 一次性拉取
|
||
try {
|
||
const { data: logData } = await apiClient.get<{
|
||
execution_id: string;
|
||
output_log: string | null;
|
||
error_log: string | null;
|
||
}>(`/execution/${record.id}/logs`);
|
||
const parts: string[] = [];
|
||
if (logData.output_log) parts.push(logData.output_log);
|
||
if (logData.error_log) parts.push(logData.error_log);
|
||
setHistoryLogLines(parts.join('\n').split('\n').filter(Boolean));
|
||
} catch {
|
||
/* 日志可能不存在,静默处理 */
|
||
} finally {
|
||
setLogLoading(false);
|
||
}
|
||
}
|
||
}, [closeHistoryWs, load]);
|
||
|
||
// CHANGE 2026-03-27 | 支持 URL 参数 openExecution 自动打开任务详情
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const openExecutionHandled = useRef(false);
|
||
useEffect(() => {
|
||
const openId = searchParams.get('openExecution');
|
||
if (!openId || openExecutionHandled.current || loading || data.length === 0) return;
|
||
openExecutionHandled.current = true;
|
||
const target = data.find((r) => r.id === openId);
|
||
if (target) {
|
||
handleRowClick(target);
|
||
} else {
|
||
handleRowClick({ id: openId, status: 'running' } as ExecutionLog);
|
||
}
|
||
searchParams.delete('openExecution');
|
||
setSearchParams(searchParams, { replace: true });
|
||
}, [data, loading, searchParams, setSearchParams, handleRowClick]);
|
||
|
||
const columns: ColumnsType<ExecutionLog> = [
|
||
{
|
||
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
|
||
render: (id: string) => (
|
||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||
{id.slice(0, 8)}…
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
|
||
render: (codes: string[]) => (
|
||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: codes?.join(', ') }}>
|
||
{codes?.join(', ') ?? '—'}
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '调度 ID', dataIndex: 'schedule_id', key: 'schedule_id', width: 120,
|
||
render: (id: string | null) => id
|
||
? <Text copyable={{ text: id }} style={{ fontSize: 11 }}>{id.slice(0, 8)}…</Text>
|
||
: '—',
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||
},
|
||
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: fmtTime },
|
||
{ title: '时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 90, render: fmtDuration },
|
||
{
|
||
title: '退出码', dataIndex: 'exit_code', key: 'exit_code', width: 70, align: 'center',
|
||
render: (v: number | null) => v != null ? (
|
||
<Tag color={v === 0 ? 'success' : 'error'}>{v}</Tag>
|
||
) : '—',
|
||
},
|
||
{
|
||
title: '操作', key: 'action', width: 140, align: 'center',
|
||
render: (_: unknown, record: ExecutionLog) => (
|
||
<Space size={0}>
|
||
{record.status === 'running' && (
|
||
<Popconfirm title="确认终止该任务?" onConfirm={(e) => { e?.stopPropagation(); handleCancelHistory(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||
终止
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
{record.status !== 'running' && (
|
||
<Popconfirm title="确认重新执行该任务?" onConfirm={(e) => { e?.stopPropagation(); handleRerun(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||
<Button type="link" icon={<PlayCircleOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||
重新执行
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Text type="secondary">最近 {data.length} 条记录</Text>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||
</div>
|
||
<Table<ExecutionLog>
|
||
rowKey="id" columns={columns} dataSource={data}
|
||
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||
size="small"
|
||
onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: 'pointer' } })}
|
||
/>
|
||
|
||
<Drawer
|
||
title={
|
||
<Space>
|
||
<span>执行详情</span>
|
||
{detail?.status === 'running' && (
|
||
wsConnected
|
||
? <Tag color="processing">实时连接中</Tag>
|
||
: <Tag>未连接</Tag>
|
||
)}
|
||
</Space>
|
||
}
|
||
open={!!detail}
|
||
onClose={() => { closeHistoryWs(); setDetail(null); }}
|
||
width={720}
|
||
styles={{ body: { padding: 12 } }}
|
||
>
|
||
{detail && (
|
||
<>
|
||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
|
||
<Descriptions.Item label="退出码">
|
||
{detail.exit_code != null ? (
|
||
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
|
||
) : '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="命令">
|
||
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* 历史日志展示 */}
|
||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<FileTextOutlined />
|
||
<Text strong>执行日志</Text>
|
||
{logLoading && <Spin size="small" />}
|
||
</div>
|
||
<div style={{ height: 400 }}>
|
||
<LogStream executionId={detail.id} lines={historyLogLines} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 主组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const TaskManager: React.FC = () => {
|
||
const items = [
|
||
{
|
||
key: 'queue',
|
||
label: <Space><UnorderedListOutlined />队列</Space>,
|
||
children: <QueueTab />,
|
||
},
|
||
{
|
||
key: 'schedule',
|
||
label: <Space><ClockCircleOutlined />调度</Space>,
|
||
children: <ScheduleTab />,
|
||
},
|
||
{
|
||
key: 'history',
|
||
label: <Space><HistoryOutlined />历史</Space>,
|
||
children: <HistoryTab />,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Title level={4} style={{ marginBottom: 16 }}>
|
||
<UnorderedListOutlined style={{ marginRight: 8 }} />
|
||
任务管理
|
||
</Title>
|
||
<Tabs defaultActiveKey="queue" items={items} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TaskManager;
|