CodeReview/src/components/audit/TerminalProgressDialog.tsx

523 lines
25 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.

import { useEffect, useRef, useState } from "react";
import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Terminal, CheckCircle, XCircle, Loader2, X as XIcon } from "lucide-react";
import { cn, calculateTaskProgress } from "@/shared/utils/utils";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { taskControl } from "@/shared/services/taskControl";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
interface TerminalProgressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
taskId: string | null;
taskType: "repository" | "zip";
}
interface LogEntry {
timestamp: string;
message: string;
type: "info" | "success" | "error" | "warning";
}
export default function TerminalProgressDialog({
open,
onOpenChange,
taskId,
taskType
}: TerminalProgressDialogProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isCompleted, setIsCompleted] = useState(false);
const [isFailed, setIsFailed] = useState(false);
const [isCancelled, setIsCancelled] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
const logsEndRef = useRef<HTMLDivElement>(null);
const pollIntervalRef = useRef<number | null>(null);
const hasInitializedLogsRef = useRef(false);
// 添加日志条目
const addLog = (message: string, type: LogEntry["type"] = "info") => {
const timestamp = new Date().toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
setLogs(prev => [...prev, { timestamp, message, type }]);
};
// 取消任务处理
const handleCancel = async () => {
if (!taskId) return;
if (!confirm('确定要取消此任务吗?已分析的结果将被保留。')) {
return;
}
// 1. 标记任务为取消状态
taskControl.cancelTask(taskId);
setIsCancelled(true);
addLog("🛑 用户取消任务,正在停止...", "error");
// 2. 立即更新数据库状态
try {
const { api } = await import("@/shared/config/database");
await api.updateAuditTask(taskId, { status: 'cancelled' } as any);
addLog("✓ 任务状态已更新为已取消", "warning");
toast.success("任务已取消");
} catch (error) {
console.error('更新取消状态失败:', error);
toast.warning("任务已标记取消,后台正在停止...");
}
};
// 自动滚动到底部
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
// 实时更新光标处的时间
useEffect(() => {
if (!open || isCompleted || isFailed || isCancelled) {
return;
}
const timeInterval = setInterval(() => {
setCurrentTime(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
}, 1000);
return () => {
clearInterval(timeInterval);
};
}, [open, isCompleted, isFailed]);
// 轮询任务状态
useEffect(() => {
if (!open || !taskId) {
// 清理状态
setLogs([]);
setIsCompleted(false);
setIsFailed(false);
hasInitializedLogsRef.current = false;
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
return;
}
// 只初始化日志一次防止React严格模式重复
if (!hasInitializedLogsRef.current) {
hasInitializedLogsRef.current = true;
// 初始化日志
addLog("🚀 审计任务已启动", "info");
addLog(`<EFBFBD> 任务任ID: ${taskId}`, "info");
addLog(`<EFBFBD> 任务类D型: ${taskType === "repository" ? "仓库审计" : "ZIP文件审计"}`, "info");
addLog("⏳ 正在初始化审计环境...", "info");
}
let lastScannedFiles = 0;
let lastIssuesCount = 0;
let lastTotalLines = 0;
let lastStatus = "";
let pollCount = 0;
let hasDataChange = false;
let isFirstPoll = true;
// 开始轮询
const pollTask = async () => {
// 如果任务已完成或失败,停止轮询
if (isCompleted || isFailed) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
return;
}
try {
pollCount++;
hasDataChange = false;
const requestStartTime = Date.now();
// 使用 api.getAuditTaskById 获取任务状态
const { api } = await import("@/shared/config/database");
const task = await api.getAuditTaskById(taskId);
const requestDuration = Date.now() - requestStartTime;
if (!task) {
addLog(`❌ 任务不存在 (${requestDuration}ms)`, "error");
throw new Error("任务不存在");
}
// 检查是否有数据变化
const statusChanged = task.status !== lastStatus;
const filesChanged = task.scanned_files !== lastScannedFiles;
const issuesChanged = task.issues_count !== lastIssuesCount;
const linesChanged = task.total_lines !== lastTotalLines;
hasDataChange = statusChanged || filesChanged || issuesChanged || linesChanged;
// 标记首次轮询已完成
if (isFirstPoll) {
isFirstPoll = false;
}
// 只在有变化时显示请求/响应信息(跳过 pending 状态)
if (hasDataChange && task.status !== "pending") {
addLog(`🔄 正在获取任务状态...`, "info");
addLog(
`✓ 状态: ${task.status} | 文件: ${task.scanned_files}/${task.total_files} | 问题: ${task.issues_count} (${requestDuration}ms)`,
"success"
);
}
// 更新上次状态
if (statusChanged) {
lastStatus = task.status;
}
// 检查任务状态
if (task.status === "pending") {
// 静默跳过 pending 状态,不显示任何日志
} else if (task.status === "running") {
// 首次进入运行状态
if (statusChanged && logs.filter(l => l.message.includes("开始扫描")).length === 0) {
addLog("🔍 开始扫描代码文件...", "info");
if (task.project) {
addLog(`📁 项目: ${task.project.name}`, "info");
if (task.branch_name) {
addLog(`🌿 分支: ${task.branch_name}`, "info");
}
}
}
// 显示进度更新(仅在有变化时)
if (filesChanged && task.scanned_files > lastScannedFiles) {
const progress = calculateTaskProgress(task.scanned_files, task.total_files);
const filesProcessed = task.scanned_files - lastScannedFiles;
addLog(
`📊 扫描进度: ${task.scanned_files || 0}/${task.total_files || 0} 文件 (${progress}%) [+${filesProcessed}]`,
"info"
);
lastScannedFiles = task.scanned_files;
}
// 显示问题发现(仅在有变化时)
if (issuesChanged && task.issues_count > lastIssuesCount) {
const newIssues = task.issues_count - lastIssuesCount;
addLog(`⚠️ 发现 ${newIssues} 个新问题 (总计: ${task.issues_count})`, "warning");
lastIssuesCount = task.issues_count;
}
// 显示代码行数(仅在有变化时)
if (linesChanged && task.total_lines > lastTotalLines) {
const newLines = task.total_lines - lastTotalLines;
addLog(`📝 已分析 ${task.total_lines.toLocaleString()} 行代码 [+${newLines.toLocaleString()}]`, "info");
lastTotalLines = task.total_lines;
}
} else if (task.status === "completed") {
// 任务完成
if (!isCompleted) {
addLog("", "info"); // 空行分隔
addLog("✅ 代码扫描完成", "success");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info");
addLog(`📊 总计扫描: ${task.total_files} 个文件`, "success");
addLog(`📝 总计分析: ${task.total_lines.toLocaleString()} 行代码`, "success");
addLog(`⚠️ 发现问题: ${task.issues_count}`, task.issues_count > 0 ? "warning" : "success");
// 解析问题类型分布
if (task.issues_count > 0) {
try {
const { api: apiImport } = await import("@/shared/config/database");
const issues = await apiImport.getAuditIssues(taskId);
const severityCounts = {
critical: issues.filter(i => i.severity === 'critical').length,
high: issues.filter(i => i.severity === 'high').length,
medium: issues.filter(i => i.severity === 'medium').length,
low: issues.filter(i => i.severity === 'low').length
};
if (severityCounts.critical > 0) {
addLog(` 🔴 严重: ${severityCounts.critical}`, "error");
}
if (severityCounts.high > 0) {
addLog(` 🟠 高: ${severityCounts.high}`, "warning");
}
if (severityCounts.medium > 0) {
addLog(` 🟡 中等: ${severityCounts.medium}`, "warning");
}
if (severityCounts.low > 0) {
addLog(` 🟢 低: ${severityCounts.low}`, "info");
}
} catch (e) {
// 静默处理错误
}
}
addLog(`⭐ 质量评分: ${task.quality_score.toFixed(1)}/100`, "success");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info");
addLog("🎉 审计任务已完成!", "success");
if (task.completed_at) {
const startTime = new Date(task.created_at).getTime();
const endTime = new Date(task.completed_at).getTime();
const duration = Math.round((endTime - startTime) / 1000);
addLog(`⏱️ 总耗时: ${duration}`, "info");
}
setIsCompleted(true);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
} else if (task.status === "cancelled") {
// 任务被取消
if (!isCancelled) {
addLog("", "info"); // 空行分隔
addLog("🛑 任务已被用户取消", "warning");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "warning");
addLog(`📊 完成统计:`, "info");
addLog(` • 已分析文件: ${task.scanned_files}/${task.total_files}`, "info");
addLog(` • 发现问题: ${task.issues_count}`, "info");
addLog(` • 代码行数: ${task.total_lines.toLocaleString()}`, "info");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "warning");
addLog("✓ 已分析的结果已保存到数据库", "success");
setIsCancelled(true);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
} else if (task.status === "failed") {
// 任务失败
if (!isFailed) {
addLog("", "info"); // 空行分隔
addLog("❌ 审计任务执行失败", "error");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
addLog("可能的原因:", "error");
addLog(" • 网络连接问题", "error");
addLog(" • 仓库访问权限不足(私有仓库需配置 Token", "error");
addLog(" • GitHub/GitLab API 限流", "error");
addLog(" • 代码文件格式错误", "error");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
addLog("💡 建议: 检查网络连接、仓库配置和 Token 设置后重试", "warning");
setIsFailed(true);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
}
} catch (error: any) {
addLog(`${error.message || "未知错误"}`, "error");
// 不中断轮询,继续尝试
}
};
// 立即执行一次
pollTask();
// 设置定时轮询每2秒
pollIntervalRef.current = window.setInterval(pollTask, 2000);
// 清理函数
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
}, [open, taskId, taskType]);
// 获取日志颜色 - 使用优雅的深红色主题
const getLogColor = (type: LogEntry["type"]) => {
switch (type) {
case "success":
return "text-emerald-400";
case "error":
return "text-rose-400";
case "warning":
return "text-amber-400";
default:
return "text-gray-200";
}
};
// 获取状态图标
const getStatusIcon = () => {
if (isFailed) {
return <XCircle className="w-5 h-5 text-rose-400" />;
}
if (isCompleted) {
return <CheckCircle className="w-5 h-5 text-emerald-400" />;
}
return <Loader2 className="w-5 h-5 text-rose-400 animate-spin" />;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
"w-[90vw] aspect-[16/9]",
"max-w-[1600px] max-h-[900px]",
"p-0 gap-0 rounded-lg overflow-hidden",
"bg-gradient-to-br from-gray-900 via-red-950/30 to-gray-900 border border-red-900/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"duration-200 shadow-2xl"
)}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
{/* 无障碍访问标题 */}
<VisuallyHidden.Root>
<DialogPrimitive.Title></DialogPrimitive.Title>
<DialogPrimitive.Description>
</DialogPrimitive.Description>
</VisuallyHidden.Root>
{/* 终端头部 */}
<div className="flex items-center justify-between px-4 py-3 bg-gradient-to-r from-red-950/50 to-gray-900/80 border-b border-red-900/30 backdrop-blur-sm">
<div className="flex items-center space-x-3">
<Terminal className="w-5 h-5 text-rose-400" />
<span className="text-sm font-medium text-gray-100"></span>
{getStatusIcon()}
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<div className="w-3 h-3 rounded-full bg-amber-500" />
<button
className="w-3 h-3 rounded-full bg-rose-500 hover:bg-rose-600 cursor-pointer transition-colors focus:outline-none"
onClick={() => onOpenChange(false)}
title="关闭"
aria-label="关闭"
/>
</div>
</div>
{/* 终端内容 */}
<div className="p-6 bg-gradient-to-b from-gray-900/95 to-gray-950/95 overflow-y-auto h-[calc(100%-120px)] font-mono text-sm backdrop-blur-sm">
<div className="space-y-2">
{logs.map((log, index) => (
<div key={index} className="flex items-start space-x-3 hover:bg-red-950/10 px-2 py-1 rounded transition-colors">
<span className="text-rose-800/70 text-xs flex-shrink-0 w-20">
[{log.timestamp}]
</span>
<span className={`${getLogColor(log.type)} flex-1`}>
{log.message}
</span>
</div>
))}
{/* 光标旋转闪烁效果 */}
{!isCompleted && !isFailed && (
<div className="flex items-center space-x-2 mt-4">
<span className="text-rose-800/70 text-xs w-20">[{currentTime}]</span>
<span className="inline-block text-rose-400 animate-spinner font-bold text-base"></span>
</div>
)}
{/* 添加自定义动画 */}
<style>{`
@keyframes spinner {
0% {
content: '|';
opacity: 1;
}
25% {
content: '/';
opacity: 0.8;
}
50% {
content: '—';
opacity: 0.6;
}
75% {
content: '\\\\';
opacity: 0.8;
}
100% {
content: '|';
opacity: 1;
}
}
.animate-spinner::before {
content: '|';
animation: spinner-content 0.8s linear infinite;
}
.animate-spinner {
animation: spinner-opacity 0.8s ease-in-out infinite;
}
@keyframes spinner-content {
0% { content: '|'; }
25% { content: '/'; }
50% { content: '—'; }
75% { content: '\\\\'; }
100% { content: '|'; }
}
@keyframes spinner-opacity {
0%, 100% { opacity: 1; }
25%, 75% { opacity: 0.8; }
50% { opacity: 0.6; }
}
`}</style>
<div ref={logsEndRef} />
</div>
</div>
{/* 底部控制和提示 */}
<div className="px-4 py-3 bg-gradient-to-r from-red-950/50 to-gray-900/80 border-t border-red-900/30 backdrop-blur-sm">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-300">
{isCancelled ? "🛑 任务已取消,已分析的结果已保存" :
isCompleted ? "✅ 任务已完成,可以关闭此窗口" :
isFailed ? "❌ 任务失败,请检查配置后重试" :
"⏳ 审计进行中,请勿关闭窗口,过程可能较慢,请耐心等待......"}
</span>
<div className="flex items-center space-x-2">
{/* 运行中显示取消按钮 */}
{!isCompleted && !isFailed && !isCancelled && (
<Button
size="sm"
variant="outline"
onClick={handleCancel}
className="h-7 text-xs bg-gray-800 border-red-600 text-red-400 hover:bg-red-900 hover:text-red-200"
>
<XIcon className="w-3 h-3 mr-1" />
</Button>
)}
{/* 已完成/失败/取消显示关闭按钮 */}
{(isCompleted || isFailed || isCancelled) && (
<button
onClick={() => onOpenChange(false)}
className="px-4 py-1.5 bg-gradient-to-r from-rose-600 to-red-600 hover:from-rose-500 hover:to-red-500 text-white rounded text-xs transition-all shadow-lg shadow-rose-900/50 font-medium"
>
</button>
)}
</div>
</div>
</div>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}