Bind annotation saves to selected series
This commit is contained in:
@@ -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;
|
||||||
try {
|
const draft = cloneAnnotation(app.draft);
|
||||||
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
|
const payload = annotationPayload(draft, $("annotationNotes").value);
|
||||||
method: "PUT",
|
const versionAtStart = app.draftVersion;
|
||||||
body: JSON.stringify(annotationPayload()),
|
app.saving = true;
|
||||||
});
|
app.savingSeriesUid = uid;
|
||||||
$("saveState").textContent = "已保存";
|
$("saveState").textContent = "保存中";
|
||||||
app.dirty = false;
|
$("saveAnnotation").disabled = true;
|
||||||
updateLocalAnnotation(data);
|
let promise;
|
||||||
} catch (err) {
|
promise = (async () => {
|
||||||
$("saveState").textContent = err.message;
|
try {
|
||||||
} finally {
|
const data = await json(`/api/series/${encodeURIComponent(ctNumber)}/${encodeURIComponent(uid)}/annotation`, {
|
||||||
app.saving = false;
|
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() {
|
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;
|
||||||
$("saveState").textContent = "AI 结果已保存";
|
updateLocalAnnotation(data, uid, { applyToActive: unchanged });
|
||||||
app.dirty = false;
|
if (unchanged) {
|
||||||
|
$("saveState").textContent = "AI 结果已保存";
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user