Export selected DICOM series
This commit is contained in:
@@ -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 = `
|
||||
<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();
|
||||
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 = `<p class="export-empty">正在读取序列...</p>`;
|
||||
$("exportModal").classList.remove("hidden");
|
||||
try {
|
||||
const seriesList = await loadSeriesForExport(ctNumber);
|
||||
app.exportDialog = { ctNumber, series: seriesList };
|
||||
renderExportSeriesList(seriesList);
|
||||
} catch (err) {
|
||||
$("exportSeriesList").innerHTML = `<p class="export-empty error-line">${escapeHtml(err.message)}</p>`;
|
||||
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 ? `<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">
|
||||
<path d="M12 3v11m0 0 4-4m-4 4-4-4M5 20h14" />
|
||||
</svg>
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user