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",
registrationUrl: "http://127.0.0.1:8109",
searchTimer: null,
offset: 0,
limit: 20,
hasMore: true,
loading: false,
};
const $ = (id) => document.getElementById(id);
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
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: "上腹部",
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 patientNameMismatch(row) {
return row.pacs_present && row.stl_asset_present && row.patient_name_match === "different";
}
function stlFileCount(row) {
return Number(row.stl_file_count || row.stl_file_count_agg || 0);
}
function stlAssetCount(row) {
return Number(row.upp_asset_count || row.stl_asset_count || 0);
}
function assetSummary(row) {
const assets = asList(row.upp_assets).length ? asList(row.upp_assets) : asList(row.stl_assets);
return assets
.slice(0, 6)
.map((asset) => {
const model = asset.algorithm_model || "STL";
const count = Number(asset.file_count || 0);
return `${model}${count ? ` ${count}个` : ""}`;
})
.join(";");
}
function setDbStatus(data) {
app.viewerUrl = data.viewer_url || app.viewerUrl;
app.registrationUrl = data.registration_url || app.registrationUrl;
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],
["已配准", counts.registered_ct_count],
];
$("metrics").innerHTML = items
.map(([label, value]) => `
${escapeHtml(label)}${Number(value || 0)}
`)
.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("、") || "无"}`,
`配准:${Number(row.registered_series || 0)} 序列,${Number(row.locked_count || 0)} 锁定`,
].join("\n");
}
function renderPartTags(parts) {
if (!parts.length) return `无部位标注`;
return parts.map((part) => `${escapeHtml(part)}`).join("");
}
function renderList() {
const list = $("relationList");
$("resultCount").textContent = String(app.rows.length);
if (!app.rows.length) {
list.innerHTML = `${app.loading ? "正在加载..." : "没有匹配记录"}
`;
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 = stlFileCount(row);
const assetCount = stlAssetCount(row);
const labels = dicomParts(row);
card.innerHTML = `
${escapeHtml(row.ct_key)}
${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}
DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount} 个` : ""}${assetCount > 1 ? ` · ${assetCount} 组` : ""}
${escapeHtml(statusLabel(row.relation_status))}
${
Number(row.registration_count || 0)
? `${Number(row.registered_series || 0)} 已配准`
: `未配准`
}
${
Number(row.duplicate_model_asset_count || 0)
? `${Number(row.duplicate_model_asset_count)} 组重复`
: ""
}
${
patientNameMismatch(row)
? `姓名需核对`
: ""
}
${row.algorithm_model ? `${escapeHtml(row.algorithm_model)}` : ""}
${renderPartTags(labels)}
`;
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 `${escapeHtml(key)}${escapeHtml(text)}`;
})
.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 viewerBaseUrl() {
try {
const url = new URL(app.viewerUrl || `${location.protocol}//${location.hostname}:8107`, location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) {
url.hostname = location.hostname;
}
url.protocol = location.protocol;
return url.toString().replace(/\/$/, "");
} catch (_) {
return `${location.protocol}//${location.hostname}:8107`;
}
}
function registrationBaseUrl() {
try {
const url = new URL(app.registrationUrl || `${location.protocol}//${location.hostname}:8109`, location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) {
url.hostname = location.hostname;
}
url.protocol = location.protocol;
return url.toString().replace(/\/$/, "");
} catch (_) {
return `${location.protocol}//${location.hostname}:8109`;
}
}
function dicomViewerUrl(row = app.active) {
if (!row?.pacs_ct_number) return "";
return `${viewerBaseUrl()}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`;
}
function registrationViewerUrl(row = app.active) {
const ct = row?.pacs_ct_number || row?.ct_key;
if (!ct) return "";
return `${registrationBaseUrl()}/?ct_number=${encodeURIComponent(ct)}`;
}
function updateOpenDicomButton() {
const button = $("openDicomBtn");
const url = dicomViewerUrl();
button.disabled = !url;
button.title = url || "当前记录没有 DICOM 检查号";
const registrationButton = $("openRegistrationBtn");
const registration = registrationViewerUrl();
registrationButton.disabled = !registration;
registrationButton.title = registration || "当前记录没有可配准 CT 号";
}
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 || "无重建模型"} · ${stlFileCount(row)} STL${stlAssetCount(row) > 1 ? ` · ${stlAssetCount(row)} 组资产` : ""}`,
);
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("、")],
["配准状态", row.registration_count ? `${Number(row.registered_series || 0)} 个序列已配准 / ${Number(row.locked_count || 0)} 条锁定` : "未配准"],
]);
renderDl("stlDetails", [
["CT号", row.stl_ct_number],
["患者", row.upp_patient_name],
["STL状态", row.stl_present ? "存在" : "缺失"],
["重建模型", row.algorithm_model],
["STL资产", `${stlAssetCount(row)} 组`],
["同模型重复", Number(row.duplicate_model_asset_count || 0) ? `${Number(row.duplicate_model_asset_count)} 组已折叠` : ""],
["资产明细", assetSummary(row)],
["UPP记录", row.upp_asset_count ? `${Number(row.upp_asset_count)} 条` : ""],
["UPP状态", row.upp_status],
["姓名核对", row.patient_name_match === "different" ? row.patient_name_match_note : row.patient_name_match === "same" ? row.patient_name_match_note || "匹配" : ""],
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
["检查时间", fmtDate(row.exam_date)],
["任务时间", fmtDate(row.task_created_at)],
["文件数", stlFileCount(row)],
["总大小", 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)],
["配准更新时间", fmtDate(row.registration_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 `暂无账号
`;
return `
`;
}
async function renderSettingsPage() {
const data = await json("/api/settings");
$("settingsContent").innerHTML = `
账号创建
当前登录:${escapeHtml(app.user?.username || "-")}
${settingsTable(data.users || [])}
系统管理
跳转与联动
- 阅片系统
- ${escapeHtml(data.viewer_url || "-")}
- 数据库
- ${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}
- DICOM表
- ${escapeHtml(data.tables?.dicom || "-")}
- 摘要表
- ${escapeHtml(data.tables?.dicom_summary || "-")}
权限说明
页面级控制
管理员查看关联、系统设置、账号密码管理
阅片员查看关联与跳转阅片系统
访客只读查看关联信息
`;
$("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(viewerBaseUrl(), "_blank", "noopener");
}
function openRegistrationViewer() {
const url = registrationViewerUrl();
if (url) window.open(url, "_blank", "noopener");
}
function openRegistrationHome() {
window.open(registrationBaseUrl(), "_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);
$("registrationHomeBtn").addEventListener("click", openRegistrationHome);
$("openDicomBtn").addEventListener("click", openDicomViewer);
$("openRegistrationBtn").addEventListener("click", openRegistrationViewer);
$("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();