const app = { token: localStorage.getItem("pacs_web_token") || "", studies: [], study: null, series: [], activeSeries: null, slice: 0, plane: "axial", window: "default", rotate: 0, zoom: 1, panX: 0, panY: 0, imageUrl: "", searchTimer: null, statusTimer: null, pendingImage: null, drag: null, }; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); 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 phaseLabel(value) { return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || ""; } function partLabel(value) { return { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", lower_abdomen: "下腹部", pelvis: "盆腔", }[value] || value; } 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; localStorage.setItem("pacs_web_token", app.token); showLogin(false); await boot(); } catch (_) { $("loginError").textContent = "登录失败"; } } function logout() { app.token = ""; localStorage.removeItem("pacs_web_token"); showLogin(true); } 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)); } catch (_) { $("dbStatus").textContent = "数据库异常"; $("dbStatus").classList.add("offline"); $("dbStatus").classList.remove("online"); } } async function loadStudies() { const q = encodeURIComponent($("studySearch").value.trim()); app.studies = await json(`/api/studies?q=${q}&limit=500`); $("studyCount").textContent = String(app.studies.length); renderStudies(); if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number); } function renderStudies() { const list = $("studyList"); list.innerHTML = ""; for (const study of app.studies) { const button = document.createElement("button"); button.className = "study-card"; button.classList.toggle("active", app.study?.ct_number === study.ct_number); const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; button.innerHTML = ` ${escapeHtml(study.ct_number)} ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")} ${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张 ${Number(study.annotated_series || 0)} 个序列已标注 `; button.onclick = () => selectStudy(study.ct_number); list.appendChild(button); } } async function selectStudy(ctNumber) { app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber }; app.series = []; app.activeSeries = null; $("activeStudyLabel").textContent = ctNumber; $("seriesCount").textContent = "读取中"; $("seriesGrid").innerHTML = ""; resetViewer(); renderStudies(); try { const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`); app.study = data.study; app.series = data.series || []; $("seriesCount").textContent = String(app.series.length); renderSeries(); if (app.series.length) selectSeries(app.series[0].series_uid); } catch (err) { $("seriesGrid").innerHTML = `

${escapeHtml(err.message)}

`; } } function renderSeries() { const grid = $("seriesGrid"); grid.innerHTML = ""; for (const series of app.series) { const skipped = Boolean(series.annotation?.skipped); const parts = series.annotation?.body_parts || []; const tags = skipped ? ["略过"] : [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean); 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)}`).join("") : "未标注"}
`; card.onclick = () => selectSeries(series.series_uid); grid.appendChild(card); loadThumb(series, card.querySelector("img")); } } 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`; } async function loadThumb(series, img) { try { const index = Math.floor((Number(series.count) || 1) / 2); const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob()); img.src = URL.createObjectURL(blob); } catch (_) { img.removeAttribute("src"); } } 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(); } function selectSeries(seriesUid) { const series = app.series.find((item) => item.series_uid === seriesUid); if (!series) return; app.activeSeries = series; app.slice = Math.floor(maxSlice() / 2); resetViewState(); $("sliceSlider").max = String(maxSlice()); $("sliceSlider").value = String(app.slice); hydrateAnnotation(); renderSeries(); updateImage(); } function resetViewer() { if (app.imageUrl) URL.revokeObjectURL(app.imageUrl); app.imageUrl = ""; app.pendingImage = null; app.activeSeries = null; $("dicomImage").removeAttribute("src"); $("imageEmpty").classList.remove("hidden"); $("sliceText").textContent = "0 / 0"; $("saveState").textContent = "未保存"; resetViewState(); } function applyTransform() { $("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`; } 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}`; $("imageEmpty").classList.add("hidden"); const ticket = Symbol("image"); app.pendingImage = ticket; try { const blob = await request(imageUrlFor()).then((res) => res.blob()); if (app.pendingImage !== ticket) return; if (app.imageUrl) URL.revokeObjectURL(app.imageUrl); app.imageUrl = URL.createObjectURL(blob); $("dicomImage").src = app.imageUrl; applyTransform(); } catch (err) { $("saveState").textContent = `图像读取失败:${err.message}`; } } function isSkippedChecked() { return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked); } function selectedParts() { return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value); } function syncPartState() { const skipped = isSkippedChecked(); document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => { input.disabled = skipped; if (skipped) input.checked = false; }); if (skipped) { document.querySelectorAll("input[name=phase]").forEach((input) => { input.checked = false; }); } const show = !skipped && selectedParts().includes("upper_abdomen"); $("phaseBox").classList.toggle("visible", show); } function hydrateAnnotation() { const annotation = app.activeSeries?.annotation || {}; const parts = new Set(annotation.body_parts || []); document.querySelectorAll(".part-grid input").forEach((input) => { if (input.value === "skip") { input.checked = Boolean(annotation.skipped); return; } input.checked = !annotation.skipped && parts.has(input.value); input.disabled = Boolean(annotation.skipped); }); document.querySelectorAll("input[name=phase]").forEach((input) => { input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || ""); }); $("annotationNotes").value = annotation.notes || ""; syncPartState(); } async function reloadCurrentStudySeries(keepUid) { const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`); app.study = data.study; app.series = data.series || []; app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null; $("seriesCount").textContent = String(app.series.length); renderSeries(); hydrateAnnotation(); } async function saveAnnotation() { if (!app.study || !app.activeSeries) return; const skipped = isSkippedChecked(); const parts = skipped ? [] : selectedParts(); let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || ""; if (parts.includes("upper_abdomen") && !phase) { $("saveState").textContent = "请选择上腹部期相"; return; } if (!parts.includes("upper_abdomen")) phase = ""; $("saveState").textContent = "保存中"; const uid = app.activeSeries.series_uid; try { await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, { method: "PUT", body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }), }); $("saveState").textContent = "已保存"; await reloadCurrentStudySeries(uid); } catch (err) { $("saveState").textContent = err.message; } } function applyAiSuggestion(data) { const parts = new Set(data.body_parts || []); document.querySelectorAll(".part-grid input").forEach((input) => { if (input.value === "skip") { input.checked = Boolean(data.skipped); return; } input.checked = !data.skipped && parts.has(input.value); }); document.querySelectorAll("input[name=phase]").forEach((input) => { input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || ""); }); if (data.notes) $("annotationNotes").value = data.notes; syncPartState(); } async function runAI() { if (!app.study || !app.activeSeries) return; const button = $("aiClassify"); button.disabled = true; $("saveState").textContent = "AI 识别中"; try { const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, { method: "POST", body: JSON.stringify({ sample_count: 3 }), }); applyAiSuggestion(data); $("saveState").textContent = "AI 建议已填入,确认后保存"; } catch (err) { $("saveState").textContent = err.message; } finally { button.disabled = false; } } async function openInfo() { 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(""); } async function openSettings() { const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]); $("settingsContent").innerHTML = `

用户创建

当前登录:admin
${settingsTable(settings.users)}

权限控制

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

AI 设置

${settings.ai?.configured ? "已配置" : "未配置"}
供应商
${escapeHtml(settings.ai?.provider || "-")}
名称
${escapeHtml(settings.ai?.name || "-")}
模型
${escapeHtml(settings.ai?.model || "-")}

数据库

${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); $("settingsModal").classList.remove("hidden"); } 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 openSettings(); } catch (err) { button.textContent = err.message; setTimeout(() => { button.disabled = false; button.textContent = "创建"; }, 1800); } } 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) 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 wire() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", logout); $("settingsBtn").addEventListener("click", openSettings); $("infoBtn").addEventListener("click", openInfo); $("aiClassify").addEventListener("click", runAI); $("zoomIn").addEventListener("click", () => changeZoom(1.18)); $("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18)); $("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", syncPartState)); $("saveAnnotation").addEventListener("click", saveAnnotation); document.querySelectorAll("[data-close]").forEach((button) => { button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden")); }); wireImageGestures(); } async function boot() { await refreshStatus(); await loadStudies(); if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000); } wire(); refreshStatus(); if (app.token) { showLogin(false); boot().catch(() => showLogin(true)); } else { showLogin(true); }