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

@@ -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) => `<em>${escapeHtml(partLabel(part))}</em>`)
.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">
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
<div class="study-part-tags">${partTags}</div>
@@ -348,11 +421,25 @@ function renderStudies() {
<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>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
${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", () => {

View File

@@ -63,6 +63,11 @@
<button data-study-part-filter="lower_abdomen">下腹部</button>
<button data-study-part-filter="pelvis">盆腔</button>
</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="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div>
</aside>

View File

@@ -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;