From 555a04f3374ac9a5c066a3dd226c779a1bd40855 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 14:27:47 +0800 Subject: [PATCH] Add admin DICOM export controls --- PACS_DICOM处理/数据处理网页端/app.py | 106 +++++++++++++++++- PACS_DICOM处理/数据处理网页端/static/app.js | 105 +++++++++++++++-- .../数据处理网页端/static/index.html | 5 + .../数据处理网页端/static/styles.css | 80 ++++++++++++- 4 files changed, 285 insertions(+), 11 deletions(-) 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 = `
${studyStatusLabel(status)}
${partTags}
@@ -348,11 +421,25 @@ function renderStudies() { ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")} ${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张 ${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注 + ${exportControls} `; - button.onclick = () => selectStudy(study.ct_number); - button.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event); - list.appendChild(button); + card.onclick = () => selectStudy(study.ct_number); + card.onkeydown = (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectStudy(study.ct_number); + } + }; + card.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event); + card.querySelector(".study-export-btn")?.addEventListener("click", (event) => exportStudy(study.ct_number, event)); + card.querySelector(".study-export-btn")?.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") exportStudy(study.ct_number, event); + }); + card.querySelector(".study-batch-check")?.addEventListener("click", (event) => event.stopPropagation()); + card.querySelector(".study-batch-check input")?.addEventListener("change", (event) => setExportSelection(study.ct_number, event.target.checked)); + list.appendChild(card); } + updateExportToolbar(); } function applyStudyCompletion(data) { @@ -1263,6 +1350,8 @@ function wire() { $("backToViewer").addEventListener("click", () => showViewerPage(true)); $("infoBtn").addEventListener("click", openInfo); $("aiClassify").addEventListener("click", runAI); + $("toggleExportMode").addEventListener("click", toggleExportMode); + $("exportSelected").addEventListener("click", exportSelectedStudies); $("zoomIn").addEventListener("click", () => changeZoom(1.18)); $("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18)); $("overlayToggle").addEventListener("click", () => { diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 2a50884..96ede80 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -63,6 +63,11 @@
+
diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 7bb7e13..9a3b444 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -262,7 +262,7 @@ button:disabled { .series-pane { padding: 14px; display: grid; - grid-template-rows: auto auto auto auto 1fr; + grid-template-rows: auto auto auto auto auto 1fr; gap: 12px; } @@ -366,6 +366,40 @@ button:disabled { background: rgba(25, 212, 194, 0.08); } +.export-toolbar { + display: grid; + grid-template-columns: 1fr 1fr auto; + align-items: center; + gap: 6px; +} + +.export-toolbar button { + height: 30px; + border: 1px solid var(--stroke); + border-radius: 7px; + background: #0b0f16; + color: var(--muted); + font-size: 12px; +} + +.export-toolbar button.active, +.export-toolbar button:not(:disabled):hover { + border-color: rgba(52, 116, 246, 0.7); + color: #dbe8ff; + background: rgba(52, 116, 246, 0.16); +} + +.export-toolbar button:disabled { + cursor: not-allowed; +} + +.export-toolbar span { + min-width: 36px; + color: var(--muted); + font-size: 12px; + text-align: right; +} + .study-list, .series-grid { min-height: 0; @@ -376,13 +410,14 @@ button:disabled { .study-card { position: relative; width: 100%; - padding: 12px 112px 12px 12px; + padding: 12px 112px 42px 12px; margin-bottom: 10px; border: 1px solid transparent; border-radius: 8px; background: #0b0f16; color: var(--text); text-align: left; + cursor: pointer; } .study-card-status-stack { @@ -468,6 +503,47 @@ button:disabled { line-height: 1.35; } +.study-export-btn, +.study-batch-check { + position: absolute; + bottom: 10px; + height: 24px; + display: inline-grid; + place-items: center; + border: 1px solid var(--stroke); + border-radius: 999px; + background: rgba(21, 28, 39, 0.92); + color: #dbe8ff; + font-size: 11px; + font-style: normal; + font-weight: 800; +} + +.study-export-btn { + right: 10px; + min-width: 50px; +} + +.study-export-btn:hover { + border-color: rgba(25, 212, 194, 0.68); + color: #baf8ee; + background: rgba(25, 212, 194, 0.1); +} + +.study-batch-check { + left: 12px; + grid-auto-flow: column; + gap: 5px; + padding: 0 8px; + color: var(--muted); +} + +.study-batch-check input { + width: 13px; + height: 13px; + accent-color: var(--cyan); +} + .series-grid { display: grid; grid-template-columns: 1fr;