1562 lines
57 KiB
JavaScript
1562 lines
57 KiB
JavaScript
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: "modified",
|
||
studySortDir: "desc",
|
||
studyFilter: "all",
|
||
studyPartFilter: "all",
|
||
exportDialog: { ctNumber: "", series: [] },
|
||
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.first_time || series.series_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 studyModifiedKey(study) {
|
||
const raw = String(study.modified_at || study.annotation_updated_at || study.completion_updated_at || study.completed_at || "");
|
||
return raw ? raw.replace(/\D/g, "").padEnd(14, "0") : studyTimeKey(study);
|
||
}
|
||
|
||
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());
|
||
if (!isAdmin()) $("exportModal").classList.add("hidden");
|
||
}
|
||
|
||
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 modifiedResult = studyModifiedKey(a).localeCompare(studyModifiedKey(b)) || timeResult;
|
||
const result = app.studySortField === "study_time" ? timeResult : modifiedResult;
|
||
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 isSeriesUndetermined(series) {
|
||
const annotation = series.annotation || {};
|
||
if (annotation.skipped) return false;
|
||
const parts = asList(annotation.body_parts);
|
||
return (
|
||
(parts.includes("upper_abdomen") && annotation.upper_abdomen_phase === "unknown") ||
|
||
(parts.includes("chest") && annotation.chest_window === "unknown")
|
||
);
|
||
}
|
||
|
||
function refreshStudyAnnotationSummary() {
|
||
if (!app.study || !app.series.length) return;
|
||
const annotated = app.series.filter(isSeriesAnnotated).length;
|
||
const undetermined = app.series.filter(isSeriesUndetermined).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),
|
||
undetermined_series: undetermined,
|
||
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 exportDownloadUrl(ctNumber, seriesUids) {
|
||
const params = new URLSearchParams();
|
||
for (const uid of seriesUids) params.append("series_uids", uid);
|
||
params.set("access_token", app.token);
|
||
return `/api/studies/${encodeURIComponent(ctNumber)}/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 defaultExportChecked(series) {
|
||
if (series.annotation?.skipped) return false;
|
||
if (app.studyPartFilter !== "all") return asList(series.annotation?.body_parts).includes(app.studyPartFilter);
|
||
return true;
|
||
}
|
||
|
||
function exportLabelText(series) {
|
||
const tags = seriesTags(series)
|
||
.filter((tag) => BODY_PARTS.some((part) => tag.label.startsWith(partLabel(part))) || tag.label === "无法判别" || tag.label === "略过/不采用")
|
||
.map((tag) => tag.label);
|
||
return tags.join(" / ") || "未标注";
|
||
}
|
||
|
||
function updateExportModalCount() {
|
||
const selected = document.querySelectorAll("#exportSeriesList input[type=checkbox]:checked").length;
|
||
$("exportModalCount").textContent = `${selected} 个序列`;
|
||
$("downloadSelectedSeries").disabled = selected === 0;
|
||
}
|
||
|
||
function renderExportSeriesList(seriesList) {
|
||
const list = $("exportSeriesList");
|
||
list.innerHTML = "";
|
||
for (const series of seriesList) {
|
||
const checked = defaultExportChecked(series);
|
||
const item = document.createElement("label");
|
||
item.className = "export-series-item";
|
||
item.classList.toggle("skipped", Boolean(series.annotation?.skipped));
|
||
item.innerHTML = `
|
||
<input type="checkbox" value="${escapeHtml(series.series_uid)}" ${checked ? "checked" : ""} />
|
||
<div>
|
||
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||
<span>序列 ${escapeHtml(series.series_number || "-")} · ${Number(series.count || 0)} 张 · ${escapeHtml(timeRange(series))}</span>
|
||
<em>${escapeHtml(exportLabelText(series))}</em>
|
||
</div>
|
||
`;
|
||
item.querySelector("input").addEventListener("change", updateExportModalCount);
|
||
list.appendChild(item);
|
||
}
|
||
updateExportModalCount();
|
||
}
|
||
|
||
async function loadSeriesForExport(ctNumber) {
|
||
if (app.study?.ct_number === ctNumber && app.series.length) return app.series;
|
||
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
|
||
const study = app.studies.find((item) => item.ct_number === ctNumber);
|
||
if (study) Object.assign(study, data.study);
|
||
return sortSeries(data.series || []);
|
||
}
|
||
|
||
async function exportStudy(ctNumber, event) {
|
||
event.stopPropagation();
|
||
if (!isAdmin()) return;
|
||
if (!(await confirmSaveIfDirty())) return;
|
||
$("exportStudyLabel").textContent = ctNumber;
|
||
$("exportHint").textContent =
|
||
app.studyPartFilter === "all"
|
||
? "默认不选择略过/不采用序列"
|
||
: `默认仅选择 ${partLabel(app.studyPartFilter)},且不选择略过/不采用序列`;
|
||
$("exportSeriesList").innerHTML = `<p class="export-empty">正在读取序列...</p>`;
|
||
$("exportModal").classList.remove("hidden");
|
||
try {
|
||
const seriesList = await loadSeriesForExport(ctNumber);
|
||
app.exportDialog = { ctNumber, series: seriesList };
|
||
renderExportSeriesList(seriesList);
|
||
} catch (err) {
|
||
$("exportSeriesList").innerHTML = `<p class="export-empty error-line">${escapeHtml(err.message)}</p>`;
|
||
updateExportModalCount();
|
||
}
|
||
}
|
||
|
||
function selectDefaultExportSeries() {
|
||
document.querySelectorAll("#exportSeriesList input[type=checkbox]").forEach((input) => {
|
||
const series = app.exportDialog.series.find((item) => item.series_uid === input.value);
|
||
input.checked = series ? defaultExportChecked(series) : false;
|
||
});
|
||
updateExportModalCount();
|
||
}
|
||
|
||
function clearExportSeries() {
|
||
document.querySelectorAll("#exportSeriesList input[type=checkbox]").forEach((input) => {
|
||
input.checked = false;
|
||
});
|
||
updateExportModalCount();
|
||
}
|
||
|
||
function downloadSelectedSeries() {
|
||
const selected = Array.from(document.querySelectorAll("#exportSeriesList input[type=checkbox]:checked")).map((input) => input.value);
|
||
if (!app.exportDialog.ctNumber || selected.length === 0) return;
|
||
triggerDownload(exportDownloadUrl(app.exportDialog.ctNumber, selected));
|
||
}
|
||
|
||
function initialCtNumber() {
|
||
return new URLSearchParams(location.search).get("ct_number") || "";
|
||
}
|
||
|
||
async function loadStudies(preferredCtNumber = "") {
|
||
const q = encodeURIComponent($("studySearch").value.trim());
|
||
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||
renderStudies();
|
||
if (!app.study && app.studies.length) {
|
||
const preferred = app.studies.find((study) => study.ct_number === preferredCtNumber);
|
||
await selectStudy((preferred || 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 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");
|
||
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
||
const bodyPartLabels = asList(study.body_parts)
|
||
.filter((part) => BODY_PARTS.includes(part))
|
||
.map((part) => partLabel(part));
|
||
const partTags = bodyPartLabels
|
||
.map((label) => `<em title="${escapeHtml(label)}">${escapeHtml(label)}</em>`)
|
||
.join("");
|
||
const studyDateTime = `${fmtDate(study.study_date)} ${fmtTime(study.study_time)}`.trim() || "未记录时间";
|
||
const annotationSummary = `${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.undetermined_series || 0)} 个序列待判别`;
|
||
const cardTitle = [
|
||
`检查号:${study.ct_number}`,
|
||
`患者:${name} · ${study.patient_id || "无ID"}`,
|
||
`时间:${studyDateTime}`,
|
||
`DICOM:${Number(study.dicom_file_count || 0)} 张`,
|
||
annotationSummary,
|
||
`部位:${bodyPartLabels.join("、") || "无"}`,
|
||
].join("\n");
|
||
const exportControls = isAdmin()
|
||
? `
|
||
<span class="study-export-btn" role="button" tabindex="0" title="选择序列导出" aria-label="选择序列导出">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 3v11m0 0 4-4m-4 4-4-4M5 20h14" />
|
||
</svg>
|
||
</span>
|
||
`
|
||
: "";
|
||
card.title = cardTitle;
|
||
card.setAttribute("aria-label", cardTitle);
|
||
card.innerHTML = `
|
||
<div class="study-card-status-stack">
|
||
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
|
||
<div class="study-part-tags">${partTags}</div>
|
||
</div>
|
||
<strong title="${escapeHtml(study.ct_number)}">${escapeHtml(study.ct_number)}</strong>
|
||
<span class="study-patient" title="${escapeHtml(`${name} · ${study.patient_id || "无ID"}`)}">${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
|
||
<div class="study-meta">
|
||
<span title="${escapeHtml(studyDateTime)}">${escapeHtml(studyDateTime)}</span>
|
||
<div class="study-summary-line">
|
||
<span title="${escapeHtml(annotationSummary)}">${escapeHtml(annotationSummary)}</span>
|
||
${exportControls}
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 === " ") {
|
||
event.preventDefault();
|
||
exportStudy(study.ct_number, event);
|
||
}
|
||
});
|
||
list.appendChild(card);
|
||
}
|
||
}
|
||
|
||
function applyStudyCompletion(data) {
|
||
const completed = Boolean(data.completed);
|
||
const modifiedAt = data.completion_updated_at || data.modified_at || data.completed_at || "";
|
||
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 || "";
|
||
study.completion_updated_at = data.completion_updated_at || study.completion_updated_at || "";
|
||
if (modifiedAt) study.modified_at = modifiedAt;
|
||
}
|
||
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 || "";
|
||
app.study.completion_updated_at = data.completion_updated_at || app.study.completion_updated_at || "";
|
||
if (modifiedAt) app.study.modified_at = modifiedAt;
|
||
}
|
||
}
|
||
|
||
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 = `<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" });
|
||
}
|
||
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())) 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("<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(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();
|
||
const modifiedAt = normalized.updated_at || new Date().toISOString();
|
||
if (app.study && modifiedAt) {
|
||
app.study.modified_at = modifiedAt;
|
||
app.study.annotation_updated_at = modifiedAt;
|
||
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
|
||
if (study) {
|
||
study.modified_at = modifiedAt;
|
||
study.annotation_updated_at = modifiedAt;
|
||
}
|
||
}
|
||
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 = `<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) {
|
||
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 = `
|
||
<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");
|
||
const sliceRail = document.querySelector(".slice-rail");
|
||
["pointerdown", "pointermove", "pointerup", "pointercancel", "click"].forEach((name) => {
|
||
sliceRail.addEventListener(name, (event) => event.stopPropagation());
|
||
});
|
||
sliceRail.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
|
||
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);
|
||
$("selectExportableSeries").addEventListener("click", selectDefaultExportSeries);
|
||
$("clearExportSeries").addEventListener("click", clearExportSeries);
|
||
$("downloadSelectedSeries").addEventListener("click", downloadSelectedSeries);
|
||
$("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(initialCtNumber());
|
||
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);
|
||
}
|