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:
lintsinghua 2025-10-25 22:14:56 +08:00
parent d9d2f8603f
commit e07ecd3ce2
12 changed files with 983 additions and 119 deletions

View File

@ -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

View File

@ -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>

View File

@ -80,12 +80,20 @@ export class CodeAnalysisEngine {
// 根据配置生成不同语言的提示词
const systemPrompt = isChineseOutput
? `你是一个专业的代码审计助手。
? `⚠️⚠️⚠️ 只输出JSON禁止输出其他任何格式禁止markdown禁止文本分析
1. titledescriptionsuggestionai_explanationxai 使
2. JSON markdown
3. JSON
JSON Schema的结果
1. JSON对象{}
2. JSON前后添加任何文字markdown标记
3. \`\`\`json或###等markdown语法
4. READMEJSON格式输出分析结果
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: 如何修复这个问题
IMPORTANTPlease 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"25line字段必须填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 IMPORTANTOutput 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)
IMPORTANTAbout 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);
}
}

View File

@ -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);
// ✓ 检查点2LLM分析完成后检查是否取消最小化浪费
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;
}

View File

@ -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);
// ✓ 检查点2LLM分析完成后检查是否取消最小化浪费
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;
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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"

View File

@ -39,6 +39,7 @@ export const TASK_STATUS = {
RUNNING: 'running',
COMPLETED: 'completed',
FAILED: 'failed',
CANCELLED: 'cancelled',
} as const;
// 用户角色

View File

@ -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();

View File

@ -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;

View File

@ -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-100NaN情况
*/
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
};
}