Improve DICOM UPP relation visualizer

This commit is contained in:
Codex
2026-05-28 08:49:16 +08:00
parent b6149a31eb
commit 4fe6466e20
7 changed files with 831 additions and 119 deletions

View File

@@ -1,10 +1,13 @@
const app = {
token: localStorage.getItem("pacs_relation_token") || "",
user: null,
rows: [],
active: null,
filter: "all",
search: "",
algorithmModel: "",
dicomModel: "",
dicomPart: "",
viewerUrl: "http://127.0.0.1:8107",
searchTimer: null,
};
@@ -19,8 +22,15 @@ function escapeHtml(value) {
.replaceAll("'", "'");
}
async function json(path) {
const res = await fetch(path);
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
showLogin(true);
throw new Error("unauthorized");
}
if (!res.ok) {
let detail = res.statusText;
try {
@@ -31,9 +41,39 @@ async function json(path) {
}
throw new Error(detail);
}
return res;
}
async function json(path, options = {}) {
const res = await request(path, options);
return res.json();
}
function showLogin(show) {
$("loginOverlay").classList.toggle("hidden", !show);
}
function showViewer() {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
}
async function showSettings() {
if (!isAdmin()) return;
$("viewerPage").classList.add("hidden");
$("settingsPage").classList.remove("hidden");
await renderSettingsPage();
}
function isAdmin() {
return app.user?.role === "管理员";
}
function applyAuthUi() {
$("userBadge").textContent = app.user ? `${app.user.username} · ${app.user.role}` : "未登录";
$("settingsBtn").classList.toggle("hidden", !isAdmin());
}
function fmtDate(value) {
const text = String(value || "");
if (!text) return "";
@@ -65,9 +105,9 @@ function uniq(list) {
function statusLabel(value) {
return {
complete: "PACS + STL",
no_stl: "PACS + 列表",
pacs_only: "仅 PACS",
complete: "DICOM + STL",
no_stl: "DICOM + UPP",
pacs_only: "仅 DICOM",
stl_only: "仅 STL",
list_only: "列表无STL",
unknown: "未知",
@@ -88,7 +128,18 @@ function partLabel(value) {
}[value] || value;
}
function dicomParts(row) {
const labels = asList(row.dicom_body_parts);
if (labels.length) return labels;
return asList(row.body_parts).map(partLabel);
}
function pendingDICOM(row) {
return Number(row.undetermined_series || 0);
}
function setDbStatus(data) {
app.viewerUrl = data.viewer_url || app.viewerUrl;
const pill = $("dbStatus");
if (data.database?.ok) {
pill.textContent = `${data.database.database} 已连接`;
@@ -101,12 +152,11 @@ function setDbStatus(data) {
function renderMetrics(counts = {}) {
const items = [
["CT 索引", counts.total_ct],
["PACS", counts.pacs_count],
["STL资产", counts.stl_asset_count],
["STL", counts.stl_count],
["完整关联", counts.complete_count],
["待判别序列", counts.undetermined_series],
["DICOM", counts.pacs_count],
["缺 STL", counts.no_stl_count],
["仅 DICOM", counts.pacs_only_count],
["待处理DICOM", counts.pending_dicom_count],
];
$("metrics").innerHTML = items
.map(([label, value]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
@@ -116,21 +166,27 @@ function renderMetrics(counts = {}) {
function cardTitle(row) {
return [
`CT号${row.ct_key}`,
`PACS${row.pacs_ct_number || "无"}`,
`DICOM${row.pacs_ct_number || "无"}`,
`STL${row.stl_ct_number || "无"}`,
`状态:${statusLabel(row.relation_status)}`,
`PACS患者:${row.pacs_patient_name || "无"}`,
`STL列表患者:${row.upp_patient_name || "无"}`,
`DICOM患者:${row.pacs_patient_name || "无"}`,
`UPP患者:${row.upp_patient_name || "无"}`,
`重建模型:${row.algorithm_model || "无"}`,
`DICOM模型${asList(row.dicom_models).join("、") || "无"}`,
`部位标注:${dicomParts(row).join("、") || "无"}`,
].join("\n");
}
function renderPartTags(parts) {
if (!parts.length) return `<span class="subtle">无部位标注</span>`;
return parts.map((part) => `<em title="${escapeHtml(part)}">${escapeHtml(part)}</em>`).join("");
}
function renderList() {
const list = $("relationList");
$("resultCount").textContent = String(app.rows.length);
if (!app.rows.length) {
list.innerHTML = `<p class="empty">没有匹配记录</p>`;
clearDetail();
return;
}
list.innerHTML = "";
@@ -141,16 +197,18 @@ function renderList() {
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("、") || "无";
const parts = dicomParts(row);
const pending = pendingDICOM(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>
<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>
<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);
@@ -175,50 +233,54 @@ function markNode(id, state, strong, meta) {
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 dicomViewerUrl(row = app.active) {
if (!row?.pacs_ct_number) return "";
return `${app.viewerUrl.replace(/\/$/, "")}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`;
}
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 updateOpenDicomButton() {
const button = $("openDicomBtn");
const url = dicomViewerUrl();
button.disabled = !url;
button.title = url || "当前记录没有 DICOM 检查号";
}
function clearDetail() {
app.active = null;
$("activeCt").textContent = "未选择 CT";
$("activeSubtitle").textContent = "从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系";
$("relationBadge").className = "relation-badge idle";
$("relationBadge").textContent = "等待选择";
markNode("nodePacs", "missing", "未选择", "检查与序列");
$("nodeCtValue").textContent = "-";
markNode("nodeStl", "missing", "未选择", "含UPP列表信息");
renderDl("pacsDetails", []);
renderDl("stlDetails", []);
updateOpenDicomButton();
}
function renderDetail(row) {
app.active = row;
$("activeCt").textContent = row.ct_key || "未选择 CT";
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"}${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"}${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
const badge = $("relationBadge");
badge.className = `relation-badge ${row.relation_status || "idle"}`;
badge.textContent = statusLabel(row.relation_status);
const pending = pendingDICOM(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)} 序列`);
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`);
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` : ""}`,
);
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],
@@ -227,10 +289,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)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`],
["标注", `${Number(row.annotated_series || 0)} 已标注 · ${pending} 待处理DICOM`],
["完成", row.completed ? "已完成" : "待处理"],
["部位", asList(row.body_parts).map(partLabel).join("、")],
["DICOM模型", asList(row.dicom_models).join("、")],
["部位标注", dicomParts(row).join("、")],
]);
renderDl("stlDetails", [
@@ -242,16 +303,15 @@ function renderDetail(row) {
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
["检查时间", fmtDate(row.exam_date)],
["任务时间", fmtDate(row.task_created_at)],
["列表记录", row.list_present ? `${Number(row.list_record_count || 0)}` : "无"],
["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],
["分类", uniq(asList(row.segment_categories)).join("、")],
["Family", uniq(asList(row.segment_families)).join("、")],
["分割分类", uniq(asList(row.segment_categories)).join("、")],
["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)],
]);
renderSegments(row);
updateOpenDicomButton();
renderList();
}
@@ -272,13 +332,14 @@ async function loadRelations() {
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);
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 {
renderList();
if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key);
if (!app.rows.length) clearDetail();
}
}
@@ -292,8 +353,174 @@ async function refreshAll() {
}
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const data = await json("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
app.token = data.token;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_relation_token", app.token);
applyAuthUi();
showLogin(false);
showViewer();
await refreshAll();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
function logout() {
app.token = "";
app.user = null;
app.rows = [];
app.active = null;
localStorage.removeItem("pacs_relation_token");
applyAuthUi();
showViewer();
clearDetail();
showLogin(true);
}
async function loadCurrentUser() {
if (!app.token) {
showLogin(true);
return false;
}
try {
app.user = await json("/api/auth/me");
applyAuthUi();
showLogin(false);
return true;
} catch (_) {
app.token = "";
localStorage.removeItem("pacs_relation_token");
applyAuthUi();
showLogin(true);
return false;
}
}
function settingsTable(users) {
if (!users?.length) return `<p class="empty">暂无账号</p>`;
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th><th>更新时间</th><th>重置密码</th></tr></thead>
<tbody>
${users
.map(
(user) => `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.role)}</td>
<td>${escapeHtml(user.status)}</td>
<td>${escapeHtml(fmtDate(user.updated_at))}</td>
<td>
<div class="password-reset">
<input data-password-user="${escapeHtml(user.username)}" placeholder="新密码" type="password" />
<button data-reset-user="${escapeHtml(user.username)}" type="button">更新</button>
</div>
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
async function renderSettingsPage() {
const data = await json("/api/settings");
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title">
<h3>账号创建</h3>
<span>当前登录:${escapeHtml(app.user?.username || "-")}</span>
</div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" />
<input name="password" type="password" placeholder="初始密码" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<select name="status">
<option value="启用">启用</option>
<option value="停用">停用</option>
</select>
<button class="primary-btn" type="submit">保存账号</button>
</form>
${settingsTable(data.users || [])}
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>系统管理</h3><span>跳转与联动</span></div>
<dl class="settings-dl">
<dt>阅片系统</dt><dd>${escapeHtml(data.viewer_url || "-")}</dd>
<dt>数据库</dt><dd>${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}</dd>
<dt>DICOM表</dt><dd>${escapeHtml(data.tables?.dicom || "-")}</dd>
<dt>摘要表</dt><dd>${escapeHtml(data.tables?.dicom_summary || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>权限说明</h3><span>页面级控制</span></div>
<div class="role-grid">
<div class="role-card"><strong>管理员</strong><span>查看关联、系统设置、账号密码管理</span></div>
<div class="role-card"><strong>阅片员</strong><span>查看关联与跳转阅片系统</span></div>
<div class="role-card"><strong>访客</strong><span>只读查看关联信息</span></div>
</div>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", saveUser);
document.querySelectorAll("[data-reset-user]").forEach((button) => {
button.addEventListener("click", () => resetPassword(button.dataset.resetUser));
});
}
async function saveUser(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
await json("/api/settings/users", {
method: "POST",
body: JSON.stringify({
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
status: String(form.get("status") || "启用"),
}),
});
await renderSettingsPage();
}
async function resetPassword(username) {
const input = Array.from(document.querySelectorAll("[data-password-user]")).find((item) => item.dataset.passwordUser === username);
const password = input?.value || "";
if (!password) return;
await json(`/api/settings/users/${encodeURIComponent(username)}/password`, {
method: "PUT",
body: JSON.stringify({ password }),
});
await renderSettingsPage();
}
function openDicomViewer() {
const url = dicomViewerUrl();
if (url) window.open(url, "_blank", "noopener");
}
function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", showSettings);
$("backToViewer").addEventListener("click", showViewer);
$("refreshBtn").addEventListener("click", refreshAll);
$("openDicomBtn").addEventListener("click", openDicomViewer);
$("searchInput").addEventListener("input", () => {
clearTimeout(app.searchTimer);
app.searchTimer = setTimeout(() => {
@@ -305,8 +532,8 @@ function wire() {
app.algorithmModel = $("algorithmFilter").value;
loadRelations();
});
$("dicomModelFilter").addEventListener("change", () => {
app.dicomModel = $("dicomModelFilter").value;
$("dicomPartFilter").addEventListener("change", () => {
app.dicomPart = $("dicomPartFilter").value;
loadRelations();
});
document.querySelectorAll("[data-filter]").forEach((button) => {
@@ -318,5 +545,12 @@ function wire() {
});
}
wire();
refreshAll();
async function boot() {
clearDetail();
applyAuthUi();
wire();
const ok = await loadCurrentUser();
if (ok) await refreshAll();
}
boot();