diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index c3d66b6..500b760 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -8,10 +8,12 @@ import json import os import secrets import subprocess +import tempfile import threading import time import urllib.error import urllib.request +import zipfile from collections import defaultdict from datetime import datetime, timedelta from pathlib import Path @@ -24,6 +26,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from PIL import Image +from starlette.background import BackgroundTask APP_DIR = Path(__file__).resolve().parent @@ -70,7 +73,7 @@ BODY_PARTS = set(BODY_PART_ORDER) PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} ROLES = { - "管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"], + "管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "批量导出", "用户创建", "权限控制", "系统设置"], "阅片员": ["查看DICOM", "编辑标注", "AI识别"], "访客": ["查看DICOM"], } @@ -121,6 +124,10 @@ class StudyCompletionIn(BaseModel): completed: bool = False +class ExportStudiesIn(BaseModel): + ct_numbers: list[str] = [] + + class UserIn(BaseModel): username: str password: str @@ -619,6 +626,85 @@ def resolve_study_root(study: dict[str, Any]) -> Path: return root +def safe_archive_name(value: Any, fallback: str = "study") -> str: + text = str(value or "").strip() + cleaned = "".join("_" if char in '<>:"/\\|?*\0' else char for char in text) + cleaned = cleaned.strip().strip(".") + return cleaned[:160] or fallback + + +def study_export_folder_name(study: dict[str, Any]) -> str: + ct_number = safe_archive_name(study.get("ct_number"), "study") + name = safe_archive_name(study.get("source_patient_name") or study.get("patient_name_dicom") or "", "") + return f"{ct_number}_{name}" if name else ct_number + + +def add_directory_to_zip(zip_file: zipfile.ZipFile, root: Path, prefix: str) -> int: + root = root.resolve() + count = 0 + for path in root.rglob("*"): + if not path.is_file(): + continue + try: + relative = path.resolve().relative_to(root) + except ValueError: + continue + zip_file.write(path, Path(prefix) / relative) + count += 1 + return count + + +def remove_file(path: str) -> None: + try: + Path(path).unlink(missing_ok=True) + except Exception: + pass + + +def build_study_export_zip(ct_numbers: list[str]) -> tuple[str, str]: + unique_numbers = [] + for ct_number in ct_numbers: + value = str(ct_number or "").strip() + if value and value not in unique_numbers: + unique_numbers.append(value) + if not unique_numbers: + raise HTTPException(status_code=400, detail="请选择需要导出的检查") + if len(unique_numbers) > 200: + raise HTTPException(status_code=400, detail="单次最多导出 200 个检查") + + temp = tempfile.NamedTemporaryFile(prefix="pacs_dicom_export_", suffix=".zip", delete=False) + temp_path = temp.name + temp.close() + total_files = 0 + notes = [] + try: + with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file: + for ct_number in unique_numbers: + study = get_study_record(ct_number) + root = resolve_study_root(study) + if not root.exists(): + notes.append(f"{ct_number}: DICOM 路径不存在 - {root}") + continue + folder = study_export_folder_name(study) + file_count = add_directory_to_zip(zip_file, root, folder) + total_files += file_count + if file_count == 0: + notes.append(f"{ct_number}: 未找到可导出的文件 - {root}") + if notes: + zip_file.writestr("导出说明.txt", "\n".join(notes)) + if total_files == 0: + remove_file(temp_path) + raise HTTPException(status_code=404, detail="没有找到可导出的 DICOM 文件") + except HTTPException: + raise + except Exception as exc: + remove_file(temp_path) + raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc + + filename = f"PACS_DICOM_{safe_archive_name(unique_numbers[0])}.zip" if len(unique_numbers) == 1 else f"PACS_DICOM_batch_{time.strftime('%Y%m%d_%H%M%S')}.zip" + return temp_path, filename + + def read_header(path: Path) -> dict[str, str]: tags = [ "SeriesInstanceUID", @@ -1129,6 +1215,24 @@ def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str = return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)} +@app.get("/api/studies/export") +def export_studies_get(ct_numbers: list[str] = Query(default=[]), _: str = Depends(require_admin)) -> FileResponse: + path, filename = build_study_export_zip(ct_numbers) + return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) + + +@app.get("/api/studies/{ct_number}/export") +def export_study(ct_number: str, _: str = Depends(require_admin)) -> FileResponse: + path, filename = build_study_export_zip([ct_number]) + return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) + + +@app.post("/api/studies/export") +def export_studies(data: ExportStudiesIn, _: str = Depends(require_admin)) -> FileResponse: + path, filename = build_study_export_zip(data.ct_numbers) + return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) + + def get_series_files(ct_number: str, series_uid: str) -> list[Path]: data = scan_study(ct_number) files = data["files"].get(series_uid) diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 9ecfde9..0c38142 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -28,6 +28,8 @@ const app = { studySortDir: "desc", studyFilter: "all", studyPartFilter: "all", + exportMode: false, + exportSelection: new Set(), aiEnabled: true, seriesSortField: "time", seriesSortDir: "asc", @@ -201,6 +203,12 @@ function isAdmin() { function applyPermissions() { $("settingsBtn").classList.toggle("hidden", !isAdmin()); + $("exportToolbar").classList.toggle("hidden", !isAdmin()); + if (!isAdmin()) { + app.exportMode = false; + app.exportSelection.clear(); + } + updateExportToolbar(); } async function refreshStatus() { @@ -313,6 +321,59 @@ function refreshStudyAnnotationSummary() { if (study) Object.assign(study, summary); } +function updateExportToolbar() { + const toolbar = $("exportToolbar"); + if (!toolbar) return; + toolbar.classList.toggle("hidden", !isAdmin()); + $("toggleExportMode").classList.toggle("active", app.exportMode); + $("toggleExportMode").textContent = app.exportMode ? "退出批量" : "批量导出"; + $("exportSelected").disabled = !app.exportMode || app.exportSelection.size === 0; + $("exportSelectionCount").textContent = `${app.exportSelection.size} 个`; +} + +function exportDownloadUrl(ctNumbers) { + const params = new URLSearchParams(); + for (const ctNumber of ctNumbers) params.append("ct_numbers", ctNumber); + params.set("access_token", app.token); + return `/api/studies/export?${params.toString()}`; +} + +function triggerDownload(url) { + const link = document.createElement("a"); + link.href = url; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function exportStudy(ctNumber, event) { + event.stopPropagation(); + if (!isAdmin()) return; + triggerDownload(`/api/studies/${encodeURIComponent(ctNumber)}/export?access_token=${encodeURIComponent(app.token)}`); +} + +function toggleExportMode() { + app.exportMode = !app.exportMode; + if (!app.exportMode) app.exportSelection.clear(); + renderStudies(); + updateExportToolbar(); +} + +function setExportSelection(ctNumber, checked) { + if (checked) { + app.exportSelection.add(ctNumber); + } else { + app.exportSelection.delete(ctNumber); + } + updateExportToolbar(); +} + +function exportSelectedStudies() { + if (!isAdmin() || app.exportSelection.size === 0) return; + triggerDownload(exportDownloadUrl(Array.from(app.exportSelection))); +} + async function loadStudies() { const q = encodeURIComponent($("studySearch").value.trim()); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); @@ -325,21 +386,33 @@ function renderStudies() { app.studies = sortStudies(app.studies); updateSortButtons(); const studies = visibleStudies(); + for (const ctNumber of Array.from(app.exportSelection)) { + if (!studies.some((study) => study.ct_number === ctNumber)) app.exportSelection.delete(ctNumber); + } $("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`; list.innerHTML = ""; for (const study of studies) { - const button = document.createElement("button"); + const card = document.createElement("div"); const status = studyStatus(study); const nextStatusLabel = status === "complete" ? "待处理" : "已完成"; - button.className = "study-card"; - button.classList.toggle("active", app.study?.ct_number === study.ct_number); - button.classList.toggle("complete", status === "complete"); + card.className = "study-card"; + card.tabIndex = 0; + card.setAttribute("role", "button"); + card.classList.toggle("active", app.study?.ct_number === study.ct_number); + card.classList.toggle("complete", status === "complete"); + card.classList.toggle("export-mode", app.exportMode && isAdmin()); const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; const partTags = asList(study.body_parts) .filter((part) => BODY_PARTS.includes(part)) .map((part) => `${escapeHtml(partLabel(part))}`) .join(""); - button.innerHTML = ` + const exportControls = isAdmin() + ? ` + ${app.exportMode ? `` : ""} + 下载 + ` + : ""; + card.innerHTML = `