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);