690 lines
23 KiB
JavaScript
690 lines
23 KiB
JavaScript
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: "上腹部",
|
||
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 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]) => `<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("、") || "无"}`,
|
||
`配准:${Number(row.registered_series || 0)} 序列,${Number(row.locked_count || 0)} 锁定`,
|
||
].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 = stlFileCount(row);
|
||
const assetCount = stlAssetCount(row);
|
||
const labels = dicomParts(row);
|
||
card.innerHTML = `
|
||
<div class="card-topline">
|
||
<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} 个` : ""}${assetCount > 1 ? ` · ${assetCount} 组` : ""}</small>
|
||
</div>
|
||
<div class="card-status">
|
||
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
||
${
|
||
Number(row.registration_count || 0)
|
||
? `<small class="registration-mini" title="${Number(row.registration_count || 0)} 条配准记录,${Number(row.locked_count || 0)} 条锁定">${Number(row.registered_series || 0)} 已配准</small>`
|
||
: `<small class="registration-mini pending">未配准</small>`
|
||
}
|
||
${
|
||
Number(row.duplicate_model_asset_count || 0)
|
||
? `<small class="name-check-warning" title="同一CT同一算法模型只能对应一个STL,已按规则保留一组">${Number(row.duplicate_model_asset_count)} 组重复</small>`
|
||
: ""
|
||
}
|
||
${
|
||
patientNameMismatch(row)
|
||
? `<small class="name-check-warning" title="${escapeHtml(row.patient_name_match_note || "DICOM 与 UPP 患者姓名不一致")}">姓名需核对</small>`
|
||
: ""
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="card-tag-rail">
|
||
${row.algorithm_model ? `<em class="model-tag" title="${escapeHtml(row.algorithm_model)}">${escapeHtml(row.algorithm_model)}</em>` : ""}
|
||
${renderPartTags(labels)}
|
||
</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 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 `<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(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();
|