Export selected DICOM series
This commit is contained in:
@@ -72,8 +72,17 @@ BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelv
|
|||||||
BODY_PARTS = set(BODY_PART_ORDER)
|
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", ""}
|
||||||
|
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 = {
|
ROLES = {
|
||||||
"管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "批量导出", "用户创建", "权限控制", "系统设置"],
|
"管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "用户创建", "权限控制", "系统设置"],
|
||||||
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
||||||
"访客": ["查看DICOM"],
|
"访客": ["查看DICOM"],
|
||||||
}
|
}
|
||||||
@@ -124,10 +133,6 @@ 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
|
||||||
@@ -639,19 +644,28 @@ def study_export_folder_name(study: dict[str, Any]) -> str:
|
|||||||
return f"{ct_number}_{name}" if name else ct_number
|
return f"{ct_number}_{name}" if name else ct_number
|
||||||
|
|
||||||
|
|
||||||
def add_directory_to_zip(zip_file: zipfile.ZipFile, root: Path, prefix: str) -> int:
|
def series_export_labels(annotation: dict[str, Any]) -> list[str]:
|
||||||
root = root.resolve()
|
labels: list[str] = []
|
||||||
count = 0
|
body_parts = valid_parts(annotation.get("body_parts") or [])
|
||||||
for path in root.rglob("*"):
|
phase = valid_phase(annotation.get("upper_abdomen_phase") or "")
|
||||||
if not path.is_file():
|
chest_window = valid_chest_window(annotation.get("chest_window") or "")
|
||||||
continue
|
for part in body_parts:
|
||||||
try:
|
if part == "upper_abdomen" and phase:
|
||||||
relative = path.resolve().relative_to(root)
|
labels.append(f"{BODY_PART_LABELS[part]}-{PHASE_LABELS.get(phase, phase)}")
|
||||||
except ValueError:
|
elif part == "chest" and chest_window:
|
||||||
continue
|
labels.append(f"{BODY_PART_LABELS[part]}-{CHEST_WINDOW_LABELS.get(chest_window, chest_window)}")
|
||||||
zip_file.write(path, Path(prefix) / relative)
|
else:
|
||||||
count += 1
|
labels.append(BODY_PART_LABELS.get(part, part))
|
||||||
return count
|
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:
|
def remove_file(path: str) -> None:
|
||||||
@@ -661,35 +675,42 @@ def remove_file(path: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def build_study_export_zip(ct_numbers: list[str]) -> tuple[str, str]:
|
def build_series_export_zip(ct_number: str, series_uids: list[str]) -> tuple[str, str]:
|
||||||
unique_numbers = []
|
selected_uids = []
|
||||||
for ct_number in ct_numbers:
|
for uid in series_uids:
|
||||||
value = str(ct_number or "").strip()
|
value = str(uid or "").strip()
|
||||||
if value and value not in unique_numbers:
|
if value and value not in selected_uids:
|
||||||
unique_numbers.append(value)
|
selected_uids.append(value)
|
||||||
if not unique_numbers:
|
if not selected_uids:
|
||||||
raise HTTPException(status_code=400, detail="请选择需要导出的检查")
|
raise HTTPException(status_code=400, detail="请选择要导出的序列")
|
||||||
if len(unique_numbers) > 200:
|
if len(selected_uids) > 200:
|
||||||
raise HTTPException(status_code=400, detail="单次最多导出 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_path = temp.name
|
||||||
temp.close()
|
temp.close()
|
||||||
total_files = 0
|
total_files = 0
|
||||||
notes = []
|
notes = []
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file:
|
with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file:
|
||||||
for ct_number in unique_numbers:
|
for order, uid in enumerate(selected_uids, start=1):
|
||||||
study = get_study_record(ct_number)
|
series = series_by_uid.get(uid)
|
||||||
root = resolve_study_root(study)
|
files = data["files"].get(uid) or []
|
||||||
if not root.exists():
|
if not series or not files:
|
||||||
notes.append(f"{ct_number}: DICOM 路径不存在 - {root}")
|
notes.append(f"{uid}: 序列不存在或没有文件")
|
||||||
continue
|
continue
|
||||||
folder = study_export_folder_name(study)
|
sequence_folder = series_export_folder_name(series, order)
|
||||||
file_count = add_directory_to_zip(zip_file, root, folder)
|
prefix = Path(study_folder) / sequence_folder
|
||||||
total_files += file_count
|
for file_index, path in enumerate(files, start=1):
|
||||||
if file_count == 0:
|
if not path.exists() or not path.is_file():
|
||||||
notes.append(f"{ct_number}: 未找到可导出的文件 - {root}")
|
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:
|
if notes:
|
||||||
zip_file.writestr("导出说明.txt", "\n".join(notes))
|
zip_file.writestr("导出说明.txt", "\n".join(notes))
|
||||||
if total_files == 0:
|
if total_files == 0:
|
||||||
@@ -701,7 +722,7 @@ def build_study_export_zip(ct_numbers: list[str]) -> tuple[str, str]:
|
|||||||
remove_file(temp_path)
|
remove_file(temp_path)
|
||||||
raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc
|
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
|
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)}
|
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")
|
@app.get("/api/studies/{ct_number}/export")
|
||||||
def export_study(ct_number: str, _: str = Depends(require_admin)) -> FileResponse:
|
def export_study(ct_number: str, series_uids: list[str] = Query(default=[]), _: str = Depends(require_admin)) -> FileResponse:
|
||||||
path, filename = build_study_export_zip([ct_number])
|
path, filename = build_series_export_zip(ct_number, series_uids)
|
||||||
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))
|
return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ const app = {
|
|||||||
studySortDir: "desc",
|
studySortDir: "desc",
|
||||||
studyFilter: "all",
|
studyFilter: "all",
|
||||||
studyPartFilter: "all",
|
studyPartFilter: "all",
|
||||||
exportMode: false,
|
exportDialog: { ctNumber: "", series: [] },
|
||||||
exportSelection: new Set(),
|
|
||||||
aiEnabled: true,
|
aiEnabled: true,
|
||||||
seriesSortField: "time",
|
seriesSortField: "time",
|
||||||
seriesSortDir: "asc",
|
seriesSortDir: "asc",
|
||||||
@@ -203,12 +202,7 @@ function isAdmin() {
|
|||||||
|
|
||||||
function applyPermissions() {
|
function applyPermissions() {
|
||||||
$("settingsBtn").classList.toggle("hidden", !isAdmin());
|
$("settingsBtn").classList.toggle("hidden", !isAdmin());
|
||||||
$("exportToolbar").classList.toggle("hidden", !isAdmin());
|
if (!isAdmin()) $("exportModal").classList.add("hidden");
|
||||||
if (!isAdmin()) {
|
|
||||||
app.exportMode = false;
|
|
||||||
app.exportSelection.clear();
|
|
||||||
}
|
|
||||||
updateExportToolbar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
@@ -321,21 +315,11 @@ function refreshStudyAnnotationSummary() {
|
|||||||
if (study) Object.assign(study, summary);
|
if (study) Object.assign(study, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateExportToolbar() {
|
function exportDownloadUrl(ctNumber, seriesUids) {
|
||||||
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();
|
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);
|
params.set("access_token", app.token);
|
||||||
return `/api/studies/export?${params.toString()}`;
|
return `/api/studies/${encodeURIComponent(ctNumber)}/export?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerDownload(url) {
|
function triggerDownload(url) {
|
||||||
@@ -347,31 +331,95 @@ function triggerDownload(url) {
|
|||||||
link.remove();
|
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 = `
|
||||||
|
<input type="checkbox" value="${escapeHtml(series.series_uid)}" ${checked ? "checked" : ""} />
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||||||
|
<span>序列 ${escapeHtml(series.series_number || "-")} · ${Number(series.count || 0)} 张 · ${escapeHtml(timeRange(series))}</span>
|
||||||
|
<em>${escapeHtml(exportLabelText(series))}</em>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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();
|
event.stopPropagation();
|
||||||
if (!isAdmin()) return;
|
if (!isAdmin()) return;
|
||||||
triggerDownload(`/api/studies/${encodeURIComponent(ctNumber)}/export?access_token=${encodeURIComponent(app.token)}`);
|
if (!(await confirmSaveIfDirty())) return;
|
||||||
}
|
$("exportStudyLabel").textContent = ctNumber;
|
||||||
|
$("exportHint").textContent =
|
||||||
function toggleExportMode() {
|
app.studyPartFilter === "all"
|
||||||
app.exportMode = !app.exportMode;
|
? "默认不选择略过/不采用序列"
|
||||||
if (!app.exportMode) app.exportSelection.clear();
|
: `默认仅选择 ${partLabel(app.studyPartFilter)},且不选择略过/不采用序列`;
|
||||||
renderStudies();
|
$("exportSeriesList").innerHTML = `<p class="export-empty">正在读取序列...</p>`;
|
||||||
updateExportToolbar();
|
$("exportModal").classList.remove("hidden");
|
||||||
}
|
try {
|
||||||
|
const seriesList = await loadSeriesForExport(ctNumber);
|
||||||
function setExportSelection(ctNumber, checked) {
|
app.exportDialog = { ctNumber, series: seriesList };
|
||||||
if (checked) {
|
renderExportSeriesList(seriesList);
|
||||||
app.exportSelection.add(ctNumber);
|
} catch (err) {
|
||||||
} else {
|
$("exportSeriesList").innerHTML = `<p class="export-empty error-line">${escapeHtml(err.message)}</p>`;
|
||||||
app.exportSelection.delete(ctNumber);
|
updateExportModalCount();
|
||||||
}
|
}
|
||||||
updateExportToolbar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportSelectedStudies() {
|
function selectDefaultExportSeries() {
|
||||||
if (!isAdmin() || app.exportSelection.size === 0) return;
|
document.querySelectorAll("#exportSeriesList input[type=checkbox]").forEach((input) => {
|
||||||
triggerDownload(exportDownloadUrl(Array.from(app.exportSelection)));
|
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() {
|
async function loadStudies() {
|
||||||
@@ -386,9 +434,6 @@ 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) {
|
||||||
@@ -400,7 +445,6 @@ function renderStudies() {
|
|||||||
card.setAttribute("role", "button");
|
card.setAttribute("role", "button");
|
||||||
card.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
card.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
||||||
card.classList.toggle("complete", status === "complete");
|
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))
|
||||||
@@ -408,8 +452,7 @@ function renderStudies() {
|
|||||||
.join("");
|
.join("");
|
||||||
const exportControls = isAdmin()
|
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="选择序列导出" aria-label="选择序列导出">
|
||||||
<span class="study-export-btn" role="button" tabindex="0" title="导出该检查影像" aria-label="导出该检查影像">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M12 3v11m0 0 4-4m-4 4-4-4M5 20h14" />
|
<path d="M12 3v11m0 0 4-4m-4 4-4-4M5 20h14" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -439,13 +482,13 @@ function renderStudies() {
|
|||||||
card.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event);
|
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("click", (event) => exportStudy(study.ct_number, event));
|
||||||
card.querySelector(".study-export-btn")?.addEventListener("keydown", (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);
|
list.appendChild(card);
|
||||||
}
|
}
|
||||||
updateExportToolbar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStudyCompletion(data) {
|
function applyStudyCompletion(data) {
|
||||||
@@ -1356,8 +1399,9 @@ 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);
|
$("selectExportableSeries").addEventListener("click", selectDefaultExportSeries);
|
||||||
$("exportSelected").addEventListener("click", exportSelectedStudies);
|
$("clearExportSeries").addEventListener("click", clearExportSeries);
|
||||||
|
$("downloadSelectedSeries").addEventListener("click", downloadSelectedSeries);
|
||||||
$("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", () => {
|
||||||
|
|||||||
@@ -63,11 +63,6 @@
|
|||||||
<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>
|
||||||
@@ -196,6 +191,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="exportModal" class="modal hidden">
|
||||||
|
<div class="modal-card export-modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2>选择导出序列</h2>
|
||||||
|
<span id="exportStudyLabel">未选择检查</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-close="exportModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="export-modal-tools">
|
||||||
|
<span id="exportHint">默认不选择略过/不采用序列</span>
|
||||||
|
<div>
|
||||||
|
<button id="selectExportableSeries" type="button">选择可用</button>
|
||||||
|
<button id="clearExportSeries" type="button">清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="exportSeriesList" class="export-series-list"></div>
|
||||||
|
<div class="export-modal-foot">
|
||||||
|
<span id="exportModalCount">0 个序列</span>
|
||||||
|
<button id="downloadSelectedSeries" type="button">开始导出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -366,40 +366,6 @@ 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;
|
||||||
@@ -525,8 +491,7 @@ button:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.study-export-btn,
|
.study-export-btn {
|
||||||
.study-batch-check {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -564,20 +529,6 @@ button:disabled {
|
|||||||
stroke-linejoin: round;
|
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 {
|
.series-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1131,6 +1082,126 @@ button:disabled {
|
|||||||
padding: 24px;
|
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,
|
.info-card,
|
||||||
.settings-section {
|
.settings-section {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user