feat(projects): add ZIP file upload support and source type tracking
- Add source_type field to projects model to distinguish between repository and ZIP sources - Implement ZIP file storage service with save, load, delete, and metadata operations - Add database migration to populate source_type for existing projects - Create ZIP upload endpoint with file handling and metadata tracking - Add ZIP download endpoint for project file retrieval - Implement project ZIP info endpoint to check file status and metadata - Update project creation to support both repository and ZIP source types - Add project type constants and utility functions for source type handling - Update database export/import to include source_type field - Extend frontend components to support ZIP file uploads in project creation - Add instant analysis page for direct ZIP file scanning without project creation - Update .gitignore to exclude uploaded ZIP files and metadata - Enhance project detail and task detail pages with ZIP file management UI
This commit is contained in:
parent
7091f891d1
commit
bfef3b35a6
|
|
@ -1,2 +1,6 @@
|
|||
.venv/
|
||||
venv/
|
||||
|
||||
# Uploaded files
|
||||
uploads/zip_files/*.zip
|
||||
uploads/zip_files/*.meta
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
"""add source_type to projects
|
||||
|
||||
Revision ID: add_source_type_001
|
||||
Revises: 73889a94a455
|
||||
Create Date: 2024-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_source_type_001'
|
||||
down_revision = '73889a94a455'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 source_type 字段,用于区分项目来源类型
|
||||
# 'repository' - 远程仓库项目 (GitHub/GitLab)
|
||||
# 'zip' - ZIP上传项目
|
||||
op.add_column('projects', sa.Column('source_type', sa.String(20), nullable=True, server_default='repository'))
|
||||
|
||||
# 根据现有数据更新 source_type
|
||||
# 如果 repository_url 为空或为 null,则设置为 'zip'
|
||||
op.execute("""
|
||||
UPDATE projects
|
||||
SET source_type = CASE
|
||||
WHEN repository_url IS NULL OR repository_url = '' THEN 'zip'
|
||||
ELSE 'repository'
|
||||
END
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('projects', 'source_type')
|
||||
|
|
@ -105,6 +105,7 @@ async def export_database(
|
|||
"id": p.id,
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"source_type": p.source_type,
|
||||
"repository_url": p.repository_url,
|
||||
"repository_type": p.repository_type,
|
||||
"default_branch": p.default_branch,
|
||||
|
|
@ -238,6 +239,7 @@ async def import_database(
|
|||
id=p_data.get("id"),
|
||||
name=p_data.get("name"),
|
||||
description=p_data.get("description"),
|
||||
source_type=p_data.get("source_type", "repository"),
|
||||
repository_url=p_data.get("repository_url"),
|
||||
repository_type=p_data.get("repository_type"),
|
||||
default_branch=p_data.get("default_branch"),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import shutil
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from app.api import deps
|
||||
from app.db.session import get_db, AsyncSessionLocal
|
||||
|
|
@ -13,20 +17,26 @@ 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
|
||||
from app.services.zip_storage import (
|
||||
save_project_zip, load_project_zip, get_project_zip_meta,
|
||||
delete_project_zip, has_project_zip
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Schemas
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
source_type: Optional[str] = "repository" # 'repository' 或 'zip'
|
||||
repository_url: Optional[str] = None
|
||||
repository_type: Optional[str] = "other"
|
||||
repository_type: Optional[str] = "other" # github, gitlab, other
|
||||
description: Optional[str] = None
|
||||
default_branch: Optional[str] = "main"
|
||||
programming_languages: Optional[List[str]] = None
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
repository_url: Optional[str] = None
|
||||
repository_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
|
@ -47,8 +57,9 @@ class ProjectResponse(BaseModel):
|
|||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
source_type: Optional[str] = "repository" # 'repository' 或 'zip'
|
||||
repository_url: Optional[str] = None
|
||||
repository_type: Optional[str] = None
|
||||
repository_type: Optional[str] = None # github, gitlab, other
|
||||
default_branch: Optional[str] = None
|
||||
programming_languages: Optional[str] = None
|
||||
owner_id: str
|
||||
|
|
@ -79,10 +90,14 @@ async def create_project(
|
|||
Create new project.
|
||||
"""
|
||||
import json
|
||||
# 根据 source_type 设置默认值
|
||||
source_type = project_in.source_type or "repository"
|
||||
|
||||
project = Project(
|
||||
name=project_in.name,
|
||||
repository_url=project_in.repository_url,
|
||||
repository_type=project_in.repository_type or "other",
|
||||
source_type=source_type,
|
||||
repository_url=project_in.repository_url if source_type == "repository" else None,
|
||||
repository_type=project_in.repository_type or "other" if source_type == "repository" else "other",
|
||||
description=project_in.description,
|
||||
default_branch=project_in.default_branch or "main",
|
||||
programming_languages=json.dumps(project_in.programming_languages or []),
|
||||
|
|
@ -326,3 +341,122 @@ async def scan_project(
|
|||
background_tasks.add_task(scan_repo_task, task.id, AsyncSessionLocal, user_config)
|
||||
|
||||
return {"task_id": task.id, "status": "started"}
|
||||
|
||||
|
||||
# ============ ZIP文件管理端点 ============
|
||||
|
||||
class ZipFileMetaResponse(BaseModel):
|
||||
has_file: bool
|
||||
original_filename: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
uploaded_at: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{id}/zip", response_model=ZipFileMetaResponse)
|
||||
async def get_project_zip_info(
|
||||
id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
获取项目ZIP文件信息
|
||||
"""
|
||||
project = await db.get(Project, id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查是否有ZIP文件
|
||||
has_file = await has_project_zip(id)
|
||||
if not has_file:
|
||||
return {"has_file": False}
|
||||
|
||||
# 获取元数据
|
||||
meta = await get_project_zip_meta(id)
|
||||
if meta:
|
||||
return {
|
||||
"has_file": True,
|
||||
"original_filename": meta.get("original_filename"),
|
||||
"file_size": meta.get("file_size"),
|
||||
"uploaded_at": meta.get("uploaded_at")
|
||||
}
|
||||
|
||||
return {"has_file": True}
|
||||
|
||||
|
||||
@router.post("/{id}/zip")
|
||||
async def upload_project_zip(
|
||||
id: str,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
上传或更新项目ZIP文件
|
||||
"""
|
||||
project = await db.get(Project, id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查权限
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此项目")
|
||||
|
||||
# 检查项目类型
|
||||
if project.source_type != "zip":
|
||||
raise HTTPException(status_code=400, detail="仅ZIP类型项目可以上传ZIP文件")
|
||||
|
||||
# 验证文件类型
|
||||
if not file.filename.lower().endswith('.zip'):
|
||||
raise HTTPException(status_code=400, detail="请上传ZIP格式文件")
|
||||
|
||||
# 保存到临时文件
|
||||
temp_file_id = str(uuid.uuid4())
|
||||
temp_file_path = f"/tmp/{temp_file_id}.zip"
|
||||
|
||||
try:
|
||||
with open(temp_file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# 检查文件大小
|
||||
file_size = os.path.getsize(temp_file_path)
|
||||
if file_size > 100 * 1024 * 1024: # 100MB limit
|
||||
raise HTTPException(status_code=400, detail="文件大小不能超过100MB")
|
||||
|
||||
# 保存到持久化存储
|
||||
meta = await save_project_zip(id, temp_file_path, file.filename)
|
||||
|
||||
return {
|
||||
"message": "ZIP文件上传成功",
|
||||
"original_filename": meta["original_filename"],
|
||||
"file_size": meta["file_size"],
|
||||
"uploaded_at": meta["uploaded_at"]
|
||||
}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
|
||||
|
||||
@router.delete("/{id}/zip")
|
||||
async def delete_project_zip_file(
|
||||
id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
删除项目ZIP文件
|
||||
"""
|
||||
project = await db.get(Project, id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查权限
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此项目")
|
||||
|
||||
deleted = await delete_project_zip(id)
|
||||
|
||||
if deleted:
|
||||
return {"message": "ZIP文件已删除"}
|
||||
else:
|
||||
return {"message": "没有找到ZIP文件"}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from app.models.analysis import InstantAnalysis
|
|||
from app.models.user_config import UserConfig
|
||||
from app.services.llm.service import LLMService
|
||||
from app.services.scanner import task_control, is_text_file, should_exclude, get_language_from_path
|
||||
from app.services.zip_storage import load_project_zip, save_project_zip, has_project_zip
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -167,9 +168,7 @@ async def process_zip_task(task_id: str, file_path: str, db_session_factory, use
|
|||
await db.commit()
|
||||
task_control.cleanup_task(task_id)
|
||||
finally:
|
||||
# Cleanup
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
# Cleanup - 只清理解压目录,不删除源ZIP文件(已持久化存储)
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
|
|
@ -184,6 +183,7 @@ async def scan_zip(
|
|||
) -> Any:
|
||||
"""
|
||||
Upload and scan a ZIP file.
|
||||
上传ZIP文件并启动扫描,同时将ZIP文件保存到持久化存储
|
||||
"""
|
||||
# Verify project exists
|
||||
project = await db.get(Project, project_id)
|
||||
|
|
@ -194,7 +194,7 @@ async def scan_zip(
|
|||
if not file.filename.lower().endswith('.zip'):
|
||||
raise HTTPException(status_code=400, detail="请上传ZIP格式文件")
|
||||
|
||||
# Save Uploaded File
|
||||
# Save Uploaded File to temp
|
||||
file_id = str(uuid.uuid4())
|
||||
file_path = f"/tmp/{file_id}.zip"
|
||||
with open(file_path, "wb") as buffer:
|
||||
|
|
@ -206,6 +206,51 @@ async def scan_zip(
|
|||
os.remove(file_path)
|
||||
raise HTTPException(status_code=400, detail="文件大小不能超过100MB")
|
||||
|
||||
# 保存ZIP文件到持久化存储
|
||||
await save_project_zip(project_id, file_path, file.filename)
|
||||
|
||||
# Create Task
|
||||
task = AuditTask(
|
||||
project_id=project_id,
|
||||
created_by=current_user.id,
|
||||
task_type="zip_upload",
|
||||
status="pending",
|
||||
scan_config="{}"
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
# 获取用户配置
|
||||
user_config = await get_user_config_dict(db, current_user.id)
|
||||
|
||||
# Trigger Background Task - 使用持久化存储的文件路径
|
||||
stored_zip_path = await load_project_zip(project_id)
|
||||
background_tasks.add_task(process_zip_task, task.id, stored_zip_path or file_path, AsyncSessionLocal, user_config)
|
||||
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/scan-stored-zip")
|
||||
async def scan_stored_zip(
|
||||
project_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
使用已存储的ZIP文件启动扫描(无需重新上传)
|
||||
"""
|
||||
# Verify project exists
|
||||
project = await db.get(Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查是否有存储的ZIP文件
|
||||
stored_zip_path = await load_project_zip(project_id)
|
||||
if not stored_zip_path:
|
||||
raise HTTPException(status_code=400, detail="项目没有已存储的ZIP文件,请先上传")
|
||||
|
||||
# Create Task
|
||||
task = AuditTask(
|
||||
project_id=project_id,
|
||||
|
|
@ -222,7 +267,7 @@ async def scan_zip(
|
|||
user_config = await get_user_config_dict(db, current_user.id)
|
||||
|
||||
# Trigger Background Task
|
||||
background_tasks.add_task(process_zip_task, task.id, file_path, AsyncSessionLocal, user_config)
|
||||
background_tasks.add_task(process_zip_task, task.id, stored_zip_path, AsyncSessionLocal, user_config)
|
||||
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class ProjectSchema(BaseModel):
|
|||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
repository_url: Optional[str] = None
|
||||
repository_type: Optional[str] = None
|
||||
default_branch: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ class Settings(BaseSettings):
|
|||
LLM_CONCURRENCY: int = 3 # LLM并发数
|
||||
LLM_GAP_MS: int = 2000 # LLM请求间隔(毫秒)
|
||||
|
||||
# ZIP文件存储配置
|
||||
ZIP_STORAGE_PATH: str = "./uploads/zip_files" # ZIP文件存储目录
|
||||
|
||||
# 输出语言配置 - 支持 zh-CN(中文)和 en-US(英文)
|
||||
OUTPUT_LANGUAGE: str = "zh-CN"
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ class Project(Base):
|
|||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, index=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 项目来源类型: 'repository' (远程仓库) 或 'zip' (ZIP上传)
|
||||
source_type = Column(String(20), default="repository", nullable=False)
|
||||
|
||||
# 仓库相关字段 (仅 source_type='repository' 时使用)
|
||||
repository_url = Column(String, nullable=True)
|
||||
repository_type = Column(String, default="other")
|
||||
repository_type = Column(String, default="other") # github, gitlab, other
|
||||
default_branch = Column(String, default="main")
|
||||
|
||||
programming_languages = Column(Text, default="[]") # Stored as JSON string
|
||||
|
||||
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||
|
|
|
|||
|
|
@ -219,14 +219,22 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N
|
|||
|
||||
# 2. 获取项目信息
|
||||
project = await db.get(Project, task.project_id)
|
||||
if not project or not project.repository_url:
|
||||
if not project:
|
||||
raise Exception("项目不存在")
|
||||
|
||||
# 检查项目类型 - 仅支持仓库类型项目
|
||||
source_type = getattr(project, 'source_type', 'repository')
|
||||
if source_type == 'zip':
|
||||
raise Exception("ZIP类型项目请使用ZIP上传扫描接口")
|
||||
|
||||
if not project.repository_url:
|
||||
raise Exception("仓库地址不存在")
|
||||
|
||||
repo_url = project.repository_url
|
||||
branch = task.branch_name or project.default_branch or "main"
|
||||
repo_type = project.repository_type or "other"
|
||||
|
||||
print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}")
|
||||
print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}, 来源: {source_type}")
|
||||
|
||||
# 3. 获取文件列表
|
||||
# 从用户配置中读取 GitHub/GitLab Token(优先使用用户配置,然后使用系统配置)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
"""
|
||||
ZIP文件存储服务
|
||||
用于管理项目的ZIP文件持久化存储
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def get_zip_storage_path() -> Path:
|
||||
"""获取ZIP文件存储目录"""
|
||||
path = Path(settings.ZIP_STORAGE_PATH)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_project_zip_path(project_id: str) -> Path:
|
||||
"""获取项目ZIP文件路径"""
|
||||
return get_zip_storage_path() / f"{project_id}.zip"
|
||||
|
||||
|
||||
def get_project_zip_meta_path(project_id: str) -> Path:
|
||||
"""获取项目ZIP文件元数据路径"""
|
||||
return get_zip_storage_path() / f"{project_id}.meta"
|
||||
|
||||
|
||||
async def save_project_zip(project_id: str, file_path: str, original_filename: str) -> dict:
|
||||
"""
|
||||
保存项目ZIP文件
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
file_path: 临时文件路径
|
||||
original_filename: 原始文件名
|
||||
|
||||
Returns:
|
||||
文件元数据
|
||||
"""
|
||||
target_path = get_project_zip_path(project_id)
|
||||
meta_path = get_project_zip_meta_path(project_id)
|
||||
|
||||
# 复制文件到存储目录
|
||||
shutil.copy2(file_path, target_path)
|
||||
|
||||
# 获取文件大小
|
||||
file_size = os.path.getsize(target_path)
|
||||
|
||||
# 保存元数据
|
||||
meta = {
|
||||
"original_filename": original_filename,
|
||||
"file_size": file_size,
|
||||
"uploaded_at": datetime.utcnow().isoformat(),
|
||||
"project_id": project_id
|
||||
}
|
||||
|
||||
import json
|
||||
with open(meta_path, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
|
||||
print(f"✓ ZIP文件已保存: {project_id} ({file_size / 1024 / 1024:.2f} MB)")
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
async def load_project_zip(project_id: str) -> Optional[str]:
|
||||
"""
|
||||
加载项目ZIP文件路径
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
|
||||
Returns:
|
||||
ZIP文件路径,如果不存在返回None
|
||||
"""
|
||||
zip_path = get_project_zip_path(project_id)
|
||||
|
||||
if zip_path.exists():
|
||||
return str(zip_path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_project_zip_meta(project_id: str) -> Optional[dict]:
|
||||
"""
|
||||
获取项目ZIP文件元数据
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
|
||||
Returns:
|
||||
元数据字典,如果不存在返回None
|
||||
"""
|
||||
meta_path = get_project_zip_meta_path(project_id)
|
||||
|
||||
if not meta_path.exists():
|
||||
return None
|
||||
|
||||
import json
|
||||
with open(meta_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
async def delete_project_zip(project_id: str) -> bool:
|
||||
"""
|
||||
删除项目ZIP文件
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
zip_path = get_project_zip_path(project_id)
|
||||
meta_path = get_project_zip_meta_path(project_id)
|
||||
|
||||
deleted = False
|
||||
|
||||
if zip_path.exists():
|
||||
os.remove(zip_path)
|
||||
deleted = True
|
||||
print(f"✓ 已删除ZIP文件: {project_id}")
|
||||
|
||||
if meta_path.exists():
|
||||
os.remove(meta_path)
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def has_project_zip(project_id: str) -> bool:
|
||||
"""
|
||||
检查项目是否有ZIP文件
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
|
||||
Returns:
|
||||
是否存在ZIP文件
|
||||
"""
|
||||
zip_path = get_project_zip_path(project_id)
|
||||
return zip_path.exists()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# This directory stores uploaded ZIP files
|
||||
# Files are stored with project ID as filename
|
||||
995
backend/uv.lock
995
backend/uv.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -22,8 +22,9 @@ import type { Project, CreateAuditTaskForm } from "@/shared/types";
|
|||
import { toast } from "sonner";
|
||||
import TerminalProgressDialog from "./TerminalProgressDialog";
|
||||
import { runRepositoryAudit } from "@/features/projects/services/repoScan";
|
||||
import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
|
||||
import { loadZipFile } from "@/shared/utils/zipStorage";
|
||||
import { scanZipFile, scanStoredZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
|
||||
import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage";
|
||||
import { isRepositoryProject, isZipProject, getSourceTypeBadge } from "@/shared/utils/projectUtils";
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -41,7 +42,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||
const [loadingZipFile, setLoadingZipFile] = useState(false);
|
||||
const [hasLoadedZip, setHasLoadedZip] = useState(false);
|
||||
const [storedZipInfo, setStoredZipInfo] = useState<ZipFileMeta | null>(null);
|
||||
const [useStoredZip, setUseStoredZip] = useState(true); // 默认使用已存储的ZIP
|
||||
|
||||
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
|
||||
project_id: "",
|
||||
|
|
@ -102,37 +104,47 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
}
|
||||
// 重置ZIP文件状态
|
||||
setZipFile(null);
|
||||
setHasLoadedZip(false);
|
||||
setStoredZipInfo(null);
|
||||
setUseStoredZip(true);
|
||||
}
|
||||
}, [open, preselectedProjectId]);
|
||||
|
||||
// 当项目ID变化时,尝试自动加载保存的ZIP文件
|
||||
// 当项目ID变化时,检查是否有已存储的ZIP文件(仅ZIP类型项目)
|
||||
useEffect(() => {
|
||||
const autoLoadZipFile = async () => {
|
||||
if (!taskForm.project_id || hasLoadedZip) return;
|
||||
const checkStoredZipFile = async () => {
|
||||
if (!taskForm.project_id) {
|
||||
setStoredZipInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = projects.find(p => p.id === taskForm.project_id);
|
||||
if (!project || project.repository_type !== 'other') return;
|
||||
// 使用 source_type 判断是否为ZIP项目
|
||||
if (!project || !isZipProject(project)) {
|
||||
setStoredZipInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingZipFile(true);
|
||||
const savedFile = await loadZipFile(taskForm.project_id);
|
||||
const zipInfo = await getZipFileInfo(taskForm.project_id);
|
||||
setStoredZipInfo(zipInfo);
|
||||
|
||||
if (savedFile) {
|
||||
setZipFile(savedFile);
|
||||
setHasLoadedZip(true);
|
||||
console.log('✓ 已自动加载保存的ZIP文件:', savedFile.name);
|
||||
toast.success(`已加载保存的ZIP文件: ${savedFile.name}`);
|
||||
if (zipInfo.has_file) {
|
||||
console.log('✓ 项目有已存储的ZIP文件:', zipInfo.original_filename);
|
||||
setUseStoredZip(true);
|
||||
} else {
|
||||
setUseStoredZip(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动加载ZIP文件失败:', error);
|
||||
console.error('检查ZIP文件失败:', error);
|
||||
setStoredZipInfo(null);
|
||||
} finally {
|
||||
setLoadingZipFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoLoadZipFile();
|
||||
}, [taskForm.project_id, projects, hasLoadedZip]);
|
||||
checkStoredZipFile();
|
||||
}, [taskForm.project_id, projects]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
|
|
@ -170,20 +182,26 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
console.log('🎯 开始创建审计任务...', {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
sourceType: project.source_type,
|
||||
repositoryType: project.repository_type
|
||||
});
|
||||
|
||||
let taskId: string;
|
||||
|
||||
// 根据项目是否有repository_url判断使用哪种扫描方式
|
||||
if (!project.repository_url || project.repository_url.trim() === '') {
|
||||
// ZIP上传的项目:需要有ZIP文件才能扫描
|
||||
if (!zipFile) {
|
||||
toast.error("请上传ZIP文件进行扫描");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📦 调用 scanZipFile...');
|
||||
// 根据项目 source_type 判断使用哪种扫描方式
|
||||
if (isZipProject(project)) {
|
||||
// ZIP上传类型项目
|
||||
if (useStoredZip && storedZipInfo?.has_file) {
|
||||
// 使用已存储的ZIP文件
|
||||
console.log('📦 ZIP项目 - 使用已存储的ZIP文件...');
|
||||
taskId = await scanStoredZipFile({
|
||||
projectId: project.id,
|
||||
excludePatterns: taskForm.exclude_patterns,
|
||||
createdBy: 'local-user'
|
||||
});
|
||||
} else if (zipFile) {
|
||||
// 上传新的ZIP文件
|
||||
console.log('📦 ZIP项目 - 上传新ZIP文件...');
|
||||
taskId = await scanZipFile({
|
||||
projectId: project.id,
|
||||
zipFile: zipFile,
|
||||
|
|
@ -191,13 +209,22 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
createdBy: 'local-user'
|
||||
});
|
||||
} else {
|
||||
// GitHub/GitLab等远程仓库
|
||||
console.log('📡 调用 runRepositoryAudit...');
|
||||
toast.error("请上传ZIP文件或使用已存储的文件进行扫描");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 仓库类型项目:从远程仓库拉取代码
|
||||
if (!project.repository_url) {
|
||||
toast.error("仓库地址不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📡 仓库项目 - 调用 runRepositoryAudit...');
|
||||
|
||||
// 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递
|
||||
taskId = await runRepositoryAudit({
|
||||
projectId: project.id,
|
||||
repoUrl: project.repository_url!,
|
||||
repoUrl: project.repository_url,
|
||||
branch: taskForm.branch_name || project.default_branch || 'main',
|
||||
exclude: taskForm.exclude_patterns,
|
||||
createdBy: 'local-user'
|
||||
|
|
@ -212,6 +239,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
taskId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
sourceType: project.source_type,
|
||||
taskType: taskForm.task_type,
|
||||
branch: taskForm.branch_name,
|
||||
hasZipFile: !!zipFile,
|
||||
|
|
@ -352,8 +380,15 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
</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 ${isRepositoryProject(project) ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{getSourceTypeBadge(project.source_type)}
|
||||
</span>
|
||||
{isRepositoryProject(project) && (
|
||||
<>
|
||||
<span className="uppercase">{project.repository_type?.toUpperCase() || 'OTHER'}</span>
|
||||
<span>{project.default_branch}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{taskForm.project_id === project.id && (
|
||||
|
|
@ -403,50 +438,101 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-6 font-mono">
|
||||
{/* ZIP项目文件上传 */}
|
||||
{(!selectedProject.repository_url || selectedProject.repository_url.trim() === '') && (
|
||||
{/* ZIP项目文件上传 - 仅ZIP类型项目显示 */}
|
||||
{isZipProject(selectedProject) && (
|
||||
<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">
|
||||
{loadingZipFile ? (
|
||||
<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"></div>
|
||||
<p className="text-sm text-blue-800 font-bold">正在加载保存的ZIP文件...</p>
|
||||
<p className="text-sm text-blue-800 font-bold">正在检查ZIP文件...</p>
|
||||
</div>
|
||||
) : zipFile ? (
|
||||
) : storedZipInfo?.has_file ? (
|
||||
// 有已存储的ZIP文件
|
||||
<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">已准备就绪</p>
|
||||
<p className="font-bold text-green-900 text-sm uppercase">已有存储的ZIP文件</p>
|
||||
<p className="text-xs text-green-700 mt-1 font-bold">
|
||||
使用保存的ZIP文件: {zipFile.name} (
|
||||
{zipFile.size >= 1024 * 1024
|
||||
? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB`
|
||||
: zipFile.size >= 1024
|
||||
? `${(zipFile.size / 1024).toFixed(2)} KB`
|
||||
: `${zipFile.size} B`
|
||||
})
|
||||
文件名: {storedZipInfo.original_filename}
|
||||
{storedZipInfo.file_size && (
|
||||
<> ({storedZipInfo.file_size >= 1024 * 1024
|
||||
? `${(storedZipInfo.file_size / 1024 / 1024).toFixed(2)} MB`
|
||||
: `${(storedZipInfo.file_size / 1024).toFixed(2)} KB`
|
||||
})</>
|
||||
)}
|
||||
</p>
|
||||
{storedZipInfo.uploaded_at && (
|
||||
<p className="text-xs text-green-600 mt-0.5">
|
||||
上传时间: {new Date(storedZipInfo.uploaded_at).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setZipFile(null);
|
||||
setHasLoadedZip(false);
|
||||
</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={() => { setUseStoredZip(true); setZipFile(null); }}
|
||||
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={() => setUseStoredZip(false)}
|
||||
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];
|
||||
if (file) {
|
||||
const validation = validateZipFile(file);
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error || "文件无效");
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setZipFile(file);
|
||||
toast.success(`已选择文件: ${file.name}`);
|
||||
}
|
||||
}}
|
||||
className="retro-btn bg-white text-black h-8 text-xs"
|
||||
>
|
||||
更换文件
|
||||
</Button>
|
||||
className="cursor-pointer retro-input pt-1.5"
|
||||
/>
|
||||
{zipFile && (
|
||||
<p className="text-xs text-amber-700 font-bold">
|
||||
新文件: {zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 没有存储的ZIP文件
|
||||
<>
|
||||
<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文件,请上传文件进行扫描
|
||||
此项目还没有存储的ZIP文件,请上传文件进行扫描
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -474,7 +560,6 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
return;
|
||||
}
|
||||
setZipFile(file);
|
||||
setHasLoadedZip(true);
|
||||
|
||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||
const sizeKB = (file.size / 1024).toFixed(2);
|
||||
|
|
@ -519,7 +604,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{taskForm.task_type === "repository" && (selectedProject.repository_url) && (
|
||||
{/* 分支选择 - 仅仓库类型项目显示 */}
|
||||
{taskForm.task_type === "repository" && isRepositoryProject(selectedProject) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch_name" className="font-bold uppercase">目标分支</Label>
|
||||
<Input
|
||||
|
|
@ -540,10 +626,16 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
<div className="text-sm font-mono">
|
||||
<p className="font-bold text-blue-900 mb-1 uppercase">选中项目:{selectedProject.name}</p>
|
||||
<div className="text-blue-800 space-y-1 font-bold">
|
||||
<p>项目类型:{isRepositoryProject(selectedProject) ? '远程仓库' : 'ZIP上传'}</p>
|
||||
{selectedProject.description && (
|
||||
<p>描述:{selectedProject.description}</p>
|
||||
)}
|
||||
{isRepositoryProject(selectedProject) && (
|
||||
<>
|
||||
<p>仓库平台:{selectedProject.repository_type?.toUpperCase() || 'OTHER'}</p>
|
||||
<p>默认分支:{selectedProject.default_branch}</p>
|
||||
</>
|
||||
)}
|
||||
{selectedProject.programming_languages && (
|
||||
<p>编程语言:{JSON.parse(selectedProject.programming_languages).join(', ')}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { apiClient } from "@/shared/api/serverClient";
|
||||
|
||||
/**
|
||||
* 上传ZIP文件并启动扫描
|
||||
*/
|
||||
export async function scanZipFile(params: {
|
||||
projectId: string;
|
||||
zipFile: File;
|
||||
|
|
@ -20,6 +23,21 @@ export async function scanZipFile(params: {
|
|||
return res.data.task_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用已存储的ZIP文件启动扫描(无需重新上传)
|
||||
*/
|
||||
export async function scanStoredZipFile(params: {
|
||||
projectId: string;
|
||||
excludePatterns?: string[];
|
||||
createdBy?: string;
|
||||
}): Promise<string> {
|
||||
const res = await apiClient.post(`/scan/scan-stored-zip`, null, {
|
||||
params: { project_id: params.projectId },
|
||||
});
|
||||
|
||||
return res.data.task_id;
|
||||
}
|
||||
|
||||
export function validateZipFile(file: File): { valid: boolean; error?: string } {
|
||||
// 检查文件类型
|
||||
if (!file.type.includes('zip') && !file.name.toLowerCase().endsWith('.zip')) {
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@ class UserManager {
|
|||
owner_id: 'local-user',
|
||||
name: '即时分析',
|
||||
description: `${language} 代码即时分析`,
|
||||
source_type: 'zip',
|
||||
repository_type: 'other',
|
||||
repository_url: undefined,
|
||||
default_branch: 'instant',
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ import {
|
|||
CheckCircle,
|
||||
Clock,
|
||||
Play,
|
||||
FileText
|
||||
FileText,
|
||||
Upload,
|
||||
GitBranch
|
||||
} from "lucide-react";
|
||||
import { api } from "@/shared/config/database";
|
||||
import { runRepositoryAudit, scanZipFile } from "@/features/projects/services";
|
||||
import type { Project, AuditTask, CreateProjectForm } from "@/shared/types";
|
||||
import { loadZipFile } from "@/shared/utils/zipStorage";
|
||||
import { isRepositoryProject, getSourceTypeLabel } from "@/shared/utils/projectUtils";
|
||||
import { toast } from "sonner";
|
||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
||||
|
|
@ -43,6 +46,7 @@ export default function ProjectDetail() {
|
|||
const [editForm, setEditForm] = useState<CreateProjectForm>({
|
||||
name: "",
|
||||
description: "",
|
||||
source_type: "repository",
|
||||
repository_url: "",
|
||||
repository_type: "github",
|
||||
default_branch: "main",
|
||||
|
|
@ -81,6 +85,7 @@ export default function ProjectDetail() {
|
|||
setEditForm({
|
||||
name: project.name,
|
||||
description: project.description || "",
|
||||
source_type: project.source_type || "repository",
|
||||
repository_url: project.repository_url || "",
|
||||
repository_type: project.repository_type || "github",
|
||||
default_branch: project.default_branch || "main",
|
||||
|
|
@ -441,7 +446,16 @@ export default function ProjectDetail() {
|
|||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-gray-600 uppercase">仓库类型</span>
|
||||
<span className="text-sm font-bold text-gray-600 uppercase">项目类型</span>
|
||||
<Badge variant="outline" className={`rounded-none border-black ${isRepositoryProject(project) ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'}`}>
|
||||
{getSourceTypeLabel(project.source_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isRepositoryProject(project) && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-gray-600 uppercase">仓库平台</span>
|
||||
<Badge variant="outline" className="rounded-none border-black bg-gray-100 text-black">
|
||||
{project.repository_type === 'github' ? 'GitHub' :
|
||||
project.repository_type === 'gitlab' ? 'GitLab' : '其他'}
|
||||
|
|
@ -452,6 +466,8 @@ export default function ProjectDetail() {
|
|||
<span className="text-sm font-bold text-gray-600 uppercase">默认分支</span>
|
||||
<span className="text-sm font-bold text-black bg-gray-100 px-2 border border-black">{project.default_branch}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-gray-600 uppercase">创建时间</span>
|
||||
|
|
@ -711,9 +727,13 @@ export default function ProjectDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仓库信息 */}
|
||||
{/* 仓库信息 - 仅远程仓库类型显示 */}
|
||||
{editForm.source_type === 'repository' && (
|
||||
<div className="space-y-4 border-t-2 border-dashed border-gray-300 pt-4">
|
||||
<h3 className="text-sm font-bold font-mono uppercase text-gray-900 bg-gray-100 inline-block px-2 border border-black">仓库信息</h3>
|
||||
<h3 className="text-sm font-bold font-mono uppercase text-gray-900 bg-blue-100 inline-block px-2 border border-black flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
仓库信息
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-repo-url" className="font-bold font-mono uppercase">仓库地址</Label>
|
||||
|
|
@ -728,7 +748,7 @@ export default function ProjectDetail() {
|
|||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-repo-type" className="font-bold font-mono uppercase">仓库类型</Label>
|
||||
<Label htmlFor="edit-repo-type" className="font-bold font-mono uppercase">仓库平台</Label>
|
||||
<Select
|
||||
value={editForm.repository_type}
|
||||
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
|
||||
|
|
@ -756,6 +776,24 @@ export default function ProjectDetail() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ZIP项目提示 */}
|
||||
{editForm.source_type === 'zip' && (
|
||||
<div className="border-t-2 border-dashed border-gray-300 pt-4">
|
||||
<div className="bg-amber-50 border-2 border-black p-4 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Upload 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-1 uppercase">ZIP上传项目</p>
|
||||
<p className="text-amber-700 text-xs">
|
||||
此项目通过ZIP文件上传创建。每次进行代码审计时,需要在创建任务时重新上传ZIP文件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编程语言 */}
|
||||
<div className="space-y-4 border-t-2 border-dashed border-gray-300 pt-4">
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ import {
|
|||
import { api } from "@/shared/config/database";
|
||||
import { validateZipFile } from "@/features/projects/services";
|
||||
import type { Project, CreateProjectForm } from "@/shared/types";
|
||||
import { saveZipFile } from "@/shared/utils/zipStorage";
|
||||
import { uploadZipFile, getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage";
|
||||
import { isRepositoryProject, isZipProject, getSourceTypeBadge } from "@/shared/utils/projectUtils";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||
|
|
@ -55,6 +56,7 @@ export default function Projects() {
|
|||
const [editForm, setEditForm] = useState<CreateProjectForm>({
|
||||
name: "",
|
||||
description: "",
|
||||
source_type: "repository",
|
||||
repository_url: "",
|
||||
repository_type: "github",
|
||||
default_branch: "main",
|
||||
|
|
@ -63,6 +65,7 @@ export default function Projects() {
|
|||
const [createForm, setCreateForm] = useState<CreateProjectForm>({
|
||||
name: "",
|
||||
description: "",
|
||||
source_type: "repository",
|
||||
repository_url: "",
|
||||
repository_type: "github",
|
||||
default_branch: "main",
|
||||
|
|
@ -71,6 +74,12 @@ export default function Projects() {
|
|||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
// 编辑对话框中的ZIP文件状态
|
||||
const [editZipInfo, setEditZipInfo] = useState<ZipFileMeta | null>(null);
|
||||
const [editZipFile, setEditZipFile] = useState<File | null>(null);
|
||||
const [loadingEditZipInfo, setLoadingEditZipInfo] = useState(false);
|
||||
const editZipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 将小写语言名转换为显示格式
|
||||
const formatLanguageName = (lang: string): string => {
|
||||
const nameMap: Record<string, string> = {
|
||||
|
|
@ -151,6 +160,7 @@ export default function Projects() {
|
|||
setCreateForm({
|
||||
name: "",
|
||||
description: "",
|
||||
source_type: "repository",
|
||||
repository_url: "",
|
||||
repository_type: "github",
|
||||
default_branch: "main",
|
||||
|
|
@ -205,15 +215,17 @@ export default function Projects() {
|
|||
});
|
||||
}, 100);
|
||||
|
||||
// 创建项目
|
||||
// 创建项目 - ZIP上传类型
|
||||
const project = await api.createProject({
|
||||
...createForm,
|
||||
repository_type: "other"
|
||||
source_type: "zip",
|
||||
repository_type: "other",
|
||||
repository_url: undefined
|
||||
} as any);
|
||||
|
||||
// 保存ZIP文件到IndexedDB(使用项目ID作为key)
|
||||
// 保存ZIP文件到后端持久化存储
|
||||
try {
|
||||
await saveZipFile(project.id, selectedFile);
|
||||
await uploadZipFile(project.id, selectedFile);
|
||||
} catch (error) {
|
||||
console.error('保存ZIP文件失败:', error);
|
||||
}
|
||||
|
|
@ -278,17 +290,33 @@ export default function Projects() {
|
|||
setShowCreateTaskDialog(true);
|
||||
};
|
||||
|
||||
const handleEditClick = (project: Project) => {
|
||||
const handleEditClick = async (project: Project) => {
|
||||
setProjectToEdit(project);
|
||||
setEditForm({
|
||||
name: project.name,
|
||||
description: project.description || "",
|
||||
source_type: project.source_type || "repository",
|
||||
repository_url: project.repository_url || "",
|
||||
repository_type: project.repository_type || "github",
|
||||
default_branch: project.default_branch || "main",
|
||||
programming_languages: project.programming_languages ? JSON.parse(project.programming_languages) : []
|
||||
});
|
||||
setEditZipFile(null);
|
||||
setEditZipInfo(null);
|
||||
setShowEditDialog(true);
|
||||
|
||||
// 如果是ZIP项目,加载ZIP文件信息
|
||||
if (project.source_type === 'zip') {
|
||||
setLoadingEditZipInfo(true);
|
||||
try {
|
||||
const zipInfo = await getZipFileInfo(project.id);
|
||||
setEditZipInfo(zipInfo);
|
||||
} catch (error) {
|
||||
console.error('加载ZIP文件信息失败:', error);
|
||||
} finally {
|
||||
setLoadingEditZipInfo(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
|
|
@ -300,10 +328,24 @@ export default function Projects() {
|
|||
}
|
||||
|
||||
try {
|
||||
// 更新项目基本信息
|
||||
await api.updateProject(projectToEdit.id, editForm);
|
||||
|
||||
// 如果有新的ZIP文件,上传它
|
||||
if (editZipFile && editForm.source_type === 'zip') {
|
||||
const result = await uploadZipFile(projectToEdit.id, editZipFile);
|
||||
if (result.success) {
|
||||
toast.success(`ZIP文件已更新: ${result.original_filename}`);
|
||||
} else {
|
||||
toast.error(`ZIP文件上传失败: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`项目 "${editForm.name}" 已更新`);
|
||||
setShowEditDialog(false);
|
||||
setProjectToEdit(null);
|
||||
setEditZipFile(null);
|
||||
setEditZipInfo(null);
|
||||
loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to update project:', error);
|
||||
|
|
@ -705,10 +747,10 @@ export default function Projects() {
|
|||
<div className="retro-card p-4 bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase text-gray-500">GitHub</p>
|
||||
<p className="font-display text-2xl font-bold">{projects.filter(p => p.repository_type === 'github').length}</p>
|
||||
<p className="font-mono text-xs font-bold uppercase text-gray-500">远程仓库</p>
|
||||
<p className="font-display text-2xl font-bold">{projects.filter(p => isRepositoryProject(p)).length}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 border border-border bg-gray-800 flex items-center justify-center text-white shadow-sm">
|
||||
<div className="w-10 h-10 border border-border bg-blue-600 flex items-center justify-center text-white shadow-sm">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -717,11 +759,11 @@ export default function Projects() {
|
|||
<div className="retro-card p-4 bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase text-gray-500">GitLab</p>
|
||||
<p className="font-display text-2xl font-bold">{projects.filter(p => p.repository_type === 'gitlab').length}</p>
|
||||
<p className="font-mono text-xs font-bold uppercase text-gray-500">ZIP上传</p>
|
||||
<p className="font-display text-2xl font-bold">{projects.filter(p => isZipProject(p)).length}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 border border-border bg-orange-500 flex items-center justify-center text-white shadow-sm">
|
||||
<Shield className="w-5 h-5" />
|
||||
<div className="w-10 h-10 border border-border bg-amber-500 flex items-center justify-center text-white shadow-sm">
|
||||
<Upload className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -765,6 +807,9 @@ export default function Projects() {
|
|||
<Badge variant="outline" className={`text-[10px] font-mono border-black ${project.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
|
||||
{project.is_active ? '活跃' : '暂停'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono border-black ${isRepositoryProject(project) ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'}`}>
|
||||
{getSourceTypeBadge(project.source_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -872,11 +917,18 @@ export default function Projects() {
|
|||
<DialogTitle className="font-mono text-xl uppercase tracking-widest flex items-center gap-2">
|
||||
<Edit className="w-5 h-5" />
|
||||
编辑项目配置
|
||||
{projectToEdit && (
|
||||
<Badge className={`ml-2 ${editForm.source_type === 'repository' ? 'bg-blue-600' : 'bg-amber-500'}`}>
|
||||
{editForm.source_type === 'repository' ? '远程仓库' : 'ZIP上传'}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-6 flex flex-col gap-6 max-h-[70vh] overflow-y-auto">
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-mono font-bold uppercase text-sm border-b-2 border-black pb-1">基本信息</h3>
|
||||
<div>
|
||||
<Label htmlFor="edit-name" className="font-mono font-bold uppercase text-xs">项目名称 *</Label>
|
||||
<Input
|
||||
|
|
@ -898,8 +950,13 @@ export default function Projects() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仓库信息 - 仅远程仓库类型显示 */}
|
||||
{editForm.source_type === 'repository' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-mono font-bold uppercase text-sm border-b-2 border-black pb-1">仓库信息</h3>
|
||||
<h3 className="font-mono font-bold uppercase text-sm border-b-2 border-black pb-1 flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
仓库信息
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-repo-url" className="font-mono font-bold uppercase text-xs">仓库地址</Label>
|
||||
|
|
@ -907,13 +964,14 @@ export default function Projects() {
|
|||
id="edit-repo-url"
|
||||
value={editForm.repository_url}
|
||||
onChange={(e) => setEditForm({ ...editForm, repository_url: e.target.value })}
|
||||
placeholder="https://github.com/user/repo"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-repo-type" className="font-mono font-bold uppercase text-xs">仓库类型</Label>
|
||||
<Label htmlFor="edit-repo-type" className="font-mono font-bold uppercase text-xs">仓库平台</Label>
|
||||
<Select
|
||||
value={editForm.repository_type}
|
||||
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
|
||||
|
|
@ -935,12 +993,122 @@ export default function Projects() {
|
|||
id="edit-default-branch"
|
||||
value={editForm.default_branch}
|
||||
onChange={(e) => setEditForm({ ...editForm, default_branch: e.target.value })}
|
||||
placeholder="main"
|
||||
className="terminal-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ZIP项目文件管理 */}
|
||||
{editForm.source_type === 'zip' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-mono font-bold uppercase text-sm border-b-2 border-black pb-1 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
ZIP文件管理
|
||||
</h3>
|
||||
|
||||
{loadingEditZipInfo ? (
|
||||
<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"></div>
|
||||
<p className="text-sm text-blue-800 font-bold font-mono">正在加载ZIP文件信息...</p>
|
||||
</div>
|
||||
) : editZipInfo?.has_file ? (
|
||||
<div className="bg-green-50 border-2 border-black p-4 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div className="flex items-start space-x-3">
|
||||
<FileText className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div className="flex-1 text-sm font-mono">
|
||||
<p className="font-bold text-green-900 mb-1 uppercase">当前存储的ZIP文件</p>
|
||||
<p className="text-green-700 text-xs">
|
||||
文件名: {editZipInfo.original_filename}
|
||||
{editZipInfo.file_size && (
|
||||
<> ({editZipInfo.file_size >= 1024 * 1024
|
||||
? `${(editZipInfo.file_size / 1024 / 1024).toFixed(2)} MB`
|
||||
: `${(editZipInfo.file_size / 1024).toFixed(2)} KB`
|
||||
})</>
|
||||
)}
|
||||
</p>
|
||||
{editZipInfo.uploaded_at && (
|
||||
<p className="text-green-600 text-xs mt-0.5">
|
||||
上传时间: {new Date(editZipInfo.uploaded_at).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 border-2 border-black p-4 shadow-[2px_2px_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-1 uppercase">暂无ZIP文件</p>
|
||||
<p className="text-amber-700 text-xs">
|
||||
此项目还没有上传ZIP文件,请上传文件以便进行代码审计。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传新文件 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono font-bold uppercase text-xs">
|
||||
{editZipInfo?.has_file ? '更新ZIP文件' : '上传ZIP文件'}
|
||||
</Label>
|
||||
<input
|
||||
ref={editZipInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const validation = validateZipFile(file);
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error || "文件无效");
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setEditZipFile(file);
|
||||
toast.success(`已选择文件: ${file.name}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{editZipFile ? (
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 border-2 border-black">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-mono font-bold">{editZipFile.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({(editZipFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditZipFile(null)}
|
||||
className="terminal-btn-primary bg-white text-black h-7 text-xs"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => editZipInputRef.current?.click()}
|
||||
className="terminal-btn-primary bg-white text-black w-full"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{editZipInfo?.has_file ? '选择新文件替换' : '选择ZIP文件'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 技术栈 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-mono font-bold uppercase text-sm border-b-2 border-black pb-1">技术栈</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { api } from "@/shared/config/database";
|
|||
import type { Project } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { deleteZipFile } from "@/shared/utils/zipStorage";
|
||||
import { isRepositoryProject, getSourceTypeBadge } from "@/shared/utils/projectUtils";
|
||||
|
||||
export default function RecycleBin() {
|
||||
const [deletedProjects, setDeletedProjects] = useState<Project[]>([]);
|
||||
|
|
@ -163,15 +164,20 @@ export default function RecycleBin() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="secondary" className="flex-shrink-0 bg-red-100 text-red-700 border-2 border-black rounded-none font-bold uppercase text-xs">
|
||||
已删除
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono border-black ${isRepositoryProject(project) ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'}`}>
|
||||
{getSourceTypeBadge(project.source_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4 font-mono">
|
||||
{/* 项目信息 */}
|
||||
<div className="space-y-3">
|
||||
{project.repository_url && (
|
||||
{isRepositoryProject(project) && project.repository_url && (
|
||||
<div className="flex items-center text-xs text-gray-600 font-bold">
|
||||
<GitBranch className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type { AuditTask, AuditIssue } from "@/shared/types";
|
|||
import { toast } from "sonner";
|
||||
import ExportReportDialog from "@/components/reports/ExportReportDialog";
|
||||
import { calculateTaskProgress } from "@/shared/utils/utils";
|
||||
import { isRepositoryProject, getSourceTypeLabel } from "@/shared/utils/projectUtils";
|
||||
|
||||
// AI解释解析函数
|
||||
function parseAIExplanation(aiExplanation: string) {
|
||||
|
|
@ -631,9 +632,15 @@ export default function TaskDetail() {
|
|||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-600 uppercase mb-1">仓库类型</p>
|
||||
<p className="text-xs font-bold text-gray-600 uppercase mb-1">项目类型</p>
|
||||
<p className="text-base font-bold">{getSourceTypeLabel(task.project.source_type)}</p>
|
||||
</div>
|
||||
{isRepositoryProject(task.project) && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-600 uppercase mb-1">仓库平台</p>
|
||||
<p className="text-base font-bold">{task.project.repository_type?.toUpperCase() || 'OTHER'}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.project.programming_languages && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-600 uppercase mb-2">编程语言</p>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,13 @@ export const PROJECT_ROLES = {
|
|||
VIEWER: 'viewer',
|
||||
} as const;
|
||||
|
||||
// 仓库类型
|
||||
// 项目来源类型
|
||||
export const PROJECT_SOURCE_TYPES = {
|
||||
REPOSITORY: 'repository',
|
||||
ZIP: 'zip',
|
||||
} as const;
|
||||
|
||||
// 仓库平台类型
|
||||
export const REPOSITORY_TYPES = {
|
||||
GITHUB: 'github',
|
||||
GITLAB: 'gitlab',
|
||||
|
|
@ -92,3 +98,6 @@ export const STORAGE_KEYS = {
|
|||
USER_PREFERENCES: 'xcodereviewer-preferences',
|
||||
RECENT_PROJECTS: 'xcodereviewer-recent-projects',
|
||||
} as const;
|
||||
|
||||
// 导出项目类型相关常量
|
||||
export * from './projectTypes';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* 项目类型相关常量
|
||||
*/
|
||||
|
||||
import type { ProjectSourceType, RepositoryPlatform } from '@/shared/types';
|
||||
|
||||
// 项目来源类型选项
|
||||
export const PROJECT_SOURCE_TYPES: Array<{
|
||||
value: ProjectSourceType;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'repository',
|
||||
label: '远程仓库',
|
||||
description: '从 GitHub/GitLab 等远程仓库拉取代码'
|
||||
},
|
||||
{
|
||||
value: 'zip',
|
||||
label: 'ZIP上传',
|
||||
description: '上传本地ZIP压缩包进行扫描'
|
||||
}
|
||||
];
|
||||
|
||||
// 仓库平台选项
|
||||
export const REPOSITORY_PLATFORMS: Array<{
|
||||
value: RepositoryPlatform;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}> = [
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'gitlab', label: 'GitLab' },
|
||||
{ value: 'other', label: '其他' }
|
||||
];
|
||||
|
||||
// 项目来源类型的颜色配置
|
||||
export const SOURCE_TYPE_COLORS: Record<ProjectSourceType, {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
}> = {
|
||||
repository: {
|
||||
bg: 'bg-blue-100',
|
||||
text: 'text-blue-800',
|
||||
border: 'border-blue-300'
|
||||
},
|
||||
zip: {
|
||||
bg: 'bg-amber-100',
|
||||
text: 'text-amber-800',
|
||||
border: 'border-amber-300'
|
||||
}
|
||||
};
|
||||
|
||||
// 仓库平台的颜色配置
|
||||
export const PLATFORM_COLORS: Record<RepositoryPlatform, {
|
||||
bg: string;
|
||||
text: string;
|
||||
}> = {
|
||||
github: { bg: 'bg-gray-800', text: 'text-white' },
|
||||
gitlab: { bg: 'bg-orange-500', text: 'text-white' },
|
||||
other: { bg: 'bg-gray-500', text: 'text-white' }
|
||||
};
|
||||
|
|
@ -20,13 +20,20 @@ export interface Profile {
|
|||
updated_at: string;
|
||||
}
|
||||
|
||||
// 项目来源类型
|
||||
export type ProjectSourceType = 'repository' | 'zip';
|
||||
|
||||
// 仓库平台类型
|
||||
export type RepositoryPlatform = 'github' | 'gitlab' | 'other';
|
||||
|
||||
// 项目相关类型
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
repository_url?: string;
|
||||
repository_type?: 'github' | 'gitlab' | 'other';
|
||||
source_type: ProjectSourceType; // 项目来源: 'repository' (远程仓库) 或 'zip' (ZIP上传)
|
||||
repository_url?: string; // 仅 source_type='repository' 时有效
|
||||
repository_type?: RepositoryPlatform; // 仓库平台: github, gitlab, other
|
||||
default_branch: string;
|
||||
programming_languages: string;
|
||||
owner_id: string;
|
||||
|
|
@ -108,8 +115,9 @@ export interface InstantAnalysis {
|
|||
export interface CreateProjectForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
repository_url?: string;
|
||||
repository_type?: 'github' | 'gitlab' | 'other';
|
||||
source_type?: ProjectSourceType; // 项目来源类型
|
||||
repository_url?: string; // 仅 source_type='repository' 时需要
|
||||
repository_type?: RepositoryPlatform; // 仓库平台
|
||||
default_branch?: string;
|
||||
programming_languages: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* 项目工具函数
|
||||
* 用于统一处理项目类型判断和相关逻辑
|
||||
*/
|
||||
|
||||
import type { Project, ProjectSourceType } from '@/shared/types';
|
||||
|
||||
/**
|
||||
* 判断项目是否为仓库类型
|
||||
*/
|
||||
export function isRepositoryProject(project: Project): boolean {
|
||||
return project.source_type === 'repository';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否为ZIP上传类型
|
||||
*/
|
||||
export function isZipProject(project: Project): boolean {
|
||||
return project.source_type === 'zip';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目来源类型的显示名称
|
||||
*/
|
||||
export function getSourceTypeLabel(sourceType: ProjectSourceType): string {
|
||||
const labels: Record<ProjectSourceType, string> = {
|
||||
repository: '远程仓库',
|
||||
zip: 'ZIP上传'
|
||||
};
|
||||
return labels[sourceType] || '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目来源类型的英文标签
|
||||
*/
|
||||
export function getSourceTypeBadge(sourceType: ProjectSourceType): string {
|
||||
const badges: Record<ProjectSourceType, string> = {
|
||||
repository: 'REPO',
|
||||
zip: 'ZIP'
|
||||
};
|
||||
return badges[sourceType] || 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库平台的显示名称
|
||||
*/
|
||||
export function getRepositoryPlatformLabel(platform?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
other: '其他'
|
||||
};
|
||||
return labels[platform || 'other'] || '其他';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否可以选择分支(仅仓库类型项目)
|
||||
*/
|
||||
export function canSelectBranch(project: Project): boolean {
|
||||
return isRepositoryProject(project) && !!project.repository_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否需要上传ZIP文件进行扫描
|
||||
*/
|
||||
export function requiresZipUpload(project: Project): boolean {
|
||||
return isZipProject(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目扫描方式的描述
|
||||
*/
|
||||
export function getScanMethodDescription(project: Project): string {
|
||||
if (isRepositoryProject(project)) {
|
||||
return `从 ${getRepositoryPlatformLabel(project.repository_type)} 仓库拉取代码`;
|
||||
}
|
||||
return '上传ZIP文件进行扫描';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目配置是否完整
|
||||
*/
|
||||
export function validateProjectConfig(project: Project): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!project.name?.trim()) {
|
||||
errors.push('项目名称不能为空');
|
||||
}
|
||||
|
||||
if (isRepositoryProject(project)) {
|
||||
if (!project.repository_url?.trim()) {
|
||||
errors.push('仓库地址不能为空');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
|
@ -1,240 +1,119 @@
|
|||
/**
|
||||
* ZIP文件存储工具
|
||||
* 用于管理保存在IndexedDB中的ZIP文件
|
||||
* 通过后端API管理项目的ZIP文件
|
||||
*/
|
||||
|
||||
const DB_NAME = 'xcodereviewer_files';
|
||||
const STORE_NAME = 'zipFiles';
|
||||
import { apiClient } from '@/shared/api/serverClient';
|
||||
|
||||
export interface ZipFileMeta {
|
||||
has_file: boolean;
|
||||
original_filename?: string;
|
||||
file_size?: number;
|
||||
uploaded_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存ZIP文件到IndexedDB
|
||||
* 获取项目ZIP文件信息
|
||||
*/
|
||||
export async function saveZipFile(projectId: string, file: File): Promise<void> {
|
||||
// 检查浏览器是否支持IndexedDB
|
||||
if (!window.indexedDB) {
|
||||
throw new Error('您的浏览器不支持IndexedDB,无法保存ZIP文件');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 不指定版本号,让IndexedDB使用当前最新版本
|
||||
const dbRequest = indexedDB.open(DB_NAME);
|
||||
|
||||
dbRequest.onupgradeneeded = (event) => {
|
||||
export async function getZipFileInfo(projectId: string): Promise<ZipFileMeta> {
|
||||
try {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
const response = await apiClient.get(`/projects/${projectId}/zip`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('创建对象存储失败:', error);
|
||||
reject(new Error('创建存储结构失败,请检查浏览器设置'));
|
||||
console.error('获取ZIP文件信息失败:', error);
|
||||
return { has_file: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dbRequest.onsuccess = async (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
/**
|
||||
* 上传项目ZIP文件
|
||||
*/
|
||||
export async function uploadZipFile(projectId: string, file: File): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
original_filename?: string;
|
||||
file_size?: number;
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// 检查对象存储是否存在,如果不存在则需要升级数据库
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.close();
|
||||
// 增加版本号以触发onupgradeneeded
|
||||
const upgradeRequest = indexedDB.open(DB_NAME, db.version + 1);
|
||||
|
||||
upgradeRequest.onupgradeneeded = (event) => {
|
||||
try {
|
||||
const upgradeDb = (event.target as IDBOpenDBRequest).result;
|
||||
if (!upgradeDb.objectStoreNames.contains(STORE_NAME)) {
|
||||
upgradeDb.createObjectStore(STORE_NAME);
|
||||
const response = await apiClient.post(`/projects/${projectId}/zip`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
original_filename: response.data.original_filename,
|
||||
file_size: response.data.file_size,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('上传ZIP文件失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || '上传失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目ZIP文件
|
||||
*/
|
||||
export async function deleteZipFile(projectId: string): Promise<boolean> {
|
||||
try {
|
||||
await apiClient.delete(`/projects/${projectId}/zip`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('升级数据库时创建对象存储失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
upgradeRequest.onsuccess = async (event) => {
|
||||
const upgradeDb = (event.target as IDBOpenDBRequest).result;
|
||||
await performSave(upgradeDb, file, projectId, resolve, reject);
|
||||
};
|
||||
|
||||
upgradeRequest.onerror = (event) => {
|
||||
const error = (event.target as IDBOpenDBRequest).error;
|
||||
console.error('升级数据库失败:', error);
|
||||
reject(new Error(`升级数据库失败: ${error?.message || '未知错误'}`));
|
||||
};
|
||||
} else {
|
||||
await performSave(db, file, projectId, resolve, reject);
|
||||
}
|
||||
};
|
||||
|
||||
dbRequest.onerror = (event) => {
|
||||
const error = (event.target as IDBOpenDBRequest).error;
|
||||
console.error('打开IndexedDB失败:', error);
|
||||
const errorMsg = error?.message || '未知错误';
|
||||
reject(new Error(`无法打开本地存储,可能是隐私模式或存储权限问题: ${errorMsg}`));
|
||||
};
|
||||
|
||||
dbRequest.onblocked = () => {
|
||||
console.warn('数据库被阻塞,可能有其他标签页正在使用');
|
||||
reject(new Error('数据库被占用,请关闭其他标签页后重试'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function performSave(
|
||||
db: IDBDatabase,
|
||||
file: File,
|
||||
projectId: string,
|
||||
resolve: () => void,
|
||||
reject: (error: Error) => void
|
||||
) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const putRequest = store.put({
|
||||
buffer: arrayBuffer,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}, projectId);
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
const error = (event.target as IDBRequest).error;
|
||||
console.error('写入数据失败:', error);
|
||||
reject(new Error(`保存ZIP文件失败: ${error?.message || '未知错误'}`));
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log(`ZIP文件已保存到项目 ${projectId} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
transaction.onerror = (event) => {
|
||||
const error = (event.target as IDBTransaction).error;
|
||||
console.error('事务失败:', error);
|
||||
reject(new Error(`保存事务失败: ${error?.message || '未知错误'}`));
|
||||
};
|
||||
|
||||
transaction.onabort = () => {
|
||||
console.error('事务被中止');
|
||||
reject(new Error('保存操作被中止'));
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('保存ZIP文件时发生异常:', error);
|
||||
reject(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从IndexedDB加载ZIP文件
|
||||
*/
|
||||
export async function loadZipFile(projectId: string): Promise<File | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 不指定版本号,让IndexedDB使用当前最新版本
|
||||
const dbRequest = indexedDB.open(DB_NAME);
|
||||
|
||||
dbRequest.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
dbRequest.onsuccess = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.close();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const getRequest = store.get(projectId);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const savedFile = getRequest.result;
|
||||
|
||||
if (savedFile && savedFile.buffer) {
|
||||
const blob = new Blob([savedFile.buffer], { type: 'application/zip' });
|
||||
const file = new File([blob], savedFile.fileName, { type: 'application/zip' });
|
||||
resolve(file);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
reject(new Error('读取ZIP文件失败'));
|
||||
};
|
||||
};
|
||||
|
||||
dbRequest.onerror = () => {
|
||||
// 数据库打开失败,可能是首次使用,返回null而不是报错
|
||||
console.warn('打开ZIP文件数据库失败,可能是首次使用');
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除ZIP文件
|
||||
*/
|
||||
export async function deleteZipFile(projectId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 不指定版本号,让IndexedDB使用当前最新版本
|
||||
const dbRequest = indexedDB.open(DB_NAME);
|
||||
|
||||
dbRequest.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
dbRequest.onsuccess = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.close();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const deleteRequest = store.delete(projectId);
|
||||
|
||||
deleteRequest.onsuccess = () => {
|
||||
console.log(`已删除项目 ${projectId} 的ZIP文件`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
deleteRequest.onerror = () => {
|
||||
reject(new Error('删除ZIP文件失败'));
|
||||
};
|
||||
};
|
||||
|
||||
dbRequest.onerror = () => {
|
||||
// 数据库打开失败,可能文件不存在,直接resolve
|
||||
console.warn('打开ZIP文件数据库失败,跳过删除操作');
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否存在ZIP文件
|
||||
*/
|
||||
export async function hasZipFile(projectId: string): Promise<boolean> {
|
||||
try {
|
||||
const file = await loadZipFile(projectId);
|
||||
return file !== null;
|
||||
} catch {
|
||||
console.error('删除ZIP文件失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项目是否有ZIP文件
|
||||
*/
|
||||
export async function hasZipFile(projectId: string): Promise<boolean> {
|
||||
const info = await getZipFileInfo(projectId);
|
||||
return info.has_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
} else if (bytes >= 1024) {
|
||||
return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
// ============ 兼容旧API(已废弃,保留以避免编译错误) ============
|
||||
|
||||
/**
|
||||
* @deprecated 使用 uploadZipFile 代替
|
||||
*/
|
||||
export async function saveZipFile(projectId: string, file: File): Promise<void> {
|
||||
const result = await uploadZipFile(projectId, file);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '保存ZIP文件失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 使用 getZipFileInfo 代替
|
||||
*/
|
||||
export async function loadZipFile(projectId: string): Promise<File | null> {
|
||||
// 后端不再返回文件内容,只返回元数据
|
||||
// 如果需要文件,应该在创建任务时直接使用后端存储的文件
|
||||
const info = await getZipFileInfo(projectId);
|
||||
if (info.has_file && info.original_filename) {
|
||||
// 返回一个虚拟的File对象,仅包含元数据
|
||||
const blob = new Blob([], { type: 'application/zip' });
|
||||
return new File([blob], info.original_filename, { type: 'application/zip' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
source_type VARCHAR(20) DEFAULT 'repository' CHECK (source_type IN ('repository', 'zip')),
|
||||
repository_url TEXT,
|
||||
repository_type VARCHAR(20) DEFAULT 'other' CHECK (repository_type IN ('github', 'gitlab', 'other')),
|
||||
default_branch VARCHAR(100) DEFAULT 'main',
|
||||
|
|
|
|||
Loading…
Reference in New Issue