Files
PACS/PACS_DICOM处理/数据处理网页端/static/app.js
2026-05-27 14:49:37 +08:00

1519 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const app = {
token: localStorage.getItem("pacs_web_token") || "",
user: null,
studies: [],
study: null,
series: [],
activeSeries: null,
draft: null,
slice: 0,
plane: "axial",
window: "default",
rotate: 0,
zoom: 1,
panX: 0,
panY: 0,
imageUrl: "",
searchTimer: null,
statusTimer: null,
saveTimer: null,
savePromise: null,
savingSeriesUid: "",
draftVersion: 0,
dirty: false,
saving: false,
pendingImage: null,
drag: null,
studySortField: "time",
studySortDir: "desc",
studyFilter: "all",
studyPartFilter: "all",
exportDialog: { ctNumber: "", series: [] },
aiEnabled: true,
seriesSortField: "time",
seriesSortDir: "asc",
showOverlay: true,
thumbUrls: new Map(),
imageCache: new Map(),
thumbObserver: null,
thumbSeries: new WeakMap(),
};
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"];
const WINDOW_PRESETS = {
bone: { wl: 500, ww: 1800 },
soft: { wl: 50, ww: 360 },
contrast: { wl: 90, ww: 140 },
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
showLogin(true);
throw new Error("unauthorized");
}
if (!res.ok) {
let detail = res.statusText;
try {
const data = await res.json();
detail = data.detail || detail;
} catch (_) {
detail = await res.text();
}
throw new Error(detail);
}
return res;
}
async function json(path, options = {}) {
const res = await request(path, options);
return res.json();
}
function showLogin(show) {
$("loginOverlay").classList.toggle("hidden", !show);
}
function fmtDate(value) {
const text = String(value || "");
if (text.length !== 8) return text;
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
function fmtTime(value) {
const digits = String(value || "").replace(/\D/g, "").slice(0, 6);
if (digits.length < 6) return value || "";
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`;
}
function timeRange(series) {
const first = fmtTime(series.first_time || series.series_time || series.study_time);
const last = fmtTime(series.last_time);
if (first && last && first !== last) return `${first}-${last}`;
return first || last || "未记录";
}
function timeKey(series) {
return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0");
}
function studyTimeKey(study) {
const date = String(study.study_date || "").replace(/\D/g, "").padEnd(8, "0");
const time = String(study.study_time || "").replace(/\D/g, "").slice(0, 6).padEnd(6, "0");
return `${date}${time}`;
}
function sortArrow(direction) {
return direction === "asc" ? "↑" : "↓";
}
function phaseLabel(value) {
return { plain: "平扫", arterial: "动脉期", portal_venous: "门静脉期", delayed: "延迟期", unknown: "无法判别" }[value] || "";
}
function chestWindowLabel(value) {
return { lung: "肺窗", mediastinal: "纵隔窗", unknown: "无法判别" }[value] || "";
}
function partLabel(value) {
return {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
}[value] || value;
}
function asList(value) {
return Array.isArray(value) ? value : [];
}
function uniq(list) {
return Array.from(new Set(list.filter(Boolean)));
}
function parseFirstNumber(value) {
const match = String(value || "").match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : null;
}
function ageText(birthDate, studyDate) {
const birth = String(birthDate || "");
const study = String(studyDate || "");
if (birth.length !== 8 || study.length !== 8) return "";
let age = Number(study.slice(0, 4)) - Number(birth.slice(0, 4));
if (study.slice(4) < birth.slice(4)) age -= 1;
return age > 0 ? `${age}Y` : "";
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const data = await json("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value, password: $("password").value }),
});
app.token = data.token;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_web_token", app.token);
applyPermissions();
showLogin(false);
await boot();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
async function logout() {
if (!(await confirmSaveIfDirty())) return;
app.token = "";
app.user = null;
localStorage.removeItem("pacs_web_token");
applyPermissions();
showViewerPage(false);
showLogin(true);
}
async function loadCurrentUser() {
if (!app.token) return;
app.user = await json("/api/auth/me");
applyPermissions();
}
function isAdmin() {
return app.user?.role === "管理员";
}
function applyPermissions() {
$("settingsBtn").classList.toggle("hidden", !isAdmin());
if (!isAdmin()) $("exportModal").classList.add("hidden");
}
async function refreshStatus() {
try {
const data = await fetch("/api/status").then((res) => res.json());
const pill = $("dbStatus");
pill.textContent = data.database?.ok ? `数据库 ${data.database.rows ?? ""}` : "数据库异常";
pill.title = data.database?.message || "";
pill.classList.toggle("offline", !data.database?.ok);
pill.classList.toggle("online", Boolean(data.database?.ok));
app.aiEnabled = Boolean(data.ai?.enabled && data.ai?.configured);
$("aiClassify").disabled = !app.aiEnabled;
$("aiClassify").title = app.aiEnabled ? "" : "AI 功能未启用或未配置";
} catch (_) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").classList.add("offline");
$("dbStatus").classList.remove("online");
}
}
function sortSeries(list) {
return [...list].sort((a, b) => {
const skipA = a.annotation?.skipped ? 1 : 0;
const skipB = b.annotation?.skipped ? 1 : 0;
if (skipA !== skipB) return skipA - skipB;
const timeResult = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true });
const countResult = Number(a.count || 0) - Number(b.count || 0) || timeResult;
const result = app.seriesSortField === "count" ? countResult : timeResult;
return app.seriesSortDir === "desc" ? -result : result;
});
}
function sortStudies(list) {
return [...list].sort((a, b) => {
const timeResult =
studyTimeKey(a).localeCompare(studyTimeKey(b)) ||
String(a.ct_number || "").localeCompare(String(b.ct_number || ""), undefined, { numeric: true });
const unannotatedResult = Number(a.unannotated_series || 0) - Number(b.unannotated_series || 0) || timeResult;
const result = app.studySortField === "unannotated" ? unannotatedResult : timeResult;
return app.studySortDir === "desc" ? -result : result;
});
}
function studyStatus(study) {
return study?.completed ? "complete" : "incomplete";
}
function studyStatusLabel(status) {
return status === "complete" ? "已完成" : "待处理";
}
function visibleStudies() {
return sortStudies(app.studies).filter((study) => {
const status = studyStatus(study);
if (app.studyFilter === "complete" && status !== "complete") return false;
if (app.studyFilter === "incomplete" && status === "complete") return false;
if (app.studyPartFilter !== "all" && !asList(study.body_parts).includes(app.studyPartFilter)) return false;
return true;
});
}
function updateSortButtons() {
document.querySelectorAll("[data-study-sort]").forEach((button) => {
const active = button.dataset.studySort === app.studySortField;
button.classList.toggle("active", active);
const arrow = button.querySelector(".sort-arrow");
if (arrow) arrow.textContent = sortArrow(active ? app.studySortDir : "desc");
});
document.querySelectorAll("[data-series-sort]").forEach((button) => {
const active = button.dataset.seriesSort === app.seriesSortField;
button.classList.toggle("active", active);
const arrow = button.querySelector(".sort-arrow");
if (arrow) arrow.textContent = sortArrow(active ? app.seriesSortDir : "asc");
});
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter);
});
document.querySelectorAll("[data-study-part-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyPartFilter === app.studyPartFilter);
});
}
function setSeries(list) {
app.series = sortSeries(list || []);
$("seriesCount").textContent = String(app.series.length);
renderSeries();
updateSortButtons();
}
function isSeriesAnnotated(series) {
const annotation = series.annotation || {};
return Boolean(annotation.skipped || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model);
}
function refreshStudyAnnotationSummary() {
if (!app.study || !app.series.length) return;
const annotated = app.series.filter(isSeriesAnnotated).length;
const total = app.series.length;
const bodyParts = BODY_PARTS.filter((part) =>
app.series.some((series) => asList(series.annotation?.body_parts).includes(part)),
);
const summary = {
annotated_series: annotated,
unannotated_series: Math.max(0, total - annotated),
series_count: total,
body_parts: bodyParts,
};
Object.assign(app.study, summary);
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
if (study) Object.assign(study, summary);
}
function exportDownloadUrl(ctNumber, seriesUids) {
const params = new URLSearchParams();
for (const uid of seriesUids) params.append("series_uids", uid);
params.set("access_token", app.token);
return `/api/studies/${encodeURIComponent(ctNumber)}/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 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;
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();
}
}
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() {
const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
renderStudies();
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
}
function renderStudies() {
const list = $("studyList");
app.studies = sortStudies(app.studies);
updateSortButtons();
const studies = visibleStudies();
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
list.innerHTML = "";
for (const study of studies) {
const card = document.createElement("div");
const status = studyStatus(study);
const nextStatusLabel = 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");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
const bodyPartLabels = asList(study.body_parts)
.filter((part) => BODY_PARTS.includes(part))
.map((part) => partLabel(part));
const partTags = bodyPartLabels
.map((label) => `<em title="${escapeHtml(label)}">${escapeHtml(label)}</em>`)
.join("");
const studyDateTime = `${fmtDate(study.study_date)} ${fmtTime(study.study_time)}`.trim() || "未记录时间";
const annotationSummary = `${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注`;
const cardTitle = [
`检查号:${study.ct_number}`,
`患者:${name} · ${study.patient_id || "无ID"}`,
`时间:${studyDateTime}`,
`DICOM${Number(study.dicom_file_count || 0)}`,
annotationSummary,
`部位:${bodyPartLabels.join("、") || "无"}`,
].join("\n");
const exportControls = isAdmin()
? `
<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>
</span>
`
: "";
card.title = cardTitle;
card.setAttribute("aria-label", cardTitle);
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>
</div>
<strong title="${escapeHtml(study.ct_number)}">${escapeHtml(study.ct_number)}</strong>
<span class="study-patient" title="${escapeHtml(`${name} · ${study.patient_id || "无ID"}`)}">${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<div class="study-meta">
<span title="${escapeHtml(studyDateTime)}">${escapeHtml(studyDateTime)}</span>
<div class="study-summary-line">
<span title="${escapeHtml(annotationSummary)}">${escapeHtml(annotationSummary)}</span>
${exportControls}
</div>
</div>
`;
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 === " ") {
event.preventDefault();
exportStudy(study.ct_number, event);
}
});
list.appendChild(card);
}
}
function applyStudyCompletion(data) {
const completed = Boolean(data.completed);
const study = app.studies.find((item) => item.ct_number === data.ct_number);
if (study) {
study.completed = completed;
study.completed_by = data.completed_by || "";
study.completed_at = data.completed_at || "";
}
if (app.study?.ct_number === data.ct_number) {
app.study.completed = completed;
app.study.completed_by = data.completed_by || "";
app.study.completed_at = data.completed_at || "";
}
}
async function toggleStudyCompletion(ctNumber, event) {
event.stopPropagation();
const study = app.studies.find((item) => item.ct_number === ctNumber);
if (!study) return;
const previous = Boolean(study.completed);
applyStudyCompletion({ ct_number: ctNumber, completed: !previous });
renderStudies();
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/completion`, {
method: "PUT",
body: JSON.stringify({ completed: !previous }),
});
applyStudyCompletion(data);
renderStudies();
} catch (err) {
applyStudyCompletion({ ct_number: ctNumber, completed: previous });
renderStudies();
$("saveState").textContent = `状态更新失败:${err.message}`;
}
}
async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber && !(await confirmSaveIfDirty())) return;
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
app.series = [];
app.activeSeries = null;
app.draft = null;
$("activeStudyLabel").textContent = ctNumber;
$("seriesCount").textContent = "读取中";
renderSeriesLoading();
resetViewer();
renderStudies();
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
app.study = data.study;
applyStudyCompletion(data.study);
setSeries(data.series || []);
refreshStudyAnnotationSummary();
renderStudies();
} catch (err) {
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
}
}
function sourceTag(value, annotation) {
if (asList(annotation.manual_body_parts).includes(value)) return "manual";
if (asList(annotation.ai_body_parts).includes(value)) return "ai";
return "";
}
function mergeTagSource(...sources) {
if (sources.includes("manual")) return "manual";
if (sources.includes("ai")) return "ai";
return "";
}
function seriesTags(series) {
const annotation = series.annotation || {};
const tags = [];
if (annotation.skipped) {
tags.push({ label: "略过/不采用", source: annotation.ai_skipped ? "ai" : "manual" });
}
const phase = phaseLabel(annotation.upper_abdomen_phase);
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
const chestWindow = chestWindowLabel(annotation.chest_window);
const chestWindowSource = annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "";
for (const part of asList(annotation.body_parts)) {
const partSource = sourceTag(part, annotation);
if (part === "upper_abdomen" && phase && annotation.upper_abdomen_phase !== "unknown") {
tags.push({ label: `${partLabel(part)}-${phase}`, source: mergeTagSource(partSource, phaseSource) });
continue;
}
if (part === "chest" && chestWindow && annotation.chest_window !== "unknown") {
tags.push({ label: `${partLabel(part)}-${chestWindow}`, source: mergeTagSource(partSource, chestWindowSource) });
continue;
}
tags.push({ label: partLabel(part), source: partSource });
}
if (phase && annotation.upper_abdomen_phase === "unknown") {
tags.push({
label: phase,
source: phaseSource,
warn: annotation.upper_abdomen_phase === "unknown",
});
}
if (chestWindow && annotation.chest_window === "unknown") {
tags.push({
label: chestWindow,
source: chestWindowSource,
warn: annotation.chest_window === "unknown",
});
}
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
return tags;
}
function renderSeries() {
const grid = $("seriesGrid");
if (app.thumbObserver) app.thumbObserver.disconnect();
grid.innerHTML = "";
for (const series of app.series) {
const skipped = Boolean(series.annotation?.skipped);
const tags = seriesTags(series);
const card = document.createElement("button");
card.className = "series-card";
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
card.classList.toggle("skipped", skipped);
card.innerHTML = `
<div class="thumb">
<img alt="" />
<span class="thumb-state">加载中</span>
<b>${Number(series.count || 0)} 张</b>
${skipped ? "<i>略过/不采用</i>" : ""}
</div>
<div class="series-copy">
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${[tag.source ? `tag-${tag.source}` : "", tag.warn ? "tag-warn" : ""].filter(Boolean).join(" ")}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
</div>
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
queueThumb(series, card.querySelector("img"));
}
}
function renderSeriesLoading() {
$("seriesGrid").innerHTML = Array.from({ length: 4 })
.map(
() => `
<div class="series-card loading-card">
<div class="thumb"><span class="thumb-state">序列读取中</span></div>
<div class="series-copy">
<strong>读取中</strong>
<span class="shot-time">正在同步序列信息</span>
<small>请稍候</small>
</div>
</div>
`,
)
.join("");
}
function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) {
return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`;
}
function cacheImageUrl(key, url) {
app.imageCache.set(key, url);
while (app.imageCache.size > 90) {
const oldestKey = app.imageCache.keys().next().value;
const oldestUrl = app.imageCache.get(oldestKey);
URL.revokeObjectURL(oldestUrl);
app.imageCache.delete(oldestKey);
}
}
function showImageMessage(message) {
const empty = $("imageEmpty");
empty.textContent = message;
empty.classList.remove("hidden");
}
function hideImageMessage() {
$("imageEmpty").classList.add("hidden");
}
async function loadThumb(series, img) {
const thumb = img.closest(".thumb");
const state = thumb?.querySelector(".thumb-state");
try {
const key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
thumb?.classList.add("thumb-ready");
return;
}
thumb?.classList.add("thumb-loading");
if (state) state.textContent = "加载中";
const index = Math.floor((Number(series.count) || 1) / 2);
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
const url = URL.createObjectURL(blob);
app.thumbUrls.set(key, url);
img.src = url;
thumb?.classList.remove("thumb-loading");
thumb?.classList.add("thumb-ready");
} catch (_) {
img.removeAttribute("src");
thumb?.classList.remove("thumb-loading");
thumb?.classList.add("thumb-failed");
if (state) state.textContent = "未加载";
}
}
function thumbKey(series) {
return `${series.ct_number || app.study?.ct_number || ""}:${series.series_uid}`;
}
function queueThumb(series, img) {
const key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
img.closest(".thumb")?.classList.add("thumb-ready");
return;
}
if (!("IntersectionObserver" in window)) {
loadThumb(series, img);
return;
}
if (!app.thumbObserver) {
app.thumbObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const target = entry.target;
app.thumbObserver.unobserve(target);
const queuedSeries = app.thumbSeries.get(target);
if (queuedSeries) loadThumb(queuedSeries, target);
}
},
{ rootMargin: "320px 0px" },
);
}
app.thumbSeries.set(img, series);
app.thumbObserver.observe(img);
}
function maxSlice() {
if (!app.activeSeries) return 0;
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
if (app.plane === "coronal") return Math.max(0, Number(app.activeSeries.rows || 1) - 1);
return Math.max(0, Number(app.activeSeries.count || 1) - 1);
}
function resetViewState() {
app.rotate = 0;
app.zoom = 1;
app.panX = 0;
app.panY = 0;
applyTransform();
}
async function selectSeries(seriesUid) {
if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid && !(await confirmSaveIfDirty())) return;
const series = app.series.find((item) => item.series_uid === seriesUid);
if (!series) return;
app.activeSeries = series;
app.slice = Math.floor(maxSlice() / 2);
app.pendingImage = null;
$("dicomImage").removeAttribute("src");
showImageMessage("图像加载中");
resetViewState();
$("sliceSlider").max = String(maxSlice());
$("sliceSlider").value = String(app.slice);
hydrateAnnotation();
renderSeries();
updateImage();
}
function resetViewer() {
app.imageUrl = "";
app.pendingImage = null;
app.activeSeries = null;
$("dicomImage").removeAttribute("src");
hideImageMessage();
$("sliceText").textContent = "0 / 0";
$("saveState").textContent = "已保存";
clearAnnotationControls();
app.dirty = false;
app.saving = false;
resetViewState();
updateOverlay();
}
function applyTransform() {
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
updateOverlay();
}
function windowInfo() {
if (WINDOW_PRESETS[app.window]) return WINDOW_PRESETS[app.window];
return {
wl: parseFirstNumber(app.activeSeries?.window_center) ?? 50,
ww: parseFirstNumber(app.activeSeries?.window_width) ?? 360,
};
}
function updateOverlay() {
const overlay = $("imageOverlay");
overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries);
$("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息";
if (!app.activeSeries) return;
const s = app.activeSeries;
const win = windowInfo();
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
const sex = s.patient_sex || "";
document.querySelector(".ov-left-top").innerHTML = [
s.patient_name || app.study?.source_patient_name || "",
[fmtDate(s.patient_birth_date), age, sex].filter(Boolean).join(" "),
s.patient_id || app.study?.patient_id || "",
fmtDate(s.study_date || app.study?.study_date),
].filter(Boolean).map(escapeHtml).join("<br />");
document.querySelector(".ov-right-top").innerHTML = [
s.institution_name || "",
s.manufacturer || "",
].filter(Boolean).map(escapeHtml).join("<br />");
document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
}
async function updateImage() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
app.slice = clamp(app.slice, 0, max);
$("sliceSlider").max = String(max);
$("sliceSlider").value = String(app.slice);
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
const url = imageUrlFor();
if (app.imageCache.has(url)) {
app.imageUrl = app.imageCache.get(url);
$("dicomImage").src = app.imageUrl;
hideImageMessage();
updateOverlay();
applyTransform();
preloadNeighborImages();
return;
}
showImageMessage("图像加载中");
updateOverlay();
const ticket = Symbol("image");
app.pendingImage = ticket;
try {
const blob = await request(url).then((res) => res.blob());
if (app.pendingImage !== ticket) return;
app.imageUrl = URL.createObjectURL(blob);
cacheImageUrl(url, app.imageUrl);
$("dicomImage").src = app.imageUrl;
hideImageMessage();
applyTransform();
preloadNeighborImages();
} catch (err) {
showImageMessage("图像读取失败");
$("saveState").textContent = `图像读取失败:${err.message}`;
}
}
function preloadNeighborImages() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
const indexes = [app.slice - 2, app.slice - 1, app.slice + 1, app.slice + 2].filter((index) => index >= 0 && index <= max);
for (const index of indexes) {
const url = imageUrlFor(app.activeSeries, index, app.window, app.plane);
if (app.imageCache.has(url)) continue;
request(url)
.then((res) => res.blob())
.then((blob) => cacheImageUrl(url, URL.createObjectURL(blob)))
.catch(() => {});
}
}
function cloneAnnotation(annotation = {}) {
return {
body_parts: asList(annotation.body_parts),
manual_body_parts: asList(annotation.manual_body_parts),
ai_body_parts: asList(annotation.ai_body_parts),
upper_abdomen_phase: annotation.upper_abdomen_phase || "",
manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "",
ai_upper_abdomen_phase: annotation.ai_upper_abdomen_phase || "",
chest_window: annotation.chest_window || "",
manual_chest_window: annotation.manual_chest_window || "",
ai_chest_window: annotation.ai_chest_window || "",
plain_ct: Boolean(annotation.plain_ct),
manual_plain_ct: annotation.manual_plain_ct ?? null,
ai_plain_ct: annotation.ai_plain_ct ?? null,
skipped: Boolean(annotation.skipped),
ai_skipped: Boolean(annotation.ai_skipped),
notes: annotation.notes || "",
ai_model: annotation.ai_model || "",
};
}
function effectiveParts(draft = app.draft) {
if (!draft) return [];
return uniq([...asList(draft.manual_body_parts), ...asList(draft.ai_body_parts)]);
}
function effectivePhase(draft = app.draft) {
if (!effectiveParts(draft).includes("upper_abdomen")) return "";
return draft.manual_upper_abdomen_phase || draft.ai_upper_abdomen_phase || "";
}
function effectiveChestWindow(draft = app.draft) {
if (!effectiveParts(draft).includes("chest")) return "";
return draft.manual_chest_window || draft.ai_chest_window || "unknown";
}
function setLabelSource(input, source) {
const label = input.closest("label");
label.classList.toggle("manual-selected", source === "manual");
label.classList.toggle("ai-selected", source === "ai");
}
function clearAnnotationControls() {
$("annotationNotes").value = "";
document.querySelectorAll(".part-grid input, input[name=phase], input[name=chestWindow]").forEach((input) => {
input.checked = false;
input.disabled = true;
setLabelSource(input, "");
});
$("phaseBox").classList.remove("visible");
$("chestWindowBox").classList.remove("visible");
}
function applyAnnotationControls() {
if (!app.draft) return;
const parts = new Set(effectiveParts());
document.querySelectorAll(".part-grid input").forEach((input) => {
const value = input.value;
if (value === "skip") {
input.checked = app.draft.skipped;
input.disabled = false;
setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : "");
return;
}
input.checked = parts.has(value);
input.disabled = false;
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
});
const phase = effectivePhase();
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = input.value === phase;
input.disabled = false;
setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : "");
});
$("phaseBox").classList.toggle("visible", parts.has("upper_abdomen"));
const chestWindow = effectiveChestWindow();
document.querySelectorAll("input[name=chestWindow]").forEach((input) => {
input.checked = input.value === chestWindow;
input.disabled = false;
setLabelSource(input, app.draft.manual_chest_window === input.value ? "manual" : app.draft.ai_chest_window === input.value ? "ai" : "");
});
$("chestWindowBox").classList.toggle("visible", parts.has("chest"));
}
function hydrateAnnotation() {
app.draft = cloneAnnotation(app.activeSeries?.annotation || {});
app.draftVersion = 0;
$("annotationNotes").value = app.draft.notes || "";
applyAnnotationControls();
}
function updateLocalAnnotation(annotation, targetSeriesUid = app.activeSeries?.series_uid, options = {}) {
if (!targetSeriesUid) return;
const normalized = cloneAnnotation(annotation);
normalized.body_parts = asList(annotation.body_parts);
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
normalized.chest_window = annotation.chest_window || "";
const index = app.series.findIndex((item) => item.series_uid === targetSeriesUid);
if (index >= 0) app.series[index] = { ...app.series[index], annotation: normalized };
app.series = sortSeries(app.series);
const isActive = app.activeSeries?.series_uid === targetSeriesUid;
const applyToActive = options.applyToActive !== false;
if (isActive && applyToActive) {
app.activeSeries = app.series.find((item) => item.series_uid === targetSeriesUid) || app.activeSeries;
app.draft = cloneAnnotation(normalized);
if (options.updateNotes !== false) $("annotationNotes").value = normalized.notes || "";
}
refreshStudyAnnotationSummary();
renderSeries();
renderStudies();
if (isActive && applyToActive) applyAnnotationControls();
}
function annotationPayload(draft = app.draft, notes = $("annotationNotes").value) {
const bodyParts = effectiveParts(draft);
return {
body_parts: bodyParts,
manual_body_parts: asList(draft.manual_body_parts),
ai_body_parts: asList(draft.ai_body_parts),
upper_abdomen_phase: effectivePhase(draft),
manual_upper_abdomen_phase: draft.manual_upper_abdomen_phase,
ai_upper_abdomen_phase: draft.ai_upper_abdomen_phase,
chest_window: effectiveChestWindow(draft),
manual_chest_window: draft.manual_chest_window,
ai_chest_window: draft.ai_chest_window,
plain_ct: false,
manual_plain_ct: null,
ai_plain_ct: null,
skipped: Boolean(draft.skipped),
ai_skipped: Boolean(draft.ai_skipped),
notes,
};
}
async function saveAnnotationNow() {
if (!app.study || !app.activeSeries || !app.draft) return;
if (app.savePromise) return app.savePromise;
const ctNumber = app.study.ct_number;
const uid = app.activeSeries.series_uid;
const draft = cloneAnnotation(app.draft);
const payload = annotationPayload(draft, $("annotationNotes").value);
const versionAtStart = app.draftVersion;
app.saving = true;
app.savingSeriesUid = uid;
$("saveState").textContent = "保存中";
$("saveAnnotation").disabled = true;
let promise;
promise = (async () => {
try {
const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/annotation`, {
method: "PUT",
body: JSON.stringify(payload),
});
const stillActive = app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid;
const unchanged = stillActive && app.draftVersion === versionAtStart;
updateLocalAnnotation(data, uid, { applyToActive: unchanged });
if (unchanged) {
$("saveState").textContent = "已保存";
app.dirty = false;
} else if (stillActive) {
$("saveState").textContent = "未保存";
app.dirty = true;
}
return data;
} catch (err) {
if (app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid) $("saveState").textContent = err.message;
return null;
} finally {
if (app.savePromise === promise) {
app.savePromise = null;
app.saving = false;
app.savingSeriesUid = "";
$("saveAnnotation").disabled = false;
}
}
})();
app.savePromise = promise;
return promise;
}
function markDirty() {
clearTimeout(app.saveTimer);
app.draftVersion += 1;
app.dirty = true;
$("saveState").textContent = "未保存";
}
async function confirmSaveIfDirty() {
if (app.savePromise) {
$("saveState").textContent = "保存中";
await app.savePromise;
}
if (!app.dirty) return true;
const shouldSave = window.confirm("当前标注尚未保存,是否先保存?");
if (shouldSave) {
await saveAnnotationNow();
return !app.dirty;
} else {
app.dirty = false;
$("saveState").textContent = "未保存";
}
return true;
}
function handlePartChange(event) {
if (!app.draft) return;
const input = event.target;
const value = input.value;
if (value === "skip") {
app.draft.skipped = input.checked;
if (!input.checked) app.draft.ai_skipped = false;
} else if (BODY_PARTS.includes(value)) {
if (input.checked) {
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
if (value === "chest" && !app.draft.manual_chest_window && !app.draft.ai_chest_window) {
app.draft.manual_chest_window = "unknown";
}
} else {
app.draft.manual_body_parts = app.draft.manual_body_parts.filter((part) => part !== value);
app.draft.ai_body_parts = app.draft.ai_body_parts.filter((part) => part !== value);
if (value === "upper_abdomen") {
app.draft.manual_upper_abdomen_phase = "";
app.draft.ai_upper_abdomen_phase = "";
}
if (value === "chest") {
app.draft.manual_chest_window = "";
app.draft.ai_chest_window = "";
}
}
}
app.draft.body_parts = effectiveParts();
app.draft.upper_abdomen_phase = effectivePhase();
app.draft.chest_window = effectiveChestWindow();
app.draft.plain_ct = false;
app.draft.manual_plain_ct = null;
app.draft.ai_plain_ct = null;
applyAnnotationControls();
markDirty();
}
function handlePhaseChange(event) {
if (!app.draft) return;
app.draft.manual_upper_abdomen_phase = event.target.value;
if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = "";
app.draft.upper_abdomen_phase = effectivePhase();
applyAnnotationControls();
markDirty();
}
function handleChestWindowChange(event) {
if (!app.draft) return;
app.draft.manual_chest_window = event.target.value;
if (app.draft.ai_chest_window !== event.target.value) app.draft.ai_chest_window = "";
app.draft.chest_window = effectiveChestWindow();
applyAnnotationControls();
markDirty();
}
async function runAI() {
if (!app.study || !app.activeSeries) return;
if (!(await confirmSaveIfDirty())) return;
const ctNumber = app.study.ct_number;
const uid = app.activeSeries.series_uid;
const versionAtStart = app.draftVersion;
const button = $("aiClassify");
button.disabled = true;
$("saveState").textContent = "AI 识别中";
try {
const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/ai`, {
method: "POST",
body: JSON.stringify({ sample_count: 3 }),
});
const stillActive = app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid;
const unchanged = stillActive && app.draftVersion === versionAtStart;
updateLocalAnnotation(data, uid, { applyToActive: unchanged });
if (unchanged) {
$("saveState").textContent = "AI 结果已保存";
app.dirty = false;
} else if (stillActive) {
$("saveState").textContent = "未保存";
app.dirty = true;
}
} catch (err) {
if (app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid) $("saveState").textContent = err.message;
} finally {
button.disabled = false;
}
}
async function openInfo() {
if (!(await confirmSaveIfDirty())) return;
if (!app.study || !app.activeSeries) return;
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
const content = $("infoContent");
content.innerHTML = "";
for (const [title, fields] of Object.entries(data.fields || {})) {
const section = document.createElement("section");
section.className = "info-card";
section.innerHTML = `<h3>${escapeHtml(title)}</h3>${Object.entries(fields)
.map(([key, value]) => `<p class="info-row"><span>${escapeHtml(key)}</span><b>${escapeHtml(value || "-")}</b></p>`)
.join("")}`;
content.appendChild(section);
}
$("infoModal").classList.remove("hidden");
}
function settingsTable(users) {
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th></tr></thead>
<tbody>
${(users || [])
.map((user) => `<tr><td>${escapeHtml(user.username)}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.status)}</td></tr>`)
.join("")}
</tbody>
</table>
`;
}
function roleCards(roles) {
return (roles || [])
.map(
(role) => `
<div class="role-card">
<strong>${escapeHtml(role.name)}</strong>
<span>${(role.permissions || []).map(escapeHtml).join(" / ")}</span>
</div>
`,
)
.join("");
}
function showViewerPage(push = true) {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
if (push && location.pathname !== "/") history.pushState({}, "", "/");
}
async function showSettingsPage(push = true) {
if (!(await confirmSaveIfDirty())) return;
if (!isAdmin()) {
if (location.pathname === "/settings") history.replaceState({}, "", "/");
showViewerPage(false);
return;
}
$("viewerPage").classList.add("hidden");
$("settingsPage").classList.remove("hidden");
if (push && location.pathname !== "/settings") history.pushState({}, "", "/settings");
await renderSettingsPage();
}
async function handleRoute(push = false) {
if (location.pathname === "/settings") {
await showSettingsPage(push);
} else {
showViewerPage(push);
}
}
async function openSettings() {
await showSettingsPage(true);
}
async function renderSettingsPage() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
const refresh = settings.summary_refresh || {};
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title"><h3>用户创建</h3><span>当前登录:${escapeHtml(app.user?.username || "-")}</span></div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" autocomplete="off" />
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<button type="submit">创建</button>
</form>
${settingsTable(settings.users)}
</section>
<section class="settings-section">
<div class="settings-title"><h3>权限控制</h3><span>按角色控制查看、标注、AI 与系统设置</span></div>
<div class="role-grid">${roleCards(settings.roles)}</div>
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.enabled ? "已启用" : "已关闭"}</span></div>
<dl>
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
<dt>状态</dt><dd>${settings.ai?.configured ? "API 已配置" : "API 未配置"}</dd>
</dl>
<button id="toggleAi" class="settings-action">${settings.ai?.enabled ? "关闭 AI" : "启用 AI"}</button>
</div>
<div>
<div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div>
<dl>
<dt>计划刷新</dt><dd>每天 04:00</dd>
<dt>最近开始</dt><dd>${escapeHtml(refresh.started_at || "-")}</dd>
<dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd>
<dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd>
</dl>
<button id="refreshSummariesNow" class="settings-action"${refresh.running ? " disabled" : ""}>立刻更新检查列表</button>
</div>
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
<dl>
<dt>主机</dt><dd>${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}</dd>
<dt>库表</dt><dd>${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}</dd>
<dt>DICOM</dt><dd>${escapeHtml(settings.dicom?.processed_root || "-")}</dd>
</dl>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", createUser);
$("toggleAi").addEventListener("click", () => toggleAI(!settings.ai?.enabled));
$("refreshSummariesNow").addEventListener("click", refreshSummariesNow);
}
async function createUser(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button");
const form = new FormData(event.currentTarget);
const payload = {
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
};
if (!payload.username || !payload.password) return;
button.disabled = true;
button.textContent = "创建中";
try {
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
await renderSettingsPage();
} catch (err) {
button.textContent = err.message;
setTimeout(() => {
button.disabled = false;
button.textContent = "创建";
}, 1800);
}
}
async function toggleAI(enabled) {
await json("/api/settings/ai", { method: "POST", body: JSON.stringify({ enabled }) });
await renderSettingsPage();
await refreshStatus();
}
async function refreshSummariesNow() {
const button = $("refreshSummariesNow");
button.disabled = true;
button.textContent = "已开始刷新";
await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) });
await renderSettingsPage();
}
function changeZoom(multiplier) {
app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
applyTransform();
}
function wireImageGestures() {
const wrap = document.querySelector(".image-wrap");
document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation());
wrap.addEventListener(
"wheel",
(event) => {
if (!app.activeSeries) return;
event.preventDefault();
changeZoom(event.deltaY < 0 ? 1.12 : 1 / 1.12);
},
{ passive: false },
);
wrap.addEventListener("pointerdown", (event) => {
if (!app.activeSeries || event.target.closest(".slice-rail")) return;
app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY };
wrap.classList.add("dragging");
wrap.setPointerCapture(event.pointerId);
});
wrap.addEventListener("pointermove", (event) => {
if (!app.drag) return;
app.panX = app.drag.panX + event.clientX - app.drag.x;
app.panY = app.drag.panY + event.clientY - app.drag.y;
applyTransform();
});
["pointerup", "pointercancel", "pointerleave"].forEach((name) => {
wrap.addEventListener(name, () => {
app.drag = null;
wrap.classList.remove("dragging");
});
});
}
function setStudySort(field) {
if (app.studySortField === field) {
app.studySortDir = app.studySortDir === "asc" ? "desc" : "asc";
} else {
app.studySortField = field;
app.studySortDir = "desc";
}
renderStudies();
}
function setSeriesSort(field) {
if (app.seriesSortField === field) {
app.seriesSortDir = app.seriesSortDir === "asc" ? "desc" : "asc";
} else {
app.seriesSortField = field;
app.seriesSortDir = field === "count" ? "desc" : "asc";
}
app.series = sortSeries(app.series);
renderSeries();
if (app.activeSeries) {
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
}
updateSortButtons();
}
function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings);
$("backToViewer").addEventListener("click", () => showViewerPage(true));
$("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI);
$("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", () => {
app.showOverlay = !app.showOverlay;
updateOverlay();
});
document.querySelectorAll("[data-study-sort]").forEach((button) => {
button.addEventListener("click", () => setStudySort(button.dataset.studySort));
});
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.studyFilter = button.dataset.studyFilter;
renderStudies();
});
});
document.querySelectorAll("[data-study-part-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.studyPartFilter = button.dataset.studyPartFilter;
renderStudies();
});
});
document.querySelectorAll("[data-series-sort]").forEach((button) => {
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
});
$("studySearch").addEventListener("input", () => {
clearTimeout(app.searchTimer);
app.searchTimer = setTimeout(loadStudies, 250);
});
$("sliceSlider").addEventListener("input", (event) => {
app.slice = Number(event.target.value);
updateImage();
});
document.querySelectorAll("[data-plane]").forEach((button) => {
button.addEventListener("click", () => {
app.plane = button.dataset.plane;
app.slice = Math.floor(maxSlice() / 2);
document.querySelectorAll("[data-plane]").forEach((b) => b.classList.toggle("active", b === button));
updateImage();
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => {
app.window = button.dataset.window;
document.querySelectorAll("[data-window]").forEach((b) => b.classList.toggle("active", b === button));
updateImage();
});
});
$("rotateLeft").addEventListener("click", () => {
app.rotate = (app.rotate - 90 + 360) % 360;
applyTransform();
});
$("rotateRight").addEventListener("click", () => {
app.rotate = (app.rotate + 90) % 360;
applyTransform();
});
$("resetView").addEventListener("click", resetViewState);
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange));
document.querySelectorAll("input[name=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange));
document.querySelectorAll("input[name=chestWindow]").forEach((input) => input.addEventListener("change", handleChestWindowChange));
$("annotationNotes").addEventListener("input", () => {
if (!app.draft) return;
app.draft.notes = $("annotationNotes").value;
markDirty();
});
$("saveAnnotation").addEventListener("click", saveAnnotationNow);
window.addEventListener("beforeunload", (event) => {
if (!app.dirty) return;
event.preventDefault();
event.returnValue = "";
});
document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
});
window.addEventListener("popstate", () => handleRoute(false));
wireImageGestures();
}
async function boot() {
await loadCurrentUser();
await refreshStatus();
if (location.pathname === "/settings" && isAdmin()) {
await showSettingsPage(false);
} else {
await loadStudies();
await handleRoute(false);
}
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
}
wire();
applyPermissions();
refreshStatus();
if (app.token) {
showLogin(false);
boot().catch(() => showLogin(true));
} else {
showLogin(true);
}