Files
Neo-ZQYY/apps/admin-web/src/pages/TaskManager.tsx
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- 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>
2026-04-06 00:03:48 +08:00

520 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 任务管理页面。
*
* 三个 Tab队列、调度、历史
* 队列 Tabrunning 状态的任务可点击查看实时 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;