Bind annotation saves to selected series

This commit is contained in:
Codex
2026-05-27 13:34:37 +08:00
parent 3a4bf5a486
commit fc681fa08b

View File

@@ -17,6 +17,9 @@ const app = {
searchTimer: null, searchTimer: null,
statusTimer: null, statusTimer: null,
saveTimer: null, saveTimer: null,
savePromise: null,
savingSeriesUid: "",
draftVersion: 0,
dirty: false, dirty: false,
saving: false, saving: false,
pendingImage: null, pendingImage: null,
@@ -176,7 +179,7 @@ async function login(event) {
} }
async function logout() { async function logout() {
await confirmSaveIfDirty(); if (!(await confirmSaveIfDirty())) return;
app.token = ""; app.token = "";
app.user = null; app.user = null;
localStorage.removeItem("pacs_web_token"); localStorage.removeItem("pacs_web_token");
@@ -373,7 +376,7 @@ async function toggleStudyCompletion(ctNumber, event) {
} }
async function selectStudy(ctNumber) { async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty(); 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.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
app.series = []; app.series = [];
app.activeSeries = null; app.activeSeries = null;
@@ -598,7 +601,7 @@ function resetViewState() {
} }
async function selectSeries(seriesUid) { async function selectSeries(seriesUid) {
if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid) await confirmSaveIfDirty(); if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid && !(await confirmSaveIfDirty())) return;
const series = app.series.find((item) => item.series_uid === seriesUid); const series = app.series.find((item) => item.series_uid === seriesUid);
if (!series) return; if (!series) return;
app.activeSeries = series; app.activeSeries = series;
@@ -736,26 +739,26 @@ function cloneAnnotation(annotation = {}) {
}; };
} }
function effectiveParts() { function effectiveParts(draft = app.draft) {
if (!app.draft) return []; if (!draft) return [];
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]); return uniq([...asList(draft.manual_body_parts), ...asList(draft.ai_body_parts)]);
} }
function effectivePhase() { function effectivePhase(draft = app.draft) {
if (!effectiveParts().includes("upper_abdomen")) return ""; if (!effectiveParts(draft).includes("upper_abdomen")) return "";
return app.draft.manual_upper_abdomen_phase || app.draft.ai_upper_abdomen_phase || ""; return draft.manual_upper_abdomen_phase || draft.ai_upper_abdomen_phase || "";
} }
function effectiveChestWindow() { function effectiveChestWindow(draft = app.draft) {
if (!effectiveParts().includes("chest")) return ""; if (!effectiveParts(draft).includes("chest")) return "";
return app.draft.manual_chest_window || app.draft.ai_chest_window || "unknown"; return draft.manual_chest_window || draft.ai_chest_window || "unknown";
} }
function effectivePlainCt() { function effectivePlainCt(draft = app.draft) {
if (!app.draft) return false; if (!draft) return false;
if (app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined) return Boolean(app.draft.manual_plain_ct); if (draft.manual_plain_ct !== null && draft.manual_plain_ct !== undefined) return Boolean(draft.manual_plain_ct);
if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct); if (draft.ai_plain_ct !== null && draft.ai_plain_ct !== undefined) return Boolean(draft.ai_plain_ct);
return Boolean(app.draft.plain_ct); return Boolean(draft.plain_ct);
} }
function setLabelSource(input, source) { function setLabelSource(input, source) {
@@ -816,83 +819,122 @@ function applyAnnotationControls() {
function hydrateAnnotation() { function hydrateAnnotation() {
app.draft = cloneAnnotation(app.activeSeries?.annotation || {}); app.draft = cloneAnnotation(app.activeSeries?.annotation || {});
app.draftVersion = 0;
$("annotationNotes").value = app.draft.notes || ""; $("annotationNotes").value = app.draft.notes || "";
applyAnnotationControls(); applyAnnotationControls();
} }
function updateLocalAnnotation(annotation) { function updateLocalAnnotation(annotation, targetSeriesUid = app.activeSeries?.series_uid, options = {}) {
if (!targetSeriesUid) return;
const normalized = cloneAnnotation(annotation); const normalized = cloneAnnotation(annotation);
normalized.body_parts = asList(annotation.body_parts); normalized.body_parts = asList(annotation.body_parts);
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || ""; normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
normalized.chest_window = annotation.chest_window || ""; normalized.chest_window = annotation.chest_window || "";
app.draft = normalized; const index = app.series.findIndex((item) => item.series_uid === targetSeriesUid);
app.activeSeries.annotation = normalized; if (index >= 0) app.series[index] = { ...app.series[index], 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); 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(); refreshStudyAnnotationSummary();
renderSeries(); renderSeries();
renderStudies(); renderStudies();
applyAnnotationControls(); if (isActive && applyToActive) applyAnnotationControls();
} }
function annotationPayload() { function annotationPayload(draft = app.draft, notes = $("annotationNotes").value) {
const bodyParts = effectiveParts(); const bodyParts = effectiveParts(draft);
return { return {
body_parts: bodyParts, body_parts: bodyParts,
manual_body_parts: app.draft.manual_body_parts, manual_body_parts: asList(draft.manual_body_parts),
ai_body_parts: app.draft.ai_body_parts, ai_body_parts: asList(draft.ai_body_parts),
upper_abdomen_phase: effectivePhase(), upper_abdomen_phase: effectivePhase(draft),
manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase, manual_upper_abdomen_phase: draft.manual_upper_abdomen_phase,
ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase, ai_upper_abdomen_phase: draft.ai_upper_abdomen_phase,
chest_window: effectiveChestWindow(), chest_window: effectiveChestWindow(draft),
manual_chest_window: app.draft.manual_chest_window, manual_chest_window: draft.manual_chest_window,
ai_chest_window: app.draft.ai_chest_window, ai_chest_window: draft.ai_chest_window,
plain_ct: effectivePlainCt(), plain_ct: effectivePlainCt(draft),
manual_plain_ct: app.draft.manual_plain_ct, manual_plain_ct: draft.manual_plain_ct,
ai_plain_ct: app.draft.ai_plain_ct, ai_plain_ct: draft.ai_plain_ct,
skipped: app.draft.skipped, skipped: Boolean(draft.skipped),
ai_skipped: app.draft.ai_skipped, ai_skipped: Boolean(draft.ai_skipped),
notes: $("annotationNotes").value, notes,
}; };
} }
async function saveAnnotationNow() { async function saveAnnotationNow() {
if (!app.study || !app.activeSeries || !app.draft) return; if (!app.study || !app.activeSeries || !app.draft) return;
if (app.saving) return; if (app.savePromise) return app.savePromise;
app.saving = true; const ctNumber = app.study.ct_number;
$("saveState").textContent = "保存中";
const uid = app.activeSeries.series_uid; 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 { try {
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, { const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/annotation`, {
method: "PUT", method: "PUT",
body: JSON.stringify(annotationPayload()), 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 = "已保存"; $("saveState").textContent = "已保存";
app.dirty = false; app.dirty = false;
updateLocalAnnotation(data); } else if (stillActive) {
} catch (err) { $("saveState").textContent = "未保存";
$("saveState").textContent = err.message; app.dirty = true;
} finally {
app.saving = false;
} }
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() { function markDirty() {
clearTimeout(app.saveTimer); clearTimeout(app.saveTimer);
app.draftVersion += 1;
app.dirty = true; app.dirty = true;
$("saveState").textContent = "未保存"; $("saveState").textContent = "未保存";
} }
async function confirmSaveIfDirty() { async function confirmSaveIfDirty() {
if (!app.dirty) return; if (app.savePromise) {
$("saveState").textContent = "保存中";
await app.savePromise;
}
if (!app.dirty) return true;
const shouldSave = window.confirm("当前标注尚未保存,是否先保存?"); const shouldSave = window.confirm("当前标注尚未保存,是否先保存?");
if (shouldSave) { if (shouldSave) {
await saveAnnotationNow(); await saveAnnotationNow();
return !app.dirty;
} else { } else {
app.dirty = false; app.dirty = false;
$("saveState").textContent = "未保存"; $("saveState").textContent = "未保存";
} }
return true;
} }
function handlePartChange(event) { function handlePartChange(event) {
@@ -957,27 +999,37 @@ function handleChestWindowChange(event) {
async function runAI() { async function runAI() {
if (!app.study || !app.activeSeries) return; 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"); const button = $("aiClassify");
button.disabled = true; button.disabled = true;
$("saveState").textContent = "AI 识别中"; $("saveState").textContent = "AI 识别中";
try { try {
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, { const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/ai`, {
method: "POST", method: "POST",
body: JSON.stringify({ sample_count: 3 }), body: JSON.stringify({ sample_count: 3 }),
}); });
updateLocalAnnotation(data); const stillActive = app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid;
$("annotationNotes").value = data.notes || ""; const unchanged = stillActive && app.draftVersion === versionAtStart;
updateLocalAnnotation(data, uid, { applyToActive: unchanged });
if (unchanged) {
$("saveState").textContent = "AI 结果已保存"; $("saveState").textContent = "AI 结果已保存";
app.dirty = false; app.dirty = false;
} else if (stillActive) {
$("saveState").textContent = "未保存";
app.dirty = true;
}
} catch (err) { } catch (err) {
$("saveState").textContent = err.message; if (app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid) $("saveState").textContent = err.message;
} finally { } finally {
button.disabled = false; button.disabled = false;
} }
} }
async function openInfo() { async function openInfo() {
await confirmSaveIfDirty(); if (!(await confirmSaveIfDirty())) return;
if (!app.study || !app.activeSeries) 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 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"); const content = $("infoContent");
@@ -1026,7 +1078,7 @@ function showViewerPage(push = true) {
} }
async function showSettingsPage(push = true) { async function showSettingsPage(push = true) {
await confirmSaveIfDirty(); if (!(await confirmSaveIfDirty())) return;
if (!isAdmin()) { if (!isAdmin()) {
if (location.pathname === "/settings") history.replaceState({}, "", "/"); if (location.pathname === "/settings") history.replaceState({}, "", "/");
showViewerPage(false); showViewerPage(false);