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:
parent
33c4df9645
commit
07810b309c
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export interface CreateAuditTaskForm {
|
|||
include_docs?: boolean;
|
||||
max_file_size?: number;
|
||||
analysis_depth?: 'basic' | 'standard' | 'deep';
|
||||
file_paths?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue