Add admin DICOM export controls

This commit is contained in:
Codex
2026-05-27 14:27:47 +08:00
parent fa38abf213
commit 555a04f337
4 changed files with 285 additions and 11 deletions

View File

@@ -8,10 +8,12 @@ import json
import os import os
import secrets import secrets
import subprocess import subprocess
import tempfile
import threading import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import zipfile
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -24,6 +26,7 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from PIL import Image from PIL import Image
from starlette.background import BackgroundTask
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
@@ -70,7 +73,7 @@ BODY_PARTS = set(BODY_PART_ORDER)
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
ROLES = { ROLES = {
"管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"], "管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "批量导出", "用户创建", "权限控制", "系统设置"],
"阅片员": ["查看DICOM", "编辑标注", "AI识别"], "阅片员": ["查看DICOM", "编辑标注", "AI识别"],
"访客": ["查看DICOM"], "访客": ["查看DICOM"],
} }
@@ -121,6 +124,10 @@ class StudyCompletionIn(BaseModel):
completed: bool = False completed: bool = False
class ExportStudiesIn(BaseModel):
ct_numbers: list[str] = []
class UserIn(BaseModel): class UserIn(BaseModel):
username: str username: str
password: str password: str
@@ -619,6 +626,85 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
return root 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]: def read_header(path: Path) -> dict[str, str]:
tags = [ tags = [
"SeriesInstanceUID", "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)} 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]: def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
data = scan_study(ct_number) data = scan_study(ct_number)
files = data["files"].get(series_uid) files = data["files"].get(series_uid)

View File

@@ -28,6 +28,8 @@ const app = {
studySortDir: "desc", studySortDir: "desc",
studyFilter: "all", studyFilter: "all",
studyPartFilter: "all", studyPartFilter: "all",
exportMode: false,
exportSelection: new Set(),
aiEnabled: true, aiEnabled: true,
seriesSortField: "time", seriesSortField: "time",
seriesSortDir: "asc", seriesSortDir: "asc",
@@ -201,6 +203,12 @@ function isAdmin() {
function applyPermissions() { function applyPermissions() {
$("settingsBtn").classList.toggle("hidden", !isAdmin()); $("settingsBtn").classList.toggle("hidden", !isAdmin());
$("exportToolbar").classList.toggle("hidden", !isAdmin());
if (!isAdmin()) {
app.exportMode = false;
app.exportSelection.clear();
}
updateExportToolbar();
} }
async function refreshStatus() { async function refreshStatus() {
@@ -313,6 +321,59 @@ function refreshStudyAnnotationSummary() {
if (study) Object.assign(study, summary); 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() { async function loadStudies() {
const q = encodeURIComponent($("studySearch").value.trim()); const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
@@ -325,21 +386,33 @@ function renderStudies() {
app.studies = sortStudies(app.studies); app.studies = sortStudies(app.studies);
updateSortButtons(); updateSortButtons();
const studies = visibleStudies(); 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}`; $("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
list.innerHTML = ""; list.innerHTML = "";
for (const study of studies) { for (const study of studies) {
const button = document.createElement("button"); const card = document.createElement("div");
const status = studyStatus(study); const status = studyStatus(study);
const nextStatusLabel = status === "complete" ? "待处理" : "已完成"; const nextStatusLabel = status === "complete" ? "待处理" : "已完成";
button.className = "study-card"; card.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number); card.tabIndex = 0;
button.classList.toggle("complete", status === "complete"); 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 name = study.source_patient_name || study.patient_name_dicom || "无姓名";
const partTags = asList(study.body_parts) const partTags = asList(study.body_parts)
.filter((part) => BODY_PARTS.includes(part)) .filter((part) => BODY_PARTS.includes(part))
.map((part) => `<em>${escapeHtml(partLabel(part))}</em>`) .map((part) => `<em>${escapeHtml(partLabel(part))}</em>`)
.join(""); .join("");
button.innerHTML = ` const exportControls = isAdmin()
? `
${app.exportMode ? `<label class="study-batch-check"><input type="checkbox" ${app.exportSelection.has(study.ct_number) ? "checked" : ""} />选择</label>` : ""}
<span class="study-export-btn" role="button" tabindex="0" title="导出该检查影像">下载</span>
`
: "";
card.innerHTML = `
<div class="study-card-status-stack"> <div class="study-card-status-stack">
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i> <i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
<div class="study-part-tags">${partTags}</div> <div class="study-part-tags">${partTags}</div>
@@ -348,11 +421,25 @@ function renderStudies() {
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span> <span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span> <span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
<span>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span> <span>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
${exportControls}
`; `;
button.onclick = () => selectStudy(study.ct_number); card.onclick = () => selectStudy(study.ct_number);
button.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event); card.onkeydown = (event) => {
list.appendChild(button); 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) { function applyStudyCompletion(data) {
@@ -1263,6 +1350,8 @@ function wire() {
$("backToViewer").addEventListener("click", () => showViewerPage(true)); $("backToViewer").addEventListener("click", () => showViewerPage(true));
$("infoBtn").addEventListener("click", openInfo); $("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI); $("aiClassify").addEventListener("click", runAI);
$("toggleExportMode").addEventListener("click", toggleExportMode);
$("exportSelected").addEventListener("click", exportSelectedStudies);
$("zoomIn").addEventListener("click", () => changeZoom(1.18)); $("zoomIn").addEventListener("click", () => changeZoom(1.18));
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18)); $("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
$("overlayToggle").addEventListener("click", () => { $("overlayToggle").addEventListener("click", () => {

View File

@@ -63,6 +63,11 @@
<button data-study-part-filter="lower_abdomen">下腹部</button> <button data-study-part-filter="lower_abdomen">下腹部</button>
<button data-study-part-filter="pelvis">盆腔</button> <button data-study-part-filter="pelvis">盆腔</button>
</div> </div>
<div id="exportToolbar" class="export-toolbar hidden">
<button id="toggleExportMode" type="button">批量导出</button>
<button id="exportSelected" type="button" disabled>导出所选</button>
<span id="exportSelectionCount">0 个</span>
</div>
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" /> <input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div> <div id="studyList" class="study-list"></div>
</aside> </aside>

View File

@@ -262,7 +262,7 @@ button:disabled {
.series-pane { .series-pane {
padding: 14px; padding: 14px;
display: grid; display: grid;
grid-template-rows: auto auto auto auto 1fr; grid-template-rows: auto auto auto auto auto 1fr;
gap: 12px; gap: 12px;
} }
@@ -366,6 +366,40 @@ button:disabled {
background: rgba(25, 212, 194, 0.08); 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, .study-list,
.series-grid { .series-grid {
min-height: 0; min-height: 0;
@@ -376,13 +410,14 @@ button:disabled {
.study-card { .study-card {
position: relative; position: relative;
width: 100%; width: 100%;
padding: 12px 112px 12px 12px; padding: 12px 112px 42px 12px;
margin-bottom: 10px; margin-bottom: 10px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
background: #0b0f16; background: #0b0f16;
color: var(--text); color: var(--text);
text-align: left; text-align: left;
cursor: pointer;
} }
.study-card-status-stack { .study-card-status-stack {
@@ -468,6 +503,47 @@ button:disabled {
line-height: 1.35; 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 { .series-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;