feat(audit): refactor task creation with file selection and advanced options

- Add FileSelectionDialog component for granular file selection in audit tasks
- Extract task form logic into useTaskForm and useZipFile custom hooks
- Create modular components: BasicConfig, AdvancedOptions, ExcludePatterns, ProjectSelector, ZipFileSection
- Add file listing endpoint GET /projects/{id}/files with branch support
- Add branch listing endpoint GET /projects/{id}/branches for repository projects
- Implement ScanRequest model with file_paths, exclude_patterns, and branch_name fields
- Update scan endpoint to accept selective file scanning and exclude patterns
- Add branch_name and exclude_patterns fields to AuditTask model
- Enhance scanner service with GitHub and GitLab file/branch retrieval functions
- Improve CreateTaskDialog with better UX for repository and ZIP file scanning
- Support per-scan configuration storage in audit tasks
- Refactor repository scan services to handle file selection and branch parameters
This commit is contained in:
lintsinghua 2025-12-06 20:47:28 +08:00
parent 33c4df9645
commit 07810b309c
17 changed files with 2271 additions and 800 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col bg-white border-2 border-black p-0 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-none">
<DialogHeader className="p-6 border-b-2 border-black bg-gray-50 flex-shrink-0">
<DialogTitle className="flex items-center space-x-2 font-display font-bold uppercase text-xl">
<FolderOpen className="w-6 h-6 text-black" />
<span></span>
</DialogTitle>
</DialogHeader>
<div className="p-6 flex-1 flex flex-col min-h-0 space-y-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<Input
placeholder="搜索文件..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 retro-input h-10"
/>
</div>
<Button variant="outline" onClick={handleSelectAll} className="retro-btn bg-white text-black h-10 px-3">
<CheckSquare className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleDeselectAll} className="retro-btn bg-white text-black h-10 px-3">
<Square className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex items-center justify-between text-sm font-mono font-bold text-gray-600">
<span> {files.length} </span>
<span> {selectedFiles.size} </span>
</div>
<div className="border-2 border-black bg-gray-50 relative overflow-hidden" style={{ height: '300px' }}>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-none h-8 w-8 border-4 border-primary border-t-transparent"></div>
</div>
) : filteredFiles.length > 0 ? (
<ScrollArea className="h-full w-full p-2">
<div className="space-y-1">
{filteredFiles.map((file) => (
<div
key={file.path}
className="flex items-center space-x-3 p-2 hover:bg-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
onClick={() => handleToggleFile(file.path)}
>
<Checkbox
checked={selectedFiles.has(file.path)}
onCheckedChange={() => handleToggleFile(file.path)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-mono truncate" title={file.path}>
{file.path}
</p>
</div>
{file.size > 0 && (
<Badge variant="outline" className="text-xs font-mono rounded-none border-gray-400 text-gray-500">
{formatSize(file.size)}
</Badge>
)}
</div>
))}
</div>
</ScrollArea>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500">
<FileText className="w-12 h-12 mb-2 opacity-20" />
<p className="font-mono text-sm"></p>
</div>
)}
</div>
</div>
<DialogFooter className="p-6 border-t-2 border-black bg-gray-50 flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="retro-btn bg-white text-black hover:bg-gray-100 mr-2">
</Button>
<Button onClick={handleConfirm} className="retro-btn bg-primary text-white hover:bg-primary/90">
<FileText className="w-4 h-4 mr-2" />
({selectedFiles.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<CreateAuditTaskForm["scan_config"]>) => void;
onOpenFileSelection: () => void;
}
export default function AdvancedOptions({
scanConfig,
onUpdate,
onOpenFileSelection,
}: AdvancedOptionsProps) {
const hasSelectedFiles =
scanConfig.file_paths && scanConfig.file_paths.length > 0;
return (
<div className="space-y-6">
<div>
<Label className="text-base font-bold uppercase"></Label>
<p className="text-sm text-gray-500 mt-1 font-bold">
</p>
</div>
<div className="grid grid-cols-2 gap-6">
{/* 左侧:复选框选项 */}
<div className="space-y-4">
<CheckboxOption
checked={scanConfig.include_tests}
onChange={(checked) => onUpdate({ include_tests: checked })}
label="包含测试文件"
description="扫描 *test*, *spec* 等测试文件"
/>
<CheckboxOption
checked={scanConfig.include_docs}
onChange={(checked) => onUpdate({ include_docs: checked })}
label="包含文档文件"
description="扫描 README, docs 等文档文件"
/>
</div>
{/* 右侧:输入选项 */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="max_file_size" className="font-bold uppercase">
(KB)
</Label>
<Input
id="max_file_size"
type="number"
value={scanConfig.max_file_size}
onChange={(e) =>
onUpdate({ max_file_size: parseInt(e.target.value) || 200 })
}
min="1"
max="10240"
className="retro-input h-10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="analysis_depth" className="font-bold uppercase">
</Label>
<Select
value={scanConfig.analysis_depth}
onValueChange={(value: "basic" | "standard" | "deep") =>
onUpdate({ analysis_depth: value })
}
>
<SelectTrigger
id="analysis_depth"
className="retro-input h-10 rounded-none border-2 border-black shadow-none focus:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-none border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<SelectItem value="basic" className="font-mono">
()
</SelectItem>
<SelectItem value="standard" className="font-mono">
()
</SelectItem>
<SelectItem value="deep" className="font-mono">
()
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 分析范围 */}
<div className="space-y-2 border-t-2 border-dashed border-gray-300 pt-4">
<Label className="font-bold uppercase"></Label>
<div className="flex items-center justify-between p-3 border-2 border-black bg-white">
<div>
<p className="text-sm font-bold uppercase">
{hasSelectedFiles
? `已选择 ${scanConfig.file_paths!.length} 个文件`
: "全量扫描 (所有文件)"}
</p>
<p className="text-xs text-gray-500 font-bold">
{hasSelectedFiles
? "仅分析选中的文件"
: "分析项目中的所有代码文件"}
</p>
</div>
<div className="flex space-x-2">
{hasSelectedFiles && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onUpdate({ file_paths: undefined })}
className="retro-btn bg-white text-red-600 hover:bg-red-50 h-8"
>
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenFileSelection}
className="retro-btn bg-white text-black hover:bg-gray-50 h-8"
>
{hasSelectedFiles ? "修改选择" : "选择文件"}
</Button>
</div>
</div>
</div>
{/* 分析深度说明 */}
<DepthExplanation />
</div>
);
}
function CheckboxOption({
checked,
onChange,
label,
description,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description: string;
}) {
return (
<div className="flex items-center space-x-3 p-3 border-2 border-black bg-white">
<Checkbox
checked={checked}
onCheckedChange={(c) => onChange(!!c)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
/>
<div>
<p className="text-sm font-bold uppercase">{label}</p>
<p className="text-xs text-gray-500 font-bold">{description}</p>
</div>
</div>
);
}
function DepthExplanation() {
return (
<div className="bg-amber-50 border-2 border-black p-4 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="text-sm font-mono">
<p className="font-bold text-amber-900 mb-2 uppercase">
</p>
<ul className="text-amber-800 space-y-1 text-xs font-bold">
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -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<CreateAuditTaskForm>) => 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 (
<div className="space-y-4 mt-6 font-mono">
{/* ZIP 项目文件上传 */}
{isZip && (
<ZipFileSection
loading={zipLoading}
storedZipInfo={storedZipInfo}
useStoredZip={useStoredZip}
zipFile={zipFile}
onSwitchToStored={onSwitchToStored}
onSwitchToUpload={onSwitchToUpload}
onFileSelect={onFileSelect}
/>
)}
<div className="grid grid-cols-2 gap-4">
{/* 任务类型 */}
<div className="space-y-2">
<Label htmlFor="task_type" className="font-bold uppercase">
</Label>
<Select
value={taskForm.task_type}
onValueChange={(value: "repository" | "instant") =>
onUpdateForm({ task_type: value })
}
>
<SelectTrigger className="retro-input h-10 rounded-none border-2 border-black shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-none border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<SelectItem value="repository">
<div className="flex items-center space-x-2">
<GitBranch className="w-4 h-4" />
<span className="font-mono"></span>
</div>
</SelectItem>
<SelectItem value="instant">
<div className="flex items-center space-x-2">
<Zap className="w-4 h-4" />
<span className="font-mono"></span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 分支选择 - 仅仓库类型项目显示 */}
{taskForm.task_type === "repository" && isRepo && (
<div className="space-y-2">
<Label htmlFor="branch_name" className="font-bold uppercase">
</Label>
<Input
id="branch_name"
value={taskForm.branch_name || ""}
onChange={(e) => onUpdateForm({ branch_name: e.target.value })}
placeholder={project.default_branch || "main"}
className="retro-input h-10"
/>
</div>
)}
</div>
{/* 项目信息展示 */}
<ProjectInfoCard project={project} />
</div>
);
}
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 (
<div className="bg-blue-50 border-2 border-black p-4 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div className="flex items-start space-x-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="text-sm font-mono">
<p className="font-bold text-blue-900 mb-1 uppercase">
{project.name}
</p>
<div className="text-blue-800 space-y-1 font-bold">
<p>{isRepo ? "远程仓库" : "ZIP上传"}</p>
{project.description && <p>{project.description}</p>}
{isRepo && (
<>
<p>
{project.repository_type?.toUpperCase() || "OTHER"}
</p>
<p>{project.default_branch}</p>
</>
)}
{languages.length > 0 && <p>{languages.join(", ")}</p>}
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-4">
<div>
<Label className="text-base font-bold uppercase"></Label>
<p className="text-sm text-gray-500 mt-1 font-bold">
</p>
</div>
{/* 常用排除模式 */}
<div className="grid grid-cols-2 gap-3">
{COMMON_EXCLUDE_PATTERNS.map((pattern) => (
<div
key={pattern.value}
className="flex items-center space-x-3 p-3 border-2 border-black bg-white hover:bg-gray-50 transition-all"
>
<Checkbox
checked={patterns.includes(pattern.value)}
onCheckedChange={() => onToggle(pattern.value)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
/>
<div className="flex-1">
<p className="text-sm font-bold uppercase">{pattern.label}</p>
<p className="text-xs text-gray-500 font-bold">
{pattern.description}
</p>
</div>
</div>
))}
</div>
{/* 自定义排除模式 */}
<CustomPatternInput onAdd={onAdd} />
{/* 已选择的排除模式 */}
{patterns.length > 0 && (
<SelectedPatterns patterns={patterns} onRemove={onRemove} />
)}
</div>
);
}
function CustomPatternInput({ onAdd }: { onAdd: (pattern: string) => void }) {
const handleAdd = (input: HTMLInputElement) => {
if (input.value.trim()) {
onAdd(input.value);
input.value = "";
}
};
return (
<div className="space-y-2">
<Label className="font-bold uppercase"></Label>
<div className="flex space-x-2">
<Input
placeholder="例如: *.tmp, test/**"
onKeyPress={(e) => {
if (e.key === "Enter") {
handleAdd(e.currentTarget);
}
}}
className="retro-input h-10"
/>
<Button
type="button"
variant="outline"
onClick={(e) => {
const input = e.currentTarget
.previousElementSibling as HTMLInputElement;
handleAdd(input);
}}
className="retro-btn bg-white text-black h-10"
>
</Button>
</div>
</div>
);
}
function SelectedPatterns({
patterns,
onRemove,
}: {
patterns: string[];
onRemove: (pattern: string) => void;
}) {
return (
<div className="space-y-2">
<Label className="font-bold uppercase"></Label>
<div className="flex flex-wrap gap-2">
{patterns.map((pattern) => (
<Badge
key={pattern}
variant="secondary"
className="cursor-pointer hover:bg-red-100 hover:text-red-800 rounded-none border-2 border-black bg-gray-100 text-black font-mono font-bold"
onClick={() => onRemove(pattern)}
>
{pattern} ×
</Badge>
))}
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base font-bold font-mono uppercase">
</Label>
<Badge
variant="outline"
className="text-xs rounded-none border-black font-mono"
>
{filteredProjects.length}
</Badge>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-black w-4 h-4" />
<Input
placeholder="搜索项目名称..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 retro-input h-10"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-60 overflow-y-auto p-1">
{loading ? (
<LoadingSpinner />
) : filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelected={selectedId === project.id}
onSelect={() => onSelect(project.id)}
/>
))
) : (
<EmptyState hasSearch={!!searchTerm} />
)}
</div>
</div>
);
}
function ProjectCard({
project,
isSelected,
onSelect,
}: {
project: Project;
isSelected: boolean;
onSelect: () => void;
}) {
const isRepo = isRepositoryProject(project);
return (
<div
className={`cursor-pointer transition-all border-2 p-4 relative ${
isSelected
? "border-primary bg-blue-50 shadow-[4px_4px_0px_0px_rgba(37,99,235,1)] translate-x-[-2px] translate-y-[-2px]"
: "border-black bg-white hover:bg-gray-50 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[-2px] hover:translate-y-[-2px]"
}`}
onClick={onSelect}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-bold text-sm font-display uppercase">
{project.name}
</h4>
{project.description && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2 font-mono">
{project.description}
</p>
)}
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500 font-mono font-bold">
<span
className={`px-1.5 py-0.5 ${isRepo ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}`}
>
{getSourceTypeBadge(project.source_type)}
</span>
{isRepo && (
<>
<span className="uppercase">
{project.repository_type?.toUpperCase() || "OTHER"}
</span>
<span>{project.default_branch}</span>
</>
)}
</div>
</div>
{isSelected && (
<div className="w-5 h-5 bg-primary border-2 border-black flex items-center justify-center">
<div className="w-2 h-2 bg-white" />
</div>
)}
</div>
</div>
);
}
function LoadingSpinner() {
return (
<div className="col-span-2 flex items-center justify-center py-8">
<div className="animate-spin rounded-none h-8 w-8 border-4 border-primary border-t-transparent" />
</div>
);
}
function EmptyState({ hasSearch }: { hasSearch: boolean }) {
return (
<div className="col-span-2 text-center py-8 text-gray-500 font-mono">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{hasSearch ? "未找到匹配的项目" : "暂无可用项目"}
</p>
</div>
);
}

View File

@ -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 (
<div className="bg-amber-50 border-2 border-black p-4 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div className="flex items-center space-x-3 p-4 bg-blue-50 border-2 border-black">
<div className="animate-spin rounded-none h-5 w-5 border-4 border-blue-600 border-t-transparent" />
<p className="text-sm text-blue-800 font-bold">ZIP文件...</p>
</div>
</div>
);
}
return (
<div className="bg-amber-50 border-2 border-black p-4 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div className="space-y-3">
{storedZipInfo?.has_file ? (
<StoredZipView
storedZipInfo={storedZipInfo}
useStoredZip={useStoredZip}
zipFile={zipFile}
onSwitchToStored={onSwitchToStored}
onSwitchToUpload={onSwitchToUpload}
onFileSelect={onFileSelect}
/>
) : (
<NoStoredZipView onFileSelect={onFileSelect} />
)}
</div>
</div>
);
}
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 (
<div className="space-y-3">
<div className="flex items-start space-x-3 p-4 bg-green-50 border-2 border-black">
<Info className="w-5 h-5 text-green-600 mt-0.5" />
<div className="flex-1">
<p className="font-bold text-green-900 text-sm uppercase">
ZIP文件
</p>
<p className="text-xs text-green-700 mt-1 font-bold">
: {storedZipInfo.original_filename}
{storedZipInfo.file_size && (
<> ({formatFileSize(storedZipInfo.file_size)})</>
)}
</p>
{storedZipInfo.uploaded_at && (
<p className="text-xs text-green-600 mt-0.5">
:{" "}
{new Date(storedZipInfo.uploaded_at).toLocaleString("zh-CN")}
</p>
)}
</div>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
checked={useStoredZip}
onChange={onSwitchToStored}
className="w-4 h-4"
/>
<span className="text-sm font-bold">使</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
checked={!useStoredZip}
onChange={onSwitchToUpload}
className="w-4 h-4"
/>
<span className="text-sm font-bold"></span>
</label>
</div>
{!useStoredZip && (
<div className="space-y-2 pt-2 border-t border-amber-300">
<Label htmlFor="zipFile" className="font-bold uppercase">
ZIP文件
</Label>
<Input
id="zipFile"
type="file"
accept=".zip"
onChange={(e) => {
const file = e.target.files?.[0];
onFileSelect(file || null, e.target);
}}
className="cursor-pointer retro-input pt-1.5"
/>
{zipFile && (
<p className="text-xs text-amber-700 font-bold">
: {zipFile.name} ({formatFileSize(zipFile.size)})
</p>
)}
</div>
)}
</div>
);
}
function NoStoredZipView({
onFileSelect,
}: {
onFileSelect: (file: File | null, input?: HTMLInputElement) => void;
}) {
return (
<>
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<p className="font-bold text-amber-900 text-sm uppercase">
ZIP文件
</p>
<p className="text-xs text-amber-700 mt-1 font-bold">
ZIP文件
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="zipFile" className="font-bold uppercase">
ZIP文件
</Label>
<Input
id="zipFile"
type="file"
accept=".zip"
onChange={(e) => {
const file = e.target.files?.[0];
onFileSelect(file || null, e.target);
}}
className="cursor-pointer retro-input pt-1.5"
/>
</div>
</>
);
}

View File

@ -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<CreateAuditTaskForm>(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<CreateAuditTaskForm>) => {
setTaskForm((prev) => ({ ...prev, ...updates }));
}, []);
const updateScanConfig = useCallback(
(updates: Partial<CreateAuditTaskForm["scan_config"]>) => {
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<Project[]>([]);
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 };
}

View File

@ -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<File | null>(null);
const [loading, setLoading] = useState(false);
const [storedZipInfo, setStoredZipInfo] = useState<ZipFileMeta | null>(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`;
}

View File

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

View File

@ -8,13 +8,19 @@ export async function scanZipFile(params: {
zipFile: File;
excludePatterns?: string[];
createdBy?: string;
filePaths?: string[];
}): Promise<string> {
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<string> {
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 },
});

View File

@ -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<any[]>([]);
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"
/>
{/* 审计选项对话框 */}
<Dialog open={showAuditOptionsDialog} onOpenChange={setShowAuditOptionsDialog}>
<DialogContent className="max-w-md bg-white border-2 border-black p-0 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-none">
<DialogHeader className="p-6 border-b-2 border-black bg-gray-50">
<DialogTitle className="flex items-center space-x-2 font-display font-bold uppercase text-xl">
<Shield className="w-6 h-6 text-black" />
<span></span>
</DialogTitle>
</DialogHeader>
<div className="p-6 space-y-4">
<Button
onClick={handleStartFullAudit}
className="w-full h-auto py-4 flex flex-col items-center justify-center space-y-2 retro-btn bg-white text-black hover:bg-gray-50 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[-2px] hover:translate-y-[-2px] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] transition-all"
>
<div className="flex items-center space-x-2">
<Activity className="w-5 h-5" />
<span className="text-lg font-bold uppercase"></span>
</div>
<span className="text-xs text-gray-500 font-mono"></span>
</Button>
<Button
onClick={handleOpenCustomAudit}
className="w-full h-auto py-4 flex flex-col items-center justify-center space-y-2 retro-btn bg-white text-black hover:bg-gray-50 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[-2px] hover:translate-y-[-2px] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] transition-all"
>
<div className="flex items-center space-x-2">
<FileText className="w-5 h-5" />
<span className="text-lg font-bold uppercase"></span>
</div>
<span className="text-xs text-gray-500 font-mono"></span>
</Button>
</div>
<DialogFooter className="p-4 border-t-2 border-black bg-gray-50">
<Button variant="outline" onClick={() => setShowAuditOptionsDialog(false)} className="w-full retro-btn bg-white text-black hover:bg-gray-100">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 文件选择对话框 */}
<FileSelectionDialog
open={showFileSelectionDialog}
onOpenChange={setShowFileSelectionDialog}
projectId={id || ''}
onConfirm={handleStartCustomAudit}
/>
</div>
);
}

View File

@ -64,6 +64,34 @@ export const api = {
}
},
async getProjectFiles(id: string, branch?: string): Promise<Array<{ path: string; size: number }>> {
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<Project> {
const res = await apiClient.post('/projects/', {
name: project.name,
@ -141,7 +169,13 @@ export const api = {
async createAuditTask(task: CreateAuditTaskForm & { created_by?: string }): Promise<AuditTask> {
// 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;

View File

@ -132,6 +132,7 @@ export interface CreateAuditTaskForm {
include_docs?: boolean;
max_file_size?: number;
analysis_depth?: 'basic' | 'standard' | 'deep';
file_paths?: string[];
};
}