Add Docker PACS DICOM web viewer

This commit is contained in:
Codex
2026-05-27 08:43:33 +08:00
parent f3e3cfff1d
commit 9c478ed392
10 changed files with 2577 additions and 0 deletions

View File

@@ -0,0 +1,613 @@
const app = {
token: localStorage.getItem("pacs_web_token") || "",
studies: [],
study: null,
series: [],
activeSeries: null,
slice: 0,
plane: "axial",
window: "default",
rotate: 0,
zoom: 1,
panX: 0,
panY: 0,
imageUrl: "",
searchTimer: null,
statusTimer: null,
pendingImage: null,
drag: null,
};
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
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 phaseLabel(value) {
return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || "";
}
function partLabel(value) {
return {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
}[value] || value;
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const data = await json("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value, password: $("password").value }),
});
app.token = data.token;
localStorage.setItem("pacs_web_token", app.token);
showLogin(false);
await boot();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
function logout() {
app.token = "";
localStorage.removeItem("pacs_web_token");
showLogin(true);
}
async function refreshStatus() {
try {
const data = await fetch("/api/status").then((res) => res.json());
const pill = $("dbStatus");
pill.textContent = data.database?.ok ? `数据库 ${data.database.rows ?? ""}` : "数据库异常";
pill.title = data.database?.message || "";
pill.classList.toggle("offline", !data.database?.ok);
pill.classList.toggle("online", Boolean(data.database?.ok));
} catch (_) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").classList.add("offline");
$("dbStatus").classList.remove("online");
}
}
async function loadStudies() {
const q = encodeURIComponent($("studySearch").value.trim());
app.studies = await json(`/api/studies?q=${q}&limit=500`);
$("studyCount").textContent = String(app.studies.length);
renderStudies();
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
}
function renderStudies() {
const list = $("studyList");
list.innerHTML = "";
for (const study of app.studies) {
const button = document.createElement("button");
button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = `
<strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
<span>${Number(study.annotated_series || 0)} 个序列已标注</span>
`;
button.onclick = () => selectStudy(study.ct_number);
list.appendChild(button);
}
}
async function selectStudy(ctNumber) {
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
app.series = [];
app.activeSeries = null;
$("activeStudyLabel").textContent = ctNumber;
$("seriesCount").textContent = "读取中";
$("seriesGrid").innerHTML = "";
resetViewer();
renderStudies();
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
app.study = data.study;
app.series = data.series || [];
$("seriesCount").textContent = String(app.series.length);
renderSeries();
if (app.series.length) selectSeries(app.series[0].series_uid);
} catch (err) {
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
}
}
function renderSeries() {
const grid = $("seriesGrid");
grid.innerHTML = "";
for (const series of app.series) {
const skipped = Boolean(series.annotation?.skipped);
const parts = series.annotation?.body_parts || [];
const tags = skipped
? ["略过"]
: [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean);
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>${escapeHtml(tag)}</em>`).join("") : "<em>未标注</em>"}</div>
</div>
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
loadThumb(series, card.querySelector("img"));
}
}
function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) {
return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`;
}
async function loadThumb(series, img) {
try {
const index = Math.floor((Number(series.count) || 1) / 2);
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
img.src = URL.createObjectURL(blob);
} catch (_) {
img.removeAttribute("src");
}
}
function maxSlice() {
if (!app.activeSeries) return 0;
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
if (app.plane === "coronal") return Math.max(0, Number(app.activeSeries.rows || 1) - 1);
return Math.max(0, Number(app.activeSeries.count || 1) - 1);
}
function resetViewState() {
app.rotate = 0;
app.zoom = 1;
app.panX = 0;
app.panY = 0;
applyTransform();
}
function selectSeries(seriesUid) {
const series = app.series.find((item) => item.series_uid === seriesUid);
if (!series) return;
app.activeSeries = series;
app.slice = Math.floor(maxSlice() / 2);
resetViewState();
$("sliceSlider").max = String(maxSlice());
$("sliceSlider").value = String(app.slice);
hydrateAnnotation();
renderSeries();
updateImage();
}
function resetViewer() {
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = "";
app.pendingImage = null;
app.activeSeries = null;
$("dicomImage").removeAttribute("src");
$("imageEmpty").classList.remove("hidden");
$("sliceText").textContent = "0 / 0";
$("saveState").textContent = "未保存";
resetViewState();
}
function applyTransform() {
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
}
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");
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 isSkippedChecked() {
return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked);
}
function selectedParts() {
return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value);
}
function syncPartState() {
const skipped = isSkippedChecked();
document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => {
input.disabled = skipped;
if (skipped) input.checked = false;
});
if (skipped) {
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = false;
});
}
const show = !skipped && selectedParts().includes("upper_abdomen");
$("phaseBox").classList.toggle("visible", show);
}
function hydrateAnnotation() {
const annotation = app.activeSeries?.annotation || {};
const parts = new Set(annotation.body_parts || []);
document.querySelectorAll(".part-grid input").forEach((input) => {
if (input.value === "skip") {
input.checked = Boolean(annotation.skipped);
return;
}
input.checked = !annotation.skipped && parts.has(input.value);
input.disabled = Boolean(annotation.skipped);
});
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || "");
});
$("annotationNotes").value = annotation.notes || "";
syncPartState();
}
async function reloadCurrentStudySeries(keepUid) {
const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`);
app.study = data.study;
app.series = data.series || [];
app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null;
$("seriesCount").textContent = String(app.series.length);
renderSeries();
hydrateAnnotation();
}
async function saveAnnotation() {
if (!app.study || !app.activeSeries) return;
const skipped = isSkippedChecked();
const parts = skipped ? [] : selectedParts();
let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || "";
if (parts.includes("upper_abdomen") && !phase) {
$("saveState").textContent = "请选择上腹部期相";
return;
}
if (!parts.includes("upper_abdomen")) phase = "";
$("saveState").textContent = "保存中";
const uid = app.activeSeries.series_uid;
try {
await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
method: "PUT",
body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }),
});
$("saveState").textContent = "已保存";
await reloadCurrentStudySeries(uid);
} catch (err) {
$("saveState").textContent = err.message;
}
}
function applyAiSuggestion(data) {
const parts = new Set(data.body_parts || []);
document.querySelectorAll(".part-grid input").forEach((input) => {
if (input.value === "skip") {
input.checked = Boolean(data.skipped);
return;
}
input.checked = !data.skipped && parts.has(input.value);
});
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || "");
});
if (data.notes) $("annotationNotes").value = data.notes;
syncPartState();
}
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 }),
});
applyAiSuggestion(data);
$("saveState").textContent = "AI 建议已填入,确认后保存";
} catch (err) {
$("saveState").textContent = err.message;
} finally {
button.disabled = false;
}
}
async function openInfo() {
if (!app.study || !app.activeSeries) return;
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
const content = $("infoContent");
content.innerHTML = "";
for (const [title, fields] of Object.entries(data.fields || {})) {
const section = document.createElement("section");
section.className = "info-card";
section.innerHTML = `<h3>${escapeHtml(title)}</h3>${Object.entries(fields)
.map(([key, value]) => `<p class="info-row"><span>${escapeHtml(key)}</span><b>${escapeHtml(value || "-")}</b></p>`)
.join("")}`;
content.appendChild(section);
}
$("infoModal").classList.remove("hidden");
}
function settingsTable(users) {
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th></tr></thead>
<tbody>
${(users || [])
.map((user) => `<tr><td>${escapeHtml(user.username)}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.status)}</td></tr>`)
.join("")}
</tbody>
</table>
`;
}
function roleCards(roles) {
return (roles || [])
.map(
(role) => `
<div class="role-card">
<strong>${escapeHtml(role.name)}</strong>
<span>${(role.permissions || []).map(escapeHtml).join(" / ")}</span>
</div>
`,
)
.join("");
}
async function openSettings() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title"><h3>用户创建</h3><span>当前登录admin</span></div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" autocomplete="off" />
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<button type="submit">创建</button>
</form>
${settingsTable(settings.users)}
</section>
<section class="settings-section">
<div class="settings-title"><h3>权限控制</h3><span>按角色控制查看、标注、AI 与系统设置</span></div>
<div class="role-grid">${roleCards(settings.roles)}</div>
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.configured ? "已配置" : "未配置"}</span></div>
<dl>
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
<dl>
<dt>主机</dt><dd>${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}</dd>
<dt>库表</dt><dd>${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}</dd>
<dt>DICOM</dt><dd>${escapeHtml(settings.dicom?.processed_root || "-")}</dd>
</dl>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", createUser);
$("settingsModal").classList.remove("hidden");
}
async function createUser(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button");
const form = new FormData(event.currentTarget);
const payload = {
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
};
if (!payload.username || !payload.password) return;
button.disabled = true;
button.textContent = "创建中";
try {
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
await openSettings();
} catch (err) {
button.textContent = err.message;
setTimeout(() => {
button.disabled = false;
button.textContent = "创建";
}, 1800);
}
}
function changeZoom(multiplier) {
app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
applyTransform();
}
function wireImageGestures() {
const wrap = document.querySelector(".image-wrap");
document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation());
wrap.addEventListener(
"wheel",
(event) => {
if (!app.activeSeries) return;
event.preventDefault();
changeZoom(event.deltaY < 0 ? 1.12 : 1 / 1.12);
},
{ passive: false },
);
wrap.addEventListener("pointerdown", (event) => {
if (!app.activeSeries) 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 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));
$("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", syncPartState));
$("saveAnnotation").addEventListener("click", saveAnnotation);
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);
}