Bind annotation saves to selected series
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user