Refine DICOM UPP relation loading
This commit is contained in:
@@ -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]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
|
||||
@@ -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 = `<p class="empty">没有匹配记录</p>`;
|
||||
clearDetail();
|
||||
list.innerHTML = `<p class="empty">${app.loading ? "正在加载..." : "没有匹配记录"}</p>`;
|
||||
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 = `
|
||||
<div class="card-row">
|
||||
<strong>${escapeHtml(row.ct_key)}</strong>
|
||||
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
||||
<div class="card-layout">
|
||||
<div class="card-main">
|
||||
<strong>${escapeHtml(row.ct_key)}</strong>
|
||||
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
|
||||
<small>DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount} 个` : ""}</small>
|
||||
</div>
|
||||
<div class="card-side">
|
||||
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
||||
<div class="inline-tags important-tags">
|
||||
${row.algorithm_model ? `<em title="${escapeHtml(row.algorithm_model)}">${escapeHtml(row.algorithm_model)}</em>` : ""}
|
||||
${renderPartTags(labels)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
|
||||
<small>${escapeHtml(date || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
|
||||
<small>重建 ${escapeHtml(row.algorithm_model || "无")}</small>
|
||||
<div class="inline-tags">${renderPartTags(parts)}</div>
|
||||
<small>${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""}</small>
|
||||
`;
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user