feat(audit): Add terminal progress dialog for audit tasks
- Implement TerminalProgressDialog component for real-time task tracking - Enhance CreateTaskDialog to launch terminal progress dialog after task creation - Add dynamic log tracking and status polling for audit tasks - Improve user experience by providing immediate feedback during task initialization - Support both repository and zip file audit task types - Implement real-time logging with timestamp and status updates - Add error handling and state management for task progress tracking
This commit is contained in:
parent
b4ab62aae5
commit
68477ebfd4
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { api } from "@/shared/config/database";
|
import { api } from "@/shared/config/database";
|
||||||
import type { Project, CreateAuditTaskForm } from "@/shared/types";
|
import type { Project, CreateAuditTaskForm } from "@/shared/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import TerminalProgressDialog from "./TerminalProgressDialog";
|
||||||
|
|
||||||
interface CreateTaskDialogProps {
|
interface CreateTaskDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -36,6 +37,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
|
||||||
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
|
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
|
||||||
project_id: "",
|
project_id: "",
|
||||||
|
|
@ -98,23 +101,21 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
try {
|
try {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
||||||
await api.createAuditTask({
|
const task = await api.createAuditTask({
|
||||||
...taskForm,
|
...taskForm,
|
||||||
created_by: null // 无登录场景下设置为null
|
created_by: null // 无登录场景下设置为null
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// 显示详细的提示信息
|
const taskId = (task as any).id;
|
||||||
toast.success("审计任务创建成功", {
|
|
||||||
description: '因为网络和代码文件大小等因素,审计时长通常至少需要1分钟,请耐心等待...',
|
|
||||||
duration: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// 关闭创建对话框
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
onTaskCreated();
|
onTaskCreated();
|
||||||
|
|
||||||
// 跳转到项目详情页面
|
// 显示终端进度窗口
|
||||||
navigate(`/projects/${taskForm.project_id}`);
|
setCurrentTaskId(taskId);
|
||||||
|
setShowTerminalDialog(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create task:', error);
|
console.error('Failed to create task:', error);
|
||||||
toast.error("创建任务失败");
|
toast.error("创建任务失败");
|
||||||
|
|
@ -547,6 +548,14 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* 终端进度对话框 */}
|
||||||
|
<TerminalProgressDialog
|
||||||
|
open={showTerminalDialog}
|
||||||
|
onOpenChange={setShowTerminalDialog}
|
||||||
|
taskId={currentTaskId}
|
||||||
|
taskType="repository"
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,461 @@
|
||||||
|
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 } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/utils/utils";
|
||||||
|
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||||
|
|
||||||
|
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 [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 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动滚动到底部
|
||||||
|
useEffect(() => {
|
||||||
|
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
// 实时更新光标处的时间
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || isCompleted || isFailed) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在有变化时显示请求/响应信息
|
||||||
|
if (hasDataChange) {
|
||||||
|
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") {
|
||||||
|
// 任务待处理(只在状态变化时显示)
|
||||||
|
if (statusChanged && logs.filter(l => l.message.includes("等待开始执行")).length === 0) {
|
||||||
|
addLog("⏳ 任务已创建,等待开始执行...", "info");
|
||||||
|
}
|
||||||
|
} 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 = task.total_files > 0
|
||||||
|
? Math.round((task.scanned_files / task.total_files) * 100)
|
||||||
|
: 0;
|
||||||
|
const filesProcessed = task.scanned_files - lastScannedFiles;
|
||||||
|
addLog(
|
||||||
|
`📊 扫描进度: ${task.scanned_files}/${task.total_files} 文件 (${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 === "failed") {
|
||||||
|
// 任务失败
|
||||||
|
if (!isFailed) {
|
||||||
|
addLog("", "info"); // 空行分隔
|
||||||
|
addLog("❌ 审计任务执行失败", "error");
|
||||||
|
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
|
||||||
|
addLog("可能的原因:", "error");
|
||||||
|
addLog(" • 网络连接问题", "error");
|
||||||
|
addLog(" • 仓库访问权限不足", "error");
|
||||||
|
addLog(" • GitHub API 限流", "error");
|
||||||
|
addLog(" • 代码文件格式错误", "error");
|
||||||
|
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
|
||||||
|
addLog("💡 建议: 检查网络连接和仓库配置后重试", "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">
|
||||||
|
{isCompleted ? "✅ 任务已完成,可以关闭此窗口" :
|
||||||
|
isFailed ? "❌ 任务失败,请检查配置后重试" :
|
||||||
|
"⏳ 审计进行中,请勿关闭窗口,过程可能较慢,请耐心等待......"}
|
||||||
|
</span>
|
||||||
|
{(isCompleted || isFailed) && (
|
||||||
|
<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>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -124,137 +124,149 @@ export async function scanZipFile(params: {
|
||||||
|
|
||||||
const taskId = (task as any).id;
|
const taskId = (task as any).id;
|
||||||
|
|
||||||
try {
|
// 启动后台扫描任务,不阻塞返回
|
||||||
// 更新任务状态为运行中
|
(async () => {
|
||||||
await api.updateAuditTask(taskId, {
|
try {
|
||||||
status: "running",
|
// 更新任务状态为运行中
|
||||||
started_at: new Date().toISOString()
|
await api.updateAuditTask(taskId, {
|
||||||
} as any);
|
status: "running",
|
||||||
|
started_at: new Date().toISOString()
|
||||||
|
} as any);
|
||||||
|
|
||||||
// 读取ZIP文件
|
// 读取ZIP文件
|
||||||
const arrayBuffer = await zipFile.arrayBuffer();
|
const arrayBuffer = await zipFile.arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
unzip(uint8Array, async (err, unzipped) => {
|
unzip(uint8Array, async (err, unzipped) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||||
reject(new Error(`ZIP文件解压失败: ${err.message}`));
|
reject(new Error(`ZIP文件解压失败: ${err.message}`));
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 筛选需要分析的文件
|
|
||||||
const filesToAnalyze: Array<{ path: string; content: string }> = [];
|
|
||||||
|
|
||||||
for (const [path, data] of Object.entries(unzipped)) {
|
|
||||||
// 跳过目录
|
|
||||||
if (path.endsWith('/')) continue;
|
|
||||||
|
|
||||||
// 检查文件类型和排除模式
|
|
||||||
if (!isTextFile(path) || shouldExclude(path, excludePatterns)) continue;
|
|
||||||
|
|
||||||
// 检查文件大小
|
|
||||||
if (data.length > MAX_FILE_SIZE_BYTES) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = new TextDecoder('utf-8').decode(data);
|
|
||||||
filesToAnalyze.push({ path, content });
|
|
||||||
} catch (decodeError) {
|
|
||||||
// 跳过无法解码的文件
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制分析文件数量
|
try {
|
||||||
const limitedFiles = filesToAnalyze
|
// 筛选需要分析的文件
|
||||||
.sort((a, b) => a.path.length - b.path.length) // 优先分析路径较短的文件
|
const filesToAnalyze: Array<{ path: string; content: string }> = [];
|
||||||
.slice(0, MAX_ANALYZE_FILES);
|
|
||||||
|
for (const [path, data] of Object.entries(unzipped)) {
|
||||||
let totalFiles = limitedFiles.length;
|
// 跳过目录
|
||||||
let scannedFiles = 0;
|
if (path.endsWith('/')) continue;
|
||||||
let totalLines = 0;
|
|
||||||
let totalIssues = 0;
|
// 检查文件类型和排除模式
|
||||||
let qualityScores: number[] = [];
|
if (!isTextFile(path) || shouldExclude(path, excludePatterns)) continue;
|
||||||
|
|
||||||
// 分析每个文件
|
// 检查文件大小
|
||||||
for (const file of limitedFiles) {
|
if (data.length > MAX_FILE_SIZE_BYTES) continue;
|
||||||
try {
|
|
||||||
const language = getLanguageFromPath(file.path);
|
try {
|
||||||
const lines = file.content.split(/\r?\n/).length;
|
const content = new TextDecoder('utf-8').decode(data);
|
||||||
totalLines += lines;
|
filesToAnalyze.push({ path, content });
|
||||||
|
} catch (decodeError) {
|
||||||
// 使用AI分析代码
|
// 跳过无法解码的文件
|
||||||
const analysis = await CodeAnalysisEngine.analyzeCode(file.content, language);
|
continue;
|
||||||
qualityScores.push(analysis.quality_score);
|
|
||||||
|
|
||||||
// 保存发现的问题
|
|
||||||
for (const issue of analysis.issues) {
|
|
||||||
await api.createAuditIssue({
|
|
||||||
task_id: taskId,
|
|
||||||
file_path: file.path,
|
|
||||||
line_number: issue.line || null,
|
|
||||||
column_number: issue.column || null,
|
|
||||||
issue_type: issue.type || "maintainability",
|
|
||||||
severity: issue.severity || "low",
|
|
||||||
title: issue.title || "Issue",
|
|
||||||
description: issue.description || null,
|
|
||||||
suggestion: issue.suggestion || null,
|
|
||||||
code_snippet: issue.code_snippet || null,
|
|
||||||
ai_explanation: issue.ai_explanation || null,
|
|
||||||
status: "open"
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
totalIssues++;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scannedFiles++;
|
// 限制分析文件数量
|
||||||
|
const limitedFiles = filesToAnalyze
|
||||||
|
.sort((a, b) => a.path.length - b.path.length) // 优先分析路径较短的文件
|
||||||
|
.slice(0, MAX_ANALYZE_FILES);
|
||||||
|
|
||||||
// 每分析10个文件更新一次进度
|
let totalFiles = limitedFiles.length;
|
||||||
if (scannedFiles % 10 === 0) {
|
let scannedFiles = 0;
|
||||||
|
let totalLines = 0;
|
||||||
|
let totalIssues = 0;
|
||||||
|
let qualityScores: number[] = [];
|
||||||
|
|
||||||
|
// 更新总文件数
|
||||||
|
await api.updateAuditTask(taskId, {
|
||||||
|
total_files: totalFiles,
|
||||||
|
scanned_files: 0,
|
||||||
|
total_lines: 0,
|
||||||
|
issues_count: 0
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// 分析每个文件
|
||||||
|
for (const file of limitedFiles) {
|
||||||
|
try {
|
||||||
|
const language = getLanguageFromPath(file.path);
|
||||||
|
const lines = file.content.split(/\r?\n/).length;
|
||||||
|
totalLines += lines;
|
||||||
|
|
||||||
|
// 使用AI分析代码
|
||||||
|
const analysis = await CodeAnalysisEngine.analyzeCode(file.content, language);
|
||||||
|
qualityScores.push(analysis.quality_score);
|
||||||
|
|
||||||
|
// 保存发现的问题
|
||||||
|
for (const issue of analysis.issues) {
|
||||||
|
await api.createAuditIssue({
|
||||||
|
task_id: taskId,
|
||||||
|
file_path: file.path,
|
||||||
|
line_number: issue.line || null,
|
||||||
|
column_number: issue.column || null,
|
||||||
|
issue_type: issue.type || "maintainability",
|
||||||
|
severity: issue.severity || "low",
|
||||||
|
title: issue.title || "Issue",
|
||||||
|
description: issue.description || null,
|
||||||
|
suggestion: issue.suggestion || null,
|
||||||
|
code_snippet: issue.code_snippet || null,
|
||||||
|
ai_explanation: issue.ai_explanation || null,
|
||||||
|
status: "open"
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
totalIssues++;
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedFiles++;
|
||||||
|
|
||||||
|
// 每分析一个文件更新一次进度
|
||||||
await api.updateAuditTask(taskId, {
|
await api.updateAuditTask(taskId, {
|
||||||
total_files: totalFiles,
|
total_files: totalFiles,
|
||||||
scanned_files: scannedFiles,
|
scanned_files: scannedFiles,
|
||||||
total_lines: totalLines,
|
total_lines: totalLines,
|
||||||
issues_count: totalIssues
|
issues_count: totalIssues
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
// 添加延迟避免API限制
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
} catch (analysisError) {
|
||||||
|
console.error(`分析文件 ${file.path} 失败:`, analysisError);
|
||||||
|
// 继续分析其他文件
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加延迟避免API限制
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
} catch (analysisError) {
|
|
||||||
console.error(`分析文件 ${file.path} 失败:`, analysisError);
|
|
||||||
// 继续分析其他文件
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算平均质量分
|
||||||
|
const avgQualityScore = qualityScores.length > 0
|
||||||
|
? qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 更新任务完成状态
|
||||||
|
await api.updateAuditTask(taskId, {
|
||||||
|
status: "completed",
|
||||||
|
total_files: totalFiles,
|
||||||
|
scanned_files: scannedFiles,
|
||||||
|
total_lines: totalLines,
|
||||||
|
issues_count: totalIssues,
|
||||||
|
quality_score: avgQualityScore,
|
||||||
|
completed_at: new Date().toISOString()
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
} catch (processingError) {
|
||||||
|
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||||
|
reject(processingError);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 计算平均质量分
|
|
||||||
const avgQualityScore = qualityScores.length > 0
|
|
||||||
? qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// 更新任务完成状态
|
|
||||||
await api.updateAuditTask(taskId, {
|
|
||||||
status: "completed",
|
|
||||||
total_files: totalFiles,
|
|
||||||
scanned_files: scannedFiles,
|
|
||||||
total_lines: totalLines,
|
|
||||||
issues_count: totalIssues,
|
|
||||||
quality_score: avgQualityScore,
|
|
||||||
completed_at: new Date().toISOString()
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
resolve(taskId);
|
|
||||||
} catch (processingError) {
|
|
||||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
|
||||||
reject(processingError);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('ZIP扫描任务执行失败:', error);
|
||||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||||
throw error;
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
|
// 立即返回任务ID,让用户可以看到进度
|
||||||
|
return taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateZipFile(file: File): { valid: boolean; error?: string } {
|
export function validateZipFile(file: File): { valid: boolean; error?: string } {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
import { useParams, Link } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -23,15 +23,17 @@ import { runRepositoryAudit } from "@/features/projects/services";
|
||||||
import type { Project, AuditTask } from "@/shared/types";
|
import type { Project, AuditTask } from "@/shared/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||||
|
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
export default function ProjectDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [tasks, setTasks] = useState<AuditTask[]>([]);
|
const [tasks, setTasks] = useState<AuditTask[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [showCreateTaskDialog, setShowCreateTaskDialog] = useState(false);
|
const [showCreateTaskDialog, setShowCreateTaskDialog] = useState(false);
|
||||||
|
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
|
||||||
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
@ -78,15 +80,12 @@ export default function ProjectDetail() {
|
||||||
|
|
||||||
console.log('审计任务创建成功,taskId:', taskId);
|
console.log('审计任务创建成功,taskId:', taskId);
|
||||||
|
|
||||||
// 显示详细的提示信息
|
// 显示终端进度窗口
|
||||||
toast.success('审计任务已启动', {
|
setCurrentTaskId(taskId);
|
||||||
description: '因为网络和代码文件大小等因素,审计时长通常至少需要1分钟,请耐心等待...',
|
setShowTerminalDialog(true);
|
||||||
duration: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
// 跳转到任务详情页面
|
// 重新加载项目数据
|
||||||
console.log('准备跳转到:', `/tasks/${taskId}`);
|
loadProjectData();
|
||||||
navigate(`/tasks/${taskId}`);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('启动审计失败:', e);
|
console.error('启动审计失败:', e);
|
||||||
toast.error(e?.message || '启动审计失败');
|
toast.error(e?.message || '启动审计失败');
|
||||||
|
|
@ -485,6 +484,14 @@ export default function ProjectDetail() {
|
||||||
onTaskCreated={handleTaskCreated}
|
onTaskCreated={handleTaskCreated}
|
||||||
preselectedProjectId={id}
|
preselectedProjectId={id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 终端进度对话框 */}
|
||||||
|
<TerminalProgressDialog
|
||||||
|
open={showTerminalDialog}
|
||||||
|
onOpenChange={setShowTerminalDialog}
|
||||||
|
taskId={currentTaskId}
|
||||||
|
taskType="repository"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import type { Project, CreateProjectForm } from "@/shared/types";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||||
|
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
|
@ -41,6 +42,8 @@ export default function Projects() {
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
|
||||||
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
const [createForm, setCreateForm] = useState<CreateProjectForm>({
|
const [createForm, setCreateForm] = useState<CreateProjectForm>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -153,15 +156,14 @@ export default function Projects() {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
|
||||||
toast.success("项目创建并开始分析");
|
// 关闭创建对话框
|
||||||
setShowCreateDialog(false);
|
setShowCreateDialog(false);
|
||||||
resetCreateForm();
|
resetCreateForm();
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
|
||||||
// 跳转到任务详情页
|
// 显示终端进度窗口
|
||||||
setTimeout(() => {
|
setCurrentTaskId(taskId);
|
||||||
window.open(`/tasks/${taskId}`, '_blank');
|
setShowTerminalDialog(true);
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Upload failed:', error);
|
console.error('Upload failed:', error);
|
||||||
|
|
@ -666,6 +668,14 @@ export default function Projects() {
|
||||||
onTaskCreated={handleTaskCreated}
|
onTaskCreated={handleTaskCreated}
|
||||||
preselectedProjectId={selectedProjectForTask}
|
preselectedProjectId={selectedProjectForTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 终端进度对话框 */}
|
||||||
|
<TerminalProgressDialog
|
||||||
|
open={showTerminalDialog}
|
||||||
|
onOpenChange={setShowTerminalDialog}
|
||||||
|
taskId={currentTaskId}
|
||||||
|
taskType="zip"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue