diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 2950ef2..182c65d 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -17,6 +17,9 @@ const app = { searchTimer: null, statusTimer: null, saveTimer: null, + savePromise: null, + savingSeriesUid: "", + draftVersion: 0, dirty: false, saving: false, pendingImage: null, @@ -176,7 +179,7 @@ async function login(event) { } async function logout() { - await confirmSaveIfDirty(); + if (!(await confirmSaveIfDirty())) return; app.token = ""; app.user = null; localStorage.removeItem("pacs_web_token"); @@ -373,7 +376,7 @@ async function toggleStudyCompletion(ctNumber, event) { } 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.series = []; app.activeSeries = null; @@ -598,7 +601,7 @@ function resetViewState() { } 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); if (!series) return; app.activeSeries = series; @@ -736,26 +739,26 @@ function cloneAnnotation(annotation = {}) { }; } -function effectiveParts() { - if (!app.draft) return []; - return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]); +function effectiveParts(draft = app.draft) { + if (!draft) return []; + return uniq([...asList(draft.manual_body_parts), ...asList(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 effectivePhase(draft = app.draft) { + if (!effectiveParts(draft).includes("upper_abdomen")) return ""; + return draft.manual_upper_abdomen_phase || draft.ai_upper_abdomen_phase || ""; } -function effectiveChestWindow() { - if (!effectiveParts().includes("chest")) return ""; - return app.draft.manual_chest_window || app.draft.ai_chest_window || "unknown"; +function effectiveChestWindow(draft = app.draft) { + if (!effectiveParts(draft).includes("chest")) return ""; + return draft.manual_chest_window || draft.ai_chest_window || "unknown"; } -function effectivePlainCt() { - if (!app.draft) 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 effectivePlainCt(draft = app.draft) { + if (!draft) return false; + if (draft.manual_plain_ct !== null && draft.manual_plain_ct !== undefined) return Boolean(draft.manual_plain_ct); + if (draft.ai_plain_ct !== null && draft.ai_plain_ct !== undefined) return Boolean(draft.ai_plain_ct); + return Boolean(draft.plain_ct); } function setLabelSource(input, source) { @@ -816,83 +819,122 @@ function applyAnnotationControls() { function hydrateAnnotation() { app.draft = cloneAnnotation(app.activeSeries?.annotation || {}); + app.draftVersion = 0; $("annotationNotes").value = app.draft.notes || ""; applyAnnotationControls(); } -function updateLocalAnnotation(annotation) { +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 || ""; - 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; + 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(); renderSeries(); renderStudies(); - applyAnnotationControls(); + if (isActive && applyToActive) applyAnnotationControls(); } -function annotationPayload() { - const bodyParts = effectiveParts(); +function annotationPayload(draft = app.draft, notes = $("annotationNotes").value) { + const bodyParts = effectiveParts(draft); return { body_parts: bodyParts, - manual_body_parts: app.draft.manual_body_parts, - ai_body_parts: app.draft.ai_body_parts, - upper_abdomen_phase: effectivePhase(), - manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase, - ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase, - chest_window: effectiveChestWindow(), - manual_chest_window: app.draft.manual_chest_window, - ai_chest_window: app.draft.ai_chest_window, - plain_ct: effectivePlainCt(), - manual_plain_ct: app.draft.manual_plain_ct, - ai_plain_ct: app.draft.ai_plain_ct, - skipped: app.draft.skipped, - ai_skipped: app.draft.ai_skipped, - notes: $("annotationNotes").value, + 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: effectivePlainCt(draft), + manual_plain_ct: draft.manual_plain_ct, + ai_plain_ct: draft.ai_plain_ct, + skipped: Boolean(draft.skipped), + ai_skipped: Boolean(draft.ai_skipped), + notes, }; } async function saveAnnotationNow() { if (!app.study || !app.activeSeries || !app.draft) return; - if (app.saving) return; - app.saving = true; - $("saveState").textContent = "保存中"; + if (app.savePromise) return app.savePromise; + const ctNumber = app.study.ct_number; 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 = "已保存"; - app.dirty = false; - updateLocalAnnotation(data); - } catch (err) { - $("saveState").textContent = err.message; - } finally { - app.saving = false; - } + 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.dirty) return; + 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) { @@ -957,27 +999,37 @@ function handleChestWindowChange(event) { 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(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, { + const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/ai`, { method: "POST", body: JSON.stringify({ sample_count: 3 }), }); - updateLocalAnnotation(data); - $("annotationNotes").value = data.notes || ""; - $("saveState").textContent = "AI 结果已保存"; - app.dirty = false; + 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) { - $("saveState").textContent = err.message; + if (app.study?.ct_number === ctNumber && app.activeSeries?.series_uid === uid) $("saveState").textContent = err.message; } finally { button.disabled = false; } } async function openInfo() { - await confirmSaveIfDirty(); + 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"); @@ -1026,7 +1078,7 @@ function showViewerPage(push = true) { } async function showSettingsPage(push = true) { - await confirmSaveIfDirty(); + if (!(await confirmSaveIfDirty())) return; if (!isAdmin()) { if (location.pathname === "/settings") history.replaceState({}, "", "/"); showViewerPage(false);