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 shutil
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.db.session import get_db, AsyncSessionLocal
|
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.user import User
|
||||||
from app.models.audit import AuditTask, AuditIssue
|
from app.models.audit import AuditTask, AuditIssue
|
||||||
from app.models.user_config import UserConfig
|
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 (
|
from app.services.zip_storage import (
|
||||||
save_project_zip, load_project_zip, get_project_zip_meta,
|
save_project_zip, load_project_zip, get_project_zip_meta,
|
||||||
delete_project_zip, has_project_zip
|
delete_project_zip, has_project_zip
|
||||||
|
|
@ -315,10 +317,108 @@ async def permanently_delete_project(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": "项目已永久删除"}
|
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")
|
@router.post("/{id}/scan")
|
||||||
async def scan_project(
|
async def scan_project(
|
||||||
id: str,
|
id: str,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
scan_request: Optional[ScanRequest] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(deps.get_current_user),
|
current_user: User = Depends(deps.get_current_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|
@ -329,21 +429,26 @@ async def scan_project(
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
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
|
# Create Task Record
|
||||||
task = AuditTask(
|
task = AuditTask(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
task_type="repository",
|
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)
|
db.add(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(task)
|
await db.refresh(task)
|
||||||
|
|
||||||
# 获取用户配置(包含解密敏感字段)
|
# 获取用户配置(包含解密敏感字段)
|
||||||
from sqlalchemy.future import select
|
|
||||||
from app.core.encryption import decrypt_sensitive_data
|
from app.core.encryption import decrypt_sensitive_data
|
||||||
import json
|
|
||||||
|
|
||||||
# 需要解密的敏感字段列表
|
# 需要解密的敏感字段列表
|
||||||
SENSITIVE_LLM_FIELDS = [
|
SENSITIVE_LLM_FIELDS = [
|
||||||
|
|
@ -377,6 +482,10 @@ async def scan_project(
|
||||||
'otherConfig': other_config,
|
'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
|
# Trigger Background Task
|
||||||
background_tasks.add_task(scan_repo_task, task.id, AsyncSessionLocal, user_config)
|
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文件已删除"}
|
return {"message": "ZIP文件已删除"}
|
||||||
else:
|
else:
|
||||||
return {"message": "没有找到ZIP文件"}
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from typing import Any, List, Optional
|
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
|
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)
|
task.total_files = len(files_to_scan)
|
||||||
await db.commit()
|
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)
|
await asyncio.sleep(settings.LLM_GAP_MS / 1000)
|
||||||
|
|
||||||
# 完成任务
|
# 完成任务
|
||||||
task.status = "completed"
|
avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0
|
||||||
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()
|
|
||||||
|
|
||||||
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)
|
task_control.cleanup_task(task_id)
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
@router.post("/upload-zip")
|
||||||
async def scan_zip(
|
async def scan_zip(
|
||||||
project_id: str,
|
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
project_id: str = Form(...),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
scan_config: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(deps.get_current_user),
|
current_user: User = Depends(deps.get_current_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|
@ -213,13 +232,21 @@ async def scan_zip(
|
||||||
# 保存ZIP文件到持久化存储
|
# 保存ZIP文件到持久化存储
|
||||||
await save_project_zip(project_id, file_path, file.filename)
|
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
|
# Create Task
|
||||||
task = AuditTask(
|
task = AuditTask(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
task_type="zip_upload",
|
task_type="zip_upload",
|
||||||
status="pending",
|
status="pending",
|
||||||
scan_config="{}"
|
scan_config=scan_config if scan_config else "{}"
|
||||||
)
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -227,6 +254,10 @@ async def scan_zip(
|
||||||
|
|
||||||
# 获取用户配置
|
# 获取用户配置
|
||||||
user_config = await get_user_config_dict(db, current_user.id)
|
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 - 使用持久化存储的文件路径
|
# Trigger Background Task - 使用持久化存储的文件路径
|
||||||
stored_zip_path = await load_project_zip(project_id)
|
stored_zip_path = await load_project_zip(project_id)
|
||||||
|
|
@ -235,10 +266,16 @@ async def scan_zip(
|
||||||
return {"task_id": task.id, "status": "queued"}
|
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")
|
@router.post("/scan-stored-zip")
|
||||||
async def scan_stored_zip(
|
async def scan_stored_zip(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
scan_request: Optional[ScanRequest] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(deps.get_current_user),
|
current_user: User = Depends(deps.get_current_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|
@ -265,7 +302,7 @@ async def scan_stored_zip(
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
task_type="zip_upload",
|
task_type="zip_upload",
|
||||||
status="pending",
|
status="pending",
|
||||||
scan_config="{}"
|
scan_config=json.dumps(scan_request.dict()) if scan_request else "{}"
|
||||||
)
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
await db.commit()
|
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 = 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
|
# Trigger Background Task
|
||||||
background_tasks.add_task(process_zip_task, task.id, stored_zip_path, AsyncSessionLocal, user_config)
|
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
|
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仓库文件列表"""
|
"""获取GitHub仓库文件列表"""
|
||||||
# 解析仓库URL
|
# 解析仓库URL
|
||||||
match = repo_url.rstrip('/').rstrip('.git')
|
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 = []
|
files = []
|
||||||
for item in tree_data.get("tree", []):
|
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)
|
size = item.get("size", 0)
|
||||||
if size <= settings.MAX_FILE_SIZE_BYTES:
|
if size <= settings.MAX_FILE_SIZE_BYTES:
|
||||||
files.append({
|
files.append({
|
||||||
|
|
@ -158,7 +199,7 @@ async def get_github_files(repo_url: str, branch: str, token: str = None) -> Lis
|
||||||
return files
|
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仓库文件列表"""
|
"""获取GitLab仓库文件列表"""
|
||||||
parsed = urlparse(repo_url)
|
parsed = urlparse(repo_url)
|
||||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
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 = []
|
files = []
|
||||||
for item in tree_data:
|
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({
|
files.append({
|
||||||
"path": item["path"],
|
"path": item["path"],
|
||||||
"url": f"{base}/api/v4/projects/{project_path}/repository/files/{quote(item['path'], safe='')}/raw?ref={quote(branch)}",
|
"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
|
repo_url = project.repository_url
|
||||||
branch = task.branch_name or project.default_branch or "main"
|
branch = task.branch_name or project.default_branch or "main"
|
||||||
repo_type = project.repository_type or "other"
|
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}")
|
print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}, 来源: {source_type}")
|
||||||
|
if task_exclude_patterns:
|
||||||
|
print(f"📋 排除模式: {task_exclude_patterns}")
|
||||||
|
|
||||||
# 3. 获取文件列表
|
# 3. 获取文件列表
|
||||||
# 从用户配置中读取 GitHub/GitLab Token(优先使用用户配置,然后使用系统配置)
|
# 从用户配置中读取 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
|
extracted_gitlab_token = None
|
||||||
|
|
||||||
if repo_type == "github":
|
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":
|
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
|
# GitLab文件可能带有token
|
||||||
if files and 'token' in files[0]:
|
if files and 'token' in files[0]:
|
||||||
extracted_gitlab_token = files[0].get('token')
|
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 仓库")
|
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)
|
task.total_files = len(files)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -380,15 +438,25 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N
|
||||||
# 5. 完成任务
|
# 5. 完成任务
|
||||||
avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0
|
avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0
|
||||||
|
|
||||||
task.status = "completed"
|
# 如果有文件需要分析但全部失败,标记为失败
|
||||||
task.completed_at = datetime.utcnow()
|
if len(files) > 0 and scanned_files == 0:
|
||||||
task.scanned_files = scanned_files
|
task.status = "failed"
|
||||||
task.total_lines = total_lines
|
task.completed_at = datetime.utcnow()
|
||||||
task.issues_count = total_issues
|
task.scanned_files = 0
|
||||||
task.quality_score = avg_quality_score
|
task.total_lines = total_lines
|
||||||
await db.commit()
|
task.issues_count = 0
|
||||||
|
task.quality_score = 0
|
||||||
print(f"✅ 任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题, 质量分 {avg_quality_score:.1f}")
|
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)
|
task_control.cleanup_task(task_id)
|
||||||
|
|
||||||
except Exception as e:
|
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;
|
branch?: string;
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
filePaths?: string[];
|
||||||
}) {
|
}) {
|
||||||
// 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递
|
// 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递
|
||||||
// The backend handles everything now.
|
// The backend handles everything now.
|
||||||
// We just need to create the task (which triggers the scan in our new api implementation)
|
// We just need to create the task (which triggers the scan in our new api implementation)
|
||||||
// or call a specific scan endpoint.
|
// or call a specific scan endpoint.
|
||||||
|
|
||||||
// In our new api.createAuditTask implementation (src/shared/api/database.ts),
|
// In our new api.createAuditTask implementation (src/shared/api/database.ts),
|
||||||
// it actually calls /projects/{id}/scan which starts the process.
|
// it actually calls /projects/{id}/scan which starts the process.
|
||||||
|
|
||||||
const task = await api.createAuditTask({
|
const task = await api.createAuditTask({
|
||||||
project_id: params.projectId,
|
project_id: params.projectId,
|
||||||
task_type: "repository",
|
task_type: "repository",
|
||||||
branch_name: params.branch || "main",
|
branch_name: params.branch || "main",
|
||||||
exclude_patterns: params.exclude || [],
|
exclude_patterns: params.exclude || [],
|
||||||
scan_config: {},
|
scan_config: {
|
||||||
|
file_paths: params.filePaths
|
||||||
|
},
|
||||||
created_by: params.createdBy || "unknown"
|
created_by: params.createdBy || "unknown"
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,19 @@ export async function scanZipFile(params: {
|
||||||
zipFile: File;
|
zipFile: File;
|
||||||
excludePatterns?: string[];
|
excludePatterns?: string[];
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
filePaths?: string[];
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", params.zipFile);
|
formData.append("file", params.zipFile);
|
||||||
formData.append("project_id", params.projectId);
|
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, {
|
const res = await apiClient.post(`/scan/upload-zip`, formData, {
|
||||||
params: { project_id: params.projectId },
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
|
|
@ -30,8 +36,13 @@ export async function scanStoredZipFile(params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
excludePatterns?: string[];
|
excludePatterns?: string[];
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
filePaths?: string[];
|
||||||
}): Promise<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 },
|
params: { project_id: params.projectId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ import { hasZipFile } from "@/shared/utils/zipStorage";
|
||||||
import { isRepositoryProject, getSourceTypeLabel } from "@/shared/utils/projectUtils";
|
import { isRepositoryProject, getSourceTypeLabel } from "@/shared/utils/projectUtils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||||
|
import FileSelectionDialog from "@/components/audit/FileSelectionDialog";
|
||||||
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { SUPPORTED_LANGUAGES } from "@/shared/constants";
|
import { SUPPORTED_LANGUAGES } from "@/shared/constants";
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
export default function ProjectDetail() {
|
||||||
|
|
@ -56,6 +58,9 @@ export default function ProjectDetail() {
|
||||||
const [latestIssues, setLatestIssues] = useState<any[]>([]);
|
const [latestIssues, setLatestIssues] = useState<any[]>([]);
|
||||||
const [loadingIssues, setLoadingIssues] = useState(false);
|
const [loadingIssues, setLoadingIssues] = useState(false);
|
||||||
|
|
||||||
|
const [showFileSelectionDialog, setShowFileSelectionDialog] = useState(false);
|
||||||
|
const [showAuditOptionsDialog, setShowAuditOptionsDialog] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'issues' && tasks.length > 0) {
|
if (activeTab === 'issues' && tasks.length > 0) {
|
||||||
loadLatestIssues();
|
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;
|
if (!project || !id) return;
|
||||||
|
|
||||||
// 检查是否有仓库地址
|
// 检查是否有仓库地址
|
||||||
|
|
@ -150,12 +173,13 @@ export default function ProjectDetail() {
|
||||||
// 有仓库地址,启动仓库审计
|
// 有仓库地址,启动仓库审计
|
||||||
try {
|
try {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
console.log('开始启动仓库审计任务...');
|
console.log('开始启动仓库审计任务...', filePaths ? `指定 ${filePaths.length} 个文件` : '全量扫描');
|
||||||
const taskId = await runRepositoryAudit({
|
const taskId = await runRepositoryAudit({
|
||||||
projectId: id,
|
projectId: id,
|
||||||
repoUrl: project.repository_url,
|
repoUrl: project.repository_url,
|
||||||
branch: project.default_branch || 'main',
|
branch: project.default_branch || 'main',
|
||||||
createdBy: undefined
|
createdBy: undefined,
|
||||||
|
filePaths: filePaths
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('审计任务创建成功,taskId:', taskId);
|
console.log('审计任务创建成功,taskId:', taskId);
|
||||||
|
|
@ -179,13 +203,14 @@ export default function ProjectDetail() {
|
||||||
const hasFile = await hasZipFile(id);
|
const hasFile = await hasZipFile(id);
|
||||||
|
|
||||||
if (hasFile) {
|
if (hasFile) {
|
||||||
console.log('找到后端存储的ZIP文件,开始启动审计...');
|
console.log('找到后端存储的ZIP文件,开始启动审计...', filePaths ? `指定 ${filePaths.length} 个文件` : '全量扫描');
|
||||||
try {
|
try {
|
||||||
// 使用后端存储的ZIP文件启动审计
|
// 使用后端存储的ZIP文件启动审计
|
||||||
const taskId = await scanStoredZipFile({
|
const taskId = await scanStoredZipFile({
|
||||||
projectId: id,
|
projectId: id,
|
||||||
excludePatterns: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
|
excludePatterns: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
|
||||||
createdBy: 'local-user'
|
createdBy: 'local-user',
|
||||||
|
filePaths: filePaths
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('审计任务创建成功,taskId:', taskId);
|
console.log('审计任务创建成功,taskId:', taskId);
|
||||||
|
|
@ -848,6 +873,54 @@ export default function ProjectDetail() {
|
||||||
taskId={currentTaskId}
|
taskId={currentTaskId}
|
||||||
taskType="repository"
|
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>
|
</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> {
|
async createProject(project: CreateProjectForm & { owner_id?: string }): Promise<Project> {
|
||||||
const res = await apiClient.post('/projects/', {
|
const res = await apiClient.post('/projects/', {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
|
@ -141,7 +169,13 @@ export const api = {
|
||||||
|
|
||||||
async createAuditTask(task: CreateAuditTaskForm & { created_by?: string }): Promise<AuditTask> {
|
async createAuditTask(task: CreateAuditTaskForm & { created_by?: string }): Promise<AuditTask> {
|
||||||
// Trigger scan on the project
|
// 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
|
// Fetch the created task
|
||||||
const taskRes = await apiClient.get(`/tasks/${res.data.task_id}`);
|
const taskRes = await apiClient.get(`/tasks/${res.data.task_id}`);
|
||||||
return taskRes.data;
|
return taskRes.data;
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export interface CreateAuditTaskForm {
|
||||||
include_docs?: boolean;
|
include_docs?: boolean;
|
||||||
max_file_size?: number;
|
max_file_size?: number;
|
||||||
analysis_depth?: 'basic' | 'standard' | 'deep';
|
analysis_depth?: 'basic' | 'standard' | 'deep';
|
||||||
|
file_paths?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue