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,
pendingImage: null,
drag: null,
seriesSort: "timeAsc",
showOverlay: true,
};
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"];
const WINDOW_PRESETS = {
bone: { wl: 500, ww: 1800 },
soft: { wl: 50, ww: 360 },
contrast: { wl: 90, ww: 140 },
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
showLogin(true);
throw new Error("unauthorized");
}
if (!res.ok) {
let detail = res.statusText;
try {
const data = await res.json();
detail = data.detail || detail;
} catch (_) {
detail = await res.text();
}
throw new Error(detail);
}
return res;
}
async function json(path, options = {}) {
const res = await request(path, options);
return res.json();
}
function showLogin(show) {
$("loginOverlay").classList.toggle("hidden", !show);
}
function fmtDate(value) {
const text = String(value || "");
if (text.length !== 8) return text;
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
function fmtTime(value) {
const digits = String(value || "").replace(/\D/g, "").slice(0, 6);
if (digits.length < 6) return value || "";
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`;
}
function timeRange(series) {
const first = fmtTime(series.first_time || series.series_time || series.study_time);
const last = fmtTime(series.last_time);
if (first && last && first !== last) return `${first}-${last}`;
return first || last || "未记录";
}
function timeKey(series) {
return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0");
}
function phaseLabel(value) {
return { arterial: "动脉期", portal_venous: "门静脉期", 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 = "登录失败";
}
}
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");
}
}
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;
if (app.seriesSort === "timeDesc") return -timeResult;
if (app.seriesSort === "countAsc") return countResult;
if (app.seriesSort === "countDesc") return -countResult;
return timeResult;
});
}
function setSeries(list) {
app.series = sortSeries(list || []);
$("seriesCount").textContent = String(app.series.length);
renderSeries();
}
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 = `
${escapeHtml(study.ct_number)}
${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}
${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张
${Number(study.annotated_series || 0)} 个序列已标注
`;
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;
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 || []);
if (app.series.length) selectSeries(app.series[0].series_uid);
} catch (err) {
$("seriesGrid").innerHTML = `
${escapeHtml(err.message)}
`;
}
}
function sourceTag(value, annotation) {
if (asList(annotation.manual_body_parts).includes(value)) return "manual";
if (asList(annotation.ai_body_parts).includes(value)) return "ai";
return "";
}
function seriesTags(series) {
const annotation = series.annotation || {};
if (annotation.skipped) {
return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }];
}
const tags = [];
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" : "ai" });
}
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
return tags;
}
function renderSeries() {
const grid = $("seriesGrid");
grid.innerHTML = "";
for (const series of app.series) {
const skipped = Boolean(series.annotation?.skipped);
const tags = seriesTags(series);
const card = document.createElement("button");
card.className = "series-card";
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
card.classList.toggle("skipped", skipped);
card.innerHTML = `
${Number(series.count || 0)} 张
${skipped ? "
略过" : ""}
${escapeHtml(series.description || "未命名序列")}
拍摄 ${escapeHtml(timeRange(series))}
序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}
${tags.length ? tags.map((tag) => `${escapeHtml(tag.label)}`).join("") : "未标注"}
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
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();
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 orientationLabels() {
if (app.plane === "sagittal") return { top: "H", bottom: "F", left: "A", right: "P" };
if (app.plane === "coronal") return { top: "H", bottom: "F", left: "R", right: "L" };
return { top: "A", bottom: "P", left: "R", right: "L" };
}
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 orient = orientationLabels();
const win = windowInfo();
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
const sex = s.patient_sex || "";
document.querySelector(".ov-left-top").innerHTML = [
s.patient_name || app.study?.source_patient_name || "",
[fmtDate(s.patient_birth_date), age, sex].filter(Boolean).join(" "),
s.patient_id || app.study?.patient_id || "",
fmtDate(s.study_date || app.study?.study_date),
].filter(Boolean).map(escapeHtml).join("
");
document.querySelector(".ov-right-top").innerHTML = [
s.institution_name || "",
s.manufacturer || "",
].filter(Boolean).map(escapeHtml).join("
");
document.querySelector(".ov-left-mid").textContent = orient.left;
document.querySelector(".ov-right-mid").textContent = orient.right;
document.querySelector(".ov-top-mid").textContent = orient.top;
document.querySelector(".ov-bottom-mid").textContent = orient.bottom;
document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}
WL:${Math.round(win.wl)}
Zoom:${app.zoom.toFixed(2)}`;
}
async function updateImage() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
app.slice = clamp(app.slice, 0, max);
$("sliceSlider").max = String(max);
$("sliceSlider").value = String(app.slice);
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
$("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 || "",
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 || app.draft.skipped) 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 effectivePlainCt() {
if (!app.draft || app.draft.skipped) 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 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 = app.draft.skipped;
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 = app.draft.skipped;
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 = app.draft.skipped;
setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : "");
});
$("phaseBox").classList.toggle("visible", !app.draft.skipped && parts.has("upper_abdomen"));
}
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 || "";
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);
renderSeries();
applyAnnotationControls();
}
function annotationPayload() {
const bodyParts = app.draft.skipped ? [] : effectiveParts();
return {
body_parts: bodyParts,
manual_body_parts: app.draft.skipped ? [] : app.draft.manual_body_parts,
ai_body_parts: app.draft.skipped ? [] : app.draft.ai_body_parts,
upper_abdomen_phase: app.draft.skipped ? "" : effectivePhase(),
manual_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.manual_upper_abdomen_phase,
ai_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.ai_upper_abdomen_phase,
plain_ct: effectivePlainCt(),
manual_plain_ct: app.draft.skipped ? null : app.draft.manual_plain_ct,
ai_plain_ct: app.draft.skipped ? null : 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;
$("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 = "已保存";
updateLocalAnnotation(data);
} catch (err) {
$("saveState").textContent = err.message;
}
}
function queueSave(delay = 450) {
clearTimeout(app.saveTimer);
$("saveState").textContent = "待保存";
app.saveTimer = setTimeout(saveAnnotationNow, delay);
}
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;
if (input.checked) {
app.draft.manual_body_parts = [];
app.draft.ai_body_parts = [];
app.draft.manual_upper_abdomen_phase = "";
app.draft.ai_upper_abdomen_phase = "";
app.draft.manual_plain_ct = null;
app.draft.ai_plain_ct = null;
}
} 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]);
} 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 = "";
}
}
}
app.draft.body_parts = effectiveParts();
app.draft.upper_abdomen_phase = effectivePhase();
app.draft.plain_ct = effectivePlainCt();
applyAnnotationControls();
queueSave();
}
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();
queueSave();
}
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 结果已保存";
} 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 = `${escapeHtml(title)}
${Object.entries(fields)
.map(([key, value]) => `${escapeHtml(key)}${escapeHtml(value || "-")}
`)
.join("")}`;
content.appendChild(section);
}
$("infoModal").classList.remove("hidden");
}
function settingsTable(users) {
return `
| 账号 | 角色 | 状态 |
${(users || [])
.map((user) => `| ${escapeHtml(user.username)} | ${escapeHtml(user.role)} | ${escapeHtml(user.status)} |
`)
.join("")}
`;
}
function roleCards(roles) {
return (roles || [])
.map(
(role) => `
${escapeHtml(role.name)}
${(role.permissions || []).map(escapeHtml).join(" / ")}
`,
)
.join("");
}
async function openSettings() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
$("settingsContent").innerHTML = `
用户创建
当前登录:admin
${settingsTable(settings.users)}
权限控制
按角色控制查看、标注、AI 与系统设置
${roleCards(settings.roles)}
AI 设置
${settings.ai?.configured ? "已配置" : "未配置"}
- 供应商
- ${escapeHtml(settings.ai?.provider || "-")}
- 名称
- ${escapeHtml(settings.ai?.name || "-")}
- 模型
- ${escapeHtml(settings.ai?.model || "-")}
数据库
${status.database?.ok ? "已连接" : "异常"}
- 主机
- ${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}
- 库表
- ${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}
- DICOM
- ${escapeHtml(settings.dicom?.processed_root || "-")}
`;
$("createUserForm").addEventListener("submit", createUser);
$("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 setSort(direction) {
app.seriesSort = direction;
document.querySelectorAll("[data-sort]").forEach((button) => {
button.classList.toggle("active", button.dataset.sort === direction);
});
app.series = sortSeries(app.series);
renderSeries();
if (app.activeSeries) {
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
}
}
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-sort]").forEach((button) => {
button.addEventListener("click", () => setSort(button.dataset.sort));
});
$("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));
$("annotationNotes").addEventListener("input", () => {
if (!app.draft) return;
app.draft.notes = $("annotationNotes").value;
queueSave(900);
});
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);
}