diff --git a/backend/app/api/v1/endpoints/projects.py b/backend/app/api/v1/endpoints/projects.py index 96211e8..8fdc3a3 100644 --- a/backend/app/api/v1/endpoints/projects.py +++ b/backend/app/api/v1/endpoints/projects.py @@ -9,6 +9,7 @@ from datetime import datetime import shutil import os import uuid +import json from app.api import deps from app.db.session import get_db, AsyncSessionLocal @@ -16,7 +17,8 @@ from app.models.project import Project from app.models.user import User from app.models.audit import AuditTask, AuditIssue from app.models.user_config import UserConfig -from app.services.scanner import scan_repo_task +import zipfile +from app.services.scanner import scan_repo_task, get_github_files, get_gitlab_files, get_github_branches, get_gitlab_branches from app.services.zip_storage import ( save_project_zip, load_project_zip, get_project_zip_meta, delete_project_zip, has_project_zip @@ -315,10 +317,108 @@ async def permanently_delete_project( await db.commit() return {"message": "项目已永久删除"} + +@router.get("/{id}/files") +async def get_project_files( + id: str, + branch: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +) -> Any: + """ + Get list of files in the project. + 可选参数 branch 用于指定仓库分支(仅对仓库类型项目有效) + """ + project = await db.get(Project, id) + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # Check permissions + if project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="无权查看此项目") + + files = [] + + if project.source_type == "zip": + # Handle ZIP project + zip_path = await load_project_zip(id) + print(f"📦 ZIP项目 {id} 文件路径: {zip_path}") + if not zip_path or not os.path.exists(zip_path): + print(f"⚠️ ZIP文件不存在: {zip_path}") + return [] + + try: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + for file_info in zip_ref.infolist(): + if not file_info.is_dir(): + name = file_info.filename + if any(p in name for p in ['node_modules/', '__pycache__/', '.git/', 'dist/', 'build/']): + continue + files.append({"path": name, "size": file_info.file_size}) + except Exception as e: + print(f"Error reading zip file: {e}") + raise HTTPException(status_code=500, detail="无法读取项目文件") + + elif project.source_type == "repository": + # Handle Repository project + if not project.repository_url: + return [] + + # Get tokens from user config + from sqlalchemy.future import select + from app.core.encryption import decrypt_sensitive_data + import json + from app.core.config import settings + + SENSITIVE_OTHER_FIELDS = ['githubToken', 'gitlabToken'] + + result = await db.execute( + select(UserConfig).where(UserConfig.user_id == current_user.id) + ) + config = result.scalar_one_or_none() + + github_token = settings.GITHUB_TOKEN + gitlab_token = settings.GITLAB_TOKEN + + if config and config.other_config: + other_config = json.loads(config.other_config) + for field in SENSITIVE_OTHER_FIELDS: + if field in other_config and other_config[field]: + decrypted_val = decrypt_sensitive_data(other_config[field]) + if field == 'githubToken': + github_token = decrypted_val + elif field == 'gitlabToken': + gitlab_token = decrypted_val + + repo_type = project.repository_type or "other" + # 使用传入的 branch 参数,如果没有则使用项目默认分支 + target_branch = branch or project.default_branch or "main" + + try: + if repo_type == "github": + repo_files = await get_github_files(project.repository_url, target_branch, github_token) + files = [{"path": f["path"], "size": 0} for f in repo_files] + elif repo_type == "gitlab": + repo_files = await get_gitlab_files(project.repository_url, target_branch, gitlab_token) + files = [{"path": f["path"], "size": 0} for f in repo_files] + except Exception as e: + print(f"Error fetching repo files: {e}") + raise HTTPException(status_code=500, detail=f"无法获取仓库文件: {str(e)}") + + return files + +class ScanRequest(BaseModel): + file_paths: Optional[List[str]] = None + full_scan: bool = True + exclude_patterns: Optional[List[str]] = None + branch_name: Optional[str] = None + + @router.post("/{id}/scan") async def scan_project( id: str, background_tasks: BackgroundTasks, + scan_request: Optional[ScanRequest] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ) -> Any: @@ -329,21 +429,26 @@ async def scan_project( if not project: raise HTTPException(status_code=404, detail="项目不存在") + # 获取分支和排除模式 + branch_name = scan_request.branch_name if scan_request else None + exclude_patterns = scan_request.exclude_patterns if scan_request else None + # Create Task Record task = AuditTask( project_id=project.id, created_by=current_user.id, task_type="repository", - status="pending" + status="pending", + branch_name=branch_name or project.default_branch or "main", + exclude_patterns=json.dumps(exclude_patterns or []), + scan_config=json.dumps(scan_request.dict()) if scan_request else "{}" ) db.add(task) await db.commit() await db.refresh(task) # 获取用户配置(包含解密敏感字段) - from sqlalchemy.future import select from app.core.encryption import decrypt_sensitive_data - import json # 需要解密的敏感字段列表 SENSITIVE_LLM_FIELDS = [ @@ -377,6 +482,10 @@ async def scan_project( 'otherConfig': other_config, } + # 将扫描配置注入到 user_config 中,以便 scan_repo_task 使用 + if scan_request and scan_request.file_paths: + user_config['scan_config'] = {'file_paths': scan_request.file_paths} + # Trigger Background Task background_tasks.add_task(scan_repo_task, task.id, AsyncSessionLocal, user_config) @@ -500,3 +609,79 @@ async def delete_project_zip_file( return {"message": "ZIP文件已删除"} else: return {"message": "没有找到ZIP文件"} + + +# ============ 分支管理端点 ============ + +@router.get("/{id}/branches") +async def get_project_branches( + id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +) -> Any: + """ + 获取项目仓库的分支列表 + """ + project = await db.get(Project, id) + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 检查是否为仓库类型项目 + if project.source_type != "repository": + raise HTTPException(status_code=400, detail="仅仓库类型项目支持获取分支") + + if not project.repository_url: + raise HTTPException(status_code=400, detail="项目未配置仓库地址") + + # 获取用户配置的 Token + from app.core.config import settings + from app.core.encryption import decrypt_sensitive_data + + config = await db.execute( + select(UserConfig).where(UserConfig.user_id == current_user.id) + ) + config = config.scalar_one_or_none() + + github_token = settings.GITHUB_TOKEN + gitlab_token = settings.GITLAB_TOKEN + + SENSITIVE_OTHER_FIELDS = ['githubToken', 'gitlabToken'] + + if config and config.other_config: + import json + other_config = json.loads(config.other_config) + for field in SENSITIVE_OTHER_FIELDS: + if field in other_config and other_config[field]: + decrypted_val = decrypt_sensitive_data(other_config[field]) + if field == 'githubToken': + github_token = decrypted_val + elif field == 'gitlabToken': + gitlab_token = decrypted_val + + repo_type = project.repository_type or "other" + + try: + if repo_type == "github": + branches = await get_github_branches(project.repository_url, github_token) + elif repo_type == "gitlab": + branches = await get_gitlab_branches(project.repository_url, gitlab_token) + else: + # 对于其他类型,返回默认分支 + branches = [project.default_branch or "main"] + + # 将默认分支放在第一位 + default_branch = project.default_branch or "main" + if default_branch in branches: + branches.remove(default_branch) + branches.insert(0, default_branch) + + return {"branches": branches, "default_branch": default_branch} + + except Exception as e: + print(f"获取分支列表失败: {e}") + # 返回默认分支作为后备 + return { + "branches": [project.default_branch or "main"], + "default_branch": project.default_branch or "main", + "error": str(e) + } diff --git a/backend/app/api/v1/endpoints/scan.py b/backend/app/api/v1/endpoints/scan.py index 78bd55f..6b20767 100644 --- a/backend/app/api/v1/endpoints/scan.py +++ b/backend/app/api/v1/endpoints/scan.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, UploadFile, File, Depends, BackgroundTasks, HTTPException +from fastapi import APIRouter, UploadFile, File, Form, Depends, BackgroundTasks, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from typing import Any, List, Optional @@ -80,7 +80,13 @@ async def process_zip_task(task_id: str, file_path: str, db_session_factory, use pass # 限制文件数量 - files_to_scan = files_to_scan[:settings.MAX_ANALYZE_FILES] + # 如果指定了特定文件,则只分析这些文件 + target_files = (user_config or {}).get('scan_config', {}).get('file_paths', []) + if target_files: + print(f"🎯 ZIP任务: 指定分析 {len(target_files)} 个文件") + files_to_scan = [f for f in files_to_scan if f['path'] in target_files] + else: + files_to_scan = files_to_scan[:settings.MAX_ANALYZE_FILES] task.total_files = len(files_to_scan) await db.commit() @@ -150,15 +156,27 @@ async def process_zip_task(task_id: str, file_path: str, db_session_factory, use await asyncio.sleep(settings.LLM_GAP_MS / 1000) # 完成任务 - task.status = "completed" - task.completed_at = datetime.utcnow() - task.scanned_files = scanned_files - task.total_lines = total_lines - task.issues_count = total_issues - task.quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0 - await db.commit() + avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0 - print(f"✅ ZIP任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题") + # 如果有文件需要分析但全部失败,标记为失败 + if len(files_to_scan) > 0 and scanned_files == 0: + task.status = "failed" + task.completed_at = datetime.utcnow() + task.scanned_files = 0 + task.total_lines = total_lines + task.issues_count = 0 + task.quality_score = 0 + await db.commit() + print(f"❌ ZIP任务 {task_id} 失败: 所有 {len(files_to_scan)} 个文件分析均失败,请检查 LLM API 配置") + else: + task.status = "completed" + task.completed_at = datetime.utcnow() + task.scanned_files = scanned_files + task.total_lines = total_lines + task.issues_count = total_issues + task.quality_score = avg_quality_score + await db.commit() + print(f"✅ ZIP任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题") task_control.cleanup_task(task_id) except Exception as e: @@ -175,9 +193,10 @@ async def process_zip_task(task_id: str, file_path: str, db_session_factory, use @router.post("/upload-zip") async def scan_zip( - project_id: str, background_tasks: BackgroundTasks, + project_id: str = Form(...), file: UploadFile = File(...), + scan_config: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ) -> Any: @@ -213,13 +232,21 @@ async def scan_zip( # 保存ZIP文件到持久化存储 await save_project_zip(project_id, file_path, file.filename) + # Parse scan_config if provided + parsed_scan_config = {} + if scan_config: + try: + parsed_scan_config = json.loads(scan_config) + except json.JSONDecodeError: + pass + # Create Task task = AuditTask( project_id=project_id, created_by=current_user.id, task_type="zip_upload", status="pending", - scan_config="{}" + scan_config=scan_config if scan_config else "{}" ) db.add(task) await db.commit() @@ -227,6 +254,10 @@ async def scan_zip( # 获取用户配置 user_config = await get_user_config_dict(db, current_user.id) + + # 将扫描配置注入到 user_config 中 + if parsed_scan_config and 'file_paths' in parsed_scan_config: + user_config['scan_config'] = {'file_paths': parsed_scan_config['file_paths']} # Trigger Background Task - 使用持久化存储的文件路径 stored_zip_path = await load_project_zip(project_id) @@ -235,10 +266,16 @@ async def scan_zip( return {"task_id": task.id, "status": "queued"} +class ScanRequest(BaseModel): + file_paths: Optional[List[str]] = None + full_scan: bool = True + + @router.post("/scan-stored-zip") async def scan_stored_zip( project_id: str, background_tasks: BackgroundTasks, + scan_request: Optional[ScanRequest] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ) -> Any: @@ -265,7 +302,7 @@ async def scan_stored_zip( created_by=current_user.id, task_type="zip_upload", status="pending", - scan_config="{}" + scan_config=json.dumps(scan_request.dict()) if scan_request else "{}" ) db.add(task) await db.commit() @@ -273,6 +310,10 @@ async def scan_stored_zip( # 获取用户配置 user_config = await get_user_config_dict(db, current_user.id) + + # 将扫描配置注入到 user_config 中,以便 process_zip_task 使用 + if scan_request and scan_request.file_paths: + user_config['scan_config'] = {'file_paths': scan_request.file_paths} # Trigger Background Task background_tasks.add_task(process_zip_task, task.id, stored_zip_path, AsyncSessionLocal, user_config) diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 8d21777..ffaee67 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -128,7 +128,48 @@ async def fetch_file_content(url: str, headers: Dict[str, str] = None) -> Option return None -async def get_github_files(repo_url: str, branch: str, token: str = None) -> List[Dict[str, str]]: +async def get_github_branches(repo_url: str, token: str = None) -> List[str]: + """获取GitHub仓库分支列表""" + match = repo_url.rstrip('/').rstrip('.git') + if 'github.com/' in match: + parts = match.split('github.com/')[-1].split('/') + if len(parts) >= 2: + owner, repo = parts[0], parts[1] + else: + raise Exception("GitHub 仓库 URL 格式错误") + else: + raise Exception("GitHub 仓库 URL 格式错误") + + branches_url = f"https://api.github.com/repos/{owner}/{repo}/branches?per_page=100" + branches_data = await github_api(branches_url, token) + + return [b["name"] for b in branches_data] + + +async def get_gitlab_branches(repo_url: str, token: str = None) -> List[str]: + """获取GitLab仓库分支列表""" + parsed = urlparse(repo_url) + base = f"{parsed.scheme}://{parsed.netloc}" + + extracted_token = token + if parsed.username: + if parsed.username == 'oauth2' and parsed.password: + extracted_token = parsed.password + elif parsed.username and not parsed.password: + extracted_token = parsed.username + + path = parsed.path.strip('/').rstrip('.git') + if not path: + raise Exception("GitLab 仓库 URL 格式错误") + + project_path = quote(path, safe='') + branches_url = f"{base}/api/v4/projects/{project_path}/repository/branches?per_page=100" + branches_data = await gitlab_api(branches_url, extracted_token) + + return [b["name"] for b in branches_data] + + +async def get_github_files(repo_url: str, branch: str, token: str = None, exclude_patterns: List[str] = None) -> List[Dict[str, str]]: """获取GitHub仓库文件列表""" # 解析仓库URL match = repo_url.rstrip('/').rstrip('.git') @@ -147,7 +188,7 @@ async def get_github_files(repo_url: str, branch: str, token: str = None) -> Lis files = [] for item in tree_data.get("tree", []): - if item.get("type") == "blob" and is_text_file(item["path"]) and not should_exclude(item["path"]): + if item.get("type") == "blob" and is_text_file(item["path"]) and not should_exclude(item["path"], exclude_patterns): size = item.get("size", 0) if size <= settings.MAX_FILE_SIZE_BYTES: files.append({ @@ -158,7 +199,7 @@ async def get_github_files(repo_url: str, branch: str, token: str = None) -> Lis return files -async def get_gitlab_files(repo_url: str, branch: str, token: str = None) -> List[Dict[str, str]]: +async def get_gitlab_files(repo_url: str, branch: str, token: str = None, exclude_patterns: List[str] = None) -> List[Dict[str, str]]: """获取GitLab仓库文件列表""" parsed = urlparse(repo_url) base = f"{parsed.scheme}://{parsed.netloc}" @@ -184,7 +225,7 @@ async def get_gitlab_files(repo_url: str, branch: str, token: str = None) -> Lis files = [] for item in tree_data: - if item.get("type") == "blob" and is_text_file(item["path"]) and not should_exclude(item["path"]): + if item.get("type") == "blob" and is_text_file(item["path"]) and not should_exclude(item["path"], exclude_patterns): files.append({ "path": item["path"], "url": f"{base}/api/v4/projects/{project_path}/repository/files/{quote(item['path'], safe='')}/raw?ref={quote(branch)}", @@ -233,8 +274,19 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N repo_url = project.repository_url branch = task.branch_name or project.default_branch or "main" repo_type = project.repository_type or "other" + + # 解析任务的排除模式 + import json as json_module + task_exclude_patterns = [] + if task.exclude_patterns: + try: + task_exclude_patterns = json_module.loads(task.exclude_patterns) + except: + pass print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}, 来源: {source_type}") + if task_exclude_patterns: + print(f"📋 排除模式: {task_exclude_patterns}") # 3. 获取文件列表 # 从用户配置中读取 GitHub/GitLab Token(优先使用用户配置,然后使用系统配置) @@ -246,9 +298,9 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N extracted_gitlab_token = None if repo_type == "github": - files = await get_github_files(repo_url, branch, github_token) + files = await get_github_files(repo_url, branch, github_token, task_exclude_patterns) elif repo_type == "gitlab": - files = await get_gitlab_files(repo_url, branch, gitlab_token) + files = await get_gitlab_files(repo_url, branch, gitlab_token, task_exclude_patterns) # GitLab文件可能带有token if files and 'token' in files[0]: extracted_gitlab_token = files[0].get('token') @@ -256,7 +308,13 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N raise Exception("不支持的仓库类型,仅支持 GitHub 和 GitLab 仓库") # 限制文件数量 - files = files[:settings.MAX_ANALYZE_FILES] + # 如果指定了特定文件,则只分析这些文件 + target_files = (user_config or {}).get('scan_config', {}).get('file_paths', []) + if target_files: + print(f"🎯 指定分析 {len(target_files)} 个文件") + files = [f for f in files if f['path'] in target_files] + else: + files = files[:settings.MAX_ANALYZE_FILES] task.total_files = len(files) await db.commit() @@ -380,15 +438,25 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N # 5. 完成任务 avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0 - task.status = "completed" - task.completed_at = datetime.utcnow() - task.scanned_files = scanned_files - task.total_lines = total_lines - task.issues_count = total_issues - task.quality_score = avg_quality_score - await db.commit() - - print(f"✅ 任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题, 质量分 {avg_quality_score:.1f}") + # 如果有文件需要分析但全部失败,标记为失败 + if len(files) > 0 and scanned_files == 0: + task.status = "failed" + task.completed_at = datetime.utcnow() + task.scanned_files = 0 + task.total_lines = total_lines + task.issues_count = 0 + task.quality_score = 0 + await db.commit() + print(f"❌ 任务 {task_id} 失败: 所有 {len(files)} 个文件分析均失败,请检查 LLM API 配置") + else: + task.status = "completed" + task.completed_at = datetime.utcnow() + task.scanned_files = scanned_files + task.total_lines = total_lines + task.issues_count = total_issues + task.quality_score = avg_quality_score + await db.commit() + print(f"✅ 任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题, 质量分 {avg_quality_score:.1f}") task_control.cleanup_task(task_id) except Exception as e: diff --git a/frontend/src/components/audit/CreateTaskDialog.tsx b/frontend/src/components/audit/CreateTaskDialog.tsx index be0979e..314e2f7 100644 --- a/frontend/src/components/audit/CreateTaskDialog.tsx +++ b/frontend/src/components/audit/CreateTaskDialog.tsx @@ -1,30 +1,56 @@ -import { useState, useEffect } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useState, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Checkbox } from "@/components/ui/checkbox"; import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Search, + ChevronRight, GitBranch, - Settings, - FileText, - AlertCircle, - Info, - Zap, + Upload, + FolderOpen, + Settings2, + Play, + Package, + Globe, Shield, - Search + Loader2, } from "lucide-react"; -import { api } from "@/shared/config/database"; -import type { Project, CreateAuditTaskForm } from "@/shared/types"; import { toast } from "sonner"; +import { api } from "@/shared/config/database"; + +import { useProjects } from "./hooks/useTaskForm"; +import { useZipFile, formatFileSize } from "./hooks/useZipFile"; import TerminalProgressDialog from "./TerminalProgressDialog"; +import FileSelectionDialog from "./FileSelectionDialog"; + import { runRepositoryAudit } from "@/features/projects/services/repoScan"; -import { scanZipFile, scanStoredZipFile, validateZipFile } from "@/features/projects/services/repoZipScan"; -import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage"; -import { isRepositoryProject, isZipProject, getSourceTypeBadge } from "@/shared/utils/projectUtils"; +import { + scanZipFile, + scanStoredZipFile, + validateZipFile, +} from "@/features/projects/services/repoZipScan"; +import { isRepositoryProject, isZipProject } from "@/shared/utils/projectUtils"; +import type { Project } from "@/shared/types"; interface CreateTaskDialogProps { open: boolean; @@ -33,843 +59,641 @@ interface CreateTaskDialogProps { preselectedProjectId?: string; } -export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, preselectedProjectId }: CreateTaskDialogProps) { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); - const [creating, setCreating] = useState(false); +const DEFAULT_EXCLUDES = [ + "node_modules/**", + ".git/**", + "dist/**", + "build/**", + "*.log", +]; + +export default function CreateTaskDialog({ + open, + onOpenChange, + onTaskCreated, + preselectedProjectId, +}: CreateTaskDialogProps) { + const [selectedProjectId, setSelectedProjectId] = useState(""); const [searchTerm, setSearchTerm] = useState(""); - const [showTerminalDialog, setShowTerminalDialog] = useState(false); + const [branch, setBranch] = useState("main"); + const [branches, setBranches] = useState([]); + const [loadingBranches, setLoadingBranches] = useState(false); + const [excludePatterns, setExcludePatterns] = useState(DEFAULT_EXCLUDES); + const [selectedFiles, setSelectedFiles] = useState(); + const [showAdvanced, setShowAdvanced] = useState(false); + const [showFileSelection, setShowFileSelection] = useState(false); + const [creating, setCreating] = useState(false); + const [uploading, setUploading] = useState(false); + const [showTerminal, setShowTerminal] = useState(false); const [currentTaskId, setCurrentTaskId] = useState(null); - const [zipFile, setZipFile] = useState(null); - const [loadingZipFile, setLoadingZipFile] = useState(false); - const [storedZipInfo, setStoredZipInfo] = useState(null); - const [useStoredZip, setUseStoredZip] = useState(true); // 默认使用已存储的ZIP - const [taskForm, setTaskForm] = useState({ - project_id: "", - task_type: "repository", - branch_name: "main", - exclude_patterns: ["node_modules/**", ".git/**", "dist/**", "build/**", "*.log"], - scan_config: { - include_tests: true, - include_docs: false, - max_file_size: 200, // KB (对齐后端默认值 200KB) - analysis_depth: "standard" - } - }); + const { projects, loading, loadProjects } = useProjects(); + const selectedProject = projects.find((p) => p.id === selectedProjectId); + const zipState = useZipFile(selectedProject, projects); - const commonExcludePatterns = [ - { label: "node_modules", value: "node_modules/**", description: "Node.js 依赖包" }, - { label: ".git", value: ".git/**", description: "Git 版本控制文件" }, - { label: "dist/build", value: "dist/**", description: "构建输出目录" }, - { label: "logs", value: "*.log", description: "日志文件" }, - { label: "cache", value: ".cache/**", description: "缓存文件" }, - { label: "temp", value: "temp/**", description: "临时文件" }, - { label: "vendor", value: "vendor/**", description: "第三方库" }, - { label: "coverage", value: "coverage/**", description: "测试覆盖率报告" } - ]; - - // 从后端加载默认配置 + // 加载分支列表 useEffect(() => { - const loadDefaultConfig = async () => { - try { - const defaultConfig = await api.getDefaultConfig(); - if (defaultConfig?.otherConfig) { - // 后端 MAX_FILE_SIZE_BYTES 是 200 * 1024 = 204800 bytes = 200KB - // 转换为KB用于前端显示 - const maxFileSizeKB = 200; // 后端默认值 200KB + const loadBranches = async () => { + if (!selectedProject || !isRepositoryProject(selectedProject)) { + setBranches([]); + return; + } - setTaskForm(prev => ({ - ...prev, - scan_config: { - ...prev.scan_config, - max_file_size: maxFileSizeKB, - } - })); + setLoadingBranches(true); + try { + const result = await api.getProjectBranches(selectedProject.id); + setBranches(result.branches); + if (result.default_branch) { + setBranch(result.default_branch); } } catch (error) { - console.error('Failed to load default config:', error); - // 使用硬编码的默认值作为后备(200KB) + console.error("加载分支失败:", error); + setBranches([selectedProject.default_branch || "main"]); + } finally { + setLoadingBranches(false); } }; - loadDefaultConfig(); - }, []); + + loadBranches(); + }, [selectedProject?.id]); + + const filteredProjects = useMemo(() => { + if (!searchTerm) return projects; + const term = searchTerm.toLowerCase(); + return projects.filter( + (p) => + p.name.toLowerCase().includes(term) || + p.description?.toLowerCase().includes(term) + ); + }, [projects, searchTerm]); useEffect(() => { if (open) { loadProjects(); - // 如果有预选择的项目ID,设置到表单中 if (preselectedProjectId) { - setTaskForm(prev => ({ ...prev, project_id: preselectedProjectId })); + setSelectedProjectId(preselectedProjectId); } - // 重置ZIP文件状态 - setZipFile(null); - setStoredZipInfo(null); - setUseStoredZip(true); + setSearchTerm(""); + setShowAdvanced(false); + zipState.reset(); } }, [open, preselectedProjectId]); - // 当项目ID变化时,检查是否有已存储的ZIP文件(仅ZIP类型项目) - useEffect(() => { - const checkStoredZipFile = async () => { - if (!taskForm.project_id) { - setStoredZipInfo(null); - return; - } - const project = projects.find(p => p.id === taskForm.project_id); - // 使用 source_type 判断是否为ZIP项目 - if (!project || !isZipProject(project)) { - setStoredZipInfo(null); - return; - } - try { - setLoadingZipFile(true); - const zipInfo = await getZipFileInfo(taskForm.project_id); - setStoredZipInfo(zipInfo); - - if (zipInfo.has_file) { - console.log('✓ 项目有已存储的ZIP文件:', zipInfo.original_filename); - setUseStoredZip(true); - } else { - setUseStoredZip(false); - } - } catch (error) { - console.error('检查ZIP文件失败:', error); - setStoredZipInfo(null); - } finally { - setLoadingZipFile(false); - } - }; - - checkStoredZipFile(); - }, [taskForm.project_id, projects]); - - const loadProjects = async () => { - try { - setLoading(true); - const data = await api.getProjects(); - setProjects(data.filter(p => p.is_active)); - } catch (error) { - console.error('Failed to load projects:', error); - toast.error("加载项目失败"); - } finally { - setLoading(false); - } - }; - - const handleCreateTask = async () => { - if (!taskForm.project_id) { + const handleStartScan = async () => { + if (!selectedProject) { toast.error("请选择项目"); return; } - if (taskForm.task_type === "repository" && !taskForm.branch_name?.trim()) { - toast.error("请输入分支名称"); - return; - } - - const project = selectedProject; - if (!project) { - toast.error("未找到选中的项目"); - return; - } - try { setCreating(true); - - console.log('🎯 开始创建审计任务...', { - projectId: project.id, - projectName: project.name, - sourceType: project.source_type, - repositoryType: project.repository_type - }); - let taskId: string; - // 根据项目 source_type 判断使用哪种扫描方式 - if (isZipProject(project)) { - // ZIP上传类型项目 - if (useStoredZip && storedZipInfo?.has_file) { - // 使用已存储的ZIP文件 - console.log('📦 ZIP项目 - 使用已存储的ZIP文件...'); + if (isZipProject(selectedProject)) { + if (zipState.useStoredZip && zipState.storedZipInfo?.has_file) { taskId = await scanStoredZipFile({ - projectId: project.id, - excludePatterns: taskForm.exclude_patterns, - createdBy: 'local-user' + projectId: selectedProject.id, + excludePatterns, + createdBy: "local-user", + filePaths: selectedFiles, }); - } else if (zipFile) { - // 上传新的ZIP文件 - console.log('📦 ZIP项目 - 上传新ZIP文件...'); + } else if (zipState.zipFile) { taskId = await scanZipFile({ - projectId: project.id, - zipFile: zipFile, - excludePatterns: taskForm.exclude_patterns, - createdBy: 'local-user' + projectId: selectedProject.id, + zipFile: zipState.zipFile, + excludePatterns, + createdBy: "local-user", }); } else { - toast.error("请上传ZIP文件或使用已存储的文件进行扫描"); + toast.error("请上传 ZIP 文件"); return; } } else { - // 仓库类型项目:从远程仓库拉取代码 - if (!project.repository_url) { - toast.error("仓库地址不能为空"); + if (!selectedProject.repository_url) { + toast.error("仓库地址为空"); return; } - - console.log('📡 仓库项目 - 调用 runRepositoryAudit...'); - - // 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递 taskId = await runRepositoryAudit({ - projectId: project.id, - repoUrl: project.repository_url, - branch: taskForm.branch_name || project.default_branch || 'main', - exclude: taskForm.exclude_patterns, - createdBy: 'local-user' + projectId: selectedProject.id, + repoUrl: selectedProject.repository_url, + branch, + exclude: excludePatterns, + createdBy: "local-user", + filePaths: selectedFiles, }); } - console.log('✅ 任务创建成功:', taskId); - - // 记录用户操作 - import('@/shared/utils/logger').then(({ logger }) => { - logger.logUserAction('创建审计任务', { - taskId, - projectId: project.id, - projectName: project.name, - sourceType: project.source_type, - taskType: taskForm.task_type, - branch: taskForm.branch_name, - hasZipFile: !!zipFile, - }); - }); - - // 关闭创建对话框 onOpenChange(false); - resetForm(); onTaskCreated(); - - // 显示终端进度窗口 setCurrentTaskId(taskId); - setShowTerminalDialog(true); + setShowTerminal(true); + toast.success("扫描任务已启动"); - toast.success("审计任务已创建并启动"); + setSelectedProjectId(""); + setSelectedFiles(undefined); + setExcludePatterns(DEFAULT_EXCLUDES); } catch (error) { - console.error('❌ 创建任务失败:', error); - - // 记录错误并显示详细信息 - import('@/shared/utils/errorHandler').then(({ handleError }) => { - handleError(error, '创建审计任务失败'); - }); - - const errorMessage = error instanceof Error ? error.message : '未知错误'; - toast.error(`创建任务失败: ${errorMessage}`); + const msg = error instanceof Error ? error.message : "未知错误"; + toast.error(`启动失败: ${msg}`); } finally { setCreating(false); } }; - const resetForm = () => { - setTaskForm({ - project_id: "", - task_type: "repository", - branch_name: "main", - exclude_patterns: ["node_modules/**", ".git/**", "dist/**", "build/**", "*.log"], - scan_config: { - include_tests: true, - include_docs: false, - max_file_size: 200, // KB (对齐后端默认值 200KB) - analysis_depth: "standard" - } - }); - setSearchTerm(""); - }; - - const toggleExcludePattern = (pattern: string) => { - const patterns = taskForm.exclude_patterns || []; - if (patterns.includes(pattern)) { - setTaskForm({ - ...taskForm, - exclude_patterns: patterns.filter(p => p !== pattern) - }); - } else { - setTaskForm({ - ...taskForm, - exclude_patterns: [...patterns, pattern] - }); + const canStart = useMemo(() => { + if (!selectedProject) return false; + if (isZipProject(selectedProject)) { + return ( + (zipState.useStoredZip && zipState.storedZipInfo?.has_file) || + !!zipState.zipFile + ); } - }; - - const addCustomPattern = (pattern: string) => { - if (pattern.trim() && !taskForm.exclude_patterns.includes(pattern.trim())) { - setTaskForm({ - ...taskForm, - exclude_patterns: [...taskForm.exclude_patterns, pattern.trim()] - }); - } - }; - - const removeExcludePattern = (pattern: string) => { - setTaskForm({ - ...taskForm, - exclude_patterns: taskForm.exclude_patterns.filter(p => p !== pattern) - }); - }; - - const selectedProject = projects.find(p => p.id === taskForm.project_id); - const filteredProjects = projects.filter(project => - project.name.toLowerCase().includes(searchTerm.toLowerCase()) || - project.description?.toLowerCase().includes(searchTerm.toLowerCase()) - ); + return !!selectedProject.repository_url && !!branch.trim(); + }, [selectedProject, zipState, branch]); return ( - - - - - - 新建审计任务 - - + <> + + + {/* Header - 机械风 */} + + + + 开始代码审计 + + -
- {/* 项目选择 */} -
-
- - - {filteredProjects.length} 个可用项目 - -
- - {/* 项目搜索 */} -
- - setSearchTerm(e.target.value)} - className="pl-10 retro-input h-10" - /> -
- - {/* 项目列表 */} -
- {loading ? ( -
-
-
- ) : filteredProjects.length > 0 ? ( - filteredProjects.map((project) => ( -
setTaskForm({ ...taskForm, project_id: project.id })} - > -
-
-

{project.name}

- {project.description && ( -

- {project.description} -

- )} -
- - {getSourceTypeBadge(project.source_type)} - - {isRepositoryProject(project) && ( - <> - {project.repository_type?.toUpperCase() || 'OTHER'} - {project.default_branch} - - )} -
-
- {taskForm.project_id === project.id && ( -
-
-
- )} -
-
- )) - ) : ( -
- -

- {searchTerm ? '未找到匹配的项目' : '暂无可用项目'} -

-
- )} -
-
- - {/* 任务配置 */} - {selectedProject && ( - - - + {/* 项目选择 */} +
+
+ + 选择项目 + + - - 基础配置 - - - - 排除规则 - - - - 高级选项 - - + {filteredProjects.length} 个 + +
- - {/* ZIP项目文件上传 - 仅ZIP类型项目显示 */} - {isZipProject(selectedProject) && ( -
-
- {loadingZipFile ? ( -
-
-

正在检查ZIP文件...

-
- ) : storedZipInfo?.has_file ? ( - // 有已存储的ZIP文件 -
-
- -
-

已有存储的ZIP文件

-

- 文件名: {storedZipInfo.original_filename} - {storedZipInfo.file_size && ( - <> ({storedZipInfo.file_size >= 1024 * 1024 - ? `${(storedZipInfo.file_size / 1024 / 1024).toFixed(2)} MB` - : `${(storedZipInfo.file_size / 1024).toFixed(2)} KB` - }) - )} -

- {storedZipInfo.uploaded_at && ( -

- 上传时间: {new Date(storedZipInfo.uploaded_at).toLocaleString('zh-CN')} -

- )} -
-
- - {/* 选择使用已存储文件还是上传新文件 */} -
- - -
+ {/* 搜索框 */} +
+ + setSearchTerm(e.target.value)} + className="pl-9 h-10 rounded-none border-2 border-black font-mono focus:ring-0 focus:border-black" + /> +
- {/* 上传新文件的输入框 */} - {!useStoredZip && ( -
- - { - const file = e.target.files?.[0]; - if (file) { - const validation = validateZipFile(file); - if (!validation.valid) { - toast.error(validation.error || "文件无效"); - e.target.value = ''; - return; - } - setZipFile(file); - toast.success(`已选择文件: ${file.name}`); - } - }} - className="cursor-pointer retro-input pt-1.5" - /> - {zipFile && ( -

- 新文件: {zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB) -

- )} -
- )} -
- ) : ( - // 没有存储的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 retro-input pt-1.5" - /> -
- - )} -
+ {/* 项目列表 */} + + {loading ? ( +
+
- )} - -
-
- - + ) : filteredProjects.length === 0 ? ( +
+ + + {searchTerm ? "未找到" : "暂无项目"} +
- - {/* 分支选择 - 仅仓库类型项目显示 */} - {taskForm.task_type === "repository" && isRepositoryProject(selectedProject) && ( -
- - setTaskForm({ ...taskForm, branch_name: e.target.value })} - placeholder={selectedProject.default_branch || "main"} - className="retro-input h-10" + ) : ( +
+ {filteredProjects.map((project) => ( + setSelectedProjectId(project.id)} /> -
- )} -
- - {/* 项目信息展示 */} -
-
- -
-

选中项目:{selectedProject.name}

-
-

项目类型:{isRepositoryProject(selectedProject) ? '远程仓库' : 'ZIP上传'}

- {selectedProject.description && ( -

描述:{selectedProject.description}

- )} - {isRepositoryProject(selectedProject) && ( - <> -

仓库平台:{selectedProject.repository_type?.toUpperCase() || 'OTHER'}

-

默认分支:{selectedProject.default_branch}

- - )} - {selectedProject.programming_languages && ( -

编程语言:{JSON.parse(selectedProject.programming_languages).join(', ')}

- )} -
-
-
-
- - - -
-
- -

- 选择要从审计中排除的文件和目录模式 -

-
- - {/* 常用排除模式 */} -
- {commonExcludePatterns.map((pattern) => ( -
- toggleExcludePattern(pattern.value)} - className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" - /> -
-

{pattern.label}

-

{pattern.description}

-
-
))}
+ )} + +
- {/* 自定义排除模式 */} -
- -
- { - if (e.key === 'Enter') { - addCustomPattern(e.currentTarget.value); - e.currentTarget.value = ''; - } - }} - className="retro-input h-10" - /> - -
+ {/* 配置区域 */} + {selectedProject && ( +
+ + 配置 + + + {isRepositoryProject(selectedProject) ? ( +
+ + + 分支 + + {loadingBranches ? ( +
+ + 加载中... +
+ ) : ( + + )}
+ ) : ( + { + if (!zipState.zipFile || !selectedProject) return; + setUploading(true); + try { + await api.uploadProjectZip(selectedProject.id, zipState.zipFile); + toast.success("文件上传成功"); + zipState.switchToStored(); + loadProjects(); + } catch (error) { + const msg = error instanceof Error ? error.message : "上传失败"; + toast.error(msg); + } finally { + setUploading(false); + } + }} + uploading={uploading} + /> + )} - {/* 已选择的排除模式 */} - {taskForm.exclude_patterns.length > 0 && ( -
- -
- {taskForm.exclude_patterns.map((pattern) => ( + {/* 高级选项 */} + + + + + 高级选项 + + + {/* 排除模式 */} +
+ + 排除模式 + +
+ {excludePatterns.map((p) => ( removeExcludePattern(pattern)} + className="rounded-none border border-black bg-white text-gray-800 font-mono text-xs cursor-pointer hover:bg-red-100 hover:text-red-700" + onClick={() => + setExcludePatterns((prev) => + prev.filter((x) => x !== p) + ) + } > - {pattern} × + {p} × ))}
-
- )} -
- - - -
-
- -

- 配置代码扫描的详细参数 -

-
- -
-
-
- - setTaskForm({ - ...taskForm, - scan_config: { ...taskForm.scan_config, include_tests: !!checked } - }) + { + if (e.key === "Enter" && e.currentTarget.value) { + const val = e.currentTarget.value.trim(); + if (val && !excludePatterns.includes(val)) { + setExcludePatterns((prev) => [...prev, val]); + } + e.currentTarget.value = ""; } - className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" - /> -
-

包含测试文件

-

扫描 *test*, *spec* 等测试文件

+ }} + /> +
+ + {/* 文件选择 */} + {(() => { + const isRepo = isRepositoryProject(selectedProject); + const isZip = isZipProject(selectedProject); + const hasStoredZip = zipState.storedZipInfo?.has_file; + const useStored = zipState.useStoredZip; + + // 可以选择文件的条件:仓库项目 或 ZIP项目使用已存储文件 + const canSelectFiles = isRepo || (isZip && useStored && hasStoredZip); + + return ( +
+
+

+ 扫描范围 +

+

+ {selectedFiles + ? `已选 ${selectedFiles.length} 个文件` + : "全部文件"} +

+
+
+ {selectedFiles && canSelectFiles && ( + + )} + +
-
+ ); + })()} + + +
+ )} +
-
- - setTaskForm({ - ...taskForm, - scan_config: { ...taskForm.scan_config, include_docs: !!checked } - }) - } - className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" - /> -
-

包含文档文件

-

扫描 README, docs 等文档文件

-
-
-
- -
-
- - - setTaskForm({ - ...taskForm, - scan_config: { - ...taskForm.scan_config, - max_file_size: parseInt(e.target.value) || 200 - } - }) - } - min="1" - max="10240" - className="retro-input h-10" - /> -
- -
- - -
-
-
- - {/* 分析深度说明 */} -
-
- -
-

分析深度说明:

-
    -
  • 基础扫描:快速检查语法错误和基本问题
  • -
  • 标准扫描:包含代码质量、安全性和性能分析
  • -
  • 深度扫描:全面分析,包含复杂度、可维护性等高级指标
  • -
-
-
-
-
- - - )} - - {/* 操作按钮 */} -
+ {/* Footer - 机械风 */} +
-
- + +
- {/* 终端进度对话框 */} -
+ + + ); -} \ No newline at end of file +} + +function ProjectCard({ + project, + selected, + onSelect, +}: { + project: Project; + selected: boolean; + onSelect: () => void; +}) { + const isRepo = isRepositoryProject(project); + + return ( +
+ + +
+ {isRepo ? ( + + ) : ( + + )} +
+ +
+
+ {project.name} + + {isRepo ? "REPO" : "ZIP"} + +
+ {project.description && ( +

+ {project.description} +

+ )} +
+
+ ); +} + +function ZipUploadCard({ + zipState, + onUpload, + uploading, +}: { + zipState: ReturnType; + onUpload: () => void; + uploading: boolean; +}) { + if (zipState.loading) { + return ( +
+
+ + 检查文件中... + +
+ ); + } + + if (zipState.storedZipInfo?.has_file) { + return ( +
+
+
+ +
+
+

+ {zipState.storedZipInfo.original_filename} +

+

+ {zipState.storedZipInfo.file_size && + formatFileSize(zipState.storedZipInfo.file_size)} + {zipState.storedZipInfo.uploaded_at && + ` · ${new Date(zipState.storedZipInfo.uploaded_at).toLocaleDateString("zh-CN")}`} +

+
+
+ +
+ + +
+ + {!zipState.useStoredZip && ( +
+ { + const file = e.target.files?.[0]; + if (file) { + const v = validateZipFile(file); + if (!v.valid) { + toast.error(v.error || "文件无效"); + e.target.value = ""; + return; + } + zipState.handleFileSelect(file, e.target); + } + }} + className="h-9 flex-1 rounded-none border-2 border-black font-mono" + /> + {zipState.zipFile && ( + + )} +
+ )} +
+ ); + } + + return ( +
+
+
+ +
+
+

+ 上传 ZIP 文件 +

+
+ { + const file = e.target.files?.[0]; + if (file) { + const v = validateZipFile(file); + if (!v.valid) { + toast.error(v.error || "文件无效"); + e.target.value = ""; + return; + } + zipState.handleFileSelect(file, e.target); + } + }} + className="h-9 flex-1 rounded-none border-2 border-black font-mono" + /> + {zipState.zipFile && ( + + )} +
+ {zipState.zipFile && ( +

+ 已选: {zipState.zipFile.name} ( + {formatFileSize(zipState.zipFile.size)}) +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/audit/FileSelectionDialog.tsx b/frontend/src/components/audit/FileSelectionDialog.tsx new file mode 100644 index 0000000..f2fa930 --- /dev/null +++ b/frontend/src/components/audit/FileSelectionDialog.tsx @@ -0,0 +1,189 @@ +import { useState, useEffect, useMemo } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { Search, FileText, CheckSquare, Square, FolderOpen } from "lucide-react"; +import { api } from "@/shared/config/database"; +import { toast } from "sonner"; + +interface FileSelectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + branch?: string; + onConfirm: (selectedFiles: string[]) => void; +} + +interface FileNode { + path: string; + size: number; +} + +export default function FileSelectionDialog({ open, onOpenChange, projectId, branch, onConfirm }: FileSelectionDialogProps) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + if (open && projectId) { + loadFiles(); + } else { + // Reset state when closed + setFiles([]); + setSelectedFiles(new Set()); + setSearchTerm(""); + } + }, [open, projectId, branch]); + + const loadFiles = async () => { + try { + setLoading(true); + const data = await api.getProjectFiles(projectId, branch); + setFiles(data); + setSelectedFiles(new Set(data.map(f => f.path))); + } catch (error) { + console.error("Failed to load files:", error); + toast.error("加载文件列表失败"); + } finally { + setLoading(false); + } + }; + + const filteredFiles = useMemo(() => { + if (!searchTerm) return files; + return files.filter(f => f.path.toLowerCase().includes(searchTerm.toLowerCase())); + }, [files, searchTerm]); + + const handleToggleFile = (path: string) => { + const newSelected = new Set(selectedFiles); + if (newSelected.has(path)) { + newSelected.delete(path); + } else { + newSelected.add(path); + } + setSelectedFiles(newSelected); + }; + + const handleSelectAll = () => { + const newSelected = new Set(selectedFiles); + filteredFiles.forEach(f => newSelected.add(f.path)); + setSelectedFiles(newSelected); + }; + + const handleDeselectAll = () => { + const newSelected = new Set(selectedFiles); + filteredFiles.forEach(f => newSelected.delete(f.path)); + setSelectedFiles(newSelected); + }; + + const handleConfirm = () => { + if (selectedFiles.size === 0) { + toast.error("请至少选择一个文件"); + return; + } + onConfirm(Array.from(selectedFiles)); + onOpenChange(false); + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + }; + + return ( + + + + + + 选择要审计的文件 + + + +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10 retro-input h-10" + /> +
+ + +
+ +
+ 共 {files.length} 个文件 + 已选择 {selectedFiles.size} 个 +
+ +
+ {loading ? ( +
+
+
+ ) : filteredFiles.length > 0 ? ( + +
+ {filteredFiles.map((file) => ( +
handleToggleFile(file.path)} + > + handleToggleFile(file.path)} + className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" + /> +
+

+ {file.path} +

+
+ {file.size > 0 && ( + + {formatSize(file.size)} + + )} +
+ ))} +
+
+ ) : ( +
+ +

没有找到文件

+
+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/audit/components/AdvancedOptions.tsx b/frontend/src/components/audit/components/AdvancedOptions.tsx new file mode 100644 index 0000000..19408be --- /dev/null +++ b/frontend/src/components/audit/components/AdvancedOptions.tsx @@ -0,0 +1,204 @@ +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertCircle } from "lucide-react"; +import type { CreateAuditTaskForm } from "@/shared/types"; + +interface AdvancedOptionsProps { + scanConfig: CreateAuditTaskForm["scan_config"]; + onUpdate: (updates: Partial) => void; + onOpenFileSelection: () => void; +} + +export default function AdvancedOptions({ + scanConfig, + onUpdate, + onOpenFileSelection, +}: AdvancedOptionsProps) { + const hasSelectedFiles = + scanConfig.file_paths && scanConfig.file_paths.length > 0; + + return ( +
+
+ +

+ 配置代码扫描的详细参数 +

+
+ +
+ {/* 左侧:复选框选项 */} +
+ onUpdate({ include_tests: checked })} + label="包含测试文件" + description="扫描 *test*, *spec* 等测试文件" + /> + onUpdate({ include_docs: checked })} + label="包含文档文件" + description="扫描 README, docs 等文档文件" + /> +
+ + {/* 右侧:输入选项 */} +
+
+ + + onUpdate({ max_file_size: parseInt(e.target.value) || 200 }) + } + min="1" + max="10240" + className="retro-input h-10" + /> +
+ +
+ + +
+
+
+ + {/* 分析范围 */} +
+ +
+
+

+ {hasSelectedFiles + ? `已选择 ${scanConfig.file_paths!.length} 个文件` + : "全量扫描 (所有文件)"} +

+

+ {hasSelectedFiles + ? "仅分析选中的文件" + : "分析项目中的所有代码文件"} +

+
+
+ {hasSelectedFiles && ( + + )} + +
+
+
+ + {/* 分析深度说明 */} + +
+ ); +} + +function CheckboxOption({ + checked, + onChange, + label, + description, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + description: string; +}) { + return ( +
+ onChange(!!c)} + className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" + /> +
+

{label}

+

{description}

+
+
+ ); +} + +function DepthExplanation() { + return ( +
+
+ +
+

+ 分析深度说明: +

+
    +
  • + • 基础扫描:快速检查语法错误和基本问题 +
  • +
  • + • 标准扫描:包含代码质量、安全性和性能分析 +
  • +
  • + • 深度扫描 + :全面分析,包含复杂度、可维护性等高级指标 +
  • +
+
+
+
+ ); +} diff --git a/frontend/src/components/audit/components/BasicConfig.tsx b/frontend/src/components/audit/components/BasicConfig.tsx new file mode 100644 index 0000000..c82968e --- /dev/null +++ b/frontend/src/components/audit/components/BasicConfig.tsx @@ -0,0 +1,152 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { GitBranch, Zap, Info } from "lucide-react"; +import type { Project, CreateAuditTaskForm } from "@/shared/types"; +import { isRepositoryProject, isZipProject } from "@/shared/utils/projectUtils"; +import ZipFileSection from "./ZipFileSection"; +import type { ZipFileMeta } from "@/shared/utils/zipStorage"; + +interface BasicConfigProps { + project: Project; + taskForm: CreateAuditTaskForm; + onUpdateForm: (updates: Partial) => void; + // ZIP 相关 + zipLoading: boolean; + storedZipInfo: ZipFileMeta | null; + useStoredZip: boolean; + zipFile: File | null; + onSwitchToStored: () => void; + onSwitchToUpload: () => void; + onFileSelect: (file: File | null, input?: HTMLInputElement) => void; +} + +export default function BasicConfig({ + project, + taskForm, + onUpdateForm, + zipLoading, + storedZipInfo, + useStoredZip, + zipFile, + onSwitchToStored, + onSwitchToUpload, + onFileSelect, +}: BasicConfigProps) { + const isRepo = isRepositoryProject(project); + const isZip = isZipProject(project); + + return ( +
+ {/* ZIP 项目文件上传 */} + {isZip && ( + + )} + +
+ {/* 任务类型 */} +
+ + +
+ + {/* 分支选择 - 仅仓库类型项目显示 */} + {taskForm.task_type === "repository" && isRepo && ( +
+ + onUpdateForm({ branch_name: e.target.value })} + placeholder={project.default_branch || "main"} + className="retro-input h-10" + /> +
+ )} +
+ + {/* 项目信息展示 */} + +
+ ); +} + +function ProjectInfoCard({ project }: { project: Project }) { + const isRepo = isRepositoryProject(project); + let languages: string[] = []; + + try { + if (project.programming_languages) { + languages = JSON.parse(project.programming_languages); + } + } catch { + // ignore + } + + return ( +
+
+ +
+

+ 选中项目:{project.name} +

+
+

项目类型:{isRepo ? "远程仓库" : "ZIP上传"}

+ {project.description &&

描述:{project.description}

} + {isRepo && ( + <> +

+ 仓库平台:{project.repository_type?.toUpperCase() || "OTHER"} +

+

默认分支:{project.default_branch}

+ + )} + {languages.length > 0 &&

编程语言:{languages.join(", ")}

} +
+
+
+
+ ); +} diff --git a/frontend/src/components/audit/components/ExcludePatterns.tsx b/frontend/src/components/audit/components/ExcludePatterns.tsx new file mode 100644 index 0000000..f260a99 --- /dev/null +++ b/frontend/src/components/audit/components/ExcludePatterns.tsx @@ -0,0 +1,139 @@ +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; + +export const COMMON_EXCLUDE_PATTERNS = [ + { + label: "node_modules", + value: "node_modules/**", + description: "Node.js 依赖包", + }, + { label: ".git", value: ".git/**", description: "Git 版本控制文件" }, + { label: "dist/build", value: "dist/**", description: "构建输出目录" }, + { label: "logs", value: "*.log", description: "日志文件" }, + { label: "cache", value: ".cache/**", description: "缓存文件" }, + { label: "temp", value: "temp/**", description: "临时文件" }, + { label: "vendor", value: "vendor/**", description: "第三方库" }, + { label: "coverage", value: "coverage/**", description: "测试覆盖率报告" }, +]; + +interface ExcludePatternsProps { + patterns: string[]; + onToggle: (pattern: string) => void; + onAdd: (pattern: string) => void; + onRemove: (pattern: string) => void; +} + +export default function ExcludePatterns({ + patterns, + onToggle, + onAdd, + onRemove, +}: ExcludePatternsProps) { + return ( +
+
+ +

+ 选择要从审计中排除的文件和目录模式 +

+
+ + {/* 常用排除模式 */} +
+ {COMMON_EXCLUDE_PATTERNS.map((pattern) => ( +
+ onToggle(pattern.value)} + className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" + /> +
+

{pattern.label}

+

+ {pattern.description} +

+
+
+ ))} +
+ + {/* 自定义排除模式 */} + + + {/* 已选择的排除模式 */} + {patterns.length > 0 && ( + + )} +
+ ); +} + +function CustomPatternInput({ onAdd }: { onAdd: (pattern: string) => void }) { + const handleAdd = (input: HTMLInputElement) => { + if (input.value.trim()) { + onAdd(input.value); + input.value = ""; + } + }; + + return ( +
+ +
+ { + if (e.key === "Enter") { + handleAdd(e.currentTarget); + } + }} + className="retro-input h-10" + /> + +
+
+ ); +} + +function SelectedPatterns({ + patterns, + onRemove, +}: { + patterns: string[]; + onRemove: (pattern: string) => void; +}) { + return ( +
+ +
+ {patterns.map((pattern) => ( + onRemove(pattern)} + > + {pattern} × + + ))} +
+
+ ); +} diff --git a/frontend/src/components/audit/components/ProjectSelector.tsx b/frontend/src/components/audit/components/ProjectSelector.tsx new file mode 100644 index 0000000..6d965ef --- /dev/null +++ b/frontend/src/components/audit/components/ProjectSelector.tsx @@ -0,0 +1,151 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Search, FileText } from "lucide-react"; +import type { Project } from "@/shared/types"; +import { + isRepositoryProject, + getSourceTypeBadge, +} from "@/shared/utils/projectUtils"; + +interface ProjectSelectorProps { + projects: Project[]; + selectedId: string; + searchTerm: string; + loading: boolean; + onSelect: (id: string) => void; + onSearchChange: (term: string) => void; +} + +export default function ProjectSelector({ + projects, + selectedId, + searchTerm, + loading, + onSelect, + onSearchChange, +}: ProjectSelectorProps) { + const filteredProjects = projects.filter( + (p) => + p.name.toLowerCase().includes(searchTerm.toLowerCase()) || + p.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+
+ + + {filteredProjects.length} 个可用项目 + +
+ +
+ + onSearchChange(e.target.value)} + className="pl-10 retro-input h-10" + /> +
+ +
+ {loading ? ( + + ) : filteredProjects.length > 0 ? ( + filteredProjects.map((project) => ( + onSelect(project.id)} + /> + )) + ) : ( + + )} +
+
+ ); +} + +function ProjectCard({ + project, + isSelected, + onSelect, +}: { + project: Project; + isSelected: boolean; + onSelect: () => void; +}) { + const isRepo = isRepositoryProject(project); + + return ( +
+
+
+

+ {project.name} +

+ {project.description && ( +

+ {project.description} +

+ )} +
+ + {getSourceTypeBadge(project.source_type)} + + {isRepo && ( + <> + + {project.repository_type?.toUpperCase() || "OTHER"} + + {project.default_branch} + + )} +
+
+ {isSelected && ( +
+
+
+ )} +
+
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function EmptyState({ hasSearch }: { hasSearch: boolean }) { + return ( +
+ +

+ {hasSearch ? "未找到匹配的项目" : "暂无可用项目"} +

+
+ ); +} diff --git a/frontend/src/components/audit/components/ZipFileSection.tsx b/frontend/src/components/audit/components/ZipFileSection.tsx new file mode 100644 index 0000000..2cd3713 --- /dev/null +++ b/frontend/src/components/audit/components/ZipFileSection.tsx @@ -0,0 +1,178 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { AlertCircle, Info } from "lucide-react"; +import type { ZipFileMeta } from "@/shared/utils/zipStorage"; +import { formatFileSize } from "../hooks/useZipFile"; + +interface ZipFileSectionProps { + loading: boolean; + storedZipInfo: ZipFileMeta | null; + useStoredZip: boolean; + zipFile: File | null; + onSwitchToStored: () => void; + onSwitchToUpload: () => void; + onFileSelect: (file: File | null, input?: HTMLInputElement) => void; +} + +export default function ZipFileSection({ + loading, + storedZipInfo, + useStoredZip, + zipFile, + onSwitchToStored, + onSwitchToUpload, + onFileSelect, +}: ZipFileSectionProps) { + if (loading) { + return ( +
+
+
+

正在检查ZIP文件...

+
+
+ ); + } + + return ( +
+
+ {storedZipInfo?.has_file ? ( + + ) : ( + + )} +
+
+ ); +} + +function StoredZipView({ + storedZipInfo, + useStoredZip, + zipFile, + onSwitchToStored, + onSwitchToUpload, + onFileSelect, +}: { + storedZipInfo: ZipFileMeta; + useStoredZip: boolean; + zipFile: File | null; + onSwitchToStored: () => void; + onSwitchToUpload: () => void; + onFileSelect: (file: File | null, input?: HTMLInputElement) => void; +}) { + return ( +
+
+ +
+

+ 已有存储的ZIP文件 +

+

+ 文件名: {storedZipInfo.original_filename} + {storedZipInfo.file_size && ( + <> ({formatFileSize(storedZipInfo.file_size)}) + )} +

+ {storedZipInfo.uploaded_at && ( +

+ 上传时间:{" "} + {new Date(storedZipInfo.uploaded_at).toLocaleString("zh-CN")} +

+ )} +
+
+ +
+ + +
+ + {!useStoredZip && ( +
+ + { + const file = e.target.files?.[0]; + onFileSelect(file || null, e.target); + }} + className="cursor-pointer retro-input pt-1.5" + /> + {zipFile && ( +

+ 新文件: {zipFile.name} ({formatFileSize(zipFile.size)}) +

+ )} +
+ )} +
+ ); +} + +function NoStoredZipView({ + onFileSelect, +}: { + onFileSelect: (file: File | null, input?: HTMLInputElement) => void; +}) { + return ( + <> +
+ +
+

+ 需要上传ZIP文件 +

+

+ 此项目还没有存储的ZIP文件,请上传文件进行扫描 +

+
+
+ +
+ + { + const file = e.target.files?.[0]; + onFileSelect(file || null, e.target); + }} + className="cursor-pointer retro-input pt-1.5" + /> +
+ + ); +} diff --git a/frontend/src/components/audit/hooks/useTaskForm.ts b/frontend/src/components/audit/hooks/useTaskForm.ts new file mode 100644 index 0000000..2d55cac --- /dev/null +++ b/frontend/src/components/audit/hooks/useTaskForm.ts @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback } from "react"; +import type { Project, CreateAuditTaskForm } from "@/shared/types"; +import { api } from "@/shared/config/database"; +import { toast } from "sonner"; + +const DEFAULT_EXCLUDE_PATTERNS = [ + "node_modules/**", + ".git/**", + "dist/**", + "build/**", + "*.log", +]; + +const DEFAULT_FORM: CreateAuditTaskForm = { + project_id: "", + task_type: "repository", + branch_name: "main", + exclude_patterns: DEFAULT_EXCLUDE_PATTERNS, + scan_config: { + include_tests: true, + include_docs: false, + max_file_size: 200, + analysis_depth: "standard", + }, +}; + +export function useTaskForm(preselectedProjectId?: string) { + const [taskForm, setTaskForm] = useState(DEFAULT_FORM); + + // 加载默认配置 + useEffect(() => { + api.getDefaultConfig().catch(() => { + // 使用默认值 + }); + }, []); + + // 预选项目 + useEffect(() => { + if (preselectedProjectId) { + setTaskForm((prev) => ({ ...prev, project_id: preselectedProjectId })); + } + }, [preselectedProjectId]); + + const resetForm = useCallback(() => { + setTaskForm(DEFAULT_FORM); + }, []); + + const updateForm = useCallback((updates: Partial) => { + setTaskForm((prev) => ({ ...prev, ...updates })); + }, []); + + const updateScanConfig = useCallback( + (updates: Partial) => { + setTaskForm((prev) => ({ + ...prev, + scan_config: { ...prev.scan_config, ...updates }, + })); + }, + [] + ); + + const toggleExcludePattern = useCallback((pattern: string) => { + setTaskForm((prev) => { + const patterns = prev.exclude_patterns || []; + const newPatterns = patterns.includes(pattern) + ? patterns.filter((p) => p !== pattern) + : [...patterns, pattern]; + return { ...prev, exclude_patterns: newPatterns }; + }); + }, []); + + const addExcludePattern = useCallback((pattern: string) => { + const trimmed = pattern.trim(); + if (!trimmed) return; + + setTaskForm((prev) => { + if (prev.exclude_patterns.includes(trimmed)) return prev; + return { ...prev, exclude_patterns: [...prev.exclude_patterns, trimmed] }; + }); + }, []); + + const removeExcludePattern = useCallback((pattern: string) => { + setTaskForm((prev) => ({ + ...prev, + exclude_patterns: prev.exclude_patterns.filter((p) => p !== pattern), + })); + }, []); + + const setSelectedFiles = useCallback((files: string[] | undefined) => { + setTaskForm((prev) => ({ + ...prev, + scan_config: { ...prev.scan_config, file_paths: files }, + })); + }, []); + + return { + taskForm, + setTaskForm, + resetForm, + updateForm, + updateScanConfig, + toggleExcludePattern, + addExcludePattern, + removeExcludePattern, + setSelectedFiles, + }; +} + +export function useProjects() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + + const loadProjects = useCallback(async () => { + try { + setLoading(true); + const data = await api.getProjects(); + setProjects(data.filter((p) => p.is_active)); + } catch (error) { + console.error("Failed to load projects:", error); + toast.error("加载项目失败"); + } finally { + setLoading(false); + } + }, []); + + return { projects, loading, loadProjects }; +} diff --git a/frontend/src/components/audit/hooks/useZipFile.ts b/frontend/src/components/audit/hooks/useZipFile.ts new file mode 100644 index 0000000..06ce2bb --- /dev/null +++ b/frontend/src/components/audit/hooks/useZipFile.ts @@ -0,0 +1,91 @@ +import { useState, useEffect, useCallback } from "react"; +import type { Project } from "@/shared/types"; +import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage"; +import { validateZipFile } from "@/features/projects/services/repoZipScan"; +import { isZipProject } from "@/shared/utils/projectUtils"; +import { toast } from "sonner"; + +export function useZipFile(project: Project | undefined, projects: Project[]) { + const [zipFile, setZipFile] = useState(null); + const [loading, setLoading] = useState(false); + const [storedZipInfo, setStoredZipInfo] = useState(null); + const [useStoredZip, setUseStoredZip] = useState(true); + + // 检查已存储的 ZIP 文件 + useEffect(() => { + const checkStoredZip = async () => { + if (!project || !isZipProject(project)) { + setStoredZipInfo(null); + return; + } + + try { + setLoading(true); + const zipInfo = await getZipFileInfo(project.id); + setStoredZipInfo(zipInfo); + setUseStoredZip(zipInfo.has_file); + } catch (error) { + console.error("检查ZIP文件失败:", error); + setStoredZipInfo(null); + } finally { + setLoading(false); + } + }; + + checkStoredZip(); + }, [project?.id, projects]); + + const handleFileSelect = useCallback( + (file: File | null, inputElement?: HTMLInputElement) => { + if (!file) { + setZipFile(null); + return; + } + + const validation = validateZipFile(file); + if (!validation.valid) { + toast.error(validation.error || "文件无效"); + if (inputElement) inputElement.value = ""; + return; + } + + setZipFile(file); + const sizeText = formatFileSize(file.size); + toast.success(`已选择文件: ${file.name} (${sizeText})`); + }, + [] + ); + + const reset = useCallback(() => { + setZipFile(null); + setStoredZipInfo(null); + setUseStoredZip(true); + }, []); + + const switchToUpload = useCallback(() => { + setUseStoredZip(false); + }, []); + + const switchToStored = useCallback(() => { + setUseStoredZip(true); + setZipFile(null); + }, []); + + return { + zipFile, + loading, + storedZipInfo, + useStoredZip, + handleFileSelect, + reset, + switchToUpload, + switchToStored, + }; +} + +export function formatFileSize(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + return `${(bytes / 1024).toFixed(2)} KB`; +} diff --git a/frontend/src/features/projects/services/repoScan.ts b/frontend/src/features/projects/services/repoScan.ts index aef8c65..d264b8c 100644 --- a/frontend/src/features/projects/services/repoScan.ts +++ b/frontend/src/features/projects/services/repoScan.ts @@ -6,21 +6,24 @@ export async function runRepositoryAudit(params: { branch?: string; exclude?: string[]; createdBy?: string; + filePaths?: string[]; }) { // 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递 // The backend handles everything now. // We just need to create the task (which triggers the scan in our new api implementation) // or call a specific scan endpoint. - + // In our new api.createAuditTask implementation (src/shared/api/database.ts), // it actually calls /projects/{id}/scan which starts the process. - + const task = await api.createAuditTask({ project_id: params.projectId, task_type: "repository", branch_name: params.branch || "main", exclude_patterns: params.exclude || [], - scan_config: {}, + scan_config: { + file_paths: params.filePaths + }, created_by: params.createdBy || "unknown" } as any); diff --git a/frontend/src/features/projects/services/repoZipScan.ts b/frontend/src/features/projects/services/repoZipScan.ts index e0e4aab..a6927a8 100644 --- a/frontend/src/features/projects/services/repoZipScan.ts +++ b/frontend/src/features/projects/services/repoZipScan.ts @@ -8,13 +8,19 @@ export async function scanZipFile(params: { zipFile: File; excludePatterns?: string[]; createdBy?: string; + filePaths?: string[]; }): Promise { const formData = new FormData(); formData.append("file", params.zipFile); formData.append("project_id", params.projectId); + const scanConfig = { + file_paths: params.filePaths, + full_scan: !params.filePaths || params.filePaths.length === 0 + }; + formData.append("scan_config", JSON.stringify(scanConfig)); + const res = await apiClient.post(`/scan/upload-zip`, formData, { - params: { project_id: params.projectId }, headers: { "Content-Type": "multipart/form-data", }, @@ -30,8 +36,13 @@ export async function scanStoredZipFile(params: { projectId: string; excludePatterns?: string[]; createdBy?: string; + filePaths?: string[]; }): Promise { - const res = await apiClient.post(`/scan/scan-stored-zip`, null, { + const scanRequest = { + file_paths: params.filePaths, + full_scan: !params.filePaths || params.filePaths.length === 0 + }; + const res = await apiClient.post(`/scan/scan-stored-zip`, scanRequest, { params: { project_id: params.projectId }, }); diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 23a158f..1ee88d8 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -31,7 +31,9 @@ import { hasZipFile } from "@/shared/utils/zipStorage"; import { isRepositoryProject, getSourceTypeLabel } from "@/shared/utils/projectUtils"; import { toast } from "sonner"; import CreateTaskDialog from "@/components/audit/CreateTaskDialog"; +import FileSelectionDialog from "@/components/audit/FileSelectionDialog"; import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { SUPPORTED_LANGUAGES } from "@/shared/constants"; export default function ProjectDetail() { @@ -56,6 +58,9 @@ export default function ProjectDetail() { const [latestIssues, setLatestIssues] = useState([]); const [loadingIssues, setLoadingIssues] = useState(false); + const [showFileSelectionDialog, setShowFileSelectionDialog] = useState(false); + const [showAuditOptionsDialog, setShowAuditOptionsDialog] = useState(false); + useEffect(() => { if (activeTab === 'issues' && tasks.length > 0) { loadLatestIssues(); @@ -142,7 +147,25 @@ export default function ProjectDetail() { } }; - const handleRunAudit = async () => { + const handleRunAudit = () => { + setShowAuditOptionsDialog(true); + }; + + const handleStartFullAudit = () => { + setShowAuditOptionsDialog(false); + startAudit(undefined); + }; + + const handleOpenCustomAudit = () => { + setShowAuditOptionsDialog(false); + setShowFileSelectionDialog(true); + }; + + const handleStartCustomAudit = (files: string[]) => { + startAudit(files); + }; + + const startAudit = async (filePaths?: string[]) => { if (!project || !id) return; // 检查是否有仓库地址 @@ -150,12 +173,13 @@ export default function ProjectDetail() { // 有仓库地址,启动仓库审计 try { setScanning(true); - console.log('开始启动仓库审计任务...'); + console.log('开始启动仓库审计任务...', filePaths ? `指定 ${filePaths.length} 个文件` : '全量扫描'); const taskId = await runRepositoryAudit({ projectId: id, repoUrl: project.repository_url, branch: project.default_branch || 'main', - createdBy: undefined + createdBy: undefined, + filePaths: filePaths }); console.log('审计任务创建成功,taskId:', taskId); @@ -179,13 +203,14 @@ export default function ProjectDetail() { const hasFile = await hasZipFile(id); if (hasFile) { - console.log('找到后端存储的ZIP文件,开始启动审计...'); + console.log('找到后端存储的ZIP文件,开始启动审计...', filePaths ? `指定 ${filePaths.length} 个文件` : '全量扫描'); try { // 使用后端存储的ZIP文件启动审计 const taskId = await scanStoredZipFile({ projectId: id, excludePatterns: ['node_modules/**', '.git/**', 'dist/**', 'build/**'], - createdBy: 'local-user' + createdBy: 'local-user', + filePaths: filePaths }); console.log('审计任务创建成功,taskId:', taskId); @@ -848,6 +873,54 @@ export default function ProjectDetail() { taskId={currentTaskId} taskType="repository" /> + + {/* 审计选项对话框 */} + + + + + + 选择审计方式 + + +
+ + + +
+ + + +
+
+ + {/* 文件选择对话框 */} +
); } \ No newline at end of file diff --git a/frontend/src/shared/api/database.ts b/frontend/src/shared/api/database.ts index 4562296..f18d009 100644 --- a/frontend/src/shared/api/database.ts +++ b/frontend/src/shared/api/database.ts @@ -64,6 +64,34 @@ export const api = { } }, + async getProjectFiles(id: string, branch?: string): Promise> { + try { + const params = branch ? { branch } : {}; + const res = await apiClient.get(`/projects/${id}/files`, { params }); + return res.data; + } catch (e) { + return []; + } + }, + + async getProjectBranches(id: string): Promise<{ branches: string[]; default_branch: string; error?: string }> { + try { + const res = await apiClient.get(`/projects/${id}/branches`); + return res.data; + } catch (e) { + return { branches: ["main"], default_branch: "main", error: String(e) }; + } + }, + + async uploadProjectZip(id: string, file: File): Promise<{ message: string; original_filename: string; file_size: number }> { + const formData = new FormData(); + formData.append('file', file); + const res = await apiClient.post(`/projects/${id}/zip`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + return res.data; + }, + async createProject(project: CreateProjectForm & { owner_id?: string }): Promise { const res = await apiClient.post('/projects/', { name: project.name, @@ -141,7 +169,13 @@ export const api = { async createAuditTask(task: CreateAuditTaskForm & { created_by?: string }): Promise { // Trigger scan on the project - const res = await apiClient.post(`/projects/${task.project_id}/scan`); + const scanRequest = { + file_paths: task.scan_config?.file_paths, + full_scan: !task.scan_config?.file_paths || task.scan_config.file_paths.length === 0, + exclude_patterns: task.exclude_patterns || [], + branch_name: task.branch_name || "main" + }; + const res = await apiClient.post(`/projects/${task.project_id}/scan`, scanRequest); // Fetch the created task const taskRes = await apiClient.get(`/tasks/${res.data.task_id}`); return taskRes.data; diff --git a/frontend/src/shared/types/index.ts b/frontend/src/shared/types/index.ts index de51965..19cc7b5 100644 --- a/frontend/src/shared/types/index.ts +++ b/frontend/src/shared/types/index.ts @@ -132,6 +132,7 @@ export interface CreateAuditTaskForm { include_docs?: boolean; max_file_size?: number; analysis_depth?: 'basic' | 'standard' | 'deep'; + file_paths?: string[]; }; }