From 2a858be1668369b700e51a50bb863494ebd4e77f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 14:43:17 +0800 Subject: [PATCH] Export selected DICOM series --- PACS_DICOM处理/数据处理网页端/app.py | 117 ++++++------ PACS_DICOM处理/数据处理网页端/static/app.js | 150 +++++++++------ .../数据处理网页端/static/index.html | 29 ++- .../数据处理网页端/static/styles.css | 171 +++++++++++++----- 4 files changed, 305 insertions(+), 162 deletions(-) diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 500b760..352e94e 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -72,8 +72,17 @@ BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelv BODY_PARTS = set(BODY_PART_ORDER) PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} +BODY_PART_LABELS = { + "head_neck": "头颈部", + "chest": "胸部", + "upper_abdomen": "上腹部", + "lower_abdomen": "下腹部", + "pelvis": "盆腔", +} +PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"} +CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"} ROLES = { - "管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "批量导出", "用户创建", "权限控制", "系统设置"], + "管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "用户创建", "权限控制", "系统设置"], "阅片员": ["查看DICOM", "编辑标注", "AI识别"], "访客": ["查看DICOM"], } @@ -124,10 +133,6 @@ class StudyCompletionIn(BaseModel): completed: bool = False -class ExportStudiesIn(BaseModel): - ct_numbers: list[str] = [] - - class UserIn(BaseModel): username: str password: str @@ -639,19 +644,28 @@ def study_export_folder_name(study: dict[str, Any]) -> str: 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 series_export_labels(annotation: dict[str, Any]) -> list[str]: + labels: list[str] = [] + body_parts = valid_parts(annotation.get("body_parts") or []) + phase = valid_phase(annotation.get("upper_abdomen_phase") or "") + chest_window = valid_chest_window(annotation.get("chest_window") or "") + for part in body_parts: + if part == "upper_abdomen" and phase: + labels.append(f"{BODY_PART_LABELS[part]}-{PHASE_LABELS.get(phase, phase)}") + elif part == "chest" and chest_window: + labels.append(f"{BODY_PART_LABELS[part]}-{CHEST_WINDOW_LABELS.get(chest_window, chest_window)}") + else: + labels.append(BODY_PART_LABELS.get(part, part)) + if annotation.get("skipped"): + labels.append("略过_不采用") + return labels or ["未标注"] + + +def series_export_folder_name(series: dict[str, Any], index: int) -> str: + number = safe_archive_name(series.get("series_number") or index, str(index)) + description = safe_archive_name(series.get("description") or "未命名序列", "未命名序列") + labels = "_".join(safe_archive_name(label, "未标注") for label in series_export_labels(series.get("annotation") or {})) + return safe_archive_name(f"序列{number}_{description}_{labels}", f"序列{index}_{labels}") def remove_file(path: str) -> None: @@ -661,35 +675,42 @@ def remove_file(path: str) -> None: 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 个检查") +def build_series_export_zip(ct_number: str, series_uids: list[str]) -> tuple[str, str]: + selected_uids = [] + for uid in series_uids: + value = str(uid or "").strip() + if value and value not in selected_uids: + selected_uids.append(value) + if not selected_uids: + raise HTTPException(status_code=400, detail="请选择要导出的序列") + if len(selected_uids) > 200: + raise HTTPException(status_code=400, detail="单次最多导出 200 个序列") - temp = tempfile.NamedTemporaryFile(prefix="pacs_dicom_export_", suffix=".zip", delete=False) + data = scan_study(ct_number) + study_folder = study_export_folder_name(data["study"]) + series_by_uid = {row["series_uid"]: row for row in data["series"]} + temp = tempfile.NamedTemporaryFile(prefix="pacs_dicom_series_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}") + for order, uid in enumerate(selected_uids, start=1): + series = series_by_uid.get(uid) + files = data["files"].get(uid) or [] + if not series or not files: + notes.append(f"{uid}: 序列不存在或没有文件") 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}") + sequence_folder = series_export_folder_name(series, order) + prefix = Path(study_folder) / sequence_folder + for file_index, path in enumerate(files, start=1): + if not path.exists() or not path.is_file(): + notes.append(f"{uid}: 文件不存在 - {path}") + continue + archive_name = Path(prefix) / f"{file_index:05d}_{safe_archive_name(path.name, 'image.dcm')}" + zip_file.write(path, str(archive_name)) + total_files += 1 if notes: zip_file.writestr("导出说明.txt", "\n".join(notes)) if total_files == 0: @@ -701,7 +722,7 @@ def build_study_export_zip(ct_numbers: list[str]) -> tuple[str, str]: 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" + filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{time.strftime('%Y%m%d_%H%M%S')}.zip" return temp_path, filename @@ -1215,21 +1236,9 @@ 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) +def export_study(ct_number: str, series_uids: list[str] = Query(default=[]), _: str = Depends(require_admin)) -> FileResponse: + path, filename = build_series_export_zip(ct_number, series_uids) return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index c5f2f24..56a1835 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -28,8 +28,7 @@ const app = { studySortDir: "desc", studyFilter: "all", studyPartFilter: "all", - exportMode: false, - exportSelection: new Set(), + exportDialog: { ctNumber: "", series: [] }, aiEnabled: true, seriesSortField: "time", seriesSortDir: "asc", @@ -203,12 +202,7 @@ function isAdmin() { function applyPermissions() { $("settingsBtn").classList.toggle("hidden", !isAdmin()); - $("exportToolbar").classList.toggle("hidden", !isAdmin()); - if (!isAdmin()) { - app.exportMode = false; - app.exportSelection.clear(); - } - updateExportToolbar(); + if (!isAdmin()) $("exportModal").classList.add("hidden"); } async function refreshStatus() { @@ -321,21 +315,11 @@ 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) { +function exportDownloadUrl(ctNumber, seriesUids) { const params = new URLSearchParams(); - for (const ctNumber of ctNumbers) params.append("ct_numbers", ctNumber); + for (const uid of seriesUids) params.append("series_uids", uid); params.set("access_token", app.token); - return `/api/studies/export?${params.toString()}`; + return `/api/studies/${encodeURIComponent(ctNumber)}/export?${params.toString()}`; } function triggerDownload(url) { @@ -347,31 +331,95 @@ function triggerDownload(url) { link.remove(); } -function exportStudy(ctNumber, event) { +function defaultExportChecked(series) { + if (series.annotation?.skipped) return false; + if (app.studyPartFilter !== "all") return asList(series.annotation?.body_parts).includes(app.studyPartFilter); + return true; +} + +function exportLabelText(series) { + const tags = seriesTags(series) + .filter((tag) => BODY_PARTS.some((part) => tag.label.startsWith(partLabel(part))) || tag.label === "无法判别" || tag.label === "略过/不采用") + .map((tag) => tag.label); + return tags.join(" / ") || "未标注"; +} + +function updateExportModalCount() { + const selected = document.querySelectorAll("#exportSeriesList input[type=checkbox]:checked").length; + $("exportModalCount").textContent = `${selected} 个序列`; + $("downloadSelectedSeries").disabled = selected === 0; +} + +function renderExportSeriesList(seriesList) { + const list = $("exportSeriesList"); + list.innerHTML = ""; + for (const series of seriesList) { + const checked = defaultExportChecked(series); + const item = document.createElement("label"); + item.className = "export-series-item"; + item.classList.toggle("skipped", Boolean(series.annotation?.skipped)); + item.innerHTML = ` + +
+ ${escapeHtml(series.description || "未命名序列")} + 序列 ${escapeHtml(series.series_number || "-")} · ${Number(series.count || 0)} 张 · ${escapeHtml(timeRange(series))} + ${escapeHtml(exportLabelText(series))} +
+ `; + item.querySelector("input").addEventListener("change", updateExportModalCount); + list.appendChild(item); + } + updateExportModalCount(); +} + +async function loadSeriesForExport(ctNumber) { + if (app.study?.ct_number === ctNumber && app.series.length) return app.series; + const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`); + const study = app.studies.find((item) => item.ct_number === ctNumber); + if (study) Object.assign(study, data.study); + return sortSeries(data.series || []); +} + +async 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); + if (!(await confirmSaveIfDirty())) return; + $("exportStudyLabel").textContent = ctNumber; + $("exportHint").textContent = + app.studyPartFilter === "all" + ? "默认不选择略过/不采用序列" + : `默认仅选择 ${partLabel(app.studyPartFilter)},且不选择略过/不采用序列`; + $("exportSeriesList").innerHTML = `

正在读取序列...

`; + $("exportModal").classList.remove("hidden"); + try { + const seriesList = await loadSeriesForExport(ctNumber); + app.exportDialog = { ctNumber, series: seriesList }; + renderExportSeriesList(seriesList); + } catch (err) { + $("exportSeriesList").innerHTML = `

${escapeHtml(err.message)}

`; + updateExportModalCount(); } - updateExportToolbar(); } -function exportSelectedStudies() { - if (!isAdmin() || app.exportSelection.size === 0) return; - triggerDownload(exportDownloadUrl(Array.from(app.exportSelection))); +function selectDefaultExportSeries() { + document.querySelectorAll("#exportSeriesList input[type=checkbox]").forEach((input) => { + const series = app.exportDialog.series.find((item) => item.series_uid === input.value); + input.checked = series ? defaultExportChecked(series) : false; + }); + updateExportModalCount(); +} + +function clearExportSeries() { + document.querySelectorAll("#exportSeriesList input[type=checkbox]").forEach((input) => { + input.checked = false; + }); + updateExportModalCount(); +} + +function downloadSelectedSeries() { + const selected = Array.from(document.querySelectorAll("#exportSeriesList input[type=checkbox]:checked")).map((input) => input.value); + if (!app.exportDialog.ctNumber || selected.length === 0) return; + triggerDownload(exportDownloadUrl(app.exportDialog.ctNumber, selected)); } async function loadStudies() { @@ -386,9 +434,6 @@ 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) { @@ -400,7 +445,6 @@ function renderStudies() { 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)) @@ -408,8 +452,7 @@ function renderStudies() { .join(""); const exportControls = isAdmin() ? ` - ${app.exportMode ? `` : ""} - + @@ -439,13 +482,13 @@ function renderStudies() { 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); + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + 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) { @@ -1356,8 +1399,9 @@ function wire() { $("backToViewer").addEventListener("click", () => showViewerPage(true)); $("infoBtn").addEventListener("click", openInfo); $("aiClassify").addEventListener("click", runAI); - $("toggleExportMode").addEventListener("click", toggleExportMode); - $("exportSelected").addEventListener("click", exportSelectedStudies); + $("selectExportableSeries").addEventListener("click", selectDefaultExportSeries); + $("clearExportSeries").addEventListener("click", clearExportSeries); + $("downloadSelectedSeries").addEventListener("click", downloadSelectedSeries); $("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 96ede80..39aa30e 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -63,11 +63,6 @@ -
@@ -196,6 +191,30 @@ + + diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 091415a..20aa132 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -366,40 +366,6 @@ 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; @@ -525,8 +491,7 @@ button:disabled { white-space: nowrap; } -.study-export-btn, -.study-batch-check { +.study-export-btn { position: absolute; bottom: 10px; height: 24px; @@ -564,20 +529,6 @@ button:disabled { stroke-linejoin: round; } -.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; @@ -1131,6 +1082,126 @@ button:disabled { padding: 24px; } +.export-modal-card { + width: min(820px, 90vw); +} + +.export-modal-tools, +.export-modal-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 24px; + border-bottom: 1px solid #e2e8f0; +} + +.export-modal-foot { + border-top: 1px solid #e2e8f0; + border-bottom: 0; +} + +.export-modal-tools span, +.export-modal-foot span { + color: #53677f; + font-size: 13px; +} + +.export-modal-tools div { + display: flex; + gap: 8px; +} + +.export-modal-tools button, +.export-modal-foot button { + height: 34px; + padding: 0 13px; + border: 1px solid #cdd8e7; + border-radius: 8px; + background: white; + color: #17314f; + font-weight: 700; +} + +.export-modal-foot button { + min-width: 130px; + border-color: #1d5ff0; + background: #1d5ff0; + color: white; +} + +.export-modal-foot button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.export-series-list { + max-height: 52vh; + overflow: auto; + padding: 12px 24px; +} + +.export-series-item { + min-height: 74px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 11px 12px; + border: 1px solid #dce6f2; + border-radius: 8px; + background: white; + color: #132033; +} + +.export-series-item + .export-series-item { + margin-top: 8px; +} + +.export-series-item.skipped { + background: #fff8ed; +} + +.export-series-item input { + margin-top: 3px; + accent-color: #19a99a; +} + +.export-series-item strong, +.export-series-item span, +.export-series-item em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-series-item strong { + color: #132033; + font-size: 14px; +} + +.export-series-item span { + margin-top: 5px; + color: #53677f; + font-size: 12px; +} + +.export-series-item em { + margin-top: 6px; + color: #08776c; + font-size: 12px; + font-style: normal; + font-weight: 700; +} + +.export-empty { + margin: 0; + padding: 24px; + color: #53677f; + text-align: center; +} + .info-card, .settings-section { padding: 16px;