Refine PACS viewer annotation workflow
This commit is contained in:
@@ -4,6 +4,7 @@ const app = {
|
||||
study: null,
|
||||
series: [],
|
||||
activeSeries: null,
|
||||
draft: null,
|
||||
slice: 0,
|
||||
plane: "axial",
|
||||
window: "default",
|
||||
@@ -14,12 +15,21 @@ const app = {
|
||||
imageUrl: "",
|
||||
searchTimer: null,
|
||||
statusTimer: null,
|
||||
saveTimer: null,
|
||||
pendingImage: null,
|
||||
drag: null,
|
||||
seriesSort: "asc",
|
||||
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 ?? "")
|
||||
@@ -80,6 +90,10 @@ function timeRange(series) {
|
||||
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] || "";
|
||||
}
|
||||
@@ -94,6 +108,28 @@ function partLabel(value) {
|
||||
}[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 = "";
|
||||
@@ -132,6 +168,22 @@ async function refreshStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
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 result = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true });
|
||||
return app.seriesSort === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -163,6 +215,7 @@ 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 = "";
|
||||
@@ -171,24 +224,45 @@ async function selectStudy(ctNumber) {
|
||||
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();
|
||||
setSeries(data.series || []);
|
||||
if (app.series.length) selectSeries(app.series[0].series_uid);
|
||||
} catch (err) {
|
||||
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceTag(value, annotation) {
|
||||
if (asList(annotation.manual_body_parts).includes(value)) return "manual";
|
||||
if (asList(annotation.ai_body_parts).includes(value)) return "ai";
|
||||
return "";
|
||||
}
|
||||
|
||||
function seriesTags(series) {
|
||||
const annotation = series.annotation || {};
|
||||
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 parts = series.annotation?.body_parts || [];
|
||||
const tags = skipped
|
||||
? ["略过"]
|
||||
: [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean);
|
||||
const tags = seriesTags(series);
|
||||
const card = document.createElement("button");
|
||||
card.className = "series-card";
|
||||
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
|
||||
@@ -203,7 +277,7 @@ function renderSeries() {
|
||||
<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 class="tag-line">${tags.length ? tags.map((tag) => `<em class="${tag.source ? `tag-${tag.source}` : ""}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => selectSeries(series.series_uid);
|
||||
@@ -262,12 +336,56 @@ function resetViewer() {
|
||||
$("dicomImage").removeAttribute("src");
|
||||
$("imageEmpty").classList.remove("hidden");
|
||||
$("sliceText").textContent = "0 / 0";
|
||||
$("saveState").textContent = "未保存";
|
||||
$("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("<br />");
|
||||
document.querySelector(".ov-right-top").innerHTML = [
|
||||
s.institution_name || "",
|
||||
s.manufacturer || "",
|
||||
].filter(Boolean).map(escapeHtml).join("<br />");
|
||||
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)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
|
||||
}
|
||||
|
||||
async function updateImage() {
|
||||
@@ -278,6 +396,7 @@ async function updateImage() {
|
||||
$("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 {
|
||||
@@ -292,95 +411,186 @@ async function updateImage() {
|
||||
}
|
||||
}
|
||||
|
||||
function isSkippedChecked() {
|
||||
return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked);
|
||||
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 selectedParts() {
|
||||
return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value);
|
||||
function effectiveParts() {
|
||||
if (!app.draft || app.draft.skipped) return [];
|
||||
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]);
|
||||
}
|
||||
|
||||
function syncPartState() {
|
||||
const skipped = isSkippedChecked();
|
||||
document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => {
|
||||
input.disabled = skipped;
|
||||
if (skipped) input.checked = false;
|
||||
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" : "");
|
||||
});
|
||||
if (skipped) {
|
||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||
input.checked = false;
|
||||
});
|
||||
}
|
||||
const show = !skipped && selectedParts().includes("upper_abdomen");
|
||||
$("phaseBox").classList.toggle("visible", show);
|
||||
|
||||
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() {
|
||||
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();
|
||||
app.draft = cloneAnnotation(app.activeSeries?.annotation || {});
|
||||
$("annotationNotes").value = app.draft.notes || "";
|
||||
applyAnnotationControls();
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
hydrateAnnotation();
|
||||
applyAnnotationControls();
|
||||
}
|
||||
|
||||
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 = "";
|
||||
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 {
|
||||
await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
|
||||
const data = 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 }),
|
||||
body: JSON.stringify(annotationPayload()),
|
||||
});
|
||||
$("saveState").textContent = "已保存";
|
||||
await reloadCurrentStudySeries(uid);
|
||||
updateLocalAnnotation(data);
|
||||
} 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;
|
||||
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;
|
||||
}
|
||||
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();
|
||||
} 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() {
|
||||
@@ -393,8 +603,9 @@ async function runAI() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sample_count: 3 }),
|
||||
});
|
||||
applyAiSuggestion(data);
|
||||
$("saveState").textContent = "AI 建议已填入,确认后保存";
|
||||
updateLocalAnnotation(data);
|
||||
$("annotationNotes").value = data.notes || "";
|
||||
$("saveState").textContent = "AI 结果已保存";
|
||||
} catch (err) {
|
||||
$("saveState").textContent = err.message;
|
||||
} finally {
|
||||
@@ -530,7 +741,7 @@ function wireImageGestures() {
|
||||
{ passive: false },
|
||||
);
|
||||
wrap.addEventListener("pointerdown", (event) => {
|
||||
if (!app.activeSeries) return;
|
||||
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);
|
||||
@@ -549,6 +760,17 @@ function wireImageGestures() {
|
||||
});
|
||||
}
|
||||
|
||||
function setSort(direction) {
|
||||
app.seriesSort = direction;
|
||||
$("sortAsc").classList.toggle("active", direction === "asc");
|
||||
$("sortDesc").classList.toggle("active", direction === "desc");
|
||||
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);
|
||||
@@ -557,6 +779,12 @@ function wire() {
|
||||
$("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();
|
||||
});
|
||||
$("sortAsc").addEventListener("click", () => setSort("asc"));
|
||||
$("sortDesc").addEventListener("click", () => setSort("desc"));
|
||||
$("studySearch").addEventListener("input", () => {
|
||||
clearTimeout(app.searchTimer);
|
||||
app.searchTimer = setTimeout(loadStudies, 250);
|
||||
@@ -589,8 +817,13 @@ function wire() {
|
||||
applyTransform();
|
||||
});
|
||||
$("resetView").addEventListener("click", resetViewState);
|
||||
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", syncPartState));
|
||||
$("saveAnnotation").addEventListener("click", saveAnnotation);
|
||||
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"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user