From 92ca5e5b389d51b7f7c9e8fd18daeb3ac1088f4c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 09:19:29 +0800 Subject: [PATCH] Refine DICOM UPP relation loading --- 数据库Web可视化/.env.example | 1 + 数据库Web可视化/app.py | 56 ++++++++++++++- 数据库Web可视化/static/app.js | 111 ++++++++++++++++++++---------- 数据库Web可视化/static/index.html | 6 +- 数据库Web可视化/static/styles.css | 94 ++++++++++++++++++++++--- 5 files changed, 217 insertions(+), 51 deletions(-) diff --git a/数据库Web可视化/.env.example b/数据库Web可视化/.env.example index d51c4ce..4ce1f1c 100644 --- a/数据库Web可视化/.env.example +++ b/数据库Web可视化/.env.example @@ -5,6 +5,7 @@ PGPASSWORD=change_me PGDATABASE=pacs_db PACS_TABLE=pacs_dicom_files PACS_SUMMARY_TABLE=pacs_dicom_study_summaries +PACS_ANNOTATION_TABLE=pacs_dicom_series_annotations UPP_ASSET_TABLE=upp_exam_assets UPP_STL_TABLE=upp_stl_files VISUALIZER_USER_TABLE=db_visualizer_users diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py index 1352601..23062ab 100644 --- a/数据库Web可视化/app.py +++ b/数据库Web可视化/app.py @@ -24,6 +24,7 @@ PGUSER = os.getenv("PGUSER", "his_user") PGDATABASE = os.getenv("PGDATABASE", "pacs_db") PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files") PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries") +PACS_ANNOTATION_TABLE = os.getenv("PACS_ANNOTATION_TABLE", "pacs_dicom_series_annotations") UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets") UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files") USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users") @@ -42,6 +43,7 @@ def ident(name: str) -> str: PACS_TABLE_SQL = ident(PACS_TABLE) PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE) +PACS_ANNOTATION_TABLE_SQL = ident(PACS_ANNOTATION_TABLE) UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE) UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE) USER_TABLE_SQL = ident(USER_TABLE) @@ -185,6 +187,52 @@ def relation_cte() -> str: SELECT * FROM public.{PACS_SUMMARY_TABLE_SQL} ), + a_label_raw AS ( + SELECT + a.ct_number, + CASE part.value + WHEN 'head_neck' THEN '头颈部' + WHEN 'chest' THEN '胸部-' || CASE COALESCE(NULLIF(a.chest_window, ''), 'unknown') + WHEN 'lung' THEN '肺窗' + WHEN 'mediastinal' THEN '纵隔窗' + ELSE '无法判别' + END + WHEN 'upper_abdomen' THEN '上腹部-' || CASE COALESCE(NULLIF(a.upper_abdomen_phase, ''), 'unknown') + WHEN 'plain' THEN '平扫' + WHEN 'arterial' THEN '动脉期' + WHEN 'portal_venous' THEN '门脉期' + WHEN 'delayed' THEN '延迟期' + ELSE '无法判别' + END + WHEN 'lower_abdomen' THEN '下腹部' + WHEN 'pelvis' THEN '盆腔' + ELSE NULL + END AS label, + CASE part.value + WHEN 'head_neck' THEN 1 + WHEN 'chest' THEN 2 + WHEN 'upper_abdomen' THEN 3 + WHEN 'lower_abdomen' THEN 4 + WHEN 'pelvis' THEN 5 + ELSE 99 + END AS sort_key + FROM public.{PACS_ANNOTATION_TABLE_SQL} a + CROSS JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value) + WHERE COALESCE(a.skipped, false) IS NOT TRUE + ), + a_label_distinct AS ( + SELECT ct_number, label, min(sort_key) AS sort_key + FROM a_label_raw + WHERE label IS NOT NULL + GROUP BY ct_number, label + ), + a_labels AS ( + SELECT + ct_number, + to_jsonb(array_agg(label ORDER BY sort_key, label)) AS dicom_annotation_labels + FROM a_label_distinct + GROUP BY ct_number + ), u_raw AS ( SELECT u.*, @@ -253,6 +301,7 @@ def relation_cte() -> str: ps.undetermined_series, ps.completed, ps.body_parts, + COALESCE(a_labels.dicom_annotation_labels, '[]'::jsonb) AS dicom_annotation_labels, to_jsonb(array_remove(ARRAY[ CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END, CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END, @@ -297,6 +346,7 @@ def relation_cte() -> str: FROM keys k LEFT JOIN p ON p.ct_key = k.ct_key LEFT JOIN ps ON ps.ct_number = p.ct_number + LEFT JOIN a_labels ON a_labels.ct_number = p.ct_number LEFT JOIN u ON u.ct_key = k.ct_key LEFT JOIN s ON s.ct_key = k.ct_key ) @@ -401,6 +451,7 @@ def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]: "tables": { "dicom": PACS_TABLE, "dicom_summary": PACS_SUMMARY_TABLE, + "dicom_annotations": PACS_ANNOTATION_TABLE, "upp_assets": UPP_ASSET_TABLE, "upp_stl": UPP_STL_TABLE, }, @@ -489,6 +540,7 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]: "tables": { "dicom": PACS_TABLE, "dicom_summary": PACS_SUMMARY_TABLE, + "dicom_annotations": PACS_ANNOTATION_TABLE, "upp_assets": UPP_ASSET_TABLE, "upp_stl": UPP_STL_TABLE, }, @@ -503,6 +555,7 @@ def relations( algorithm_model: str = "", dicom_part: str = "", limit: int = Query(default=300, ge=1, le=1000), + offset: int = Query(default=0, ge=0), user: dict[str, str] = Depends(current_user), ) -> list[dict[str, Any]]: where = relation_where(q, status, algorithm_model, dicom_part) @@ -517,7 +570,7 @@ def relations( study_description, exam_description, algorithm_model, upp_status, pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed, list_present, list_record_count, stl_file_count, stl_file_count_agg, - body_parts, dicom_body_parts, segment_categories, segment_families, + body_parts, dicom_body_parts, dicom_annotation_labels, segment_categories, segment_families, pacs_updated_at, upp_updated_at, stl_updated_at FROM relation {where} @@ -528,6 +581,7 @@ def relations( COALESCE(study_time, '') DESC, ct_key LIMIT {int(limit)} + OFFSET {int(offset)} """, timeout=24, ) diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js index 04ad8c1..5074f90 100644 --- a/数据库Web可视化/static/app.js +++ b/数据库Web可视化/static/app.js @@ -9,6 +9,10 @@ const app = { dicomPart: "", viewerUrl: "http://127.0.0.1:8107", searchTimer: null, + offset: 0, + limit: 20, + hasMore: true, + loading: false, }; const $ = (id) => document.getElementById(id); @@ -53,6 +57,12 @@ 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"); @@ -109,15 +119,11 @@ function statusLabel(value) { no_stl: "DICOM + UPP", pacs_only: "仅 DICOM", stl_only: "仅 STL", - list_only: "列表无STL", + list_only: "仅 UPP", unknown: "未知", }[value] || value || "未知"; } -function matchLabel(value) { - return { exact: "CT 号一致" }[value] || "未匹配"; -} - function partLabel(value) { return { head_neck: "头颈部", @@ -134,8 +140,9 @@ function dicomParts(row) { return asList(row.body_parts).map(partLabel); } -function pendingDICOM(row) { - return Number(row.undetermined_series || 0); +function annotationLabels(row) { + const labels = asList(row.dicom_annotation_labels); + return labels.length ? labels : dicomParts(row); } function setDbStatus(data) { @@ -154,9 +161,8 @@ function renderMetrics(counts = {}) { const items = [ ["完整关联", counts.complete_count], ["DICOM", counts.pacs_count], - ["缺 STL", counts.no_stl_count], ["仅 DICOM", counts.pacs_only_count], - ["待处理DICOM", counts.pending_dicom_count], + ["仅 STL", counts.stl_only_count], ]; $("metrics").innerHTML = items .map(([label, value]) => `
${escapeHtml(label)}${Number(value || 0)}
`) @@ -172,7 +178,7 @@ function cardTitle(row) { `DICOM患者:${row.pacs_patient_name || "无"}`, `UPP患者:${row.upp_patient_name || "无"}`, `重建模型:${row.algorithm_model || "无"}`, - `部位标注:${dicomParts(row).join("、") || "无"}`, + `部位标注:${annotationLabels(row).join("、") || "无"}`, ].join("\n"); } @@ -185,8 +191,8 @@ function renderList() { const list = $("relationList"); $("resultCount").textContent = String(app.rows.length); if (!app.rows.length) { - list.innerHTML = `

没有匹配记录

`; - clearDetail(); + list.innerHTML = `

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

`; + if (!app.loading) clearDetail(); return; } list.innerHTML = ""; @@ -195,20 +201,23 @@ function renderList() { card.className = "relation-card"; card.classList.toggle("active", app.active?.ct_key === row.ct_key); 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); - const parts = dicomParts(row); - const pending = pendingDICOM(row); + const labels = annotationLabels(row); card.innerHTML = ` -
- ${escapeHtml(row.ct_key)} - ${escapeHtml(statusLabel(row.relation_status))} +
+
+ ${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))} +
+ ${row.algorithm_model ? `${escapeHtml(row.algorithm_model)}` : ""} + ${renderPartTags(labels)} +
+
- ${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")} - ${escapeHtml(date || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个 - 重建 ${escapeHtml(row.algorithm_model || "无")} -
${renderPartTags(parts)}
- ${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""} `; card.onclick = () => selectRelation(row.ct_key); list.appendChild(card); @@ -262,18 +271,18 @@ function clearDetail() { function renderDetail(row) { app.active = row; $("activeCt").textContent = row.ct_key || "未选择 CT"; - $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"} ↔ ${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`; + $("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 pending = pendingDICOM(row); + const labels = annotationLabels(row); $("nodeCtValue").textContent = row.ct_key || "-"; markNode( "nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", - `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列${pending ? ` · ${pending} 待处理DICOM` : ""}`, + `${Number(row.pacs_series_count || 0)} 序列 · ${labels.join("、") || "未标注"}`, ); markNode( "nodeStl", @@ -289,9 +298,9 @@ function renderDetail(row) { ["检查时间", `${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)} 已标注 · ${pending} 待处理DICOM`], + ["标注", `${Number(row.annotated_series || 0)} 已标注`], ["完成", row.completed ? "已完成" : "待处理"], - ["部位标注", dicomParts(row).join("、")], + ["部位标注", labels.join("、")], ]); renderDl("stlDetails", [ @@ -303,7 +312,6 @@ function renderDetail(row) { ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], ["检查时间", fmtDate(row.exam_date)], ["任务时间", fmtDate(row.task_created_at)], - ["UPP列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"], ["文件数", 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], @@ -326,30 +334,48 @@ async function loadStatus() { renderMetrics(data.counts || {}); } -async function loadRelations() { +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", "500"); + 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); - app.rows = await json(`/api/relations?${params.toString()}`); - if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) { - await selectRelation(app.rows[0].ct_key); - } else { + 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 (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key); - if (!app.rows.length) clearDetail(); + 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(); + await loadRelations(true); } catch (err) { $("dbStatus").textContent = err.message; $("dbStatus").className = "status-pill offline"; + setLoading(false); } } @@ -514,13 +540,22 @@ function openDicomViewer() { if (url) window.open(url, "_blank", "noopener"); } +function openViewerHome() { + window.open(app.viewerUrl.replace(/\/$/, ""), "_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(() => { diff --git a/数据库Web可视化/static/index.html b/数据库Web可视化/static/index.html index ba46408..0e01f54 100644 --- a/数据库Web可视化/static/index.html +++ b/数据库Web可视化/static/index.html @@ -16,6 +16,7 @@
+ 数据库 未登录 @@ -23,6 +24,7 @@
+
diff --git a/数据库Web可视化/static/styles.css b/数据库Web可视化/static/styles.css index 242b6e8..f39ebbc 100644 --- a/数据库Web可视化/static/styles.css +++ b/数据库Web可视化/static/styles.css @@ -88,6 +88,37 @@ button { gap: 10px; } +.top-loading { + position: fixed; + top: 66px; + left: 0; + right: 0; + z-index: 15; + height: 3px; + overflow: hidden; + background: rgba(31, 44, 60, 0.72); +} + +.top-loading span { + position: absolute; + top: 0; + bottom: 0; + width: 36%; + border-radius: 999px; + background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent); + animation: loading-sweep 1.1s ease-in-out infinite; +} + +@keyframes loading-sweep { + from { + transform: translateX(-110%); + } + + to { + transform: translateX(310%); + } +} + .hidden { display: none !important; } @@ -279,13 +310,17 @@ button { } .filter-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; gap: 7px; + overflow-x: auto; + padding-bottom: 4px; margin-top: 10px; + scrollbar-width: thin; } .filter-grid button { + min-width: 112px; + flex: 0 0 auto; height: 30px; border: 1px solid var(--line); border-radius: 7px; @@ -304,6 +339,26 @@ button { height: 100%; overflow: auto; padding-right: 3px; + padding-bottom: 34px; +} + +.list-panel { + position: relative; +} + +.list-loading { + position: absolute; + right: 14px; + bottom: 10px; + left: 14px; + height: 26px; + display: grid; + place-items: center; + border: 1px solid rgba(25, 212, 194, 0.32); + border-radius: 999px; + color: #baf8ee; + background: rgba(7, 10, 15, 0.88); + font-size: 12px; } .relation-card { @@ -342,11 +397,28 @@ button { font-size: 12px; } -.card-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; +.card-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(112px, 0.72fr); + gap: 10px; + align-items: start; +} + +.card-main, +.card-side { + min-width: 0; + display: grid; + gap: 7px; +} + +.card-side { + justify-items: end; +} + +.important-tags { + max-width: 100%; + justify-content: flex-end; + flex-wrap: wrap; } .mini-badge { @@ -402,12 +474,16 @@ button { } .metrics { - display: grid; - grid-template-columns: repeat(5, minmax(116px, 1fr)); + display: flex; gap: 10px; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: thin; } .metric { + min-width: 140px; + flex: 1 0 140px; min-height: 76px; padding: 13px; border: 1px solid var(--line);