Refactor: Unified pipeline execution, simplified UI, and fixed Docker config

- Backend: Refactored tasks.py to directly invoke run_single_fna_pipeline.py for consistency.
- Backend: Changed output format to ZIP and added auto-cleanup of intermediate files.
- Backend: Fixed language parameter passing in API and tasks.
- Frontend: Removed CRISPR Fusion UI elements from Submit and Monitor views.
- Frontend: Implemented simulated progress bar for better UX.
- Frontend: Restored One-click load button and added result file structure documentation.
- Docker: Fixed critical Restarting loop by removing incorrect image directive in docker-compose.yml.
- Docker: Optimized Dockerfile to correct .pixi environment path issues and prevent accidental deletion of frontend assets.
This commit is contained in:
zly
2026-01-20 20:25:25 +08:00
parent 5067169b0b
commit c75c85c53b
134 changed files with 146457 additions and 996647 deletions

View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path
import os
router = APIRouter()
# Define the path to examples relative to this file
# This file: backend/app/api/v1/examples.py
# Root: backend/app/api/v1/../../../../.. = root (where Data/ is)
# But in Docker, backend/ is at /app/backend and Data/ is at /app/Data
# So we need to go up 5 levels to get to /app (or project root)
current_file = Path(__file__).resolve()
# Go up 5 levels: v1 -> api -> app -> backend -> root
PROJECT_ROOT = current_file.parent.parent.parent.parent.parent
EXAMPLES_DIR = PROJECT_ROOT / "Data" / "examples"
@router.get("/")
async def list_examples():
"""List available example files"""
if not EXAMPLES_DIR.exists():
return []
files = []
# Sort files to ensure consistent order (e.g. C15.fna, then 97-27.fna)
# Actually just grab all .fna files
if EXAMPLES_DIR.exists():
for f in sorted(EXAMPLES_DIR.glob("*.fna")):
files.append({
"name": f.name,
"size": f.stat().st_size
})
return files
@router.get("/{filename}")
async def get_example(filename: str):
"""Get an example file content"""
file_path = EXAMPLES_DIR / filename
# Ensure directory exists
if not EXAMPLES_DIR.exists():
raise HTTPException(status_code=404, detail="Examples directory not found")
# Security check to prevent path traversal
try:
if not file_path.resolve().is_relative_to(EXAMPLES_DIR.resolve()):
raise HTTPException(status_code=403, detail="Access denied")
except ValueError:
# Can happen if paths are on different drives or completely messed up
raise HTTPException(status_code=403, detail="Access denied")
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream"
)

View File

@@ -31,8 +31,7 @@ async def create_job(
min_coverage: float = Form(0.6),
allow_unknown_families: bool = Form(False),
require_index_hit: bool = Form(True),
crispr_fusion: bool = Form(False),
crispr_weight: float = Form(0.0),
lang: str = Form("zh"),
db: Session = Depends(get_db),
i18n: I18n = Depends(get_i18n)
):
@@ -48,6 +47,7 @@ async def create_job(
min_coverage: 最小覆盖度 (0-1)
allow_unknown_families: 是否允许未知家族
require_index_hit: 是否要求索引命中
lang: 报告语言 (zh/en)
"""
# 验证文件类型
allowed_extensions = {".fna", ".fa", ".fasta", ".faa"}
@@ -91,8 +91,6 @@ async def create_job(
min_coverage=int(min_coverage * 100),
allow_unknown_families=int(allow_unknown_families),
require_index_hit=int(require_index_hit),
crispr_fusion=int(crispr_fusion),
crispr_weight=int(crispr_weight * 100),
)
db.add(job)
@@ -111,8 +109,7 @@ async def create_job(
min_coverage=min_coverage,
allow_unknown_families=allow_unknown_families,
require_index_hit=require_index_hit,
crispr_fusion=crispr_fusion,
crispr_weight=crispr_weight,
lang=lang,
)
job.celery_task_id = task.id

View File

@@ -1,11 +1,12 @@
"""结果下载 API"""
from fastapi import APIRouter, HTTPException, Response, Depends
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy.orm import Session
from pathlib import Path
import tarfile
import io
import shutil
import json
from ...database import get_db
from ...models.job import Job, JobStatus
@@ -53,6 +54,39 @@ async def download_results(job_id: str, db: Session = Depends(get_db), i18n: I18
)
@router.get("/{job_id}/crispr")
async def get_crispr_results(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""获取 CRISPR 分析结果"""
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
if not job.crispr_fusion:
# 即使没启用如果用户请求了我们也可以返回一个提示或者空数据但400可能更合适
# 或者仅仅返回空对象
return {"status": "disabled", "message": "CRISPR analysis disabled"}
job_output_dir = RESULTS_DIR / job_id
# 优先返回融合分析结果
fusion_file = job_output_dir / "crispr" / "fusion_analysis.json"
if fusion_file.exists():
with open(fusion_file) as f:
return json.load(f)
# 其次返回检测结果
detect_file = job_output_dir / "crispr" / "results.json"
if detect_file.exists():
with open(detect_file) as f:
return json.load(f)
# 如果任务已完成但文件不存在
if job.status == JobStatus.COMPLETED:
return {"status": "empty", "message": "No CRISPR elements detected"}
return {"status": "pending", "message": "Analysis in progress"}
@router.delete("/{job_id}")
async def delete_job(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""删除任务及其结果"""
@@ -60,6 +94,18 @@ async def delete_job(job_id: str, db: Session = Depends(get_db), i18n: I18n = De
if not job:
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
# 如果任务正在运行或排队,尝试取消 Celery 任务
if job.status in [JobStatus.PENDING, JobStatus.QUEUED, JobStatus.RUNNING] and job.celery_task_id:
try:
from ...core.celery_app import celery_app
celery_app.control.revoke(job.celery_task_id, terminate=True)
# 标记为已取消 (虽然后面马上删除了,但为了逻辑完整性)
job.status = JobStatus.FAILED
job.error_message = "Task cancelled by user"
db.commit()
except Exception as e:
print(f"Failed to revoke task {job.celery_task_id}: {e}")
# 删除磁盘上的文件
job_input_dir = Path(settings.UPLOAD_DIR) / job_id
job_output_dir = RESULTS_DIR / job_id