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: "modified", 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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "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("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } 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.first_time || series.series_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 studyModifiedKey(study) { const raw = String(study.modified_at || study.annotation_updated_at || study.completion_updated_at || study.completed_at || ""); return raw ? raw.replace(/\D/g, "").padEnd(14, "0") : studyTimeKey(study); } 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: "上腹部", 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 modifiedResult = studyModifiedKey(a).localeCompare(studyModifiedKey(b)) || timeResult; const result = app.studySortField === "study_time" ? timeResult : modifiedResult; 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 isSeriesUndetermined(series) { const annotation = series.annotation || {}; if (annotation.skipped) return false; const parts = asList(annotation.body_parts); return ( (parts.includes("upper_abdomen") && annotation.upper_abdomen_phase === "unknown") || (parts.includes("chest") && annotation.chest_window === "unknown") ); } function refreshStudyAnnotationSummary() { if (!app.study || !app.series.length) return; const annotated = app.series.filter(isSeriesAnnotated).length; const undetermined = app.series.filter(isSeriesUndetermined).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), undetermined_series: undetermined, 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 = `
${escapeHtml(series.description || "未命名序列")} 序列 ${escapeHtml(series.series_number || "-")} · ${Number(series.count || 0)} 张 · ${escapeHtml(timeRange(series))} ${escapeHtml(exportLabelText(series))}
`; 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 = `

正在读取序列...

`; $("exportModal").classList.remove("hidden"); try { const seriesList = await loadSeriesForExport(ctNumber); app.exportDialog = { ctNumber, series: seriesList }; renderExportSeriesList(seriesList); } catch (err) { $("exportSeriesList").innerHTML = `

${escapeHtml(err.message)}

`; 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)); } function initialCtNumber() { return new URLSearchParams(location.search).get("ct_number") || ""; } function siblingSystemUrl(port, params = {}) { const url = new URL(window.location.href); url.port = String(port); url.pathname = "/"; url.search = ""; Object.entries(params).forEach(([key, value]) => { if (value) url.searchParams.set(key, value); }); return url.toString(); } function openSiblingSystem(kind) { const ctNumber = app.study?.ct_number || initialCtNumber(); const port = kind === "registration" ? 8109 : 8108; window.open(siblingSystemUrl(port, { ct_number: ctNumber }), "_blank"); } async function loadStudies(preferredCtNumber = "") { const q = encodeURIComponent($("studySearch").value.trim()); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); renderStudies(); if (!app.study && app.studies.length) { const preferred = app.studies.find((study) => study.ct_number === preferredCtNumber); await selectStudy((preferred || 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) => `${escapeHtml(label)}`) .join(""); const studyDateTime = `${fmtDate(study.study_date)} ${fmtTime(study.study_time)}`.trim() || "未记录时间"; const annotationSummary = `${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.undetermined_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() ? ` ` : ""; card.title = cardTitle; card.setAttribute("aria-label", cardTitle); card.innerHTML = `
${studyStatusLabel(status)}
${partTags}
${escapeHtml(study.ct_number)} ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}
${escapeHtml(studyDateTime)}
${escapeHtml(annotationSummary)} ${exportControls}
`; 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 modifiedAt = data.completion_updated_at || data.modified_at || data.completed_at || ""; 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 || ""; study.completion_updated_at = data.completion_updated_at || study.completion_updated_at || ""; if (modifiedAt) study.modified_at = modifiedAt; } 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 || ""; app.study.completion_updated_at = data.completion_updated_at || app.study.completion_updated_at || ""; if (modifiedAt) app.study.modified_at = modifiedAt; } } 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 = `

${escapeHtml(err.message)}

`; } } 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 = `
加载中 ${Number(series.count || 0)} 张 ${skipped ? "略过/不采用" : ""}
${escapeHtml(series.description || "未命名序列")} 拍摄 ${escapeHtml(timeRange(series))} 序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}
${tags.length ? tags.map((tag) => `${escapeHtml(tag.label)}`).join("") : "未标注"}
`; card.onclick = () => selectSeries(series.series_uid); grid.appendChild(card); queueThumb(series, card.querySelector("img")); } } function renderSeriesLoading() { $("seriesGrid").innerHTML = Array.from({ length: 4 }) .map( () => `
序列读取中
读取中 正在同步序列信息 请稍候
`, ) .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("
"); document.querySelector(".ov-right-top").innerHTML = [ s.institution_name || "", s.manufacturer || "", ].filter(Boolean).map(escapeHtml).join("
"); document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`; document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}
WL:${Math.round(win.wl)}
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(); const modifiedAt = normalized.updated_at || new Date().toISOString(); if (app.study && modifiedAt) { app.study.modified_at = modifiedAt; app.study.annotation_updated_at = modifiedAt; const study = app.studies.find((item) => item.ct_number === app.study.ct_number); if (study) { study.modified_at = modifiedAt; study.annotation_updated_at = modifiedAt; } } 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 = `

${escapeHtml(title)}

${Object.entries(fields) .map(([key, value]) => `

${escapeHtml(key)}${escapeHtml(value || "-")}

`) .join("")}`; content.appendChild(section); } $("infoModal").classList.remove("hidden"); } function settingsTable(users) { return ` ${(users || []) .map((user) => ``) .join("")}
账号角色状态
${escapeHtml(user.username)}${escapeHtml(user.role)}${escapeHtml(user.status)}
`; } function roleCards(roles) { return (roles || []) .map( (role) => `
${escapeHtml(role.name)} ${(role.permissions || []).map(escapeHtml).join(" / ")}
`, ) .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 = `

用户创建

当前登录:${escapeHtml(app.user?.username || "-")}
${settingsTable(settings.users)}

权限控制

按角色控制查看、标注、AI 与系统设置
${roleCards(settings.roles)}

AI 设置

${settings.ai?.enabled ? "已启用" : "已关闭"}
供应商
${escapeHtml(settings.ai?.provider || "-")}
名称
${escapeHtml(settings.ai?.name || "-")}
模型
${escapeHtml(settings.ai?.model || "-")}
状态
${settings.ai?.configured ? "API 已配置" : "API 未配置"}

检查摘要

${refresh.running ? "刷新中" : "待命"}
计划刷新
每天 04:00(北京时间,全量)
下次刷新
${escapeHtml(refresh.next_scheduled_at || "-")}
最近开始
${escapeHtml(refresh.started_at || "-")}
最近完成
${escapeHtml(refresh.finished_at || "-")}
状态
${escapeHtml(refresh.message || "-")}

数据库

${status.database?.ok ? "已连接" : "异常"}
主机
${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}
库表
${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}
DICOM
${escapeHtml(settings.dicom?.processed_root || "-")}
`; $("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({}) }); let refresh = {}; for (let i = 0; i < 300; i += 1) { await sleep(2000); const settings = await json("/api/settings"); refresh = settings.summary_refresh || {}; button.textContent = refresh.running ? refresh.message || "刷新中" : "刷新完成"; if (!refresh.running) break; } await loadStudies(app.study?.ct_number || initialCtNumber()); await renderSettingsPage(); } function changeZoom(multiplier) { app.zoom = clamp(app.zoom * multiplier, 0.25, 6); applyTransform(); } function wireImageGestures() { const wrap = document.querySelector(".image-wrap"); const sliceRail = document.querySelector(".slice-rail"); ["pointerdown", "pointermove", "pointerup", "pointercancel", "click"].forEach((name) => { sliceRail.addEventListener(name, (event) => event.stopPropagation()); }); sliceRail.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); 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); $("registrationLinkBtn").addEventListener("click", () => openSiblingSystem("registration")); $("relationLinkBtn").addEventListener("click", () => openSiblingSystem("relation")); $("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(initialCtNumber()); 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); }