diff --git a/src/components/audit/CreateTaskDialog.tsx b/src/components/audit/CreateTaskDialog.tsx index 018b43e..36eacc9 100644 --- a/src/components/audit/CreateTaskDialog.tsx +++ b/src/components/audit/CreateTaskDialog.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [showTerminalDialog, setShowTerminalDialog] = useState(false); const [currentTaskId, setCurrentTaskId] = useState(null); + const [zipFile, setZipFile] = useState(null); const [taskForm, setTaskForm] = useState({ 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 + {/* ZIP项目文件上传 */} + {(!selectedProject.repository_url || selectedProject.repository_url.trim() === '') && ( + + +
+
+ +
+

ZIP项目需要上传文件

+

+ 该项目是通过ZIP上传创建的,请重新上传ZIP文件进行扫描 +

+
+
+ +
+ + { + 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 && ( +

+ ✓ 已选择: {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` + }) +

+ )} +
+
+
+
+ )} +
@@ -305,7 +410,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
- {taskForm.task_type === "repository" && ( + {taskForm.task_type === "repository" && (selectedProject.repository_url) && (
([]); 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(null); const pollIntervalRef = useRef(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({
- {/* 底部提示 */} + {/* 底部控制和提示 */}
- {isCompleted ? "✅ 任务已完成,可以关闭此窗口" : + {isCancelled ? "🛑 任务已取消,已分析的结果已保存" : + isCompleted ? "✅ 任务已完成,可以关闭此窗口" : isFailed ? "❌ 任务失败,请检查配置后重试" : "⏳ 审计进行中,请勿关闭窗口,过程可能较慢,请耐心等待......"} - {(isCompleted || isFailed) && ( - - )} + +
+ {/* 运行中显示取消按钮 */} + {!isCompleted && !isFailed && !isCancelled && ( + + )} + + {/* 已完成/失败/取消显示关闭按钮 */} + {(isCompleted || isFailed || isCancelled) && ( + + )} +
diff --git a/src/features/analysis/services/codeAnalysis.ts b/src/features/analysis/services/codeAnalysis.ts index 4981367..c6e1fc6 100644 --- a/src/features/analysis/services/codeAnalysis.ts +++ b/src/features/analysis/services/codeAnalysis.ts @@ -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); } } diff --git a/src/features/projects/services/repoScan.ts b/src/features/projects/services/repoScan.ts index 5431a9e..8a4cb55 100644 --- a/src/features/projects/services/repoScan.ts +++ b/src/features/projects/services/repoScan.ts @@ -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; } diff --git a/src/features/projects/services/repoZipScan.ts b/src/features/projects/services/repoZipScan.ts index 6a37875..a73dcfe 100644 --- a/src/features/projects/services/repoZipScan.ts +++ b/src/features/projects/services/repoZipScan.ts @@ -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 { 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; } diff --git a/src/pages/AuditTasks.tsx b/src/pages/AuditTasks.tsx index 99d4015..480b8a8 100644 --- a/src/pages/AuditTasks.tsx +++ b/src/pages/AuditTasks.tsx @@ -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([]); @@ -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 ; case 'running': return ; case 'failed': return ; + case 'cancelled': return ; default: return ; } }; @@ -233,7 +274,8 @@ export default function AuditTasks() { {task.status === 'completed' ? '已完成' : task.status === 'running' ? '运行中' : - task.status === 'failed' ? '失败' : '等待中'} + task.status === 'failed' ? '失败' : + task.status === 'cancelled' ? '已取消' : '等待中'} @@ -261,18 +303,18 @@ export default function AuditTasks() {
扫描进度 - {task.scanned_files} / {task.total_files} 文件 + {task.scanned_files || 0} / {task.total_files || 0} 文件
- {Math.round((task.scanned_files / task.total_files) * 100)}% 完成 + {calculateTaskProgress(task.scanned_files, task.total_files)}% 完成
diff --git a/src/pages/InstantAnalysis.tsx b/src/pages/InstantAnalysis.tsx index b64d805..ce52d08 100644 --- a/src/pages/InstantAnalysis.tsx +++ b/src/pages/InstantAnalysis.tsx @@ -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(null); const [analysisTime, setAnalysisTime] = useState(0); + const [exportDialogOpen, setExportDialogOpen] = useState(false); const fileInputRef = useRef(null); const loadingCardRef = useRef(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) => (
@@ -554,6 +615,16 @@ public class Example { {language.charAt(0).toUpperCase() + language.slice(1)} + + {/* 导出按钮 */} +
@@ -727,6 +798,19 @@ public class Example { )} + + {/* 导出报告对话框 */} + {result && (() => { + const data = getTempTaskAndIssues(); + return data ? ( + + ) : null; + })()} ); } \ No newline at end of file diff --git a/src/pages/TaskDetail.tsx b/src/pages/TaskDetail.tsx index 1643961..00e2589 100644 --- a/src/pages/TaskDetail.tsx +++ b/src/pages/TaskDetail.tsx @@ -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 ; case 'running': return ; case 'failed': return ; + case 'cancelled': return ; default: return ; } }; + 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 (
- + + )} + + {/* 已完成的任务显示导出按钮 */} {task.status === 'completed' && (