Files
PACS/PACS_DICOM处理/数据处理网页端/static/app.js
2026-05-27 10:28:51 +08:00

853 lines
30 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_web_token") || "",
studies: [],
study: null,
series: [],
activeSeries: null,
draft: null,
slice: 0,
plane: "axial",
window: "default",
rotate: 0,
zoom: 1,
panX: 0,
panY: 0,
imageUrl: "",
searchTimer: null,
statusTimer: null,
saveTimer: null,
pendingImage: null,
drag: null,
seriesSort: "timeAsc",
showOverlay: true,
};
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"];
const WINDOW_PRESETS = {
bone: { wl: 500, ww: 1800 },
soft: { wl: 50, ww: 360 },
contrast: { wl: 90, ww: 140 },
};
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 fmtDate(value) {
const text = String(value || "");
if (text.length !== 8) return text;
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
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 timeRange(series) {
const first = fmtTime(series.first_time || series.series_time || series.study_time);
const last = fmtTime(series.last_time);
if (first && last && first !== last) return `${first}-${last}`;
return first || last || "未记录";
}
function timeKey(series) {
return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0");
}
function phaseLabel(value) {
return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || "";
}
function partLabel(value) {
return {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
}[value] || value;
}
function asList(value) {
return Array.isArray(value) ? value : [];
}
function uniq(list) {
return Array.from(new Set(list.filter(Boolean)));
}
function parseFirstNumber(value) {
const match = String(value || "").match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : null;
}
function ageText(birthDate, studyDate) {
const birth = String(birthDate || "");
const study = String(studyDate || "");
if (birth.length !== 8 || study.length !== 8) return "";
let age = Number(study.slice(0, 4)) - Number(birth.slice(0, 4));
if (study.slice(4) < birth.slice(4)) age -= 1;
return age > 0 ? `${age}Y` : "";
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const data = await json("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value, password: $("password").value }),
});
app.token = data.token;
localStorage.setItem("pacs_web_token", app.token);
showLogin(false);
await boot();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
function logout() {
app.token = "";
localStorage.removeItem("pacs_web_token");
showLogin(true);
}
async function refreshStatus() {
try {
const data = await fetch("/api/status").then((res) => res.json());
const pill = $("dbStatus");
pill.textContent = data.database?.ok ? `数据库 ${data.database.rows ?? ""}` : "数据库异常";
pill.title = data.database?.message || "";
pill.classList.toggle("offline", !data.database?.ok);
pill.classList.toggle("online", Boolean(data.database?.ok));
} catch (_) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").classList.add("offline");
$("dbStatus").classList.remove("online");
}
}
function sortSeries(list) {
return [...list].sort((a, b) => {
const skipA = a.annotation?.skipped ? 1 : 0;
const skipB = b.annotation?.skipped ? 1 : 0;
if (skipA !== skipB) return skipA - skipB;
const timeResult = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true });
const countResult = Number(a.count || 0) - Number(b.count || 0) || timeResult;
if (app.seriesSort === "timeDesc") return -timeResult;
if (app.seriesSort === "countAsc") return countResult;
if (app.seriesSort === "countDesc") return -countResult;
return timeResult;
});
}
function setSeries(list) {
app.series = sortSeries(list || []);
$("seriesCount").textContent = String(app.series.length);
renderSeries();
}
async function loadStudies() {
const q = encodeURIComponent($("studySearch").value.trim());
app.studies = await json(`/api/studies?q=${q}&limit=500`);
$("studyCount").textContent = String(app.studies.length);
renderStudies();
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
}
function renderStudies() {
const list = $("studyList");
list.innerHTML = "";
for (const study of app.studies) {
const button = document.createElement("button");
button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = `
<strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
<span>${Number(study.annotated_series || 0)} 个序列已标注</span>
`;
button.onclick = () => selectStudy(study.ct_number);
list.appendChild(button);
}
}
async function selectStudy(ctNumber) {
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
app.series = [];
app.activeSeries = null;
app.draft = null;
$("activeStudyLabel").textContent = ctNumber;
$("seriesCount").textContent = "读取中";
$("seriesGrid").innerHTML = "";
resetViewer();
renderStudies();
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
app.study = data.study;
setSeries(data.series || []);
if (app.series.length) selectSeries(app.series[0].series_uid);
} catch (err) {
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
}
}
function sourceTag(value, annotation) {
if (asList(annotation.manual_body_parts).includes(value)) return "manual";
if (asList(annotation.ai_body_parts).includes(value)) return "ai";
return "";
}
function seriesTags(series) {
const annotation = series.annotation || {};
if (annotation.skipped) {
return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }];
}
const tags = [];
if (annotation.plain_ct) {
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
}
for (const part of asList(annotation.body_parts)) {
tags.push({ label: partLabel(part), source: sourceTag(part, annotation) });
}
const phase = phaseLabel(annotation.upper_abdomen_phase);
if (phase) {
tags.push({ label: phase, source: annotation.manual_upper_abdomen_phase ? "manual" : "ai" });
}
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
return tags;
}
function renderSeries() {
const grid = $("seriesGrid");
grid.innerHTML = "";
for (const series of app.series) {
const skipped = Boolean(series.annotation?.skipped);
const tags = seriesTags(series);
const card = document.createElement("button");
card.className = "series-card";
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
card.classList.toggle("skipped", skipped);
card.innerHTML = `
<div class="thumb">
<img alt="" />
<b>${Number(series.count || 0)} 张</b>
${skipped ? "<i>略过</i>" : ""}
</div>
<div class="series-copy">
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${tag.source ? `tag-${tag.source}` : ""}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
</div>
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
loadThumb(series, card.querySelector("img"));
}
}
function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) {
return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`;
}
async function loadThumb(series, img) {
try {
const index = Math.floor((Number(series.count) || 1) / 2);
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
img.src = URL.createObjectURL(blob);
} catch (_) {
img.removeAttribute("src");
}
}
function maxSlice() {
if (!app.activeSeries) return 0;
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
if (app.plane === "coronal") return Math.max(0, Number(app.activeSeries.rows || 1) - 1);
return Math.max(0, Number(app.activeSeries.count || 1) - 1);
}
function resetViewState() {
app.rotate = 0;
app.zoom = 1;
app.panX = 0;
app.panY = 0;
applyTransform();
}
function selectSeries(seriesUid) {
const series = app.series.find((item) => item.series_uid === seriesUid);
if (!series) return;
app.activeSeries = series;
app.slice = Math.floor(maxSlice() / 2);
resetViewState();
$("sliceSlider").max = String(maxSlice());
$("sliceSlider").value = String(app.slice);
hydrateAnnotation();
renderSeries();
updateImage();
}
function resetViewer() {
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = "";
app.pendingImage = null;
app.activeSeries = null;
$("dicomImage").removeAttribute("src");
$("imageEmpty").classList.remove("hidden");
$("sliceText").textContent = "0 / 0";
$("saveState").textContent = "自动保存";
resetViewState();
updateOverlay();
}
function applyTransform() {
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
updateOverlay();
}
function windowInfo() {
if (WINDOW_PRESETS[app.window]) return WINDOW_PRESETS[app.window];
return {
wl: parseFirstNumber(app.activeSeries?.window_center) ?? 50,
ww: parseFirstNumber(app.activeSeries?.window_width) ?? 360,
};
}
function orientationLabels() {
if (app.plane === "sagittal") return { top: "H", bottom: "F", left: "A", right: "P" };
if (app.plane === "coronal") return { top: "H", bottom: "F", left: "R", right: "L" };
return { top: "A", bottom: "P", left: "R", right: "L" };
}
function updateOverlay() {
const overlay = $("imageOverlay");
overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries);
$("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息";
if (!app.activeSeries) return;
const s = app.activeSeries;
const orient = orientationLabels();
const win = windowInfo();
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
const sex = s.patient_sex || "";
document.querySelector(".ov-left-top").innerHTML = [
s.patient_name || app.study?.source_patient_name || "",
[fmtDate(s.patient_birth_date), age, sex].filter(Boolean).join(" "),
s.patient_id || app.study?.patient_id || "",
fmtDate(s.study_date || app.study?.study_date),
].filter(Boolean).map(escapeHtml).join("<br />");
document.querySelector(".ov-right-top").innerHTML = [
s.institution_name || "",
s.manufacturer || "",
].filter(Boolean).map(escapeHtml).join("<br />");
document.querySelector(".ov-left-mid").textContent = orient.left;
document.querySelector(".ov-right-mid").textContent = orient.right;
document.querySelector(".ov-top-mid").textContent = orient.top;
document.querySelector(".ov-bottom-mid").textContent = orient.bottom;
document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
}
async function updateImage() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
app.slice = clamp(app.slice, 0, max);
$("sliceSlider").max = String(max);
$("sliceSlider").value = String(app.slice);
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
$("imageEmpty").classList.add("hidden");
updateOverlay();
const ticket = Symbol("image");
app.pendingImage = ticket;
try {
const blob = await request(imageUrlFor()).then((res) => res.blob());
if (app.pendingImage !== ticket) return;
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = URL.createObjectURL(blob);
$("dicomImage").src = app.imageUrl;
applyTransform();
} catch (err) {
$("saveState").textContent = `图像读取失败:${err.message}`;
}
}
function cloneAnnotation(annotation = {}) {
return {
body_parts: asList(annotation.body_parts),
manual_body_parts: asList(annotation.manual_body_parts),
ai_body_parts: asList(annotation.ai_body_parts),
upper_abdomen_phase: annotation.upper_abdomen_phase || "",
manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "",
ai_upper_abdomen_phase: annotation.ai_upper_abdomen_phase || "",
plain_ct: Boolean(annotation.plain_ct),
manual_plain_ct: annotation.manual_plain_ct ?? null,
ai_plain_ct: annotation.ai_plain_ct ?? null,
skipped: Boolean(annotation.skipped),
ai_skipped: Boolean(annotation.ai_skipped),
notes: annotation.notes || "",
ai_model: annotation.ai_model || "",
};
}
function effectiveParts() {
if (!app.draft || app.draft.skipped) return [];
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]);
}
function effectivePhase() {
if (!effectiveParts().includes("upper_abdomen")) return "";
return app.draft.manual_upper_abdomen_phase || app.draft.ai_upper_abdomen_phase || "";
}
function effectivePlainCt() {
if (!app.draft || app.draft.skipped) return false;
if (app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined) return Boolean(app.draft.manual_plain_ct);
if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct);
return Boolean(app.draft.plain_ct);
}
function setLabelSource(input, source) {
const label = input.closest("label");
label.classList.toggle("manual-selected", source === "manual");
label.classList.toggle("ai-selected", source === "ai");
}
function applyAnnotationControls() {
if (!app.draft) return;
const parts = new Set(effectiveParts());
document.querySelectorAll(".part-grid input").forEach((input) => {
const value = input.value;
if (value === "skip") {
input.checked = app.draft.skipped;
input.disabled = false;
setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : "");
return;
}
if (value === "plain_ct") {
input.checked = effectivePlainCt();
input.disabled = app.draft.skipped;
setLabelSource(input, app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined ? "manual" : app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined ? "ai" : "");
return;
}
input.checked = parts.has(value);
input.disabled = app.draft.skipped;
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
});
const phase = effectivePhase();
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = input.value === phase;
input.disabled = app.draft.skipped;
setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : "");
});
$("phaseBox").classList.toggle("visible", !app.draft.skipped && parts.has("upper_abdomen"));
}
function hydrateAnnotation() {
app.draft = cloneAnnotation(app.activeSeries?.annotation || {});
$("annotationNotes").value = app.draft.notes || "";
applyAnnotationControls();
}
function updateLocalAnnotation(annotation) {
const normalized = cloneAnnotation(annotation);
normalized.body_parts = asList(annotation.body_parts);
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
app.draft = normalized;
app.activeSeries.annotation = normalized;
const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid);
if (index >= 0) app.series[index].annotation = normalized;
app.series = sortSeries(app.series);
renderSeries();
applyAnnotationControls();
}
function annotationPayload() {
const bodyParts = app.draft.skipped ? [] : effectiveParts();
return {
body_parts: bodyParts,
manual_body_parts: app.draft.skipped ? [] : app.draft.manual_body_parts,
ai_body_parts: app.draft.skipped ? [] : app.draft.ai_body_parts,
upper_abdomen_phase: app.draft.skipped ? "" : effectivePhase(),
manual_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.manual_upper_abdomen_phase,
ai_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.ai_upper_abdomen_phase,
plain_ct: effectivePlainCt(),
manual_plain_ct: app.draft.skipped ? null : app.draft.manual_plain_ct,
ai_plain_ct: app.draft.skipped ? null : app.draft.ai_plain_ct,
skipped: app.draft.skipped,
ai_skipped: app.draft.ai_skipped,
notes: $("annotationNotes").value,
};
}
async function saveAnnotationNow() {
if (!app.study || !app.activeSeries || !app.draft) return;
$("saveState").textContent = "保存中";
const uid = app.activeSeries.series_uid;
try {
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
method: "PUT",
body: JSON.stringify(annotationPayload()),
});
$("saveState").textContent = "已保存";
updateLocalAnnotation(data);
} catch (err) {
$("saveState").textContent = err.message;
}
}
function queueSave(delay = 450) {
clearTimeout(app.saveTimer);
$("saveState").textContent = "待保存";
app.saveTimer = setTimeout(saveAnnotationNow, delay);
}
function handlePartChange(event) {
if (!app.draft) return;
const input = event.target;
const value = input.value;
if (value === "skip") {
app.draft.skipped = input.checked;
if (!input.checked) app.draft.ai_skipped = false;
if (input.checked) {
app.draft.manual_body_parts = [];
app.draft.ai_body_parts = [];
app.draft.manual_upper_abdomen_phase = "";
app.draft.ai_upper_abdomen_phase = "";
app.draft.manual_plain_ct = null;
app.draft.ai_plain_ct = null;
}
} else if (value === "plain_ct") {
if (input.checked) {
app.draft.manual_plain_ct = true;
app.draft.ai_plain_ct = null;
} else {
app.draft.manual_plain_ct = false;
app.draft.ai_plain_ct = null;
}
} else if (BODY_PARTS.includes(value)) {
if (input.checked) {
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
} else {
app.draft.manual_body_parts = app.draft.manual_body_parts.filter((part) => part !== value);
app.draft.ai_body_parts = app.draft.ai_body_parts.filter((part) => part !== value);
if (value === "upper_abdomen") {
app.draft.manual_upper_abdomen_phase = "";
app.draft.ai_upper_abdomen_phase = "";
}
}
}
app.draft.body_parts = effectiveParts();
app.draft.upper_abdomen_phase = effectivePhase();
app.draft.plain_ct = effectivePlainCt();
applyAnnotationControls();
queueSave();
}
function handlePhaseChange(event) {
if (!app.draft) return;
app.draft.manual_upper_abdomen_phase = event.target.value;
if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = "";
app.draft.upper_abdomen_phase = effectivePhase();
applyAnnotationControls();
queueSave();
}
async function runAI() {
if (!app.study || !app.activeSeries) return;
const button = $("aiClassify");
button.disabled = true;
$("saveState").textContent = "AI 识别中";
try {
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, {
method: "POST",
body: JSON.stringify({ sample_count: 3 }),
});
updateLocalAnnotation(data);
$("annotationNotes").value = data.notes || "";
$("saveState").textContent = "AI 结果已保存";
} catch (err) {
$("saveState").textContent = err.message;
} finally {
button.disabled = false;
}
}
async function openInfo() {
if (!app.study || !app.activeSeries) return;
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
const content = $("infoContent");
content.innerHTML = "";
for (const [title, fields] of Object.entries(data.fields || {})) {
const section = document.createElement("section");
section.className = "info-card";
section.innerHTML = `<h3>${escapeHtml(title)}</h3>${Object.entries(fields)
.map(([key, value]) => `<p class="info-row"><span>${escapeHtml(key)}</span><b>${escapeHtml(value || "-")}</b></p>`)
.join("")}`;
content.appendChild(section);
}
$("infoModal").classList.remove("hidden");
}
function settingsTable(users) {
return `
<table class="settings-table">
<thead><tr><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></tr>`)
.join("")}
</tbody>
</table>
`;
}
function roleCards(roles) {
return (roles || [])
.map(
(role) => `
<div class="role-card">
<strong>${escapeHtml(role.name)}</strong>
<span>${(role.permissions || []).map(escapeHtml).join(" / ")}</span>
</div>
`,
)
.join("");
}
async function openSettings() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title"><h3>用户创建</h3><span>当前登录admin</span></div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" autocomplete="off" />
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<button type="submit">创建</button>
</form>
${settingsTable(settings.users)}
</section>
<section class="settings-section">
<div class="settings-title"><h3>权限控制</h3><span>按角色控制查看、标注、AI 与系统设置</span></div>
<div class="role-grid">${roleCards(settings.roles)}</div>
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.configured ? "已配置" : "未配置"}</span></div>
<dl>
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
<dl>
<dt>主机</dt><dd>${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}</dd>
<dt>库表</dt><dd>${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}</dd>
<dt>DICOM</dt><dd>${escapeHtml(settings.dicom?.processed_root || "-")}</dd>
</dl>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", createUser);
$("settingsModal").classList.remove("hidden");
}
async function createUser(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button");
const form = new FormData(event.currentTarget);
const payload = {
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
};
if (!payload.username || !payload.password) return;
button.disabled = true;
button.textContent = "创建中";
try {
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
await openSettings();
} catch (err) {
button.textContent = err.message;
setTimeout(() => {
button.disabled = false;
button.textContent = "创建";
}, 1800);
}
}
function changeZoom(multiplier) {
app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
applyTransform();
}
function wireImageGestures() {
const wrap = document.querySelector(".image-wrap");
document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation());
wrap.addEventListener(
"wheel",
(event) => {
if (!app.activeSeries) return;
event.preventDefault();
changeZoom(event.deltaY < 0 ? 1.12 : 1 / 1.12);
},
{ passive: false },
);
wrap.addEventListener("pointerdown", (event) => {
if (!app.activeSeries || event.target.closest(".slice-rail")) return;
app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY };
wrap.classList.add("dragging");
wrap.setPointerCapture(event.pointerId);
});
wrap.addEventListener("pointermove", (event) => {
if (!app.drag) return;
app.panX = app.drag.panX + event.clientX - app.drag.x;
app.panY = app.drag.panY + event.clientY - app.drag.y;
applyTransform();
});
["pointerup", "pointercancel", "pointerleave"].forEach((name) => {
wrap.addEventListener(name, () => {
app.drag = null;
wrap.classList.remove("dragging");
});
});
}
function setSort(direction) {
app.seriesSort = direction;
document.querySelectorAll("[data-sort]").forEach((button) => {
button.classList.toggle("active", button.dataset.sort === direction);
});
app.series = sortSeries(app.series);
renderSeries();
if (app.activeSeries) {
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
}
}
function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings);
$("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI);
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
$("overlayToggle").addEventListener("click", () => {
app.showOverlay = !app.showOverlay;
updateOverlay();
});
document.querySelectorAll("[data-sort]").forEach((button) => {
button.addEventListener("click", () => setSort(button.dataset.sort));
});
$("studySearch").addEventListener("input", () => {
clearTimeout(app.searchTimer);
app.searchTimer = setTimeout(loadStudies, 250);
});
$("sliceSlider").addEventListener("input", (event) => {
app.slice = Number(event.target.value);
updateImage();
});
document.querySelectorAll("[data-plane]").forEach((button) => {
button.addEventListener("click", () => {
app.plane = button.dataset.plane;
app.slice = Math.floor(maxSlice() / 2);
document.querySelectorAll("[data-plane]").forEach((b) => b.classList.toggle("active", b === button));
updateImage();
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => {
app.window = button.dataset.window;
document.querySelectorAll("[data-window]").forEach((b) => b.classList.toggle("active", b === button));
updateImage();
});
});
$("rotateLeft").addEventListener("click", () => {
app.rotate = (app.rotate - 90 + 360) % 360;
applyTransform();
});
$("rotateRight").addEventListener("click", () => {
app.rotate = (app.rotate + 90) % 360;
applyTransform();
});
$("resetView").addEventListener("click", resetViewState);
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange));
document.querySelectorAll("input[name=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange));
$("annotationNotes").addEventListener("input", () => {
if (!app.draft) return;
app.draft.notes = $("annotationNotes").value;
queueSave(900);
});
document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
});
wireImageGestures();
}
async function boot() {
await refreshStatus();
await loadStudies();
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
}
wire();
refreshStatus();
if (app.token) {
showLogin(false);
boot().catch(() => showLogin(true));
} else {
showLogin(true);
}