本次同步 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/(内嵌独立仓库,远程亦未收录其源码)
279 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|