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/
|
||||||
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,
|
"id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"description": p.description,
|
"description": p.description,
|
||||||
|
"source_type": p.source_type,
|
||||||
"repository_url": p.repository_url,
|
"repository_url": p.repository_url,
|
||||||
"repository_type": p.repository_type,
|
"repository_type": p.repository_type,
|
||||||
"default_branch": p.default_branch,
|
"default_branch": p.default_branch,
|
||||||
|
|
@ -238,6 +239,7 @@ async def import_database(
|
||||||
id=p_data.get("id"),
|
id=p_data.get("id"),
|
||||||
name=p_data.get("name"),
|
name=p_data.get("name"),
|
||||||
description=p_data.get("description"),
|
description=p_data.get("description"),
|
||||||
|
source_type=p_data.get("source_type", "repository"),
|
||||||
repository_url=p_data.get("repository_url"),
|
repository_url=p_data.get("repository_url"),
|
||||||
repository_type=p_data.get("repository_type"),
|
repository_type=p_data.get("repository_type"),
|
||||||
default_branch=p_data.get("default_branch"),
|
default_branch=p_data.get("default_branch"),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
from typing import Any, List, Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -13,20 +17,26 @@ 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
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
# Schemas
|
# Schemas
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
source_type: Optional[str] = "repository" # 'repository' 或 'zip'
|
||||||
repository_url: Optional[str] = None
|
repository_url: Optional[str] = None
|
||||||
repository_type: Optional[str] = "other"
|
repository_type: Optional[str] = "other" # github, gitlab, other
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
default_branch: Optional[str] = "main"
|
default_branch: Optional[str] = "main"
|
||||||
programming_languages: Optional[List[str]] = None
|
programming_languages: Optional[List[str]] = None
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
source_type: Optional[str] = None
|
||||||
repository_url: Optional[str] = None
|
repository_url: Optional[str] = None
|
||||||
repository_type: Optional[str] = None
|
repository_type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
@ -47,8 +57,9 @@ class ProjectResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
source_type: Optional[str] = "repository" # 'repository' 或 'zip'
|
||||||
repository_url: Optional[str] = None
|
repository_url: Optional[str] = None
|
||||||
repository_type: Optional[str] = None
|
repository_type: Optional[str] = None # github, gitlab, other
|
||||||
default_branch: Optional[str] = None
|
default_branch: Optional[str] = None
|
||||||
programming_languages: Optional[str] = None
|
programming_languages: Optional[str] = None
|
||||||
owner_id: str
|
owner_id: str
|
||||||
|
|
@ -79,10 +90,14 @@ async def create_project(
|
||||||
Create new project.
|
Create new project.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
# 根据 source_type 设置默认值
|
||||||
|
source_type = project_in.source_type or "repository"
|
||||||
|
|
||||||
project = Project(
|
project = Project(
|
||||||
name=project_in.name,
|
name=project_in.name,
|
||||||
repository_url=project_in.repository_url,
|
source_type=source_type,
|
||||||
repository_type=project_in.repository_type or "other",
|
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,
|
description=project_in.description,
|
||||||
default_branch=project_in.default_branch or "main",
|
default_branch=project_in.default_branch or "main",
|
||||||
programming_languages=json.dumps(project_in.programming_languages or []),
|
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)
|
background_tasks.add_task(scan_repo_task, task.id, AsyncSessionLocal, user_config)
|
||||||
|
|
||||||
return {"task_id": task.id, "status": "started"}
|
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.models.user_config import UserConfig
|
||||||
from app.services.llm.service import LLMService
|
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.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
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -167,9 +168,7 @@ async def process_zip_task(task_id: str, file_path: str, db_session_factory, use
|
||||||
await db.commit()
|
await db.commit()
|
||||||
task_control.cleanup_task(task_id)
|
task_control.cleanup_task(task_id)
|
||||||
finally:
|
finally:
|
||||||
# Cleanup
|
# Cleanup - 只清理解压目录,不删除源ZIP文件(已持久化存储)
|
||||||
if os.path.exists(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
if extract_dir.exists():
|
if extract_dir.exists():
|
||||||
shutil.rmtree(extract_dir)
|
shutil.rmtree(extract_dir)
|
||||||
|
|
||||||
|
|
@ -184,6 +183,7 @@ async def scan_zip(
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Upload and scan a ZIP file.
|
Upload and scan a ZIP file.
|
||||||
|
上传ZIP文件并启动扫描,同时将ZIP文件保存到持久化存储
|
||||||
"""
|
"""
|
||||||
# Verify project exists
|
# Verify project exists
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
|
|
@ -194,7 +194,7 @@ async def scan_zip(
|
||||||
if not file.filename.lower().endswith('.zip'):
|
if not file.filename.lower().endswith('.zip'):
|
||||||
raise HTTPException(status_code=400, detail="请上传ZIP格式文件")
|
raise HTTPException(status_code=400, detail="请上传ZIP格式文件")
|
||||||
|
|
||||||
# Save Uploaded File
|
# Save Uploaded File to temp
|
||||||
file_id = str(uuid.uuid4())
|
file_id = str(uuid.uuid4())
|
||||||
file_path = f"/tmp/{file_id}.zip"
|
file_path = f"/tmp/{file_id}.zip"
|
||||||
with open(file_path, "wb") as buffer:
|
with open(file_path, "wb") as buffer:
|
||||||
|
|
@ -206,6 +206,51 @@ async def scan_zip(
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
raise HTTPException(status_code=400, detail="文件大小不能超过100MB")
|
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
|
# Create Task
|
||||||
task = AuditTask(
|
task = AuditTask(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -222,7 +267,7 @@ 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)
|
||||||
|
|
||||||
# Trigger Background Task
|
# 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"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class ProjectSchema(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
source_type: Optional[str] = None
|
||||||
repository_url: Optional[str] = None
|
repository_url: Optional[str] = None
|
||||||
repository_type: Optional[str] = None
|
repository_type: Optional[str] = None
|
||||||
default_branch: Optional[str] = None
|
default_branch: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ class Settings(BaseSettings):
|
||||||
LLM_CONCURRENCY: int = 3 # LLM并发数
|
LLM_CONCURRENCY: int = 3 # LLM并发数
|
||||||
LLM_GAP_MS: int = 2000 # LLM请求间隔(毫秒)
|
LLM_GAP_MS: int = 2000 # LLM请求间隔(毫秒)
|
||||||
|
|
||||||
|
# ZIP文件存储配置
|
||||||
|
ZIP_STORAGE_PATH: str = "./uploads/zip_files" # ZIP文件存储目录
|
||||||
|
|
||||||
# 输出语言配置 - 支持 zh-CN(中文)和 en-US(英文)
|
# 输出语言配置 - 支持 zh-CN(中文)和 en-US(英文)
|
||||||
OUTPUT_LANGUAGE: str = "zh-CN"
|
OUTPUT_LANGUAGE: str = "zh-CN"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,15 @@ class Project(Base):
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
name = Column(String, index=True, nullable=False)
|
name = Column(String, index=True, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
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_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")
|
default_branch = Column(String, default="main")
|
||||||
|
|
||||||
programming_languages = Column(Text, default="[]") # Stored as JSON string
|
programming_languages = Column(Text, default="[]") # Stored as JSON string
|
||||||
|
|
||||||
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
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. 获取项目信息
|
# 2. 获取项目信息
|
||||||
project = await db.get(Project, task.project_id)
|
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("仓库地址不存在")
|
raise Exception("仓库地址不存在")
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}")
|
print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}, 来源: {source_type}")
|
||||||
|
|
||||||
# 3. 获取文件列表
|
# 3. 获取文件列表
|
||||||
# 从用户配置中读取 GitHub/GitLab Token(优先使用用户配置,然后使用系统配置)
|
# 从用户配置中读取 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 { toast } from "sonner";
|
||||||
import TerminalProgressDialog from "./TerminalProgressDialog";
|
import TerminalProgressDialog from "./TerminalProgressDialog";
|
||||||
import { runRepositoryAudit } from "@/features/projects/services/repoScan";
|
import { runRepositoryAudit } from "@/features/projects/services/repoScan";
|
||||||
import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
|
import { scanZipFile, scanStoredZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
|
||||||
import { loadZipFile } from "@/shared/utils/zipStorage";
|
import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage";
|
||||||
|
import { isRepositoryProject, isZipProject, getSourceTypeBadge } from "@/shared/utils/projectUtils";
|
||||||
|
|
||||||
interface CreateTaskDialogProps {
|
interface CreateTaskDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -41,7 +42,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||||
const [loadingZipFile, setLoadingZipFile] = useState(false);
|
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>({
|
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
|
||||||
project_id: "",
|
project_id: "",
|
||||||
|
|
@ -102,37 +104,47 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
}
|
}
|
||||||
// 重置ZIP文件状态
|
// 重置ZIP文件状态
|
||||||
setZipFile(null);
|
setZipFile(null);
|
||||||
setHasLoadedZip(false);
|
setStoredZipInfo(null);
|
||||||
|
setUseStoredZip(true);
|
||||||
}
|
}
|
||||||
}, [open, preselectedProjectId]);
|
}, [open, preselectedProjectId]);
|
||||||
|
|
||||||
// 当项目ID变化时,尝试自动加载保存的ZIP文件
|
// 当项目ID变化时,检查是否有已存储的ZIP文件(仅ZIP类型项目)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const autoLoadZipFile = async () => {
|
const checkStoredZipFile = async () => {
|
||||||
if (!taskForm.project_id || hasLoadedZip) return;
|
if (!taskForm.project_id) {
|
||||||
|
setStoredZipInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const project = projects.find(p => p.id === taskForm.project_id);
|
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 {
|
try {
|
||||||
setLoadingZipFile(true);
|
setLoadingZipFile(true);
|
||||||
const savedFile = await loadZipFile(taskForm.project_id);
|
const zipInfo = await getZipFileInfo(taskForm.project_id);
|
||||||
|
setStoredZipInfo(zipInfo);
|
||||||
|
|
||||||
if (savedFile) {
|
if (zipInfo.has_file) {
|
||||||
setZipFile(savedFile);
|
console.log('✓ 项目有已存储的ZIP文件:', zipInfo.original_filename);
|
||||||
setHasLoadedZip(true);
|
setUseStoredZip(true);
|
||||||
console.log('✓ 已自动加载保存的ZIP文件:', savedFile.name);
|
} else {
|
||||||
toast.success(`已加载保存的ZIP文件: ${savedFile.name}`);
|
setUseStoredZip(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('自动加载ZIP文件失败:', error);
|
console.error('检查ZIP文件失败:', error);
|
||||||
|
setStoredZipInfo(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingZipFile(false);
|
setLoadingZipFile(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
autoLoadZipFile();
|
checkStoredZipFile();
|
||||||
}, [taskForm.project_id, projects, hasLoadedZip]);
|
}, [taskForm.project_id, projects]);
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -170,20 +182,26 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
console.log('🎯 开始创建审计任务...', {
|
console.log('🎯 开始创建审计任务...', {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
|
sourceType: project.source_type,
|
||||||
repositoryType: project.repository_type
|
repositoryType: project.repository_type
|
||||||
});
|
});
|
||||||
|
|
||||||
let taskId: string;
|
let taskId: string;
|
||||||
|
|
||||||
// 根据项目是否有repository_url判断使用哪种扫描方式
|
// 根据项目 source_type 判断使用哪种扫描方式
|
||||||
if (!project.repository_url || project.repository_url.trim() === '') {
|
if (isZipProject(project)) {
|
||||||
// ZIP上传的项目:需要有ZIP文件才能扫描
|
// ZIP上传类型项目
|
||||||
if (!zipFile) {
|
if (useStoredZip && storedZipInfo?.has_file) {
|
||||||
toast.error("请上传ZIP文件进行扫描");
|
// 使用已存储的ZIP文件
|
||||||
return;
|
console.log('📦 ZIP项目 - 使用已存储的ZIP文件...');
|
||||||
}
|
taskId = await scanStoredZipFile({
|
||||||
|
projectId: project.id,
|
||||||
console.log('📦 调用 scanZipFile...');
|
excludePatterns: taskForm.exclude_patterns,
|
||||||
|
createdBy: 'local-user'
|
||||||
|
});
|
||||||
|
} else if (zipFile) {
|
||||||
|
// 上传新的ZIP文件
|
||||||
|
console.log('📦 ZIP项目 - 上传新ZIP文件...');
|
||||||
taskId = await scanZipFile({
|
taskId = await scanZipFile({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
zipFile: zipFile,
|
zipFile: zipFile,
|
||||||
|
|
@ -191,13 +209,22 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
createdBy: 'local-user'
|
createdBy: 'local-user'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// GitHub/GitLab等远程仓库
|
toast.error("请上传ZIP文件或使用已存储的文件进行扫描");
|
||||||
console.log('📡 调用 runRepositoryAudit...');
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 仓库类型项目:从远程仓库拉取代码
|
||||||
|
if (!project.repository_url) {
|
||||||
|
toast.error("仓库地址不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📡 仓库项目 - 调用 runRepositoryAudit...');
|
||||||
|
|
||||||
// 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递
|
// 后端会从用户配置中读取 GitHub/GitLab Token,前端不需要传递
|
||||||
taskId = await runRepositoryAudit({
|
taskId = await runRepositoryAudit({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
repoUrl: project.repository_url!,
|
repoUrl: project.repository_url,
|
||||||
branch: taskForm.branch_name || project.default_branch || 'main',
|
branch: taskForm.branch_name || project.default_branch || 'main',
|
||||||
exclude: taskForm.exclude_patterns,
|
exclude: taskForm.exclude_patterns,
|
||||||
createdBy: 'local-user'
|
createdBy: 'local-user'
|
||||||
|
|
@ -212,6 +239,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
taskId,
|
taskId,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
|
sourceType: project.source_type,
|
||||||
taskType: taskForm.task_type,
|
taskType: taskForm.task_type,
|
||||||
branch: taskForm.branch_name,
|
branch: taskForm.branch_name,
|
||||||
hasZipFile: !!zipFile,
|
hasZipFile: !!zipFile,
|
||||||
|
|
@ -352,8 +380,15 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500 font-mono font-bold">
|
<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 className="uppercase">{project.repository_type?.toUpperCase() || 'OTHER'}</span>
|
||||||
<span>{project.default_branch}</span>
|
<span>{project.default_branch}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{taskForm.project_id === project.id && (
|
{taskForm.project_id === project.id && (
|
||||||
|
|
@ -403,50 +438,101 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="basic" className="space-y-4 mt-6 font-mono">
|
<TabsContent value="basic" className="space-y-4 mt-6 font-mono">
|
||||||
{/* ZIP项目文件上传 */}
|
{/* ZIP项目文件上传 - 仅ZIP类型项目显示 */}
|
||||||
{(!selectedProject.repository_url || selectedProject.repository_url.trim() === '') && (
|
{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="bg-amber-50 border-2 border-black p-4 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{loadingZipFile ? (
|
{loadingZipFile ? (
|
||||||
<div className="flex items-center space-x-3 p-4 bg-blue-50 border-2 border-black">
|
<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>
|
<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>
|
</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">
|
<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" />
|
<Info className="w-5 h-5 text-green-600 mt-0.5" />
|
||||||
<div className="flex-1">
|
<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">
|
<p className="text-xs text-green-700 mt-1 font-bold">
|
||||||
使用保存的ZIP文件: {zipFile.name} (
|
文件名: {storedZipInfo.original_filename}
|
||||||
{zipFile.size >= 1024 * 1024
|
{storedZipInfo.file_size && (
|
||||||
? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB`
|
<> ({storedZipInfo.file_size >= 1024 * 1024
|
||||||
: zipFile.size >= 1024
|
? `${(storedZipInfo.file_size / 1024 / 1024).toFixed(2)} MB`
|
||||||
? `${(zipFile.size / 1024).toFixed(2)} KB`
|
: `${(storedZipInfo.file_size / 1024).toFixed(2)} KB`
|
||||||
: `${zipFile.size} B`
|
})</>
|
||||||
})
|
)}
|
||||||
</p>
|
</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>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
{/* 选择使用已存储文件还是上传新文件 */}
|
||||||
onClick={() => {
|
<div className="flex items-center space-x-4">
|
||||||
setZipFile(null);
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
setHasLoadedZip(false);
|
<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"
|
className="cursor-pointer retro-input pt-1.5"
|
||||||
>
|
/>
|
||||||
更换文件
|
{zipFile && (
|
||||||
</Button>
|
<p className="text-xs text-amber-700 font-bold">
|
||||||
|
新文件: {zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
// 没有存储的ZIP文件
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-amber-900 text-sm uppercase">需要上传ZIP文件</p>
|
<p className="font-bold text-amber-900 text-sm uppercase">需要上传ZIP文件</p>
|
||||||
<p className="text-xs text-amber-700 mt-1 font-bold">
|
<p className="text-xs text-amber-700 mt-1 font-bold">
|
||||||
未找到保存的ZIP文件,请上传文件进行扫描
|
此项目还没有存储的ZIP文件,请上传文件进行扫描
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -474,7 +560,6 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setZipFile(file);
|
setZipFile(file);
|
||||||
setHasLoadedZip(true);
|
|
||||||
|
|
||||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||||
const sizeKB = (file.size / 1024).toFixed(2);
|
const sizeKB = (file.size / 1024).toFixed(2);
|
||||||
|
|
@ -519,7 +604,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{taskForm.task_type === "repository" && (selectedProject.repository_url) && (
|
{/* 分支选择 - 仅仓库类型项目显示 */}
|
||||||
|
{taskForm.task_type === "repository" && isRepositoryProject(selectedProject) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="branch_name" className="font-bold uppercase">目标分支</Label>
|
<Label htmlFor="branch_name" className="font-bold uppercase">目标分支</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -540,10 +626,16 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
||||||
<div className="text-sm font-mono">
|
<div className="text-sm font-mono">
|
||||||
<p className="font-bold text-blue-900 mb-1 uppercase">选中项目:{selectedProject.name}</p>
|
<p className="font-bold text-blue-900 mb-1 uppercase">选中项目:{selectedProject.name}</p>
|
||||||
<div className="text-blue-800 space-y-1 font-bold">
|
<div className="text-blue-800 space-y-1 font-bold">
|
||||||
|
<p>项目类型:{isRepositoryProject(selectedProject) ? '远程仓库' : 'ZIP上传'}</p>
|
||||||
{selectedProject.description && (
|
{selectedProject.description && (
|
||||||
<p>描述:{selectedProject.description}</p>
|
<p>描述:{selectedProject.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{isRepositoryProject(selectedProject) && (
|
||||||
|
<>
|
||||||
|
<p>仓库平台:{selectedProject.repository_type?.toUpperCase() || 'OTHER'}</p>
|
||||||
<p>默认分支:{selectedProject.default_branch}</p>
|
<p>默认分支:{selectedProject.default_branch}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{selectedProject.programming_languages && (
|
{selectedProject.programming_languages && (
|
||||||
<p>编程语言:{JSON.parse(selectedProject.programming_languages).join(', ')}</p>
|
<p>编程语言:{JSON.parse(selectedProject.programming_languages).join(', ')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { apiClient } from "@/shared/api/serverClient";
|
import { apiClient } from "@/shared/api/serverClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传ZIP文件并启动扫描
|
||||||
|
*/
|
||||||
export async function scanZipFile(params: {
|
export async function scanZipFile(params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
zipFile: File;
|
zipFile: File;
|
||||||
|
|
@ -20,6 +23,21 @@ export async function scanZipFile(params: {
|
||||||
return res.data.task_id;
|
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 } {
|
export function validateZipFile(file: File): { valid: boolean; error?: string } {
|
||||||
// 检查文件类型
|
// 检查文件类型
|
||||||
if (!file.type.includes('zip') && !file.name.toLowerCase().endsWith('.zip')) {
|
if (!file.type.includes('zip') && !file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,7 @@ class UserManager {
|
||||||
owner_id: 'local-user',
|
owner_id: 'local-user',
|
||||||
name: '即时分析',
|
name: '即时分析',
|
||||||
description: `${language} 代码即时分析`,
|
description: `${language} 代码即时分析`,
|
||||||
|
source_type: 'zip',
|
||||||
repository_type: 'other',
|
repository_type: 'other',
|
||||||
repository_url: undefined,
|
repository_url: undefined,
|
||||||
default_branch: 'instant',
|
default_branch: 'instant',
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,15 @@ import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Play,
|
Play,
|
||||||
FileText
|
FileText,
|
||||||
|
Upload,
|
||||||
|
GitBranch
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/shared/config/database";
|
import { api } from "@/shared/config/database";
|
||||||
import { runRepositoryAudit, scanZipFile } from "@/features/projects/services";
|
import { runRepositoryAudit, scanZipFile } from "@/features/projects/services";
|
||||||
import type { Project, AuditTask, CreateProjectForm } from "@/shared/types";
|
import type { Project, AuditTask, CreateProjectForm } from "@/shared/types";
|
||||||
import { loadZipFile } from "@/shared/utils/zipStorage";
|
import { loadZipFile } from "@/shared/utils/zipStorage";
|
||||||
|
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 TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
|
||||||
|
|
@ -43,6 +46,7 @@ export default function ProjectDetail() {
|
||||||
const [editForm, setEditForm] = useState<CreateProjectForm>({
|
const [editForm, setEditForm] = useState<CreateProjectForm>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
source_type: "repository",
|
||||||
repository_url: "",
|
repository_url: "",
|
||||||
repository_type: "github",
|
repository_type: "github",
|
||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
|
|
@ -81,6 +85,7 @@ export default function ProjectDetail() {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
name: project.name,
|
name: project.name,
|
||||||
description: project.description || "",
|
description: project.description || "",
|
||||||
|
source_type: project.source_type || "repository",
|
||||||
repository_url: project.repository_url || "",
|
repository_url: project.repository_url || "",
|
||||||
repository_type: project.repository_type || "github",
|
repository_type: project.repository_type || "github",
|
||||||
default_branch: project.default_branch || "main",
|
default_branch: project.default_branch || "main",
|
||||||
|
|
@ -441,7 +446,16 @@ export default function ProjectDetail() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<Badge variant="outline" className="rounded-none border-black bg-gray-100 text-black">
|
||||||
{project.repository_type === 'github' ? 'GitHub' :
|
{project.repository_type === 'github' ? 'GitHub' :
|
||||||
project.repository_type === 'gitlab' ? 'GitLab' : '其他'}
|
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-gray-600 uppercase">默认分支</span>
|
||||||
<span className="text-sm font-bold text-black bg-gray-100 px-2 border border-black">{project.default_branch}</span>
|
<span className="text-sm font-bold text-black bg-gray-100 px-2 border border-black">{project.default_branch}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
||||||
|
|
@ -711,9 +727,13 @@ export default function ProjectDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 仓库信息 */}
|
{/* 仓库信息 - 仅远程仓库类型显示 */}
|
||||||
|
{editForm.source_type === 'repository' && (
|
||||||
<div className="space-y-4 border-t-2 border-dashed border-gray-300 pt-4">
|
<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>
|
<div>
|
||||||
<Label htmlFor="edit-repo-url" className="font-bold font-mono uppercase">仓库地址</Label>
|
<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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<Select
|
||||||
value={editForm.repository_type}
|
value={editForm.repository_type}
|
||||||
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
|
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
|
||||||
|
|
@ -756,6 +776,24 @@ export default function ProjectDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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 { api } from "@/shared/config/database";
|
||||||
import { validateZipFile } from "@/features/projects/services";
|
import { validateZipFile } from "@/features/projects/services";
|
||||||
import type { Project, CreateProjectForm } from "@/shared/types";
|
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 { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
|
||||||
|
|
@ -55,6 +56,7 @@ export default function Projects() {
|
||||||
const [editForm, setEditForm] = useState<CreateProjectForm>({
|
const [editForm, setEditForm] = useState<CreateProjectForm>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
source_type: "repository",
|
||||||
repository_url: "",
|
repository_url: "",
|
||||||
repository_type: "github",
|
repository_type: "github",
|
||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
|
|
@ -63,6 +65,7 @@ export default function Projects() {
|
||||||
const [createForm, setCreateForm] = useState<CreateProjectForm>({
|
const [createForm, setCreateForm] = useState<CreateProjectForm>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
source_type: "repository",
|
||||||
repository_url: "",
|
repository_url: "",
|
||||||
repository_type: "github",
|
repository_type: "github",
|
||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
|
|
@ -71,6 +74,12 @@ export default function Projects() {
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
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 formatLanguageName = (lang: string): string => {
|
||||||
const nameMap: Record<string, string> = {
|
const nameMap: Record<string, string> = {
|
||||||
|
|
@ -151,6 +160,7 @@ export default function Projects() {
|
||||||
setCreateForm({
|
setCreateForm({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
source_type: "repository",
|
||||||
repository_url: "",
|
repository_url: "",
|
||||||
repository_type: "github",
|
repository_type: "github",
|
||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
|
|
@ -205,15 +215,17 @@ export default function Projects() {
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// 创建项目
|
// 创建项目 - ZIP上传类型
|
||||||
const project = await api.createProject({
|
const project = await api.createProject({
|
||||||
...createForm,
|
...createForm,
|
||||||
repository_type: "other"
|
source_type: "zip",
|
||||||
|
repository_type: "other",
|
||||||
|
repository_url: undefined
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// 保存ZIP文件到IndexedDB(使用项目ID作为key)
|
// 保存ZIP文件到后端持久化存储
|
||||||
try {
|
try {
|
||||||
await saveZipFile(project.id, selectedFile);
|
await uploadZipFile(project.id, selectedFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存ZIP文件失败:', error);
|
console.error('保存ZIP文件失败:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -278,17 +290,33 @@ export default function Projects() {
|
||||||
setShowCreateTaskDialog(true);
|
setShowCreateTaskDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditClick = (project: Project) => {
|
const handleEditClick = async (project: Project) => {
|
||||||
setProjectToEdit(project);
|
setProjectToEdit(project);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
name: project.name,
|
name: project.name,
|
||||||
description: project.description || "",
|
description: project.description || "",
|
||||||
|
source_type: project.source_type || "repository",
|
||||||
repository_url: project.repository_url || "",
|
repository_url: project.repository_url || "",
|
||||||
repository_type: project.repository_type || "github",
|
repository_type: project.repository_type || "github",
|
||||||
default_branch: project.default_branch || "main",
|
default_branch: project.default_branch || "main",
|
||||||
programming_languages: project.programming_languages ? JSON.parse(project.programming_languages) : []
|
programming_languages: project.programming_languages ? JSON.parse(project.programming_languages) : []
|
||||||
});
|
});
|
||||||
|
setEditZipFile(null);
|
||||||
|
setEditZipInfo(null);
|
||||||
setShowEditDialog(true);
|
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 () => {
|
const handleSaveEdit = async () => {
|
||||||
|
|
@ -300,10 +328,24 @@ export default function Projects() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 更新项目基本信息
|
||||||
await api.updateProject(projectToEdit.id, editForm);
|
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}" 已更新`);
|
toast.success(`项目 "${editForm.name}" 已更新`);
|
||||||
setShowEditDialog(false);
|
setShowEditDialog(false);
|
||||||
setProjectToEdit(null);
|
setProjectToEdit(null);
|
||||||
|
setEditZipFile(null);
|
||||||
|
setEditZipInfo(null);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update project:', 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="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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs font-bold uppercase text-gray-500">GitHub</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 => p.repository_type === 'github').length}</p>
|
<p className="font-display text-2xl font-bold">{projects.filter(p => isRepositoryProject(p)).length}</p>
|
||||||
</div>
|
</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" />
|
<GitBranch className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</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="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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs font-bold uppercase text-gray-500">GitLab</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 => p.repository_type === 'gitlab').length}</p>
|
<p className="font-display text-2xl font-bold">{projects.filter(p => isZipProject(p)).length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 border border-border bg-orange-500 flex items-center justify-center text-white shadow-sm">
|
<div className="w-10 h-10 border border-border bg-amber-500 flex items-center justify-center text-white shadow-sm">
|
||||||
<Shield className="w-5 h-5" />
|
<Upload className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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'}`}>
|
<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 ? '活跃' : '暂停'}
|
{project.is_active ? '活跃' : '暂停'}
|
||||||
</Badge>
|
</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>
|
</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">
|
<DialogTitle className="font-mono text-xl uppercase tracking-widest flex items-center gap-2">
|
||||||
<Edit className="w-5 h-5" />
|
<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>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="p-6 flex flex-col gap-6 max-h-[70vh] overflow-y-auto">
|
<div className="p-6 flex flex-col gap-6 max-h-[70vh] overflow-y-auto">
|
||||||
|
{/* 基本信息 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-mono font-bold uppercase text-sm border-b-2 border-black pb-1">基本信息</h3>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-name" className="font-mono font-bold uppercase text-xs">项目名称 *</Label>
|
<Label htmlFor="edit-name" className="font-mono font-bold uppercase text-xs">项目名称 *</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -898,8 +950,13 @@ export default function Projects() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 仓库信息 - 仅远程仓库类型显示 */}
|
||||||
|
{editForm.source_type === 'repository' && (
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<Label htmlFor="edit-repo-url" className="font-mono font-bold uppercase text-xs">仓库地址</Label>
|
<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"
|
id="edit-repo-url"
|
||||||
value={editForm.repository_url}
|
value={editForm.repository_url}
|
||||||
onChange={(e) => setEditForm({ ...editForm, repository_url: e.target.value })}
|
onChange={(e) => setEditForm({ ...editForm, repository_url: e.target.value })}
|
||||||
|
placeholder="https://github.com/user/repo"
|
||||||
className="terminal-input"
|
className="terminal-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<Select
|
||||||
value={editForm.repository_type}
|
value={editForm.repository_type}
|
||||||
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
|
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
|
||||||
|
|
@ -935,12 +993,122 @@ export default function Projects() {
|
||||||
id="edit-default-branch"
|
id="edit-default-branch"
|
||||||
value={editForm.default_branch}
|
value={editForm.default_branch}
|
||||||
onChange={(e) => setEditForm({ ...editForm, default_branch: e.target.value })}
|
onChange={(e) => setEditForm({ ...editForm, default_branch: e.target.value })}
|
||||||
|
placeholder="main"
|
||||||
className="terminal-input"
|
className="terminal-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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">技术栈</h3>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<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 type { Project } from "@/shared/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { deleteZipFile } from "@/shared/utils/zipStorage";
|
import { deleteZipFile } from "@/shared/utils/zipStorage";
|
||||||
|
import { isRepositoryProject, getSourceTypeBadge } from "@/shared/utils/projectUtils";
|
||||||
|
|
||||||
export default function RecycleBin() {
|
export default function RecycleBin() {
|
||||||
const [deletedProjects, setDeletedProjects] = useState<Project[]>([]);
|
const [deletedProjects, setDeletedProjects] = useState<Project[]>([]);
|
||||||
|
|
@ -163,15 +164,20 @@ export default function RecycleBin() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="p-4 space-y-4 font-mono">
|
<div className="p-4 space-y-4 font-mono">
|
||||||
{/* 项目信息 */}
|
{/* 项目信息 */}
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex items-center text-xs text-gray-600 font-bold">
|
||||||
<GitBranch className="w-4 h-4 mr-2 flex-shrink-0" />
|
<GitBranch className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type { AuditTask, AuditIssue } from "@/shared/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ExportReportDialog from "@/components/reports/ExportReportDialog";
|
import ExportReportDialog from "@/components/reports/ExportReportDialog";
|
||||||
import { calculateTaskProgress } from "@/shared/utils/utils";
|
import { calculateTaskProgress } from "@/shared/utils/utils";
|
||||||
|
import { isRepositoryProject, getSourceTypeLabel } from "@/shared/utils/projectUtils";
|
||||||
|
|
||||||
// AI解释解析函数
|
// AI解释解析函数
|
||||||
function parseAIExplanation(aiExplanation: string) {
|
function parseAIExplanation(aiExplanation: string) {
|
||||||
|
|
@ -631,9 +632,15 @@ export default function TaskDetail() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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>
|
<p className="text-base font-bold">{task.project.repository_type?.toUpperCase() || 'OTHER'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{task.project.programming_languages && (
|
{task.project.programming_languages && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold text-gray-600 uppercase mb-2">编程语言</p>
|
<p className="text-xs font-bold text-gray-600 uppercase mb-2">编程语言</p>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,13 @@ export const PROJECT_ROLES = {
|
||||||
VIEWER: 'viewer',
|
VIEWER: 'viewer',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 仓库类型
|
// 项目来源类型
|
||||||
|
export const PROJECT_SOURCE_TYPES = {
|
||||||
|
REPOSITORY: 'repository',
|
||||||
|
ZIP: 'zip',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 仓库平台类型
|
||||||
export const REPOSITORY_TYPES = {
|
export const REPOSITORY_TYPES = {
|
||||||
GITHUB: 'github',
|
GITHUB: 'github',
|
||||||
GITLAB: 'gitlab',
|
GITLAB: 'gitlab',
|
||||||
|
|
@ -92,3 +98,6 @@ export const STORAGE_KEYS = {
|
||||||
USER_PREFERENCES: 'xcodereviewer-preferences',
|
USER_PREFERENCES: 'xcodereviewer-preferences',
|
||||||
RECENT_PROJECTS: 'xcodereviewer-recent-projects',
|
RECENT_PROJECTS: 'xcodereviewer-recent-projects',
|
||||||
} as const;
|
} 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;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 项目来源类型
|
||||||
|
export type ProjectSourceType = 'repository' | 'zip';
|
||||||
|
|
||||||
|
// 仓库平台类型
|
||||||
|
export type RepositoryPlatform = 'github' | 'gitlab' | 'other';
|
||||||
|
|
||||||
// 项目相关类型
|
// 项目相关类型
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
repository_url?: string;
|
source_type: ProjectSourceType; // 项目来源: 'repository' (远程仓库) 或 'zip' (ZIP上传)
|
||||||
repository_type?: 'github' | 'gitlab' | 'other';
|
repository_url?: string; // 仅 source_type='repository' 时有效
|
||||||
|
repository_type?: RepositoryPlatform; // 仓库平台: github, gitlab, other
|
||||||
default_branch: string;
|
default_branch: string;
|
||||||
programming_languages: string;
|
programming_languages: string;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
|
|
@ -108,8 +115,9 @@ export interface InstantAnalysis {
|
||||||
export interface CreateProjectForm {
|
export interface CreateProjectForm {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
repository_url?: string;
|
source_type?: ProjectSourceType; // 项目来源类型
|
||||||
repository_type?: 'github' | 'gitlab' | 'other';
|
repository_url?: string; // 仅 source_type='repository' 时需要
|
||||||
|
repository_type?: RepositoryPlatform; // 仓库平台
|
||||||
default_branch?: string;
|
default_branch?: string;
|
||||||
programming_languages: 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文件存储工具
|
* ZIP文件存储工具
|
||||||
* 用于管理保存在IndexedDB中的ZIP文件
|
* 通过后端API管理项目的ZIP文件
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DB_NAME = 'xcodereviewer_files';
|
import { apiClient } from '@/shared/api/serverClient';
|
||||||
const STORE_NAME = 'zipFiles';
|
|
||||||
|
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> {
|
export async function getZipFileInfo(projectId: string): Promise<ZipFileMeta> {
|
||||||
// 检查浏览器是否支持IndexedDB
|
|
||||||
if (!window.indexedDB) {
|
|
||||||
throw new Error('您的浏览器不支持IndexedDB,无法保存ZIP文件');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// 不指定版本号,让IndexedDB使用当前最新版本
|
|
||||||
const dbRequest = indexedDB.open(DB_NAME);
|
|
||||||
|
|
||||||
dbRequest.onupgradeneeded = (event) => {
|
|
||||||
try {
|
try {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const response = await apiClient.get(`/projects/${projectId}/zip`);
|
||||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
return response.data;
|
||||||
db.createObjectStore(STORE_NAME);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建对象存储失败:', error);
|
console.error('获取ZIP文件信息失败:', error);
|
||||||
reject(new 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 {
|
try {
|
||||||
const upgradeDb = (event.target as IDBOpenDBRequest).result;
|
const response = await apiClient.post(`/projects/${projectId}/zip`, formData, {
|
||||||
if (!upgradeDb.objectStoreNames.contains(STORE_NAME)) {
|
headers: {
|
||||||
upgradeDb.createObjectStore(STORE_NAME);
|
'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) {
|
} catch (error) {
|
||||||
console.error('升级数据库时创建对象存储失败:', error);
|
console.error('删除ZIP文件失败:', 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 {
|
|
||||||
return false;
|
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(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
source_type VARCHAR(20) DEFAULT 'repository' CHECK (source_type IN ('repository', 'zip')),
|
||||||
repository_url TEXT,
|
repository_url TEXT,
|
||||||
repository_type VARCHAR(20) DEFAULT 'other' CHECK (repository_type IN ('github', 'gitlab', 'other')),
|
repository_type VARCHAR(20) DEFAULT 'other' CHECK (repository_type IN ('github', 'gitlab', 'other')),
|
||||||
default_branch VARCHAR(100) DEFAULT 'main',
|
default_branch VARCHAR(100) DEFAULT 'main',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue