Use exact CT links for PACS STL view

This commit is contained in:
Codex
2026-05-28 08:17:54 +08:00
parent 48b0e23d64
commit b6149a31eb
5 changed files with 160 additions and 99 deletions

View File

@@ -3,6 +3,8 @@ const app = {
active: null,
filter: "all",
search: "",
algorithmModel: "",
dicomModel: "",
searchTimer: null,
};
@@ -63,16 +65,17 @@ function uniq(list) {
function statusLabel(value) {
return {
complete: "PACS + UPP + STL",
no_stl: "PACS + UPP",
complete: "PACS + STL",
no_stl: "PACS + 列表",
pacs_only: "仅 PACS",
upp_only: "仅 UPP",
stl_only: "仅 STL",
list_only: "列表无STL",
unknown: "未知",
}[value] || value || "未知";
}
function matchLabel(value) {
return { exact: "CT 号精确一致", normalized: "去 D 前缀匹配" }[value] || "未匹配";
return { exact: "CT 号一致" }[value] || "未匹配";
}
function partLabel(value) {
@@ -100,7 +103,7 @@ function renderMetrics(counts = {}) {
const items = [
["CT 索引", counts.total_ct],
["PACS", counts.pacs_count],
["UPP", counts.upp_count],
["STL资产", counts.stl_asset_count],
["STL", counts.stl_count],
["完整关联", counts.complete_count],
["待判别序列", counts.undetermined_series],
@@ -112,12 +115,14 @@ function renderMetrics(counts = {}) {
function cardTitle(row) {
return [
`规范CT号${row.norm_ct}`,
`CT号${row.ct_key}`,
`PACS${row.pacs_ct_number || "无"}`,
`UPP${row.upp_ct_number || "无"}`,
`STL${row.stl_ct_number || "无"}`,
`状态:${statusLabel(row.relation_status)}`,
`PACS患者${row.pacs_patient_name || "无"}`,
`UPP患者:${row.upp_patient_name || "无"}`,
`STL列表患者:${row.upp_patient_name || "无"}`,
`重建模型:${row.algorithm_model || "无"}`,
`DICOM模型${asList(row.dicom_models).join("、") || "无"}`,
].join("\n");
}
@@ -132,20 +137,22 @@ function renderList() {
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.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 dicomModels = asList(row.dicom_models).join("、") || "无";
card.innerHTML = `
<div class="card-row">
<strong>${escapeHtml(row.norm_ct)}</strong>
<strong>${escapeHtml(row.ct_key)}</strong>
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
</div>
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
<small>${escapeHtml(date || "无时间")} · PACS ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
<small>重建 ${escapeHtml(row.algorithm_model || "无")} · DICOM ${escapeHtml(dicomModels)}</small>
<small>${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
`;
card.onclick = () => selectRelation(row.norm_ct);
card.onclick = () => selectRelation(row.ct_key);
list.appendChild(card);
}
}
@@ -203,16 +210,15 @@ function renderSegments(row) {
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)}`;
$("activeCt").textContent = row.ct_key || "未选择 CT";
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"}${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
const badge = $("relationBadge");
badge.className = `relation-badge ${row.relation_status || "idle"} ${row.match_type === "normalized" ? "normalized" : ""}`;
badge.className = `relation-badge ${row.relation_status || "idle"}`;
badge.textContent = statusLabel(row.relation_status);
$("nodeCtValue").textContent = row.norm_ct || "-";
$("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)} 序列`);
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)}`);
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],
@@ -224,22 +230,19 @@ function renderDetail(row) {
["标注", `${Number(row.annotated_series || 0)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`],
["完成", row.completed ? "已完成" : "待处理"],
["部位", asList(row.body_parts).map(partLabel).join("、")],
["DICOM模型", asList(row.dicom_models).join("、")],
]);
renderDl("uppDetails", [
["CT号", row.upp_ct_number],
renderDl("stlDetails", [
["CT号", row.stl_ct_number],
["STL状态", row.stl_present ? "存在" : "缺失"],
["重建模型", row.algorithm_model],
["UPP状态", row.upp_status],
["患者", 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],
@@ -252,8 +255,8 @@ function renderDetail(row) {
renderList();
}
async function selectRelation(normCt) {
const row = await json(`/api/relations/${encodeURIComponent(normCt)}`);
async function selectRelation(ctKey) {
const row = await json(`/api/relations/${encodeURIComponent(ctKey)}`);
renderDetail(row);
}
@@ -268,12 +271,14 @@ async function loadRelations() {
params.set("status", app.filter);
params.set("limit", "500");
if (app.search) params.set("q", app.search);
if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel);
if (app.dicomModel) params.set("dicom_model", app.dicomModel);
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);
if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) {
await selectRelation(app.rows[0].ct_key);
} else {
renderList();
if (!app.active && app.rows.length) await selectRelation(app.rows[0].norm_ct);
if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key);
}
}
@@ -296,6 +301,14 @@ function wire() {
loadRelations();
}, 250);
});
$("algorithmFilter").addEventListener("change", () => {
app.algorithmModel = $("algorithmFilter").value;
loadRelations();
});
$("dicomModelFilter").addEventListener("change", () => {
app.dicomModel = $("dicomModelFilter").value;
loadRelations();
});
document.querySelectorAll("[data-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.filter = button.dataset.filter;

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PACS / UPP 数据库关联可视化</title>
<title>PACS / STL 数据库关联可视化</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
@@ -11,8 +11,8 @@
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>PACS / UPP 数据库关联可视化</h1>
<p>以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产</p>
<h1>PACS / STL 数据库关联可视化</h1>
<p>以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产</p>
</div>
</div>
<div class="top-actions">
@@ -28,14 +28,28 @@
<h2>CT 索引</h2>
<span id="resultCount">0</span>
</div>
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述" />
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述 / 模型" />
<div class="model-filters">
<select id="algorithmFilter">
<option value="">全部重建模型</option>
<option value="肝胆模型">肝胆模型</option>
<option value="泌尿模型">泌尿模型</option>
<option value="胸外模型">胸外模型</option>
</select>
<select id="dicomModelFilter">
<option value="">全部DICOM模型</option>
<option value="肝胆模型">肝胆模型</option>
<option value="泌尿模型">泌尿模型</option>
<option value="胸外模型">胸外模型</option>
</select>
</div>
<div class="filter-grid">
<button class="active" data-filter="all">全部</button>
<button data-filter="complete">已关联</button>
<button data-filter="no_stl">缺 STL</button>
<button data-filter="pacs_only">仅 PACS</button>
<button data-filter="upp_only">UPP</button>
<button data-filter="normalized">D 前缀</button>
<button data-filter="stl_only">STL</button>
<button data-filter="list_only">列表无STL</button>
<button data-filter="undetermined">待判别</button>
</div>
</section>
@@ -52,7 +66,7 @@
<div class="hero-head">
<div>
<h2 id="activeCt">未选择 CT</h2>
<p id="activeSubtitle">从左侧选择一个 CT 号查看两个系统之间的关系</p>
<p id="activeSubtitle">从左侧选择一个 CT 号查看 PACS 与 STL 之间的关系</p>
</div>
<span id="relationBadge" class="relation-badge idle">等待选择</span>
</div>
@@ -70,16 +84,10 @@
<em>规范索引</em>
</div>
<div class="link-line"></div>
<div id="nodeUpp" class="link-node">
<span>UPP 列表</span>
<strong>未选择</strong>
<em id="nodeUppMeta">任务与状态</em>
</div>
<div class="link-line"></div>
<div id="nodeStl" class="link-node">
<span>STL 资产</span>
<span>UPP STL 资产</span>
<strong>未选择</strong>
<em id="nodeStlMeta">分割文件</em>
<em id="nodeStlMeta">含UPP列表信息</em>
</div>
</div>
</section>
@@ -89,10 +97,6 @@
<h3>PACS DICOM</h3>
<dl id="pacsDetails"></dl>
</article>
<article class="panel detail-card">
<h3>UPP 列表</h3>
<dl id="uppDetails"></dl>
</article>
<article class="panel detail-card">
<h3>UPP STL</h3>
<dl id="stlDetails"></dl>

View File

@@ -112,14 +112,14 @@ button {
.status-pill.offline,
.relation-badge.pacs_only,
.relation-badge.upp_only {
.relation-badge.stl_only,
.relation-badge.list_only {
border-color: rgba(251, 113, 133, 0.46);
color: #fecdd3;
background: rgba(251, 113, 133, 0.08);
}
.relation-badge.no_stl,
.relation-badge.normalized {
.relation-badge.no_stl {
border-color: rgba(240, 181, 78, 0.5);
color: #ffe0a3;
background: rgba(240, 181, 78, 0.08);
@@ -218,6 +218,28 @@ button {
border-color: rgba(52, 118, 246, 0.76);
}
.model-filters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.model-filters select {
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 8px;
outline: 0;
background: #080d15;
color: var(--text);
}
.model-filters select:focus {
border-color: rgba(52, 118, 246, 0.76);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -303,14 +325,14 @@ button {
color: #a5f5d4;
}
.mini-badge.no_stl,
.mini-badge.normalized {
.mini-badge.no_stl {
border-color: rgba(240, 181, 78, 0.5);
color: #ffe0a3;
}
.mini-badge.pacs_only,
.mini-badge.upp_only {
.mini-badge.stl_only,
.mini-badge.list_only {
border-color: rgba(251, 113, 133, 0.48);
color: #fecdd3;
}
@@ -342,7 +364,7 @@ button {
.link-map {
display: grid;
grid-template-columns: minmax(150px, 1fr) 48px minmax(150px, 0.8fr) 48px minmax(150px, 1fr) 48px minmax(150px, 1fr);
grid-template-columns: minmax(180px, 1fr) 56px minmax(160px, 0.72fr) 56px minmax(180px, 1fr);
align-items: center;
gap: 8px;
margin-top: 16px;
@@ -401,7 +423,7 @@ button {
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}