const app = { token: localStorage.getItem("pacs_relation_token") || "", user: null, rows: [], active: null, filter: "all", search: "", algorithmModel: "", dicomPart: "", viewerUrl: "http://127.0.0.1:8107", searchTimer: null, offset: 0, limit: 20, hasMore: true, loading: false, }; const $ = (id) => document.getElementById(id); 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 setLoading(show) { app.loading = show; $("topLoading").classList.toggle("hidden", !show); $("listLoading").classList.toggle("hidden", !show || !app.rows.length); } function showViewer() { $("viewerPage").classList.remove("hidden"); $("settingsPage").classList.add("hidden"); } async function showSettings() { if (!isAdmin()) return; $("viewerPage").classList.add("hidden"); $("settingsPage").classList.remove("hidden"); await renderSettingsPage(); } function isAdmin() { return app.user?.role === "管理员"; } function applyAuthUi() { $("userBadge").textContent = app.user ? `${app.user.username} · ${app.user.role}` : "未登录"; $("settingsBtn").classList.toggle("hidden", !isAdmin()); } function fmtDate(value) { const text = String(value || ""); if (!text) return ""; if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; return text.replace("T", " ").slice(0, 19); } 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 fmtBytes(value) { const size = Number(value || 0); if (!size) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))); return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; } function asList(value) { return Array.isArray(value) ? value : []; } function uniq(list) { return Array.from(new Set(list.filter(Boolean))); } function statusLabel(value) { return { complete: "DICOM + STL", no_stl: "DICOM + UPP", pacs_only: "仅 DICOM", stl_only: "仅 STL", list_only: "仅 UPP", unknown: "未知", }[value] || value || "未知"; } function partLabel(value) { return { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", lower_abdomen: "下腹部", pelvis: "盆腔", }[value] || value; } function dicomParts(row) { const labels = asList(row.dicom_body_parts); if (labels.length) return labels; return asList(row.body_parts).map(partLabel); } function annotationLabels(row) { const labels = asList(row.dicom_annotation_labels); return labels.length ? labels : dicomParts(row); } function patientNameMismatch(row) { return row.pacs_present && row.stl_asset_present && row.patient_name_match === "different"; } function setDbStatus(data) { app.viewerUrl = data.viewer_url || app.viewerUrl; const pill = $("dbStatus"); if (data.database?.ok) { pill.textContent = `${data.database.database} 已连接`; pill.className = "status-pill online"; } else { pill.textContent = "数据库异常"; pill.className = "status-pill offline"; } } function renderMetrics(counts = {}) { const items = [ ["完整关联", counts.complete_count], ["DICOM", counts.pacs_count], ["仅 DICOM", counts.pacs_only_count], ["仅 STL", counts.stl_only_count], ]; $("metrics").innerHTML = items .map(([label, value]) => `
${escapeHtml(label)}${Number(value || 0)}
`) .join(""); } function cardTitle(row) { return [ `CT号:${row.ct_key}`, `DICOM:${row.pacs_ct_number || "无"}`, `STL:${row.stl_ct_number || "无"}`, `状态:${statusLabel(row.relation_status)}`, `DICOM患者:${row.pacs_patient_name || "无"}`, `UPP患者:${row.upp_patient_name || "无"}`, `重建模型:${row.algorithm_model || "无"}`, `部位标注:${annotationLabels(row).join("、") || "无"}`, ].join("\n"); } function renderPartTags(parts) { if (!parts.length) return `无部位标注`; return parts.map((part) => `${escapeHtml(part)}`).join(""); } function renderList() { const list = $("relationList"); $("resultCount").textContent = String(app.rows.length); if (!app.rows.length) { list.innerHTML = `

${app.loading ? "正在加载..." : "没有匹配记录"}

`; if (!app.loading) clearDetail(); return; } list.innerHTML = ""; for (const row of app.rows) { const card = document.createElement("button"); card.className = "relation-card"; card.classList.toggle("active", app.active?.ct_key === row.ct_key); card.title = cardTitle(row); const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0); const labels = dicomParts(row); card.innerHTML = `
${escapeHtml(row.ct_key)} ${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")} DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount} 个` : ""}
${escapeHtml(statusLabel(row.relation_status))} ${ patientNameMismatch(row) ? `姓名需核对` : "" }
${row.algorithm_model ? `${escapeHtml(row.algorithm_model)}` : ""} ${renderPartTags(labels)}
`; card.onclick = () => selectRelation(row.ct_key); list.appendChild(card); } } function renderDl(id, entries) { const html = entries .map(([key, value]) => { const text = value || value === 0 ? String(value) : "-"; return `
${escapeHtml(key)}
${escapeHtml(text)}
`; }) .join(""); $(id).innerHTML = html; } function markNode(id, state, strong, meta) { const node = $(id); node.classList.remove("ok", "warn", "missing"); node.classList.add(state); node.querySelector("strong").textContent = strong; node.querySelector("em").textContent = meta; } function viewerBaseUrl() { try { const url = new URL(app.viewerUrl || `${location.protocol}//${location.hostname}:8107`, location.href); if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) { url.hostname = location.hostname; } url.protocol = location.protocol; return url.toString().replace(/\/$/, ""); } catch (_) { return `${location.protocol}//${location.hostname}:8107`; } } function dicomViewerUrl(row = app.active) { if (!row?.pacs_ct_number) return ""; return `${viewerBaseUrl()}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`; } function updateOpenDicomButton() { const button = $("openDicomBtn"); const url = dicomViewerUrl(); button.disabled = !url; button.title = url || "当前记录没有 DICOM 检查号"; } function clearDetail() { app.active = null; $("activeCt").textContent = "未选择 CT"; $("activeSubtitle").textContent = "从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系"; $("relationBadge").className = "relation-badge idle"; $("relationBadge").textContent = "等待选择"; markNode("nodePacs", "missing", "未选择", "检查与序列"); $("nodeCtValue").textContent = "-"; markNode("nodeStl", "missing", "未选择", "含UPP列表信息"); renderDl("pacsDetails", []); renderDl("stlDetails", []); updateOpenDicomButton(); } function renderDetail(row) { app.active = row; $("activeCt").textContent = row.ct_key || "未选择 CT"; $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"} ↔ ${row.stl_ct_number || "无 STL"}`; const badge = $("relationBadge"); badge.className = `relation-badge ${row.relation_status || "idle"}`; badge.textContent = statusLabel(row.relation_status); const labels = annotationLabels(row); $("nodeCtValue").textContent = row.ct_key || "-"; markNode( "nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.pacs_series_count || 0)} 序列 · ${labels.join("、") || "未标注"}`, ); markNode( "nodeStl", row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing", row.stl_ct_number || "缺失", `${row.algorithm_model || "无重建模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`, ); renderDl("pacsDetails", [ ["CT号", row.pacs_ct_number], ["患者", row.pacs_patient_name], ["患者ID", row.patient_id], ["检查时间", `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}`.trim()], ["描述", row.study_description], ["序列/文件", `${Number(row.pacs_series_count || 0)} / ${Number(row.dicom_file_count || 0)}`], ["标注", `${Number(row.annotated_series || 0)} 已标注`], ["完成", row.completed ? "已完成" : "待处理"], ["部位标注", labels.join("、")], ]); renderDl("stlDetails", [ ["CT号", row.stl_ct_number], ["患者", row.upp_patient_name], ["STL状态", row.stl_present ? "存在" : "缺失"], ["重建模型", row.algorithm_model], ["UPP状态", row.upp_status], ["姓名核对", row.patient_name_match === "different" ? row.patient_name_match_note : row.patient_name_match === "same" ? row.patient_name_match_note || "匹配" : ""], ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], ["检查时间", fmtDate(row.exam_date)], ["任务时间", fmtDate(row.task_created_at)], ["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)], ["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)], ["目录", row.processed_stl_dir], ["分割分类", uniq(asList(row.segment_categories)).join("、")], ["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)], ]); updateOpenDicomButton(); renderList(); } async function selectRelation(ctKey) { const row = await json(`/api/relations/${encodeURIComponent(ctKey)}`); renderDetail(row); } async function loadStatus() { const data = await json("/api/status"); setDbStatus(data); renderMetrics(data.counts || {}); } async function loadRelations(reset = true) { if (app.loading) return; setLoading(true); if (reset) { app.offset = 0; app.hasMore = true; app.rows = []; app.active = null; renderList(); } if (!app.hasMore) { setLoading(false); return; } const params = new URLSearchParams(); params.set("status", app.filter); params.set("limit", String(app.limit)); params.set("offset", String(app.offset)); if (app.search) params.set("q", app.search); if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel); if (app.dicomPart) params.set("dicom_part", app.dicomPart); try { const rows = await json(`/api/relations?${params.toString()}`); app.rows = reset ? rows : [...app.rows, ...rows.filter((row) => !app.rows.some((item) => item.ct_key === row.ct_key))]; app.offset += rows.length; app.hasMore = rows.length === app.limit; renderList(); if (reset && app.rows.length) await selectRelation(app.rows[0].ct_key); if (reset && !app.rows.length) clearDetail(); } finally { setLoading(false); } } async function refreshAll() { try { await loadStatus(); await loadRelations(true); } catch (err) { $("dbStatus").textContent = err.message; $("dbStatus").className = "status-pill offline"; setLoading(false); } } async function login(event) { event.preventDefault(); $("loginError").textContent = ""; try { const data = await json("/api/auth/login", { method: "POST", body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }), }); app.token = data.token; app.user = { username: data.username, role: data.role }; localStorage.setItem("pacs_relation_token", app.token); applyAuthUi(); showLogin(false); showViewer(); await refreshAll(); } catch (_) { $("loginError").textContent = "登录失败"; } } function logout() { app.token = ""; app.user = null; app.rows = []; app.active = null; localStorage.removeItem("pacs_relation_token"); applyAuthUi(); showViewer(); clearDetail(); showLogin(true); } async function loadCurrentUser() { if (!app.token) { showLogin(true); return false; } try { app.user = await json("/api/auth/me"); applyAuthUi(); showLogin(false); return true; } catch (_) { app.token = ""; localStorage.removeItem("pacs_relation_token"); applyAuthUi(); showLogin(true); return false; } } function settingsTable(users) { if (!users?.length) return `

暂无账号

`; return ` ${users .map( (user) => ` `, ) .join("")}
账号角色状态更新时间重置密码
${escapeHtml(user.username)} ${escapeHtml(user.role)} ${escapeHtml(user.status)} ${escapeHtml(fmtDate(user.updated_at))}
`; } async function renderSettingsPage() { const data = await json("/api/settings"); $("settingsContent").innerHTML = `

账号创建

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

系统管理

跳转与联动
阅片系统
${escapeHtml(data.viewer_url || "-")}
数据库
${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}
DICOM表
${escapeHtml(data.tables?.dicom || "-")}
摘要表
${escapeHtml(data.tables?.dicom_summary || "-")}

权限说明

页面级控制
管理员查看关联、系统设置、账号密码管理
阅片员查看关联与跳转阅片系统
访客只读查看关联信息
`; $("createUserForm").addEventListener("submit", saveUser); document.querySelectorAll("[data-reset-user]").forEach((button) => { button.addEventListener("click", () => resetPassword(button.dataset.resetUser)); }); } async function saveUser(event) { event.preventDefault(); const form = new FormData(event.currentTarget); await json("/api/settings/users", { method: "POST", body: JSON.stringify({ username: String(form.get("username") || "").trim(), password: String(form.get("password") || ""), role: String(form.get("role") || "阅片员"), status: String(form.get("status") || "启用"), }), }); await renderSettingsPage(); } async function resetPassword(username) { const input = Array.from(document.querySelectorAll("[data-password-user]")).find((item) => item.dataset.passwordUser === username); const password = input?.value || ""; if (!password) return; await json(`/api/settings/users/${encodeURIComponent(username)}/password`, { method: "PUT", body: JSON.stringify({ password }), }); await renderSettingsPage(); } function openDicomViewer() { const url = dicomViewerUrl(); if (url) window.open(url, "_blank", "noopener"); } function openViewerHome() { window.open(viewerBaseUrl(), "_blank", "noopener"); } function wire() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", logout); $("settingsBtn").addEventListener("click", showSettings); $("backToViewer").addEventListener("click", showViewer); $("refreshBtn").addEventListener("click", refreshAll); $("viewerHomeBtn").addEventListener("click", openViewerHome); $("openDicomBtn").addEventListener("click", openDicomViewer); $("relationList").addEventListener("scroll", () => { const list = $("relationList"); if (list.scrollTop + list.clientHeight >= list.scrollHeight - 140) loadRelations(false); }); $("searchInput").addEventListener("input", () => { clearTimeout(app.searchTimer); app.searchTimer = setTimeout(() => { app.search = $("searchInput").value.trim(); loadRelations(); }, 250); }); $("algorithmFilter").addEventListener("change", () => { app.algorithmModel = $("algorithmFilter").value; loadRelations(); }); $("dicomPartFilter").addEventListener("change", () => { app.dicomPart = $("dicomPartFilter").value; loadRelations(); }); document.querySelectorAll("[data-filter]").forEach((button) => { button.addEventListener("click", () => { app.filter = button.dataset.filter; document.querySelectorAll("[data-filter]").forEach((item) => item.classList.toggle("active", item === button)); loadRelations(); }); }); } async function boot() { clearDetail(); applyAuthUi(); wire(); const ok = await loadCurrentUser(); if (ok) await refreshAll(); } boot();