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

1053 lines
37 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,
dirty: false,
saving: false,
pendingImage: null,
drag: null,
studySortField: "time",
studySortDir: "desc",
seriesSortField: "time",
seriesSortDir: "asc",
showOverlay: true,
thumbUrls: 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;
localStorage.setItem("pacs_web_token", app.token);
showLogin(false);
await boot();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
async function logout() {
await confirmSaveIfDirty();
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;
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 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");
});
}
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`));
$("studyCount").textContent = String(app.studies.length);
renderStudies();
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
}
function renderStudies() {
const list = $("studyList");
app.studies = sortStudies(app.studies);
updateSortButtons();
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)} 个序列已标注,${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 = "读取中";
$("seriesGrid").innerHTML = "";
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 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" });
}
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" : annotation.ai_upper_abdomen_phase ? "ai" : "",
warn: annotation.upper_abdomen_phase === "unknown",
});
}
const chestWindow = chestWindowLabel(annotation.chest_window);
if (chestWindow) {
tags.push({
label: chestWindow,
source: annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "",
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="" />
<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 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 key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
return;
}
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;
} catch (_) {
img.removeAttribute("src");
}
}
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);
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);
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 = "已保存";
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}`;
$("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 || "",
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("");
}
async function openSettings() {
await confirmSaveIfDirty();
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 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);
$("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-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"));
});
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);
}