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,
savePromise: null,
savingSeriesUid: "",
draftVersion: 0,
dirty: false,
saving: false,
pendingImage: null,
drag: null,
studySortField: "time",
studySortDir: "desc",
studyFilter: "all",
studyPartFilter: "all",
exportMode: false,
exportSelection: new Set(),
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("<", "<")
.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 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 { plain: "平扫", 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() {
if (!(await confirmSaveIfDirty())) return;
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());
$("exportToolbar").classList.toggle("hidden", !isAdmin());
if (!isAdmin()) {
app.exportMode = false;
app.exportSelection.clear();
}
updateExportToolbar();
}
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) {
return study?.completed ? "complete" : "incomplete";
}
function studyStatusLabel(status) {
return status === "complete" ? "已完成" : "待处理";
}
function visibleStudies() {
return sortStudies(app.studies).filter((study) => {
const status = studyStatus(study);
if (app.studyFilter === "complete" && status !== "complete") return false;
if (app.studyFilter === "incomplete" && status === "complete") return false;
if (app.studyPartFilter !== "all" && !asList(study.body_parts).includes(app.studyPartFilter)) return false;
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);
});
document.querySelectorAll("[data-study-part-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyPartFilter === app.studyPartFilter);
});
}
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 || 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 bodyParts = BODY_PARTS.filter((part) =>
app.series.some((series) => asList(series.annotation?.body_parts).includes(part)),
);
const summary = {
annotated_series: annotated,
unannotated_series: Math.max(0, total - annotated),
series_count: total,
body_parts: bodyParts,
};
Object.assign(app.study, summary);
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
if (study) Object.assign(study, summary);
}
function updateExportToolbar() {
const toolbar = $("exportToolbar");
if (!toolbar) return;
toolbar.classList.toggle("hidden", !isAdmin());
$("toggleExportMode").classList.toggle("active", app.exportMode);
$("toggleExportMode").textContent = app.exportMode ? "退出批量" : "批量导出";
$("exportSelected").disabled = !app.exportMode || app.exportSelection.size === 0;
$("exportSelectionCount").textContent = `${app.exportSelection.size} 个`;
}
function exportDownloadUrl(ctNumbers) {
const params = new URLSearchParams();
for (const ctNumber of ctNumbers) params.append("ct_numbers", ctNumber);
params.set("access_token", app.token);
return `/api/studies/export?${params.toString()}`;
}
function triggerDownload(url) {
const link = document.createElement("a");
link.href = url;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
}
function exportStudy(ctNumber, event) {
event.stopPropagation();
if (!isAdmin()) return;
triggerDownload(`/api/studies/${encodeURIComponent(ctNumber)}/export?access_token=${encodeURIComponent(app.token)}`);
}
function toggleExportMode() {
app.exportMode = !app.exportMode;
if (!app.exportMode) app.exportSelection.clear();
renderStudies();
updateExportToolbar();
}
function setExportSelection(ctNumber, checked) {
if (checked) {
app.exportSelection.add(ctNumber);
} else {
app.exportSelection.delete(ctNumber);
}
updateExportToolbar();
}
function exportSelectedStudies() {
if (!isAdmin() || app.exportSelection.size === 0) return;
triggerDownload(exportDownloadUrl(Array.from(app.exportSelection)));
}
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();
for (const ctNumber of Array.from(app.exportSelection)) {
if (!studies.some((study) => study.ct_number === ctNumber)) app.exportSelection.delete(ctNumber);
}
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
list.innerHTML = "";
for (const study of studies) {
const card = document.createElement("div");
const status = studyStatus(study);
const nextStatusLabel = status === "complete" ? "待处理" : "已完成";
card.className = "study-card";
card.tabIndex = 0;
card.setAttribute("role", "button");
card.classList.toggle("active", app.study?.ct_number === study.ct_number);
card.classList.toggle("complete", status === "complete");
card.classList.toggle("export-mode", app.exportMode && isAdmin());
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
const partTags = asList(study.body_parts)
.filter((part) => BODY_PARTS.includes(part))
.map((part) => `${escapeHtml(partLabel(part))}`)
.join("");
const exportControls = isAdmin()
? `
${app.exportMode ? `` : ""}
下载
`
: "";
card.innerHTML = `
${studyStatusLabel(status)}
${partTags}
${escapeHtml(study.ct_number)}
${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}
${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张
${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注
${exportControls}
`;
card.onclick = () => selectStudy(study.ct_number);
card.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectStudy(study.ct_number);
}
};
card.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event);
card.querySelector(".study-export-btn")?.addEventListener("click", (event) => exportStudy(study.ct_number, event));
card.querySelector(".study-export-btn")?.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") exportStudy(study.ct_number, event);
});
card.querySelector(".study-batch-check")?.addEventListener("click", (event) => event.stopPropagation());
card.querySelector(".study-batch-check input")?.addEventListener("change", (event) => setExportSelection(study.ct_number, event.target.checked));
list.appendChild(card);
}
updateExportToolbar();
}
function applyStudyCompletion(data) {
const completed = Boolean(data.completed);
const study = app.studies.find((item) => item.ct_number === data.ct_number);
if (study) {
study.completed = completed;
study.completed_by = data.completed_by || "";
study.completed_at = data.completed_at || "";
}
if (app.study?.ct_number === data.ct_number) {
app.study.completed = completed;
app.study.completed_by = data.completed_by || "";
app.study.completed_at = data.completed_at || "";
}
}
async function toggleStudyCompletion(ctNumber, event) {
event.stopPropagation();
const study = app.studies.find((item) => item.ct_number === ctNumber);
if (!study) return;
const previous = Boolean(study.completed);
applyStudyCompletion({ ct_number: ctNumber, completed: !previous });
renderStudies();
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/completion`, {
method: "PUT",
body: JSON.stringify({ completed: !previous }),
});
applyStudyCompletion(data);
renderStudies();
} catch (err) {
applyStudyCompletion({ ct_number: ctNumber, completed: previous });
renderStudies();
$("saveState").textContent = `状态更新失败:${err.message}`;
}
}
async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber && !(await confirmSaveIfDirty())) return;
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;
applyStudyCompletion(data.study);
setSeries(data.series || []);
refreshStudyAnnotationSummary();
renderStudies();
} catch (err) {
$("seriesGrid").innerHTML = `${escapeHtml(err.message)}
`;
}
}
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" });
}
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 = `
加载中
${Number(series.count || 0)} 张
${skipped ? "
略过/不采用" : ""}
${escapeHtml(series.description || "未命名序列")}
拍摄 ${escapeHtml(timeRange(series))}
序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}
${tags.length ? tags.map((tag) => `${escapeHtml(tag.label)}`).join("") : "未标注"}
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
queueThumb(series, card.querySelector("img"));
}
}
function renderSeriesLoading() {
$("seriesGrid").innerHTML = Array.from({ length: 4 })
.map(
() => `
`,
)
.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())) return;
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("
");
document.querySelector(".ov-right-top").innerHTML = [
s.institution_name || "",
s.manufacturer || "",
].filter(Boolean).map(escapeHtml).join("
");
document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}
WL:${Math.round(win.wl)}
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(draft = app.draft) {
if (!draft) return [];
return uniq([...asList(draft.manual_body_parts), ...asList(draft.ai_body_parts)]);
}
function effectivePhase(draft = app.draft) {
if (!effectiveParts(draft).includes("upper_abdomen")) return "";
return draft.manual_upper_abdomen_phase || draft.ai_upper_abdomen_phase || "";
}
function effectiveChestWindow(draft = app.draft) {
if (!effectiveParts(draft).includes("chest")) return "";
return draft.manual_chest_window || draft.ai_chest_window || "unknown";
}
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;
}
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 || {});
app.draftVersion = 0;
$("annotationNotes").value = app.draft.notes || "";
applyAnnotationControls();
}
function updateLocalAnnotation(annotation, targetSeriesUid = app.activeSeries?.series_uid, options = {}) {
if (!targetSeriesUid) return;
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 || "";
const index = app.series.findIndex((item) => item.series_uid === targetSeriesUid);
if (index >= 0) app.series[index] = { ...app.series[index], annotation: normalized };
app.series = sortSeries(app.series);
const isActive = app.activeSeries?.series_uid === targetSeriesUid;
const applyToActive = options.applyToActive !== false;
if (isActive && applyToActive) {
app.activeSeries = app.series.find((item) => item.series_uid === targetSeriesUid) || app.activeSeries;
app.draft = cloneAnnotation(normalized);
if (options.updateNotes !== false) $("annotationNotes").value = normalized.notes || "";
}
refreshStudyAnnotationSummary();
renderSeries();
renderStudies();
if (isActive && applyToActive) applyAnnotationControls();
}
function annotationPayload(draft = app.draft, notes = $("annotationNotes").value) {
const bodyParts = effectiveParts(draft);
return {
body_parts: bodyParts,
manual_body_parts: asList(draft.manual_body_parts),
ai_body_parts: asList(draft.ai_body_parts),
upper_abdomen_phase: effectivePhase(draft),
manual_upper_abdomen_phase: draft.manual_upper_abdomen_phase,
ai_upper_abdomen_phase: draft.ai_upper_abdomen_phase,
chest_window: effectiveChestWindow(draft),
manual_chest_window: draft.manual_chest_window,
ai_chest_window: draft.ai_chest_window,
plain_ct: false,
manual_plain_ct: null,
ai_plain_ct: null,
skipped: Boolean(draft.skipped),
ai_skipped: Boolean(draft.ai_skipped),
notes,
};
}
async function saveAnnotationNow() {
if (!app.study || !app.activeSeries || !app.draft) return;
if (app.savePromise) return app.savePromise;
const ctNumber = app.study.ct_number;
const uid = app.activeSeries.series_uid;
const draft = cloneAnnotation(app.draft);
const payload = annotationPayload(draft, $("annotationNotes").value);
const versionAtStart = app.draftVersion;
app.saving = true;
app.savingSeriesUid = uid;
$("saveState").textContent = "保存中";
$("saveAnnotation").disabled = true;
let promise;
promise = (async () => {
try {
const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/annotation`, {
method: "PUT",
body: JSON.stringify(payload),
});
const stillActive = app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid;
const unchanged = stillActive && app.draftVersion === versionAtStart;
updateLocalAnnotation(data, uid, { applyToActive: unchanged });
if (unchanged) {
$("saveState").textContent = "已保存";
app.dirty = false;
} else if (stillActive) {
$("saveState").textContent = "未保存";
app.dirty = true;
}
return data;
} catch (err) {
if (app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid) $("saveState").textContent = err.message;
return null;
} finally {
if (app.savePromise === promise) {
app.savePromise = null;
app.saving = false;
app.savingSeriesUid = "";
$("saveAnnotation").disabled = false;
}
}
})();
app.savePromise = promise;
return promise;
}
function markDirty() {
clearTimeout(app.saveTimer);
app.draftVersion += 1;
app.dirty = true;
$("saveState").textContent = "未保存";
}
async function confirmSaveIfDirty() {
if (app.savePromise) {
$("saveState").textContent = "保存中";
await app.savePromise;
}
if (!app.dirty) return true;
const shouldSave = window.confirm("当前标注尚未保存,是否先保存?");
if (shouldSave) {
await saveAnnotationNow();
return !app.dirty;
} else {
app.dirty = false;
$("saveState").textContent = "未保存";
}
return true;
}
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 (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 = false;
app.draft.manual_plain_ct = null;
app.draft.ai_plain_ct = null;
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;
if (!(await confirmSaveIfDirty())) return;
const ctNumber = app.study.ct_number;
const uid = app.activeSeries.series_uid;
const versionAtStart = app.draftVersion;
const button = $("aiClassify");
button.disabled = true;
$("saveState").textContent = "AI 识别中";
try {
const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/ai`, {
method: "POST",
body: JSON.stringify({ sample_count: 3 }),
});
const stillActive = app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid;
const unchanged = stillActive && app.draftVersion === versionAtStart;
updateLocalAnnotation(data, uid, { applyToActive: unchanged });
if (unchanged) {
$("saveState").textContent = "AI 结果已保存";
app.dirty = false;
} else if (stillActive) {
$("saveState").textContent = "未保存";
app.dirty = true;
}
} catch (err) {
if (app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid) $("saveState").textContent = err.message;
} finally {
button.disabled = false;
}
}
async function openInfo() {
if (!(await confirmSaveIfDirty())) return;
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 = `${escapeHtml(title)}
${Object.entries(fields)
.map(([key, value]) => `${escapeHtml(key)}${escapeHtml(value || "-")}
`)
.join("")}`;
content.appendChild(section);
}
$("infoModal").classList.remove("hidden");
}
function settingsTable(users) {
return `
| 账号 | 角色 | 状态 |
${(users || [])
.map((user) => `| ${escapeHtml(user.username)} | ${escapeHtml(user.role)} | ${escapeHtml(user.status)} |
`)
.join("")}
`;
}
function roleCards(roles) {
return (roles || [])
.map(
(role) => `
${escapeHtml(role.name)}
${(role.permissions || []).map(escapeHtml).join(" / ")}
`,
)
.join("");
}
function showViewerPage(push = true) {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
if (push && location.pathname !== "/") history.pushState({}, "", "/");
}
async function showSettingsPage(push = true) {
if (!(await confirmSaveIfDirty())) return;
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 = `
用户创建
当前登录:${escapeHtml(app.user?.username || "-")}
${settingsTable(settings.users)}
权限控制
按角色控制查看、标注、AI 与系统设置
${roleCards(settings.roles)}
AI 设置
${settings.ai?.enabled ? "已启用" : "已关闭"}
- 供应商
- ${escapeHtml(settings.ai?.provider || "-")}
- 名称
- ${escapeHtml(settings.ai?.name || "-")}
- 模型
- ${escapeHtml(settings.ai?.model || "-")}
- 状态
- ${settings.ai?.configured ? "API 已配置" : "API 未配置"}
检查摘要
${refresh.running ? "刷新中" : "待命"}
- 计划刷新
- 每天 04:00
- 最近开始
- ${escapeHtml(refresh.started_at || "-")}
- 最近完成
- ${escapeHtml(refresh.finished_at || "-")}
- 状态
- ${escapeHtml(refresh.message || "-")}
数据库
${status.database?.ok ? "已连接" : "异常"}
- 主机
- ${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}
- 库表
- ${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}
- DICOM
- ${escapeHtml(settings.dicom?.processed_root || "-")}
`;
$("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);
$("toggleExportMode").addEventListener("click", toggleExportMode);
$("exportSelected").addEventListener("click", exportSelectedStudies);
$("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-study-part-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.studyPartFilter = button.dataset.studyPartFilter;
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);
}