Files
PACS/数据库Web可视化/static/app.js
2026-05-28 00:46:41 +08:00

310 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const app = {
rows: [],
active: null,
filter: "all",
search: "",
searchTimer: null,
};
const $ = (id) => document.getElementById(id);
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
.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 = `<p class="empty">没有匹配记录</p>`;
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 = `
<div class="card-row">
<strong>${escapeHtml(row.norm_ct)}</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(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
`;
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 `<dt>${escapeHtml(key)}</dt><dd title="${escapeHtml(text)}">${escapeHtml(text)}</dd>`;
})
.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 = `<p class="empty">没有 STL 分割文件</p>`;
return;
}
$("segmentGroups").innerHTML = Array.from(groups.entries())
.map(
([group, names]) => `
<div class="segment-group">
<strong>${escapeHtml(group)}</strong>
<div class="segment-tags">${names.map((name) => `<em title="${escapeHtml(name)}">${escapeHtml(name)}</em>`).join("")}</div>
</div>
`,
)
.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();