Files
deepdata/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx
T
沃巴蒂 ff22ed3a8b feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow
本次同步 09-12 至 09-28 的代码变更,主要内容:

新增功能
- 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService
  对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口
- 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、
  ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器
- 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator
- 新增 tools/minio-file-server MinIO 文件服务

功能增强
- 数据清洗:cleaning_task_service/scheduler/routes 重构,
  data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整
- 数据标注:annotation sync.py、project.py 接口与schema 更新
- 数据采集、算子市场、首页与菜单路由相关页面调整
- DataJuicer 算子接入(useOperatorOperations、dj_ops_schema)

结构调整
- deepflow/ 更名为 deep-flow/(419 个文件重命名)
- 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本

未纳入本次提交
- scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录)
- *.backup*、__pycache__ 产物
- zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物)
- DataX/(内嵌独立仓库,远程亦未收录其源码)
2026-10-03 22:44:56 +08:00

279 lines
11 KiB
TypeScript

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<LogEntry[]>([]);
const [selectedRun, setSelectedRun] = useState(retryCount + 1);
const [isStreaming, setIsStreaming] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const prevRetryCountRef = useRef(retryCount);
// 🔴 核心修复:用于测量容器高度的 Ref
const listContainerRef = useRef<HTMLDivElement>(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 切断外部滚动
<div className="relative flex flex-col h-[600px] w-full bg-gray-800 border border-gray-700 rounded-lg overflow-hidden text-gray-300">
{/* 1. 头部区域 (绝对固定,不参与滚动) */}
<div className="flex-none p-3 bg-gray-800 border-b border-gray-700 z-10 shadow-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-500">{t("dataCleansing.detail.logTable.selectRun")}:</label>
<select
value={selectedRun}
onChange={(e) => setSelectedRun(Number(e.target.value))}
className="bg-gray-700 border border-gray-600 !text-white text-sm rounded-md px-2.5 py-1.5 min-w-[120px]"
>
{Array.from({ length: retryCount + 1 }, (_, i) => retryCount + 1 - i).map((num) => (
<option key={num} value={num}>
{t("dataCleansing.detail.logTable.currentDisplay", { num }) || `第 ${num} 次运行`}
</option>
))}
</select>
{isStreaming ? (
<span className="text-xs text-blue-400 animate-pulse flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-blue-400"></span>实时输出中...
</span>
) : isWaitingForLogs ? (
<span className="text-xs text-yellow-400 animate-pulse flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-yellow-400"></span>正在连接...
</span>
) : (
<span className="text-xs text-gray-500">历史日志</span>
)}
</div>
<div className="flex items-center gap-3">
<button onClick={handleCopyLogs} disabled={!hasContent} className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-gray-600 hover:bg-gray-700 border border-gray-500 rounded-md transition-colors disabled:opacity-50">
<Copy className="w-4 h-4" />
<span>复制</span>
</button>
<button onClick={handleDownload} className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 border border-blue-500 rounded-md transition-colors">
<Download className="w-4 h-4" />
<span>下载</span>
</button>
</div>
</div>
</div>
{/* 2. 内容区域 (自适应剩余高度,内部滚动) */}
{/* 🔴 关键:ref 用于测量高度,overflow-hidden 确保内容不溢出 */}
<div className="flex-1 relative overflow-hidden bg-gray-900/50" ref={listContainerRef}>
{hasContent ? (
// 🔴 VirtualList 使用动态计算的高度,确保滚动条只在这个区域内生成
<VirtualList
key={selectedRun}
data={logsWithIndex}
height={listHeight}
itemHeight={22}
itemKey="index"
// 确保滚动条样式正常(部分浏览器可能需要)
className="scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent"
>
{(log: LogEntryWithIndex) => (
<div className="flex gap-3 py-0.5 whitespace-pre-wrap px-4">
<span className={`min-w-20 ${getLevelClass(log.level)}`}>[{log.level}]</span>
<span className="text-gray-100">{log.message}</span>
</div>
)}
</VirtualList>
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
{isLoading ? (
<>
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
<p>正在加载日志...</p>
</>
) : isWaitingForLogs ? (
<>
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
<p className="text-sm">等待任务产生日志...</p>
<p className="text-xs text-gray-600 mt-1">Runtime 正在初始化。</p>
</>
) : taskStatus === TaskStatus.PENDING ? (
<p>任务排队中,等待执行...</p>
) : (
<p className="text-center">当前任务无可用日志</p>
)}
</div>
)}
</div>
</div>
);
}