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

592 lines
19 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 = {
token: localStorage.getItem("pacs_relation_token") || "",
user: null,
rows: [],
active: null,
filter: "all",
search: "",
algorithmModel: "",
dicomPart: "",
viewerUrl: "http://127.0.0.1:8107",
searchTimer: null,
offset: 0,
limit: 20,
hasMore: true,
loading: false,
};
const $ = (id) => document.getElementById(id);
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 {
const data = await res.json();
detail = data.detail || detail;
} catch (_) {
detail = await res.text();
}
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 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");
}
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 "";
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: "DICOM + STL",
no_stl: "DICOM + UPP",
pacs_only: "仅 DICOM",
stl_only: "仅 STL",
list_only: "仅 UPP",
unknown: "未知",
}[value] || value || "未知";
}
function partLabel(value) {
return {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
}[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 annotationLabels(row) {
const labels = asList(row.dicom_annotation_labels);
return labels.length ? labels : dicomParts(row);
}
function setDbStatus(data) {
app.viewerUrl = data.viewer_url || app.viewerUrl;
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 = [
["完整关联", counts.complete_count],
["DICOM", counts.pacs_count],
["仅 DICOM", counts.pacs_only_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>`)
.join("");
}
function cardTitle(row) {
return [
`CT号${row.ct_key}`,
`DICOM${row.pacs_ct_number || "无"}`,
`STL${row.stl_ct_number || "无"}`,
`状态:${statusLabel(row.relation_status)}`,
`DICOM患者${row.pacs_patient_name || "无"}`,
`UPP患者${row.upp_patient_name || "无"}`,
`重建模型:${row.algorithm_model || "无"}`,
`部位标注:${annotationLabels(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">${app.loading ? "正在加载..." : "没有匹配记录"}</p>`;
if (!app.loading) clearDetail();
return;
}
list.innerHTML = "";
for (const row of app.rows) {
const card = document.createElement("button");
card.className = "relation-card";
card.classList.toggle("active", app.active?.ct_key === row.ct_key);
card.title = cardTitle(row);
const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0);
const labels = annotationLabels(row);
card.innerHTML = `
<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>
`;
card.onclick = () => selectRelation(row.ct_key);
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 dicomViewerUrl(row = app.active) {
if (!row?.pacs_ct_number) return "";
return `${app.viewerUrl.replace(/\/$/, "")}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`;
}
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 || "无 DICOM"}${row.stl_ct_number || "无 STL"}`;
const badge = $("relationBadge");
badge.className = `relation-badge ${row.relation_status || "idle"}`;
badge.textContent = statusLabel(row.relation_status);
const labels = annotationLabels(row);
$("nodeCtValue").textContent = row.ct_key || "-";
markNode(
"nodePacs",
row.pacs_present ? "ok" : "missing",
row.pacs_ct_number || "缺失",
`${Number(row.pacs_series_count || 0)} 序列 · ${labels.join("、") || "未标注"}`,
);
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],
["患者", 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)} 已标注`],
["完成", row.completed ? "已完成" : "待处理"],
["部位标注", labels.join("、")],
]);
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)],
["文件数", 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("、")],
["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)],
]);
updateOpenDicomButton();
renderList();
}
async function selectRelation(ctKey) {
const row = await json(`/api/relations/${encodeURIComponent(ctKey)}`);
renderDetail(row);
}
async function loadStatus() {
const data = await json("/api/status");
setDbStatus(data);
renderMetrics(data.counts || {});
}
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", 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);
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 (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(true);
} catch (err) {
$("dbStatus").textContent = err.message;
$("dbStatus").className = "status-pill offline";
setLoading(false);
}
}
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 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(() => {
app.search = $("searchInput").value.trim();
loadRelations();
}, 250);
});
$("algorithmFilter").addEventListener("change", () => {
app.algorithmModel = $("algorithmFilter").value;
loadRelations();
});
$("dicomPartFilter").addEventListener("change", () => {
app.dicomPart = $("dicomPartFilter").value;
loadRelations();
});
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();
});
});
}
async function boot() {
clearDetail();
applyAuthUi();
wire();
const ok = await loadCurrentUser();
if (ok) await refreshAll();
}
boot();