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:
lintsinghua 2025-11-28 17:38:12 +08:00
parent 7091f891d1
commit bfef3b35a6
25 changed files with 2180 additions and 409 deletions

4
backend/.gitignore vendored
View File

@ -1,2 +1,6 @@
.venv/ .venv/
venv/ venv/
# Uploaded files
uploads/zip_files/*.zip
uploads/zip_files/*.meta

View File

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

View File

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

View File

@ -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文件"}

View File

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

View File

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

View File

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

View File

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

View File

@ -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优先使用用户配置然后使用系统配置

View File

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

2
backend/uploads/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# This directory stores uploaded ZIP files
# Files are stored with project ID as filename

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' }
};

View File

@ -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[];
} }

View File

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

View File

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

View File

@ -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',