feat(audit): Implement task cancellation and progress tracking
- Add task cancellation functionality to allow users to stop ongoing audit tasks. - Introduce a global task control manager to manage task states and cancellations. - Update CreateTaskDialog and TerminalProgressDialog components to handle ZIP file uploads and repository audits. - Enhance progress tracking in both ZIP and repository scans, ensuring real-time updates. - Modify task status handling to include 'cancelled' state and update UI accordingly. - Refactor utility functions for calculating task progress percentages. - Update relevant components and services to support new task management features.
This commit is contained in:
parent
d9d2f8603f
commit
e07ecd3ce2
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -23,6 +22,8 @@ import { api } from "@/shared/config/database";
|
|||
import type { Project, CreateAuditTaskForm } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import TerminalProgressDialog from "./TerminalProgressDialog";
|
||||
import { runRepositoryAudit } from "@/features/projects/services/repoScan";
|
||||
import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -32,13 +33,13 @@ interface CreateTaskDialogProps {
|
|||
}
|
||||
|
||||
export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, preselectedProjectId }: CreateTaskDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||
|
||||
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
|
||||
project_id: "",
|
||||
|
|
@ -98,15 +99,52 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
return;
|
||||
}
|
||||
|
||||
const project = selectedProject;
|
||||
if (!project) {
|
||||
toast.error("未找到选中的项目");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
|
||||
const task = await api.createAuditTask({
|
||||
...taskForm,
|
||||
created_by: null // 无登录场景下设置为null
|
||||
} as any);
|
||||
console.log('🎯 开始创建审计任务...', {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
repositoryType: project.repository_type
|
||||
});
|
||||
|
||||
let taskId: string;
|
||||
|
||||
// 根据项目是否有repository_url判断使用哪种扫描方式
|
||||
if (!project.repository_url || project.repository_url.trim() === '') {
|
||||
// ZIP上传的项目:需要有ZIP文件才能扫描
|
||||
if (!zipFile) {
|
||||
toast.error("请上传ZIP文件进行扫描");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📦 调用 scanZipFile...');
|
||||
taskId = await scanZipFile({
|
||||
projectId: project.id,
|
||||
zipFile: zipFile,
|
||||
excludePatterns: taskForm.exclude_patterns,
|
||||
createdBy: 'local-user'
|
||||
});
|
||||
} else {
|
||||
// GitHub/GitLab等远程仓库
|
||||
console.log('📡 调用 runRepositoryAudit...');
|
||||
taskId = await runRepositoryAudit({
|
||||
projectId: project.id,
|
||||
repoUrl: project.repository_url!,
|
||||
branch: taskForm.branch_name || project.default_branch || 'main',
|
||||
exclude: taskForm.exclude_patterns,
|
||||
githubToken: undefined,
|
||||
createdBy: 'local-user'
|
||||
});
|
||||
}
|
||||
|
||||
const taskId = (task as any).id;
|
||||
console.log('✅ 任务创建成功:', taskId);
|
||||
|
||||
// 关闭创建对话框
|
||||
onOpenChange(false);
|
||||
|
|
@ -116,9 +154,11 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
// 显示终端进度窗口
|
||||
setCurrentTaskId(taskId);
|
||||
setShowTerminalDialog(true);
|
||||
|
||||
toast.success("审计任务已创建并启动");
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
toast.error("创建任务失败");
|
||||
console.error('❌ 创建任务失败:', error);
|
||||
toast.error("创建任务失败: " + (error as Error).message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
|
|
@ -278,6 +318,71 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-6">
|
||||
{/* ZIP项目文件上传 */}
|
||||
{(!selectedProject.repository_url || selectedProject.repository_url.trim() === '') && (
|
||||
<Card className="bg-amber-50 border-amber-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-900 text-sm">ZIP项目需要上传文件</p>
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
该项目是通过ZIP上传创建的,请重新上传ZIP文件进行扫描
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zipFile">上传ZIP文件</Label>
|
||||
<Input
|
||||
id="zipFile"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
console.log('📁 选择的文件:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
sizeMB: (file.size / 1024 / 1024).toFixed(2)
|
||||
});
|
||||
|
||||
const validation = validateZipFile(file);
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error || "文件无效");
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setZipFile(file);
|
||||
|
||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||
const sizeKB = (file.size / 1024).toFixed(2);
|
||||
const sizeText = file.size >= 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
|
||||
|
||||
toast.success(`已选择文件: ${file.name} (${sizeText})`);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{zipFile && (
|
||||
<p className="text-xs text-green-600">
|
||||
✓ 已选择: {zipFile.name} (
|
||||
{zipFile.size >= 1024 * 1024
|
||||
? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB`
|
||||
: zipFile.size >= 1024
|
||||
? `${(zipFile.size / 1024).toFixed(2)} KB`
|
||||
: `${zipFile.size} B`
|
||||
})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task_type">任务类型</Label>
|
||||
|
|
@ -305,7 +410,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{taskForm.task_type === "repository" && (
|
||||
{taskForm.task_type === "repository" && (selectedProject.repository_url) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch_name">目标分支</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
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 { 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;
|
||||
|
|
@ -27,6 +30,7 @@ export default function TerminalProgressDialog({
|
|||
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);
|
||||
|
|
@ -42,6 +46,31 @@ export default function TerminalProgressDialog({
|
|||
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" });
|
||||
|
|
@ -49,7 +78,7 @@ export default function TerminalProgressDialog({
|
|||
|
||||
// 实时更新光标处的时间
|
||||
useEffect(() => {
|
||||
if (!open || isCompleted || isFailed) {
|
||||
if (!open || isCompleted || isFailed || isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -171,12 +200,10 @@ export default function TerminalProgressDialog({
|
|||
|
||||
// 显示进度更新(仅在有变化时)
|
||||
if (filesChanged && task.scanned_files > lastScannedFiles) {
|
||||
const progress = task.total_files > 0
|
||||
? Math.round((task.scanned_files / task.total_files) * 100)
|
||||
: 0;
|
||||
const progress = calculateTaskProgress(task.scanned_files, task.total_files);
|
||||
const filesProcessed = task.scanned_files - lastScannedFiles;
|
||||
addLog(
|
||||
`📊 扫描进度: ${task.scanned_files}/${task.total_files} 文件 (${progress}%) [+${filesProcessed}]`,
|
||||
`📊 扫描进度: ${task.scanned_files || 0}/${task.total_files || 0} 文件 (${progress}%) [+${filesProcessed}]`,
|
||||
"info"
|
||||
);
|
||||
lastScannedFiles = task.scanned_files;
|
||||
|
|
@ -252,6 +279,25 @@ export default function TerminalProgressDialog({
|
|||
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) {
|
||||
|
|
@ -436,22 +482,40 @@ export default function TerminalProgressDialog({
|
|||
</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 ? "✅ 任务已完成,可以关闭此窗口" :
|
||||
{isCancelled ? "🛑 任务已取消,已分析的结果已保存" :
|
||||
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 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>
|
||||
|
|
|
|||
|
|
@ -80,12 +80,20 @@ export class CodeAnalysisEngine {
|
|||
|
||||
// 根据配置生成不同语言的提示词
|
||||
const systemPrompt = isChineseOutput
|
||||
? `你是一个专业的代码审计助手。
|
||||
? `⚠️⚠️⚠️ 只输出JSON,禁止输出其他任何格式!禁止markdown!禁止文本分析!⚠️⚠️⚠️
|
||||
|
||||
【重要】请严格遵守以下规则:
|
||||
1. 所有文本内容(title、description、suggestion、ai_explanation、xai 等)必须使用简体中文
|
||||
2. 仅输出 JSON 格式,不要添加任何额外的文字、解释或 markdown 标记
|
||||
3. 确保 JSON 格式完全正确,所有字符串值都要正确转义
|
||||
你是一个专业的代码审计助手。你的任务是分析代码并返回严格符合JSON Schema的结果。
|
||||
|
||||
【最重要】输出格式要求:
|
||||
1. 必须只输出纯JSON对象,从{开始,到}结束
|
||||
2. 禁止在JSON前后添加任何文字、说明、markdown标记
|
||||
3. 禁止输出\`\`\`json或###等markdown语法
|
||||
4. 如果是文档文件(如README),也必须以JSON格式输出分析结果
|
||||
|
||||
【内容要求】:
|
||||
1. 所有文本内容必须统一使用简体中文
|
||||
2. JSON字符串值中的特殊字符必须正确转义(换行用\\n,双引号用\\",反斜杠用\\\\)
|
||||
3. code_snippet字段必须使用\\n表示换行
|
||||
|
||||
请从以下维度全面分析代码:
|
||||
- 编码规范和代码风格
|
||||
|
|
@ -103,16 +111,64 @@ ${schema}
|
|||
- title: 问题的简短标题(中文)
|
||||
- description: 详细描述问题(中文)
|
||||
- suggestion: 具体的修复建议(中文)
|
||||
- line: 问题所在的行号(从1开始计数,必须准确对应代码中的行号)
|
||||
- column: 问题所在的列号(从1开始计数,指向问题代码的起始位置)
|
||||
- code_snippet: 包含问题的代码片段(建议包含问题行及其前后1-2行作为上下文,保持原始缩进格式)
|
||||
- ai_explanation: AI 的深入解释(中文)
|
||||
- xai.what: 这是什么问题(中文)
|
||||
- xai.why: 为什么会有这个问题(中文)
|
||||
- xai.how: 如何修复这个问题(中文)`
|
||||
: `You are a professional code auditing assistant.
|
||||
- xai.how: 如何修复这个问题(中文)
|
||||
|
||||
【IMPORTANT】Please strictly follow these rules:
|
||||
1. All text content (title, description, suggestion, ai_explanation, xai, etc.) MUST be in English
|
||||
2. Output ONLY valid JSON format, without any additional text, explanations, or markdown markers
|
||||
3. Ensure the JSON format is completely correct with all string values properly escaped
|
||||
【重要】关于行号和代码片段:
|
||||
1. line 必须是问题代码的行号!!!代码左侧有"行号|"标注,例如"25| const x = 1"表示第25行,line字段必须填25
|
||||
2. column 是问题代码在该行中的起始列位置(从1开始,不包括"行号|"前缀部分)
|
||||
3. code_snippet 应该包含问题代码及其上下文(前后各1-2行),去掉"行号|"前缀,保持原始代码的缩进
|
||||
4. 如果代码片段包含多行,必须使用 \\n 表示换行符(这是JSON的要求)
|
||||
5. 如果无法确定准确的行号,不要填写line和column字段(不要填0)
|
||||
|
||||
【严格禁止】:
|
||||
- 禁止在任何字段中使用英文,所有内容必须是简体中文
|
||||
- 禁止在JSON字符串值中使用真实换行符,必须用\\n转义
|
||||
- 禁止输出markdown代码块标记(如\`\`\`json)
|
||||
|
||||
示例(假设代码中第25行是 "25| config[password] = user_password"):
|
||||
{
|
||||
"issues": [{
|
||||
"type": "security",
|
||||
"severity": "high",
|
||||
"title": "密码明文存储",
|
||||
"description": "密码以明文形式存储在配置文件中",
|
||||
"suggestion": "使用加密算法对密码进行加密存储",
|
||||
"line": 25,
|
||||
"column": 5,
|
||||
"code_snippet": "config[password] = user_password\\nconfig.save()",
|
||||
"ai_explanation": "明文存储密码存在安全风险",
|
||||
"xai": {
|
||||
"what": "密码未加密直接存储",
|
||||
"why": "容易被未授权访问获取",
|
||||
"how": "使用AES等加密算法加密后再存储"
|
||||
}
|
||||
}],
|
||||
"quality_score": 75,
|
||||
"summary": {"total_issues": 1, "critical_issues": 0, "high_issues": 1, "medium_issues": 0, "low_issues": 0},
|
||||
"metrics": {"complexity": 80, "maintainability": 75, "security": 70, "performance": 85}
|
||||
}
|
||||
|
||||
⚠️ 重要提醒:line字段必须从代码左侧的行号标注中读取,不要猜测或填0!`
|
||||
: `⚠️⚠️⚠️ OUTPUT JSON ONLY! NO OTHER FORMAT! NO MARKDOWN! NO TEXT ANALYSIS! ⚠️⚠️⚠️
|
||||
|
||||
You are a professional code auditing assistant. Your task is to analyze code and return results in strict JSON Schema format.
|
||||
|
||||
【MOST IMPORTANT】Output format requirements:
|
||||
1. MUST output pure JSON object only, starting with { and ending with }
|
||||
2. NO text, explanation, or markdown markers before or after JSON
|
||||
3. NO \`\`\`json or ### markdown syntax
|
||||
4. Even for document files (like README), output analysis in JSON format
|
||||
|
||||
【Content requirements】:
|
||||
1. All text content MUST be in English ONLY
|
||||
2. Special characters in JSON strings must be properly escaped (\\n for newlines, \\" for quotes, \\\\ for backslashes)
|
||||
3. code_snippet field MUST use \\n for newlines
|
||||
|
||||
Please comprehensively analyze the code from the following dimensions:
|
||||
- Coding standards and code style
|
||||
|
|
@ -130,14 +186,69 @@ Note:
|
|||
- title: Brief title of the issue (in English)
|
||||
- description: Detailed description of the issue (in English)
|
||||
- suggestion: Specific fix suggestions (in English)
|
||||
- line: Line number where the issue occurs (1-indexed, must accurately correspond to the line in the code)
|
||||
- column: Column number where the issue starts (1-indexed, pointing to the start position of the problematic code)
|
||||
- code_snippet: Code snippet containing the issue (should include the problem line plus 1-2 lines before and after for context, preserve original indentation)
|
||||
- ai_explanation: AI's in-depth explanation (in English)
|
||||
- xai.what: What is this issue (in English)
|
||||
- xai.why: Why does this issue exist (in English)
|
||||
- xai.how: How to fix this issue (in English)`;
|
||||
- xai.how: How to fix this issue (in English)
|
||||
|
||||
【IMPORTANT】About line numbers and code snippets:
|
||||
1. 'line' MUST be the line number from code!!! Code has "lineNumber|" prefix, e.g. "25| const x = 1" means line 25, you MUST set line to 25
|
||||
2. 'column' is the starting column position in that line (1-indexed, excluding the "lineNumber|" prefix)
|
||||
3. 'code_snippet' should include the problematic code with context (1-2 lines before/after), remove "lineNumber|" prefix, preserve indentation
|
||||
4. If code snippet has multiple lines, use \\n for newlines (JSON requirement)
|
||||
5. If you cannot determine the exact line number, do NOT fill line and column fields (don't use 0)
|
||||
|
||||
【STRICTLY PROHIBITED】:
|
||||
- NO Chinese characters in any field - English ONLY
|
||||
- NO real newline characters in JSON string values - must use \\n
|
||||
- NO markdown code block markers (like \`\`\`json)
|
||||
|
||||
Example (assuming line 25 in code is "25| config[password] = user_password"):
|
||||
{
|
||||
"issues": [{
|
||||
"type": "security",
|
||||
"severity": "high",
|
||||
"title": "Plain text password storage",
|
||||
"description": "Password is stored in plain text in config file",
|
||||
"suggestion": "Use encryption algorithm to encrypt password before storage",
|
||||
"line": 25,
|
||||
"column": 5,
|
||||
"code_snippet": "config[password] = user_password\\nconfig.save()",
|
||||
"ai_explanation": "Storing passwords in plain text poses security risks",
|
||||
"xai": {
|
||||
"what": "Password stored without encryption",
|
||||
"why": "Easy to access by unauthorized users",
|
||||
"how": "Use AES or similar encryption before storing"
|
||||
}
|
||||
}],
|
||||
"quality_score": 75,
|
||||
"summary": {"total_issues": 1, "critical_issues": 0, "high_issues": 1, "medium_issues": 0, "low_issues": 0},
|
||||
"metrics": {"complexity": 80, "maintainability": 75, "security": 70, "performance": 85}
|
||||
}
|
||||
|
||||
⚠️ CRITICAL: Read line numbers from the "lineNumber|" prefix on the left of each code line. Do NOT guess or use 0!`;
|
||||
|
||||
// 为代码添加行号,帮助LLM准确定位问题
|
||||
const codeWithLineNumbers = code.split('\n').map((line, idx) => `${idx + 1}| ${line}`).join('\n');
|
||||
|
||||
const userPrompt = isChineseOutput
|
||||
? `编程语言: ${language}\n\n请分析以下代码:\n\n${code}`
|
||||
: `Programming Language: ${language}\n\nPlease analyze the following code:\n\n${code}`;
|
||||
? `编程语言: ${language}
|
||||
|
||||
⚠️ 代码已标注行号(格式:行号| 代码内容),请根据行号准确填写 line 字段!
|
||||
|
||||
请分析以下代码:
|
||||
|
||||
${codeWithLineNumbers}`
|
||||
: `Programming Language: ${language}
|
||||
|
||||
⚠️ Code is annotated with line numbers (format: lineNumber| code), please fill the 'line' field accurately based on these numbers!
|
||||
|
||||
Please analyze the following code:
|
||||
|
||||
${codeWithLineNumbers}`;
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
|
|
@ -228,6 +339,46 @@ Note:
|
|||
});
|
||||
|
||||
const issues = Array.isArray(parsed?.issues) ? parsed.issues : [];
|
||||
|
||||
// 规范化issues,确保数据格式正确
|
||||
issues.forEach((issue: any, index: number) => {
|
||||
// 验证行号和列号的合理性
|
||||
if (issue.line !== undefined) {
|
||||
const originalLine = issue.line;
|
||||
const parsedLine = parseInt(issue.line);
|
||||
// 如果行号是0或无效值,设置为undefined而不是1(表示未知位置)
|
||||
if (isNaN(parsedLine) || parsedLine <= 0) {
|
||||
console.warn(`⚠️ 问题 #${index + 1} "${issue.title}" 的行号无效: ${originalLine},已设置为 undefined`);
|
||||
issue.line = undefined;
|
||||
} else {
|
||||
issue.line = parsedLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (issue.column !== undefined) {
|
||||
const originalColumn = issue.column;
|
||||
const parsedColumn = parseInt(issue.column);
|
||||
// 如果列号是0或无效值,设置为undefined而不是1
|
||||
if (isNaN(parsedColumn) || parsedColumn <= 0) {
|
||||
console.warn(`⚠️ 问题 #${index + 1} "${issue.title}" 的列号无效: ${originalColumn},已设置为 undefined`);
|
||||
issue.column = undefined;
|
||||
} else {
|
||||
issue.column = parsedColumn;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保所有文本字段都存在且是字符串类型
|
||||
const textFields = ['title', 'description', 'suggestion', 'ai_explanation'];
|
||||
textFields.forEach(field => {
|
||||
if (issue[field] && typeof issue[field] !== 'string') {
|
||||
issue[field] = String(issue[field]);
|
||||
}
|
||||
});
|
||||
|
||||
// code_snippet已经由JSON.parse正确处理,不需要额外处理
|
||||
// JSON.parse会自动将\\n转换为真实的换行符,这正是我们想要的
|
||||
});
|
||||
|
||||
const metrics = parsed?.metrics ?? this.estimateMetricsFromIssues(issues);
|
||||
const qualityScore = parsed?.quality_score ?? this.calculateQualityScore(metrics, issues);
|
||||
|
||||
|
|
@ -279,61 +430,124 @@ Note:
|
|||
.replace(/^\uFEFF/, '')
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||
|
||||
// 更激进的字符串值清理
|
||||
// 使用更宽松的正则来匹配字符串值,包括可能包含未转义引号的情况
|
||||
cleaned = cleaned.replace(/"([^"]+)":\s*"([^"]*)"/gs, (match, key, value) => {
|
||||
// 转义所有特殊字符
|
||||
let escaped = value
|
||||
// 移除或替换所有控制字符(包括换行、制表符等)
|
||||
.replace(/[\x00-\x1F\x7F-\x9F]/g, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 0x0A) return '\\n'; // \n
|
||||
if (code === 0x0D) return '\\r'; // \r
|
||||
if (code === 0x09) return '\\t'; // \t
|
||||
return ''; // 移除其他控制字符
|
||||
})
|
||||
// 转义反斜杠(必须在其他转义之前)
|
||||
.replace(/\\/g, '\\\\')
|
||||
// 转义双引号
|
||||
.replace(/"/g, '\\"')
|
||||
// 移除中文引号和其他可能导致问题的字符
|
||||
.replace(/[""'']/g, '')
|
||||
// 确保没有未闭合的转义序列
|
||||
.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
|
||||
// 使用状态机智能处理JSON字符串值中的控制字符
|
||||
// 这种方法可以正确处理包含换行符、引号等特殊字符的多行字符串
|
||||
let result = '';
|
||||
let inString = false;
|
||||
let isKey = false; // 是否在处理键名
|
||||
let prevChar = '';
|
||||
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const char = cleaned[i];
|
||||
const nextChar = cleaned[i + 1] || '';
|
||||
|
||||
// 检测字符串的开始和结束(检查前一个字符不是未转义的反斜杠)
|
||||
if (char === '"' && prevChar !== '\\') {
|
||||
if (!inString) {
|
||||
// 字符串开始 - 判断是键还是值
|
||||
// 简单判断:如果前面有冒号,则是值,否则是键
|
||||
const beforeQuote = result.slice(Math.max(0, result.length - 10));
|
||||
isKey = !beforeQuote.includes(':') || beforeQuote.lastIndexOf(':') < beforeQuote.lastIndexOf('{') || beforeQuote.lastIndexOf(':') < beforeQuote.lastIndexOf(',');
|
||||
}
|
||||
inString = !inString;
|
||||
result += char;
|
||||
prevChar = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 在字符串值内部(非键名)处理特殊字符
|
||||
if (inString && !isKey) {
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
// 转义控制字符
|
||||
if (code === 0x0A) { // 换行符
|
||||
result += '\\n';
|
||||
prevChar = 'n'; // 防止被识别为转义符
|
||||
continue;
|
||||
} else if (code === 0x0D) { // 回车符
|
||||
result += '\\r';
|
||||
prevChar = 'r';
|
||||
continue;
|
||||
} else if (code === 0x09) { // 制表符
|
||||
result += '\\t';
|
||||
prevChar = 't';
|
||||
continue;
|
||||
} else if (code < 0x20 || (code >= 0x7F && code <= 0x9F)) {
|
||||
// 其他控制字符:移除
|
||||
prevChar = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理反斜杠
|
||||
if (char === '\\' && nextChar && '"\\/bfnrtu'.indexOf(nextChar) === -1) {
|
||||
// 无效的转义序列,转义反斜杠本身
|
||||
result += '\\\\';
|
||||
prevChar = '\\';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 移除中文引号(使用Unicode编码避免语法错误)
|
||||
const charCode = char.charCodeAt(0);
|
||||
if (charCode === 0x201C || charCode === 0x201D || charCode === 0x2018 || charCode === 0x2019) {
|
||||
prevChar = char;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认情况:保持字符不变
|
||||
result += char;
|
||||
prevChar = char;
|
||||
}
|
||||
|
||||
return `"${key}": "${escaped}"`;
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
return result;
|
||||
};
|
||||
|
||||
// 尝试多种方式解析
|
||||
const attempts = [
|
||||
// 1. 直接清理和修复
|
||||
// 1. 直接解析原始响应(如果LLM输出格式完美)
|
||||
() => {
|
||||
return JSON.parse(text);
|
||||
},
|
||||
// 2. 清理后再解析
|
||||
() => {
|
||||
const cleaned = cleanText(text);
|
||||
const fixed = fixJsonFormat(cleaned);
|
||||
return JSON.parse(fixed);
|
||||
},
|
||||
// 2. 提取 JSON 对象(贪婪匹配,找到第一个完整的 JSON)
|
||||
// 3. 提取 JSON 对象(智能匹配,处理字符串中的花括号)
|
||||
() => {
|
||||
const cleaned = cleanText(text);
|
||||
// 找到第一个 { 的位置
|
||||
const startIdx = cleaned.indexOf('{');
|
||||
if (startIdx === -1) throw new Error('No JSON object found');
|
||||
|
||||
// 从第一个 { 开始,找到匹配的 }
|
||||
// 从第一个 { 开始,找到匹配的 },需要考虑字符串中的引号
|
||||
let braceCount = 0;
|
||||
let endIdx = -1;
|
||||
let inString = false;
|
||||
let prevChar = '';
|
||||
|
||||
for (let i = startIdx; i < cleaned.length; i++) {
|
||||
if (cleaned[i] === '{') braceCount++;
|
||||
if (cleaned[i] === '}') {
|
||||
braceCount--;
|
||||
if (braceCount === 0) {
|
||||
endIdx = i + 1;
|
||||
break;
|
||||
const char = cleaned[i];
|
||||
|
||||
// 检测字符串边界(排除转义的引号)
|
||||
if (char === '"' && prevChar !== '\\') {
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
// 只在字符串外部统计花括号
|
||||
if (!inString) {
|
||||
if (char === '{') braceCount++;
|
||||
if (char === '}') {
|
||||
braceCount--;
|
||||
if (braceCount === 0) {
|
||||
endIdx = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevChar = char;
|
||||
}
|
||||
|
||||
if (endIdx === -1) throw new Error('Incomplete JSON object');
|
||||
|
|
@ -342,7 +556,7 @@ Note:
|
|||
const fixed = fixJsonFormat(jsonStr);
|
||||
return JSON.parse(fixed);
|
||||
},
|
||||
// 3. 去除 markdown 代码块
|
||||
// 4. 去除 markdown 代码块
|
||||
() => {
|
||||
const cleaned = cleanText(text);
|
||||
const codeBlockMatch = cleaned.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
|
||||
|
|
@ -352,7 +566,7 @@ Note:
|
|||
}
|
||||
throw new Error('No code block found');
|
||||
},
|
||||
// 4. 尝试修复截断的 JSON
|
||||
// 5. 尝试修复截断的 JSON
|
||||
() => {
|
||||
const cleaned = cleanText(text);
|
||||
const startIdx = cleaned.indexOf('{');
|
||||
|
|
@ -377,12 +591,18 @@ Note:
|
|||
let lastError: any = null;
|
||||
for (let i = 0; i < attempts.length; i++) {
|
||||
try {
|
||||
return attempts[i]();
|
||||
const result = attempts[i]();
|
||||
if (i > 0) {
|
||||
console.log(`✅ JSON解析成功(方法 ${i + 1}/${attempts.length})`);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (i === 1) {
|
||||
console.warn('提取 JSON 对象后解析失败:', e);
|
||||
if (i === 0) {
|
||||
console.warn('直接解析失败,尝试清理后解析...', e);
|
||||
} else if (i === 2) {
|
||||
console.warn('提取 JSON 对象后解析失败:', e);
|
||||
} else if (i === 3) {
|
||||
console.warn('从代码块提取 JSON 失败:', e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { api } from "@/shared/config/database";
|
||||
import { CodeAnalysisEngine } from "@/features/analysis/services";
|
||||
import { taskControl } from "@/shared/services/taskControl";
|
||||
|
||||
type GithubTreeItem = { path: string; type: "blob" | "tree"; size?: number; url: string; sha: string };
|
||||
|
||||
const TEXT_EXTENSIONS = [
|
||||
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c", ".h", ".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml", ".md"
|
||||
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c", ".h", ".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml"
|
||||
// 注意:已移除 .md,因为文档文件会导致LLM返回非JSON格式
|
||||
];
|
||||
const MAX_FILE_SIZE_BYTES = 200 * 1024;
|
||||
const MAX_ANALYZE_FILES = Number(import.meta.env.VITE_MAX_ANALYZE_FILES || 40);
|
||||
|
|
@ -42,14 +44,32 @@ export async function runRepositoryAudit(params: {
|
|||
branch_name: branch,
|
||||
exclude_patterns: excludes,
|
||||
scan_config: {},
|
||||
created_by: params.createdBy
|
||||
created_by: params.createdBy,
|
||||
total_files: 0,
|
||||
scanned_files: 0,
|
||||
total_lines: 0,
|
||||
issues_count: 0,
|
||||
quality_score: 0
|
||||
} as any);
|
||||
|
||||
const taskId = (task as any).id as string;
|
||||
|
||||
console.log(`🚀 GitHub任务已创建: ${taskId},准备启动后台扫描...`);
|
||||
|
||||
// 启动后台审计任务,不阻塞返回
|
||||
(async () => {
|
||||
console.log(`🎬 后台扫描任务开始执行: ${taskId}`);
|
||||
try {
|
||||
// 更新任务状态为运行中
|
||||
console.log(`📋 任务 ${taskId}: 开始更新状态为 running`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
total_files: 0,
|
||||
scanned_files: 0
|
||||
} as any);
|
||||
console.log(`✅ 任务 ${taskId}: 状态已更新为 running`);
|
||||
|
||||
const m = params.repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
|
||||
if (!m) throw new Error("仅支持 GitHub 仓库 URL,例如 https://github.com/owner/repo");
|
||||
const owner = m[1];
|
||||
|
|
@ -63,12 +83,27 @@ export async function runRepositoryAudit(params: {
|
|||
.sort((a, b) => (a.path.length - b.path.length))
|
||||
.slice(0, MAX_ANALYZE_FILES);
|
||||
|
||||
// 初始化进度,设置总文件数
|
||||
console.log(`📊 任务 ${taskId}: 设置总文件数 ${files.length}`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
total_files: files.length,
|
||||
scanned_files: 0
|
||||
} as any);
|
||||
|
||||
let totalFiles = 0, totalLines = 0, createdIssues = 0;
|
||||
let index = 0;
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const current = index++;
|
||||
if (current >= files.length) break;
|
||||
|
||||
// ✓ 检查点1:分析文件前检查是否取消
|
||||
if (taskControl.isCancelled(taskId)) {
|
||||
console.log(`🛑 [检查点1] 任务 ${taskId} 已被用户取消,停止分析(在文件 ${current}/${files.length} 前)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const f = files[current];
|
||||
totalFiles++;
|
||||
try {
|
||||
|
|
@ -80,6 +115,13 @@ export async function runRepositoryAudit(params: {
|
|||
totalLines += content.split(/\r?\n/).length;
|
||||
const language = (f.path.split(".").pop() || "").toLowerCase();
|
||||
const analysis = await CodeAnalysisEngine.analyzeCode(content, language);
|
||||
|
||||
// ✓ 检查点2:LLM分析完成后检查是否取消(最小化浪费)
|
||||
if (taskControl.isCancelled(taskId)) {
|
||||
console.log(`🛑 [检查点2] 任务 ${taskId} 在LLM分析完成后检测到取消,跳过保存结果(文件: ${f.path})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = analysis.issues || [];
|
||||
createdIssues += issues.length;
|
||||
for (const issue of issues) {
|
||||
|
|
@ -100,10 +142,19 @@ export async function runRepositoryAudit(params: {
|
|||
resolved_at: null
|
||||
} as any);
|
||||
}
|
||||
if (totalFiles % 10 === 0) {
|
||||
await api.updateAuditTask(taskId, { status: "running", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues } as any);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 每分析一个文件都更新进度,确保实时性
|
||||
console.log(`📈 GitHub任务 ${taskId}: 进度 ${totalFiles}/${files.length} (${Math.round(totalFiles/files.length*100)}%)`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
total_files: files.length,
|
||||
scanned_files: totalFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: createdIssues
|
||||
} as any);
|
||||
} catch (fileError) {
|
||||
console.error(`分析文件失败:`, fileError);
|
||||
}
|
||||
await new Promise(r=>setTimeout(r, LLM_GAP_MS));
|
||||
}
|
||||
};
|
||||
|
|
@ -111,13 +162,49 @@ export async function runRepositoryAudit(params: {
|
|||
const pool = Array.from({ length: Math.min(LLM_CONCURRENCY, files.length) }, () => worker());
|
||||
await Promise.all(pool);
|
||||
|
||||
await api.updateAuditTask(taskId, { status: "completed", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues, quality_score: 0 } as any);
|
||||
} catch (e) {
|
||||
console.error('审计任务执行失败:', e);
|
||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||
}
|
||||
})();
|
||||
// 再次检查是否被取消
|
||||
if (taskControl.isCancelled(taskId)) {
|
||||
console.log(`🛑 任务 ${taskId} 扫描结束时检测到取消状态`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "cancelled",
|
||||
total_files: files.length,
|
||||
scanned_files: totalFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: createdIssues,
|
||||
completed_at: new Date().toISOString()
|
||||
} as any);
|
||||
taskControl.cleanupTask(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算质量评分(如果没有问题则100分,否则根据问题数量递减)
|
||||
const qualityScore = createdIssues === 0 ? 100 : Math.max(0, 100 - createdIssues * 2);
|
||||
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "completed",
|
||||
total_files: files.length,
|
||||
scanned_files: totalFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: createdIssues,
|
||||
quality_score: qualityScore,
|
||||
completed_at: new Date().toISOString()
|
||||
} as any);
|
||||
|
||||
taskControl.cleanupTask(taskId);
|
||||
} catch (e) {
|
||||
console.error('❌ GitHub审计任务执行失败:', e);
|
||||
console.error('错误详情:', e);
|
||||
try {
|
||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||
} catch (updateError) {
|
||||
console.error('更新失败状态也失败了:', updateError);
|
||||
}
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('⚠️ GitHub后台任务未捕获的错误:', err);
|
||||
});
|
||||
|
||||
console.log(`✅ 返回任务ID: ${taskId},后台任务正在执行中...`);
|
||||
// 立即返回任务ID,让用户可以跳转到任务详情页面
|
||||
return taskId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { unzip } from "fflate";
|
||||
import { CodeAnalysisEngine } from "@/features/analysis/services";
|
||||
import { api } from "@/shared/config/database";
|
||||
import { taskControl } from "@/shared/services/taskControl";
|
||||
|
||||
const TEXT_EXTENSIONS = [
|
||||
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c", ".h",
|
||||
".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml", ".md"
|
||||
".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml"
|
||||
// 注意:已移除 .md,因为文档文件会导致LLM返回非JSON格式
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 200 * 1024; // 200KB
|
||||
const MAX_ANALYZE_FILES = 50;
|
||||
|
||||
// 从环境变量读取配置,豆包等API需要更长的延迟
|
||||
const LLM_GAP_MS = Number(import.meta.env.VITE_LLM_GAP_MS) || 2000; // 默认2秒,避免API限流
|
||||
|
||||
function isTextFile(path: string): boolean {
|
||||
return TEXT_EXTENSIONS.some(ext => path.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
|
@ -112,26 +117,38 @@ export async function scanZipFile(params: {
|
|||
}): Promise<string> {
|
||||
const { projectId, zipFile, excludePatterns = [], createdBy } = params;
|
||||
|
||||
// 创建审计任务
|
||||
// 创建审计任务,初始化进度字段
|
||||
const task = await api.createAuditTask({
|
||||
project_id: projectId,
|
||||
task_type: "repository",
|
||||
branch_name: "uploaded",
|
||||
exclude_patterns: excludePatterns,
|
||||
scan_config: { source: "zip_upload" },
|
||||
created_by: createdBy
|
||||
created_by: createdBy,
|
||||
total_files: 0,
|
||||
scanned_files: 0,
|
||||
total_lines: 0,
|
||||
issues_count: 0,
|
||||
quality_score: 0
|
||||
} as any);
|
||||
|
||||
const taskId = (task as any).id;
|
||||
|
||||
console.log(`🚀 ZIP任务已创建: ${taskId},准备启动后台扫描...`);
|
||||
|
||||
// 启动后台扫描任务,不阻塞返回
|
||||
(async () => {
|
||||
console.log(`🎬 后台扫描任务开始执行: ${taskId}`);
|
||||
try {
|
||||
// 更新任务状态为运行中
|
||||
console.log(`📋 ZIP任务 ${taskId}: 开始更新状态为 running`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString()
|
||||
started_at: new Date().toISOString(),
|
||||
total_files: 0,
|
||||
scanned_files: 0
|
||||
} as any);
|
||||
console.log(`✅ ZIP任务 ${taskId}: 状态已更新为 running`);
|
||||
|
||||
// 读取ZIP文件
|
||||
const arrayBuffer = await zipFile.arrayBuffer();
|
||||
|
|
@ -178,9 +195,12 @@ export async function scanZipFile(params: {
|
|||
let totalLines = 0;
|
||||
let totalIssues = 0;
|
||||
let qualityScores: number[] = [];
|
||||
let failedFiles = 0;
|
||||
|
||||
// 更新总文件数
|
||||
console.log(`📊 ZIP任务 ${taskId}: 设置总文件数 ${totalFiles}`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
total_files: totalFiles,
|
||||
scanned_files: 0,
|
||||
total_lines: 0,
|
||||
|
|
@ -189,6 +209,22 @@ export async function scanZipFile(params: {
|
|||
|
||||
// 分析每个文件
|
||||
for (const file of limitedFiles) {
|
||||
// ✓ 检查点1:分析文件前检查是否取消
|
||||
if (taskControl.isCancelled(taskId)) {
|
||||
console.log(`🛑 [检查点1] 任务 ${taskId} 已被用户取消(${scannedFiles}/${totalFiles} 完成),停止分析`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "cancelled",
|
||||
total_files: totalFiles,
|
||||
scanned_files: scannedFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: totalIssues,
|
||||
completed_at: new Date().toISOString()
|
||||
} as any);
|
||||
taskControl.cleanupTask(taskId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const language = getLanguageFromPath(file.path);
|
||||
const lines = file.content.split(/\r?\n/).length;
|
||||
|
|
@ -196,6 +232,23 @@ export async function scanZipFile(params: {
|
|||
|
||||
// 使用AI分析代码
|
||||
const analysis = await CodeAnalysisEngine.analyzeCode(file.content, language);
|
||||
|
||||
// ✓ 检查点2:LLM分析完成后检查是否取消(最小化浪费)
|
||||
if (taskControl.isCancelled(taskId)) {
|
||||
console.log(`🛑 [检查点2] 任务 ${taskId} 在LLM分析完成后检测到取消,跳过保存结果(文件: ${file.path})`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "cancelled",
|
||||
total_files: totalFiles,
|
||||
scanned_files: scannedFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: totalIssues,
|
||||
completed_at: new Date().toISOString()
|
||||
} as any);
|
||||
taskControl.cleanupTask(taskId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
qualityScores.push(analysis.quality_score);
|
||||
|
||||
// 保存发现的问题
|
||||
|
|
@ -220,19 +273,45 @@ export async function scanZipFile(params: {
|
|||
|
||||
scannedFiles++;
|
||||
|
||||
// 每分析一个文件更新一次进度
|
||||
// 每分析一个文件都更新进度,确保实时性
|
||||
console.log(`📈 ZIP任务 ${taskId}: 进度 ${scannedFiles}/${totalFiles} (${Math.round(scannedFiles/totalFiles*100)}%)`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
total_files: totalFiles,
|
||||
scanned_files: scannedFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: totalIssues
|
||||
} as any);
|
||||
|
||||
// 添加延迟避免API限制
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// 添加延迟避免API限制(已分析成功,正常延迟)
|
||||
await new Promise(resolve => setTimeout(resolve, LLM_GAP_MS));
|
||||
} catch (analysisError) {
|
||||
console.error(`分析文件 ${file.path} 失败:`, analysisError);
|
||||
// 继续分析其他文件
|
||||
failedFiles++;
|
||||
scannedFiles++; // 即使失败也要增加计数
|
||||
|
||||
console.error(`❌ 分析文件 ${file.path} 失败 (${failedFiles}/${scannedFiles}):`, analysisError);
|
||||
|
||||
// 如果是API频率限制错误,增加较长延迟
|
||||
const errorMsg = (analysisError as Error).message || '';
|
||||
if (errorMsg.includes('频率超限') || errorMsg.includes('429') || errorMsg.includes('Too Many Requests')) {
|
||||
// 检测到限流,逐步增加延迟时间
|
||||
const waitTime = Math.min(60000, 10000 + failedFiles * 5000); // 10秒起步,每次失败增加5秒,最多60秒
|
||||
console.warn(`⏳ API频率限制!等待${waitTime/1000}秒后继续... (已失败: ${failedFiles}次)`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
} else {
|
||||
// 其他错误,等待较短时间
|
||||
await new Promise(resolve => setTimeout(resolve, LLM_GAP_MS));
|
||||
}
|
||||
|
||||
// 更新进度(即使失败也要显示进度)
|
||||
console.log(`📈 ZIP任务 ${taskId}: 进度 ${scannedFiles}/${totalFiles} (${Math.round(scannedFiles/totalFiles*100)}%) - 失败: ${failedFiles}`);
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "running",
|
||||
total_files: totalFiles,
|
||||
scanned_files: scannedFiles,
|
||||
total_lines: totalLines,
|
||||
issues_count: totalIssues
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,9 +320,19 @@ export async function scanZipFile(params: {
|
|||
? qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length
|
||||
: 0;
|
||||
|
||||
// 判断任务完成状态
|
||||
const successRate = totalFiles > 0 ? ((scannedFiles - failedFiles) / totalFiles) * 100 : 0;
|
||||
const taskStatus = failedFiles >= totalFiles ? "failed" : "completed";
|
||||
|
||||
console.log(`📊 扫描完成统计: 总计${totalFiles}个文件, 成功${scannedFiles - failedFiles}个, 失败${failedFiles}个, 成功率${successRate.toFixed(1)}%`);
|
||||
|
||||
if (failedFiles > 0 && failedFiles < totalFiles) {
|
||||
console.warn(`⚠️ 部分文件分析失败,但任务标记为完成。建议检查.env配置或更换LLM提供商`);
|
||||
}
|
||||
|
||||
// 更新任务完成状态
|
||||
await api.updateAuditTask(taskId, {
|
||||
status: "completed",
|
||||
status: taskStatus,
|
||||
total_files: totalFiles,
|
||||
scanned_files: scannedFiles,
|
||||
total_lines: totalLines,
|
||||
|
|
@ -260,11 +349,19 @@ export async function scanZipFile(params: {
|
|||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('ZIP扫描任务执行失败:', error);
|
||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||
console.error('❌ ZIP扫描任务执行失败:', error);
|
||||
console.error('错误详情:', error);
|
||||
try {
|
||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||
} catch (updateError) {
|
||||
console.error('更新失败状态也失败了:', updateError);
|
||||
}
|
||||
}
|
||||
})();
|
||||
})().catch(err => {
|
||||
console.error('⚠️ 后台任务未捕获的错误:', err);
|
||||
});
|
||||
|
||||
console.log(`✅ 返回任务ID: ${taskId},后台任务正在执行中...`);
|
||||
// 立即返回任务ID,让用户可以看到进度
|
||||
return taskId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type { AuditTask } from "@/shared/types";
|
|||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||
import { calculateTaskProgress } from "@/shared/utils/utils";
|
||||
|
||||
export default function AuditTasks() {
|
||||
const [tasks, setTasks] = useState<AuditTask[]>([]);
|
||||
|
|
@ -30,6 +31,44 @@ export default function AuditTasks() {
|
|||
loadTasks();
|
||||
}, []);
|
||||
|
||||
// 静默更新活动任务的进度(不触发loading状态)
|
||||
useEffect(() => {
|
||||
const activeTasks = tasks.filter(
|
||||
task => task.status === 'running' || task.status === 'pending'
|
||||
);
|
||||
|
||||
if (activeTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
// 只获取活动任务的最新数据
|
||||
const updatedData = await api.getAuditTasks();
|
||||
|
||||
// 使用函数式更新,确保基于最新状态
|
||||
setTasks(prevTasks => {
|
||||
return prevTasks.map(prevTask => {
|
||||
const updated = updatedData.find(t => t.id === prevTask.id);
|
||||
// 只有在进度、状态或问题数真正变化时才更新
|
||||
if (updated && (
|
||||
updated.status !== prevTask.status ||
|
||||
updated.scanned_files !== prevTask.scanned_files ||
|
||||
updated.issues_count !== prevTask.issues_count
|
||||
)) {
|
||||
return updated;
|
||||
}
|
||||
return prevTask;
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('静默更新任务列表失败:', error);
|
||||
}
|
||||
}, 3000); // 每3秒静默更新一次
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [tasks.map(t => t.id + t.status).join(',')]);
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
|
@ -48,6 +87,7 @@ export default function AuditTasks() {
|
|||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'running': return 'bg-red-50 text-red-800';
|
||||
case 'failed': return 'bg-red-100 text-red-900';
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
|
@ -57,6 +97,7 @@ export default function AuditTasks() {
|
|||
case 'completed': return <CheckCircle className="w-4 h-4" />;
|
||||
case 'running': return <Activity className="w-4 h-4" />;
|
||||
case 'failed': return <AlertTriangle className="w-4 h-4" />;
|
||||
case 'cancelled': return <Clock className="w-4 h-4" />;
|
||||
default: return <Clock className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
|
@ -233,7 +274,8 @@ export default function AuditTasks() {
|
|||
<Badge className={getStatusColor(task.status)}>
|
||||
{task.status === 'completed' ? '已完成' :
|
||||
task.status === 'running' ? '运行中' :
|
||||
task.status === 'failed' ? '失败' : '等待中'}
|
||||
task.status === 'failed' ? '失败' :
|
||||
task.status === 'cancelled' ? '已取消' : '等待中'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
|
@ -261,18 +303,18 @@ export default function AuditTasks() {
|
|||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">扫描进度</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{task.scanned_files} / {task.total_files} 文件
|
||||
{task.scanned_files || 0} / {task.total_files || 0} 文件
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-primary to-accent h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.round((task.scanned_files / task.total_files) * 100)}%` }}
|
||||
style={{ width: `${calculateTaskProgress(task.scanned_files, task.total_files)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-right mt-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
{Math.round((task.scanned_files / task.total_files) * 100)}% 完成
|
||||
{calculateTaskProgress(task.scanned_files, task.total_files)}% 完成
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ import {
|
|||
TrendingUp,
|
||||
Upload,
|
||||
Zap,
|
||||
X
|
||||
X,
|
||||
Download
|
||||
} from "lucide-react";
|
||||
import { CodeAnalysisEngine } from "@/features/analysis/services";
|
||||
import { api } from "@/shared/config/database";
|
||||
import type { CodeAnalysisResult } from "@/shared/types";
|
||||
import type { CodeAnalysisResult, AuditTask, AuditIssue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import ExportReportDialog from "@/components/reports/ExportReportDialog";
|
||||
|
||||
// AI解释解析函数
|
||||
function parseAIExplanation(aiExplanation: string) {
|
||||
|
|
@ -53,6 +55,7 @@ export default function InstantAnalysis() {
|
|||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<CodeAnalysisResult | null>(null);
|
||||
const [analysisTime, setAnalysisTime] = useState(0);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadingCardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -272,6 +275,64 @@ public class Example {
|
|||
setAnalysisTime(0);
|
||||
};
|
||||
|
||||
// 构造临时任务和问题数据用于导出
|
||||
const getTempTaskAndIssues = () => {
|
||||
if (!result) return null;
|
||||
|
||||
const tempTask: AuditTask = {
|
||||
id: 'instant-' + Date.now(),
|
||||
project_id: 'instant-analysis',
|
||||
task_type: 'instant',
|
||||
status: 'completed',
|
||||
branch_name: undefined,
|
||||
exclude_patterns: '[]',
|
||||
scan_config: JSON.stringify({ language }),
|
||||
total_files: 1,
|
||||
scanned_files: 1,
|
||||
total_lines: code.split('\n').length,
|
||||
issues_count: result.issues.length,
|
||||
quality_score: result.quality_score,
|
||||
started_at: undefined,
|
||||
completed_at: new Date().toISOString(),
|
||||
created_by: 'local-user',
|
||||
created_at: new Date().toISOString(),
|
||||
project: {
|
||||
id: 'instant',
|
||||
owner_id: 'local-user',
|
||||
name: '即时分析',
|
||||
description: `${language} 代码即时分析`,
|
||||
repository_type: 'other',
|
||||
repository_url: undefined,
|
||||
default_branch: 'instant',
|
||||
programming_languages: JSON.stringify([language]),
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const tempIssues: AuditIssue[] = result.issues.map((issue, index) => ({
|
||||
id: `instant-issue-${index}`,
|
||||
task_id: tempTask.id,
|
||||
file_path: `instant-analysis.${language}`,
|
||||
line_number: issue.line || undefined,
|
||||
column_number: issue.column || undefined,
|
||||
issue_type: issue.type as any,
|
||||
severity: issue.severity as any,
|
||||
title: issue.title,
|
||||
description: issue.description || undefined,
|
||||
suggestion: issue.suggestion || undefined,
|
||||
code_snippet: issue.code_snippet || undefined,
|
||||
ai_explanation: issue.ai_explanation || (issue.xai ? JSON.stringify(issue.xai) : undefined),
|
||||
status: 'open',
|
||||
resolved_by: undefined,
|
||||
resolved_at: undefined,
|
||||
created_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
return { task: tempTask, issues: tempIssues };
|
||||
};
|
||||
|
||||
// 渲染问题的函数,使用紧凑样式
|
||||
const renderIssue = (issue: any, index: number) => (
|
||||
<div key={index} className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-gray-300 transition-all duration-200 group">
|
||||
|
|
@ -554,6 +615,16 @@ public class Example {
|
|||
<Badge variant="outline" className="text-xs">
|
||||
{language.charAt(0).toUpperCase() + language.slice(1)}
|
||||
</Badge>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setExportDialogOpen(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -727,6 +798,19 @@ public class Example {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 导出报告对话框 */}
|
||||
{result && (() => {
|
||||
const data = getTempTaskAndIssues();
|
||||
return data ? (
|
||||
<ExportReportDialog
|
||||
open={exportDialogOpen}
|
||||
onOpenChange={setExportDialogOpen}
|
||||
task={data.task}
|
||||
issues={data.issues}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,12 +21,15 @@ import {
|
|||
Code,
|
||||
Lightbulb,
|
||||
Info,
|
||||
Zap
|
||||
Zap,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { api } from "@/shared/config/database";
|
||||
import type { AuditTask, AuditIssue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import ExportReportDialog from "@/components/reports/ExportReportDialog";
|
||||
import { calculateTaskProgress } from "@/shared/utils/utils";
|
||||
import { taskControl } from "@/shared/services/taskControl";
|
||||
|
||||
// AI解释解析函数
|
||||
function parseAIExplanation(aiExplanation: string) {
|
||||
|
|
@ -328,6 +331,40 @@ export default function TaskDetail() {
|
|||
}
|
||||
}, [id]);
|
||||
|
||||
// 对于运行中或等待中的任务,静默更新进度(不触发loading状态)
|
||||
useEffect(() => {
|
||||
if (!task || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 运行中或等待中的任务需要定时更新
|
||||
if (task.status === 'running' || task.status === 'pending') {
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
// 静默获取任务数据,不触发loading状态
|
||||
const [taskData, issuesData] = await Promise.all([
|
||||
api.getAuditTaskById(id),
|
||||
api.getAuditIssues(id)
|
||||
]);
|
||||
|
||||
// 只有数据真正变化时才更新状态
|
||||
if (taskData && (
|
||||
taskData.status !== task.status ||
|
||||
taskData.scanned_files !== task.scanned_files ||
|
||||
taskData.issues_count !== task.issues_count
|
||||
)) {
|
||||
setTask(taskData);
|
||||
setIssues(issuesData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('静默更新任务失败:', error);
|
||||
}
|
||||
}, 3000); // 每3秒静默更新一次
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [task?.status, task?.scanned_files, id]);
|
||||
|
||||
const loadTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
|
||||
|
|
@ -353,6 +390,7 @@ export default function TaskDetail() {
|
|||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'running': return 'bg-red-50 text-red-800';
|
||||
case 'failed': return 'bg-red-100 text-red-900';
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
|
@ -362,11 +400,41 @@ export default function TaskDetail() {
|
|||
case 'completed': return <CheckCircle className="w-4 h-4" />;
|
||||
case 'running': return <Activity className="w-4 h-4" />;
|
||||
case 'failed': return <AlertTriangle className="w-4 h-4" />;
|
||||
case 'cancelled': return <Clock className="w-4 h-4" />;
|
||||
default: return <Clock className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!id || !task) return;
|
||||
|
||||
if (!confirm('确定要取消此任务吗?已分析的结果将被保留。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 标记任务为取消状态(让后台循环检测到)
|
||||
taskControl.cancelTask(id);
|
||||
|
||||
// 2. 立即更新本地状态显示
|
||||
setTask(prev => prev ? { ...prev, status: 'cancelled' as const } : prev);
|
||||
|
||||
// 3. 尝试立即更新数据库(后台也会更新,这里是双保险)
|
||||
try {
|
||||
await api.updateAuditTask(id, { status: 'cancelled' } as any);
|
||||
toast.success("任务已取消");
|
||||
} catch (error) {
|
||||
console.error('更新取消状态失败:', error);
|
||||
toast.warning("任务已标记取消,后台正在停止...");
|
||||
}
|
||||
|
||||
// 4. 1秒后再次刷新,确保显示最新状态
|
||||
setTimeout(() => {
|
||||
loadTaskDetail();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
|
|
@ -390,7 +458,7 @@ export default function TaskDetail() {
|
|||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/tasks">
|
||||
<Link to="/audit-tasks">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回任务列表
|
||||
|
|
@ -410,14 +478,15 @@ export default function TaskDetail() {
|
|||
);
|
||||
}
|
||||
|
||||
const progressPercentage = Math.round((task.scanned_files / task.total_files) * 100);
|
||||
// 使用公共函数计算进度百分比
|
||||
const progressPercentage = calculateTaskProgress(task.scanned_files, task.total_files);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/tasks">
|
||||
<Link to="/audit-tasks">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回任务列表
|
||||
|
|
@ -435,9 +504,25 @@ export default function TaskDetail() {
|
|||
<span className="ml-2">
|
||||
{task.status === 'completed' ? '已完成' :
|
||||
task.status === 'running' ? '运行中' :
|
||||
task.status === 'failed' ? '失败' : '等待中'}
|
||||
task.status === 'failed' ? '失败' :
|
||||
task.status === 'cancelled' ? '已取消' : '等待中'}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{/* 运行中或等待中的任务显示取消按钮 */}
|
||||
{(task.status === 'running' || task.status === 'pending') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消任务
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 已完成的任务显示导出按钮 */}
|
||||
{task.status === 'completed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export const TASK_STATUS = {
|
|||
RUNNING: 'running',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const;
|
||||
|
||||
// 用户角色
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 全局任务控制管理器
|
||||
* 用于取消正在运行的审计任务
|
||||
*/
|
||||
|
||||
class TaskControlManager {
|
||||
private cancelledTasks: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
*/
|
||||
cancelTask(taskId: string) {
|
||||
this.cancelledTasks.add(taskId);
|
||||
console.log(`🛑 任务 ${taskId} 已标记为取消`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否被取消
|
||||
*/
|
||||
isCancelled(taskId: string): boolean {
|
||||
return this.cancelledTasks.has(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已完成任务的控制状态
|
||||
*/
|
||||
cleanupTask(taskId: string) {
|
||||
this.cancelledTasks.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const taskControl = new TaskControlManager();
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ export interface AuditTask {
|
|||
id: string;
|
||||
project_id: string;
|
||||
task_type: 'repository' | 'instant';
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
branch_name?: string;
|
||||
exclude_patterns: string;
|
||||
scan_config: string;
|
||||
|
|
|
|||
|
|
@ -39,10 +39,10 @@ export function debounce<T extends (...args: any[]) => any>(
|
|||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
let timeout: number | undefined;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
timeout = setTimeout(() => func(...args), wait) as unknown as number;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -161,4 +161,49 @@ export function copyToClipboard(text: string): Promise<void> {
|
|||
document.body.removeChild(textArea);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算任务扫描进度百分比
|
||||
* @param scannedFiles 已扫描文件数
|
||||
* @param totalFiles 总文件数
|
||||
* @returns 进度百分比(0-100),安全处理NaN情况
|
||||
*/
|
||||
export function calculateTaskProgress(scannedFiles?: number, totalFiles?: number): number {
|
||||
// 处理未定义或无效值
|
||||
const scanned = scannedFiles || 0;
|
||||
const total = totalFiles || 0;
|
||||
|
||||
// 避免除以0
|
||||
if (total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 计算百分比并四舍五入
|
||||
const percentage = (scanned / total) * 100;
|
||||
|
||||
// 确保返回值在0-100之间
|
||||
return Math.min(100, Math.max(0, Math.round(percentage)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务进度的完整信息
|
||||
* @param scannedFiles 已扫描文件数
|
||||
* @param totalFiles 总文件数
|
||||
* @returns 包含百分比、显示文本等信息的对象
|
||||
*/
|
||||
export function getTaskProgressInfo(scannedFiles?: number, totalFiles?: number) {
|
||||
const scanned = scannedFiles || 0;
|
||||
const total = totalFiles || 0;
|
||||
const percentage = calculateTaskProgress(scanned, total);
|
||||
|
||||
return {
|
||||
percentage,
|
||||
scanned,
|
||||
total,
|
||||
text: `${scanned} / ${total} 文件`,
|
||||
percentText: `${percentage}%`,
|
||||
isComplete: total > 0 && scanned >= total,
|
||||
isStarted: scanned > 0
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue