const app = { rows: [], active: null, filter: "all", search: "", searchTimer: null, }; const $ = (id) => document.getElementById(id); function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } async function json(path) { const res = await fetch(path); 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.json(); } 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: "PACS + UPP + STL", no_stl: "PACS + UPP", pacs_only: "仅 PACS", upp_only: "仅 UPP", unknown: "未知", }[value] || value || "未知"; } function matchLabel(value) { return { exact: "CT 号精确一致", normalized: "去 D 前缀匹配" }[value] || "未匹配"; } function partLabel(value) { return { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", lower_abdomen: "下腹部", pelvis: "盆腔", }[value] || value; } function setDbStatus(data) { 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 = [ ["CT 索引", counts.total_ct], ["PACS", counts.pacs_count], ["UPP", counts.upp_count], ["STL", counts.stl_count], ["完整关联", counts.complete_count], ["待判别序列", counts.undetermined_series], ]; $("metrics").innerHTML = items .map(([label, value]) => `
${escapeHtml(label)}${Number(value || 0)}
`) .join(""); } function cardTitle(row) { return [ `规范CT号:${row.norm_ct}`, `PACS:${row.pacs_ct_number || "无"}`, `UPP:${row.upp_ct_number || "无"}`, `状态:${statusLabel(row.relation_status)}`, `PACS患者:${row.pacs_patient_name || "无"}`, `UPP患者:${row.upp_patient_name || "无"}`, ].join("\n"); } function renderList() { const list = $("relationList"); $("resultCount").textContent = String(app.rows.length); if (!app.rows.length) { list.innerHTML = `

没有匹配记录

`; return; } list.innerHTML = ""; for (const row of app.rows) { const card = document.createElement("button"); card.className = "relation-card"; card.classList.toggle("active", app.active?.norm_ct === row.norm_ct); card.title = cardTitle(row); const date = row.study_date ? `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}` : fmtDate(row.exam_date || row.task_created_at); const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0); card.innerHTML = `
${escapeHtml(row.norm_ct)} ${escapeHtml(statusLabel(row.relation_status))}
${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")} ${escapeHtml(date || "无时间")} · PACS ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个 ${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""} `; card.onclick = () => selectRelation(row.norm_ct); 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 groupSegments(row) { const names = asList(row.segment_names); const families = asList(row.segment_families); const categories = asList(row.segment_categories); const groups = new Map(); names.forEach((name, index) => { const key = categories[index] || families[index] || "未分类"; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(name); }); return groups; } function renderSegments(row) { const groups = groupSegments(row); const count = asList(row.segment_names).length; $("segmentCount").textContent = `${count} 个`; if (!count) { $("segmentGroups").innerHTML = `

没有 STL 分割文件

`; return; } $("segmentGroups").innerHTML = Array.from(groups.entries()) .map( ([group, names]) => `
${escapeHtml(group)}
${names.map((name) => `${escapeHtml(name)}`).join("")}
`, ) .join(""); } function renderDetail(row) { app.active = row; $("activeCt").textContent = row.norm_ct || "未选择 CT"; $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.upp_ct_number || "无 UPP"} · ${matchLabel(row.match_type)}`; const badge = $("relationBadge"); badge.className = `relation-badge ${row.relation_status || "idle"} ${row.match_type === "normalized" ? "normalized" : ""}`; badge.textContent = statusLabel(row.relation_status); $("nodeCtValue").textContent = row.norm_ct || "-"; markNode("nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列`); markNode("nodeUpp", row.upp_present ? "ok" : "missing", row.upp_ct_number || "缺失", row.upp_status || row.algorithm_model || "无任务状态"); markNode("nodeStl", row.stl_present ? "ok" : "warn", row.stl_present ? "已生成" : "未生成", `${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL · ${fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)}`); 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)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`], ["完成", row.completed ? "已完成" : "待处理"], ["部位", asList(row.body_parts).map(partLabel).join("、")], ]); renderDl("uppDetails", [ ["CT号", row.upp_ct_number], ["患者", row.upp_patient_name], ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], ["检查时间", fmtDate(row.exam_date)], ["任务时间", fmtDate(row.task_created_at)], ["算法模型", row.algorithm_model], ["状态", row.upp_status], ["列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"], ["患者名", row.patient_name_match === "same" ? "两侧一致" : row.patient_name_match === "different" ? "两侧不同" : "无法判断"], ]); renderDl("stlDetails", [ ["STL状态", row.stl_present ? "存在" : "缺失"], ["文件数", 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("、")], ["Family", uniq(asList(row.segment_families)).join("、")], ["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)], ]); renderSegments(row); renderList(); } async function selectRelation(normCt) { const row = await json(`/api/relations/${encodeURIComponent(normCt)}`); renderDetail(row); } async function loadStatus() { const data = await json("/api/status"); setDbStatus(data); renderMetrics(data.counts || {}); } async function loadRelations() { const params = new URLSearchParams(); params.set("status", app.filter); params.set("limit", "500"); if (app.search) params.set("q", app.search); app.rows = await json(`/api/relations?${params.toString()}`); if (app.rows.length && !app.rows.some((row) => row.norm_ct === app.active?.norm_ct)) { await selectRelation(app.rows[0].norm_ct); } else { renderList(); if (!app.active && app.rows.length) await selectRelation(app.rows[0].norm_ct); } } async function refreshAll() { try { await loadStatus(); await loadRelations(); } catch (err) { $("dbStatus").textContent = err.message; $("dbStatus").className = "status-pill offline"; } } function wire() { $("refreshBtn").addEventListener("click", refreshAll); $("searchInput").addEventListener("input", () => { clearTimeout(app.searchTimer); app.searchTimer = setTimeout(() => { app.search = $("searchInput").value.trim(); loadRelations(); }, 250); }); 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(); }); }); } wire(); refreshAll();