Files
PACS/PACS_DICOM处理/数据处理网页端/static/app.js

1276 lines
45 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") || "",
user: null,
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,
dirty: false,
saving: false,
pendingImage: null,
drag: null,
studySortField: "time",
studySortDir: "desc",
studyFilter: "all",
aiEnabled: true,
seriesSortField: "time",
seriesSortDir: "asc",
showOverlay: true,
thumbUrls: new Map(),
imageCache: new Map(),
thumbObserver: null,
thumbSeries: new WeakMap(),
};
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 studyTimeKey(study) {
const date = String(study.study_date || "").replace(/\D/g, "").padEnd(8, "0");
const time = String(study.study_time || "").replace(/\D/g, "").slice(0, 6).padEnd(6, "0");
return `${date}${time}`;
}
function sortArrow(direction) {
return direction === "asc" ? "↑" : "↓";
}
function phaseLabel(value) {
return { arterial: "动脉期", portal_venous: "门静脉期", delayed: "延迟期", unknown: "无法判别" }[value] || "";
}
function chestWindowLabel(value) {
return { lung: "肺窗", mediastinal: "纵隔窗", 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;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_web_token", app.token);
applyPermissions();
showLogin(false);
await boot();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
async function logout() {
await confirmSaveIfDirty();
app.token = "";
app.user = null;
localStorage.removeItem("pacs_web_token");
applyPermissions();
showViewerPage(false);
showLogin(true);
}
async function loadCurrentUser() {
if (!app.token) return;
app.user = await json("/api/auth/me");
applyPermissions();
}
function isAdmin() {
return app.user?.role === "管理员";
}
function applyPermissions() {
$("settingsBtn").classList.toggle("hidden", !isAdmin());
}
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));
app.aiEnabled = Boolean(data.ai?.enabled && data.ai?.configured);
$("aiClassify").disabled = !app.aiEnabled;
$("aiClassify").title = app.aiEnabled ? "" : "AI 功能未启用或未配置";
} 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;
const result = app.seriesSortField === "count" ? countResult : timeResult;
return app.seriesSortDir === "desc" ? -result : result;
});
}
function sortStudies(list) {
return [...list].sort((a, b) => {
const timeResult =
studyTimeKey(a).localeCompare(studyTimeKey(b)) ||
String(a.ct_number || "").localeCompare(String(b.ct_number || ""), undefined, { numeric: true });
const unannotatedResult = Number(a.unannotated_series || 0) - Number(b.unannotated_series || 0) || timeResult;
const result = app.studySortField === "unannotated" ? unannotatedResult : timeResult;
return app.studySortDir === "desc" ? -result : result;
});
}
function studyStatus(study) {
const total = Number(study.series_count || 0);
const unannotated = Number(study.unannotated_series || 0);
if (total > 0 && unannotated === 0) return "complete";
return "incomplete";
}
function studyStatusLabel(status) {
return status === "complete" ? "已完成" : "待处理";
}
function visibleStudies() {
return sortStudies(app.studies).filter((study) => {
const status = studyStatus(study);
if (app.studyFilter === "complete") return status === "complete";
if (app.studyFilter === "incomplete") return status !== "complete";
return true;
});
}
function updateSortButtons() {
document.querySelectorAll("[data-study-sort]").forEach((button) => {
const active = button.dataset.studySort === app.studySortField;
button.classList.toggle("active", active);
const arrow = button.querySelector(".sort-arrow");
if (arrow) arrow.textContent = sortArrow(active ? app.studySortDir : "desc");
});
document.querySelectorAll("[data-series-sort]").forEach((button) => {
const active = button.dataset.seriesSort === app.seriesSortField;
button.classList.toggle("active", active);
const arrow = button.querySelector(".sort-arrow");
if (arrow) arrow.textContent = sortArrow(active ? app.seriesSortDir : "asc");
});
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter);
});
}
function setSeries(list) {
app.series = sortSeries(list || []);
$("seriesCount").textContent = String(app.series.length);
renderSeries();
updateSortButtons();
}
function isSeriesAnnotated(series) {
const annotation = series.annotation || {};
return Boolean(annotation.skipped || annotation.plain_ct || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model);
}
function refreshStudyAnnotationSummary() {
if (!app.study || !app.series.length) return;
const annotated = app.series.filter(isSeriesAnnotated).length;
const total = app.series.length;
const summary = {
annotated_series: annotated,
unannotated_series: Math.max(0, total - annotated),
series_count: total,
};
Object.assign(app.study, summary);
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
if (study) Object.assign(study, summary);
}
async function loadStudies() {
const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
renderStudies();
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
}
function renderStudies() {
const list = $("studyList");
app.studies = sortStudies(app.studies);
updateSortButtons();
const studies = visibleStudies();
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
list.innerHTML = "";
for (const study of studies) {
const button = document.createElement("button");
const status = studyStatus(study);
button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
button.classList.toggle("complete", status === "complete");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = `
<i class="study-status-badge status-${status}" title="${status === "complete" ? "已处理完毕" : "仍有序列未标注"}">${studyStatusLabel(status)}</i>
<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)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
`;
button.onclick = () => selectStudy(study.ct_number);
list.appendChild(button);
}
}
async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
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 = "读取中";
renderSeriesLoading();
resetViewer();
renderStudies();
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
app.study = data.study;
setSeries(data.series || []);
refreshStudyAnnotationSummary();
renderStudies();
} 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 mergeTagSource(...sources) {
if (sources.includes("manual")) return "manual";
if (sources.includes("ai")) return "ai";
return "";
}
function seriesTags(series) {
const annotation = series.annotation || {};
const tags = [];
if (annotation.skipped) {
tags.push({ label: "略过/不采用", source: annotation.ai_skipped ? "ai" : "manual" });
}
if (annotation.plain_ct) {
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
}
const phase = phaseLabel(annotation.upper_abdomen_phase);
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
const chestWindow = chestWindowLabel(annotation.chest_window);
const chestWindowSource = annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "";
for (const part of asList(annotation.body_parts)) {
const partSource = sourceTag(part, annotation);
if (part === "upper_abdomen" && phase && annotation.upper_abdomen_phase !== "unknown") {
tags.push({ label: `${partLabel(part)}-${phase}`, source: mergeTagSource(partSource, phaseSource) });
continue;
}
if (part === "chest" && chestWindow && annotation.chest_window !== "unknown") {
tags.push({ label: `${partLabel(part)}-${chestWindow}`, source: mergeTagSource(partSource, chestWindowSource) });
continue;
}
tags.push({ label: partLabel(part), source: partSource });
}
if (phase && annotation.upper_abdomen_phase === "unknown") {
tags.push({
label: phase,
source: phaseSource,
warn: annotation.upper_abdomen_phase === "unknown",
});
}
if (chestWindow && annotation.chest_window === "unknown") {
tags.push({
label: chestWindow,
source: chestWindowSource,
warn: annotation.chest_window === "unknown",
});
}
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
return tags;
}
function renderSeries() {
const grid = $("seriesGrid");
if (app.thumbObserver) app.thumbObserver.disconnect();
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="" />
<span class="thumb-state">加载中</span>
<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}` : "", tag.warn ? "tag-warn" : ""].filter(Boolean).join(" ")}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
</div>
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
queueThumb(series, card.querySelector("img"));
}
}
function renderSeriesLoading() {
$("seriesGrid").innerHTML = Array.from({ length: 4 })
.map(
() => `
<div class="series-card loading-card">
<div class="thumb"><span class="thumb-state">序列读取中</span></div>
<div class="series-copy">
<strong>读取中</strong>
<span class="shot-time">正在同步序列信息</span>
<small>请稍候</small>
</div>
</div>
`,
)
.join("");
}
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`;
}
function cacheImageUrl(key, url) {
app.imageCache.set(key, url);
while (app.imageCache.size > 90) {
const oldestKey = app.imageCache.keys().next().value;
const oldestUrl = app.imageCache.get(oldestKey);
URL.revokeObjectURL(oldestUrl);
app.imageCache.delete(oldestKey);
}
}
function showImageMessage(message) {
const empty = $("imageEmpty");
empty.textContent = message;
empty.classList.remove("hidden");
}
function hideImageMessage() {
$("imageEmpty").classList.add("hidden");
}
async function loadThumb(series, img) {
const thumb = img.closest(".thumb");
const state = thumb?.querySelector(".thumb-state");
try {
const key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
thumb?.classList.add("thumb-ready");
return;
}
thumb?.classList.add("thumb-loading");
if (state) state.textContent = "加载中";
const index = Math.floor((Number(series.count) || 1) / 2);
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
const url = URL.createObjectURL(blob);
app.thumbUrls.set(key, url);
img.src = url;
thumb?.classList.remove("thumb-loading");
thumb?.classList.add("thumb-ready");
} catch (_) {
img.removeAttribute("src");
thumb?.classList.remove("thumb-loading");
thumb?.classList.add("thumb-failed");
if (state) state.textContent = "未加载";
}
}
function thumbKey(series) {
return `${series.ct_number || app.study?.ct_number || ""}:${series.series_uid}`;
}
function queueThumb(series, img) {
const key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
img.closest(".thumb")?.classList.add("thumb-ready");
return;
}
if (!("IntersectionObserver" in window)) {
loadThumb(series, img);
return;
}
if (!app.thumbObserver) {
app.thumbObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const target = entry.target;
app.thumbObserver.unobserve(target);
const queuedSeries = app.thumbSeries.get(target);
if (queuedSeries) loadThumb(queuedSeries, target);
}
},
{ rootMargin: "320px 0px" },
);
}
app.thumbSeries.set(img, series);
app.thumbObserver.observe(img);
}
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();
}
async function selectSeries(seriesUid) {
if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid) await confirmSaveIfDirty();
const series = app.series.find((item) => item.series_uid === seriesUid);
if (!series) return;
app.activeSeries = series;
app.slice = Math.floor(maxSlice() / 2);
app.pendingImage = null;
$("dicomImage").removeAttribute("src");
showImageMessage("图像加载中");
resetViewState();
$("sliceSlider").max = String(maxSlice());
$("sliceSlider").value = String(app.slice);
hydrateAnnotation();
renderSeries();
updateImage();
}
function resetViewer() {
app.imageUrl = "";
app.pendingImage = null;
app.activeSeries = null;
$("dicomImage").removeAttribute("src");
hideImageMessage();
$("sliceText").textContent = "0 / 0";
$("saveState").textContent = "已保存";
clearAnnotationControls();
app.dirty = false;
app.saving = false;
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 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 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-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}`;
const url = imageUrlFor();
if (app.imageCache.has(url)) {
app.imageUrl = app.imageCache.get(url);
$("dicomImage").src = app.imageUrl;
hideImageMessage();
updateOverlay();
applyTransform();
preloadNeighborImages();
return;
}
showImageMessage("图像加载中");
updateOverlay();
const ticket = Symbol("image");
app.pendingImage = ticket;
try {
const blob = await request(url).then((res) => res.blob());
if (app.pendingImage !== ticket) return;
app.imageUrl = URL.createObjectURL(blob);
cacheImageUrl(url, app.imageUrl);
$("dicomImage").src = app.imageUrl;
hideImageMessage();
applyTransform();
preloadNeighborImages();
} catch (err) {
showImageMessage("图像读取失败");
$("saveState").textContent = `图像读取失败:${err.message}`;
}
}
function preloadNeighborImages() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
const indexes = [app.slice - 2, app.slice - 1, app.slice + 1, app.slice + 2].filter((index) => index >= 0 && index <= max);
for (const index of indexes) {
const url = imageUrlFor(app.activeSeries, index, app.window, app.plane);
if (app.imageCache.has(url)) continue;
request(url)
.then((res) => res.blob())
.then((blob) => cacheImageUrl(url, URL.createObjectURL(blob)))
.catch(() => {});
}
}
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 || "",
chest_window: annotation.chest_window || "",
manual_chest_window: annotation.manual_chest_window || "",
ai_chest_window: annotation.ai_chest_window || "",
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) 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 effectiveChestWindow() {
if (!effectiveParts().includes("chest")) return "";
return app.draft.manual_chest_window || app.draft.ai_chest_window || "unknown";
}
function effectivePlainCt() {
if (!app.draft) 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 clearAnnotationControls() {
$("annotationNotes").value = "";
document.querySelectorAll(".part-grid input, input[name=phase], input[name=chestWindow]").forEach((input) => {
input.checked = false;
input.disabled = true;
setLabelSource(input, "");
});
$("phaseBox").classList.remove("visible");
$("chestWindowBox").classList.remove("visible");
}
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 = false;
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 = false;
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 = false;
setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : "");
});
$("phaseBox").classList.toggle("visible", parts.has("upper_abdomen"));
const chestWindow = effectiveChestWindow();
document.querySelectorAll("input[name=chestWindow]").forEach((input) => {
input.checked = input.value === chestWindow;
input.disabled = false;
setLabelSource(input, app.draft.manual_chest_window === input.value ? "manual" : app.draft.ai_chest_window === input.value ? "ai" : "");
});
$("chestWindowBox").classList.toggle("visible", parts.has("chest"));
}
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 || "";
normalized.chest_window = annotation.chest_window || "";
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);
refreshStudyAnnotationSummary();
renderSeries();
renderStudies();
applyAnnotationControls();
}
function annotationPayload() {
const bodyParts = effectiveParts();
return {
body_parts: bodyParts,
manual_body_parts: app.draft.manual_body_parts,
ai_body_parts: app.draft.ai_body_parts,
upper_abdomen_phase: effectivePhase(),
manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase,
ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase,
chest_window: effectiveChestWindow(),
manual_chest_window: app.draft.manual_chest_window,
ai_chest_window: app.draft.ai_chest_window,
plain_ct: effectivePlainCt(),
manual_plain_ct: app.draft.manual_plain_ct,
ai_plain_ct: 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;
if (app.saving) return;
app.saving = true;
$("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 = "已保存";
app.dirty = false;
updateLocalAnnotation(data);
} catch (err) {
$("saveState").textContent = err.message;
} finally {
app.saving = false;
}
}
function markDirty() {
clearTimeout(app.saveTimer);
app.dirty = true;
$("saveState").textContent = "未保存";
}
async function confirmSaveIfDirty() {
if (!app.dirty) return;
const shouldSave = window.confirm("当前标注尚未保存,是否先保存?");
if (shouldSave) {
await saveAnnotationNow();
} else {
app.dirty = false;
$("saveState").textContent = "未保存";
}
}
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;
} 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]);
if (value === "chest" && !app.draft.manual_chest_window && !app.draft.ai_chest_window) {
app.draft.manual_chest_window = "unknown";
}
} 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 = "";
}
if (value === "chest") {
app.draft.manual_chest_window = "";
app.draft.ai_chest_window = "";
}
}
}
app.draft.body_parts = effectiveParts();
app.draft.upper_abdomen_phase = effectivePhase();
app.draft.chest_window = effectiveChestWindow();
app.draft.plain_ct = effectivePlainCt();
applyAnnotationControls();
markDirty();
}
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();
markDirty();
}
function handleChestWindowChange(event) {
if (!app.draft) return;
app.draft.manual_chest_window = event.target.value;
if (app.draft.ai_chest_window !== event.target.value) app.draft.ai_chest_window = "";
app.draft.chest_window = effectiveChestWindow();
applyAnnotationControls();
markDirty();
}
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 结果已保存";
app.dirty = false;
} catch (err) {
$("saveState").textContent = err.message;
} finally {
button.disabled = false;
}
}
async function openInfo() {
await confirmSaveIfDirty();
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("");
}
function showViewerPage(push = true) {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
if (push && location.pathname !== "/") history.pushState({}, "", "/");
}
async function showSettingsPage(push = true) {
await confirmSaveIfDirty();
if (!isAdmin()) {
if (location.pathname === "/settings") history.replaceState({}, "", "/");
showViewerPage(false);
return;
}
$("viewerPage").classList.add("hidden");
$("settingsPage").classList.remove("hidden");
if (push && location.pathname !== "/settings") history.pushState({}, "", "/settings");
await renderSettingsPage();
}
async function handleRoute(push = false) {
if (location.pathname === "/settings") {
await showSettingsPage(push);
} else {
showViewerPage(push);
}
}
async function openSettings() {
await showSettingsPage(true);
}
async function renderSettingsPage() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
const refresh = settings.summary_refresh || {};
$("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="新账号" 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?.enabled ? "已启用" : "已关闭"}</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>
<dt>状态</dt><dd>${settings.ai?.configured ? "API 已配置" : "API 未配置"}</dd>
</dl>
<button id="toggleAi" class="settings-action">${settings.ai?.enabled ? "关闭 AI" : "启用 AI"}</button>
</div>
<div>
<div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div>
<dl>
<dt>计划刷新</dt><dd>每天 04:00</dd>
<dt>最近开始</dt><dd>${escapeHtml(refresh.started_at || "-")}</dd>
<dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd>
<dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd>
</dl>
<button id="refreshSummariesNow" class="settings-action"${refresh.running ? " disabled" : ""}>立刻更新检查列表</button>
</div>
</section>
<section class="settings-section split">
<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);
$("toggleAi").addEventListener("click", () => toggleAI(!settings.ai?.enabled));
$("refreshSummariesNow").addEventListener("click", refreshSummariesNow);
}
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 renderSettingsPage();
} catch (err) {
button.textContent = err.message;
setTimeout(() => {
button.disabled = false;
button.textContent = "创建";
}, 1800);
}
}
async function toggleAI(enabled) {
await json("/api/settings/ai", { method: "POST", body: JSON.stringify({ enabled }) });
await renderSettingsPage();
await refreshStatus();
}
async function refreshSummariesNow() {
const button = $("refreshSummariesNow");
button.disabled = true;
button.textContent = "已开始刷新";
await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) });
await renderSettingsPage();
}
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 setStudySort(field) {
if (app.studySortField === field) {
app.studySortDir = app.studySortDir === "asc" ? "desc" : "asc";
} else {
app.studySortField = field;
app.studySortDir = "desc";
}
renderStudies();
}
function setSeriesSort(field) {
if (app.seriesSortField === field) {
app.seriesSortDir = app.seriesSortDir === "asc" ? "desc" : "asc";
} else {
app.seriesSortField = field;
app.seriesSortDir = field === "count" ? "desc" : "asc";
}
app.series = sortSeries(app.series);
renderSeries();
if (app.activeSeries) {
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
}
updateSortButtons();
}
function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings);
$("backToViewer").addEventListener("click", () => showViewerPage(true));
$("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-study-sort]").forEach((button) => {
button.addEventListener("click", () => setStudySort(button.dataset.studySort));
});
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.studyFilter = button.dataset.studyFilter;
renderStudies();
});
});
document.querySelectorAll("[data-series-sort]").forEach((button) => {
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
});
$("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));
document.querySelectorAll("input[name=chestWindow]").forEach((input) => input.addEventListener("change", handleChestWindowChange));
$("annotationNotes").addEventListener("input", () => {
if (!app.draft) return;
app.draft.notes = $("annotationNotes").value;
markDirty();
});
$("saveAnnotation").addEventListener("click", saveAnnotationNow);
window.addEventListener("beforeunload", (event) => {
if (!app.dirty) return;
event.preventDefault();
event.returnValue = "";
});
document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
});
window.addEventListener("popstate", () => handleRoute(false));
wireImageGestures();
}
async function boot() {
await loadCurrentUser();
await refreshStatus();
if (location.pathname === "/settings" && isAdmin()) {
await showSettingsPage(false);
} else {
await loadStudies();
await handleRoute(false);
}
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
}
wire();
applyPermissions();
refreshStatus();
if (app.token) {
showLogin(false);
boot().catch(() => showLogin(true));
} else {
showLogin(true);
}