import { useEffect, useState, useRef, useMemo, useCallback, useLayoutEffect } from "react"; import { useParams } from "react-router"; import { Download, Copy, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { App } from "antd"; import VirtualList from 'rc-virtual-list'; import { queryCleaningTaskLogByIdUsingGet, streamCleaningTaskLog, downloadCleaningTaskLog } from "../../cleansing.api"; import { TaskStatus } from "../../cleansing.model"; interface LogEntry { level: string; message: string; } interface LogEntryWithIndex extends LogEntry { index: number; } export default function LogsTable({ retryCount, taskName, taskStatus }: { retryCount: number; taskName: string; taskStatus?: TaskStatus; }) { const { id = "" } = useParams(); const { t } = useTranslation(); const { message } = App.useApp(); // 内部日志状态 const [logs, setLogs] = useState([]); const [selectedRun, setSelectedRun] = useState(retryCount + 1); const [isStreaming, setIsStreaming] = useState(false); const [isLoading, setIsLoading] = useState(false); const eventSourceRef = useRef(null); const prevRetryCountRef = useRef(retryCount); // 🔴 核心修复:用于测量容器高度的 Ref const listContainerRef = useRef(null); const [listHeight, setListHeight] = useState(500); // 默认高度,防止闪烁 // 🔴 核心修复:动态测量可用高度 useLayoutEffect(() => { const updateHeight = () => { if (listContainerRef.current) { setListHeight(listContainerRef.current.clientHeight); } }; updateHeight(); // 监听容器大小变化(例如窗口缩放或标签页切换) const resizeObserver = new ResizeObserver(updateHeight); if (listContainerRef.current) { resizeObserver.observe(listContainerRef.current); } return () => resizeObserver.disconnect(); }, []); // 1. 停止流 const stopStreaming = useCallback(() => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } setIsStreaming(false); }, []); // 2. 开启流 (Tail 模式,仅追加) const startStreaming = useCallback(() => { const isLatest = selectedRun === retryCount + 1; if (!isLatest || isStreaming) return; setIsStreaming(true); const es = streamCleaningTaskLog(id, selectedRun - 1); eventSourceRef.current = es; es.onmessage = (event) => { try { const logEntry: LogEntry = JSON.parse(event.data); if (logEntry.message === "[END_OF_STREAM]" || logEntry.message === "[WAITING_TIMEOUT]") { if (logEntry.message === "[END_OF_STREAM]") stopStreaming(); return; } setLogs(prev => [...prev, logEntry]); } catch (e) { console.error("SSE parse error", e); } }; es.onerror = () => stopStreaming(); }, [id, selectedRun, retryCount, isStreaming, stopStreaming]); // Effect A: 加载历史数据 useEffect(() => { stopStreaming(); setLogs([]); setIsLoading(true); const currentRetry = selectedRun - 1; queryCleaningTaskLogByIdUsingGet(id, currentRetry) .then(res => { setLogs(res.data || []); setIsLoading(false); }) .catch(() => setIsLoading(false)); }, [selectedRun, id, stopStreaming]); // Effect B: 管理实时流连接 useEffect(() => { const isLatest = selectedRun === retryCount + 1; const shouldStream = isLatest && taskStatus === TaskStatus.RUNNING; if (shouldStream && !isStreaming && !isLoading) { startStreaming(); } else if (!shouldStream) { stopStreaming(); } }, [selectedRun, retryCount, taskStatus, isStreaming, isLoading, startStreaming, stopStreaming]); // Effect C: 智能同步 (只在用户查看最新轮次且任务重启时跟随) useEffect(() => { if (retryCount !== prevRetryCountRef.current) { const wasViewingLatest = (selectedRun - 1) === prevRetryCountRef.current; if (wasViewingLatest) { setSelectedRun(retryCount + 1); } prevRetryCountRef.current = retryCount; } }, [retryCount, selectedRun]); useEffect(() => { return () => stopStreaming(); }, [stopStreaming]); // 3. 复制逻辑 const handleCopyLogs = () => { if (logs.length === 0) { message.warning("当前没有可复制的日志"); return; } const text = logs.map(log => `[${log.level}] ${log.message}`).join('\n'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => message.success("日志已复制")); } else { fallbackCopy(text); } }; const fallbackCopy = (text: string) => { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); message.success("日志已复制"); } catch (err) { message.error("复制失败"); } document.body.removeChild(ta); }; const handleDownload = async () => { try { const blob = await downloadCleaningTaskLog(id, selectedRun - 1); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${taskName}_run_${selectedRun}.log`; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); } catch (e) { console.error("Download failed", e); } }; const hasContent = logs.length > 0; const logsWithIndex: LogEntryWithIndex[] = useMemo(() => logs.map((log, index) => ({ ...log, index })), [logs]); const getLevelClass = (level: string) => { if (level.includes("ERROR") || level.includes("FATAL")) return "text-red-500"; if (level.includes("WARN")) return "text-yellow-500"; return "text-green-500"; }; const isWaitingForLogs = !hasContent && !isLoading && taskStatus === TaskStatus.RUNNING; return ( // 🔴 核心修复:使用 Flex 布局 + 固定高度 + overflow-hidden 切断外部滚动
{/* 1. 头部区域 (绝对固定,不参与滚动) */}
{isStreaming ? ( 实时输出中... ) : isWaitingForLogs ? ( 正在连接... ) : ( 历史日志 )}
{/* 2. 内容区域 (自适应剩余高度,内部滚动) */} {/* 🔴 关键:ref 用于测量高度,overflow-hidden 确保内容不溢出 */}
{hasContent ? ( // 🔴 VirtualList 使用动态计算的高度,确保滚动条只在这个区域内生成 {(log: LogEntryWithIndex) => (
[{log.level}] {log.message}
)}
) : (
{isLoading ? ( <>

正在加载日志...

) : isWaitingForLogs ? ( <>

等待任务产生日志...

Runtime 正在初始化。

) : taskStatus === TaskStatus.PENDING ? (

任务排队中,等待执行...

) : (

当前任务无可用日志

)}
)}
); }