Refine skip and manual save annotations
This commit is contained in:
@@ -16,6 +16,8 @@ const app = {
|
||||
searchTimer: null,
|
||||
statusTimer: null,
|
||||
saveTimer: null,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
pendingImage: null,
|
||||
drag: null,
|
||||
seriesSort: "timeAsc",
|
||||
@@ -147,7 +149,8 @@ async function login(event) {
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
async function logout() {
|
||||
await confirmSaveIfDirty();
|
||||
app.token = "";
|
||||
localStorage.removeItem("pacs_web_token");
|
||||
showLogin(true);
|
||||
@@ -216,6 +219,7 @@ function renderStudies() {
|
||||
}
|
||||
|
||||
async function selectStudy(ctNumber) {
|
||||
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
|
||||
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
||||
app.series = [];
|
||||
app.activeSeries = null;
|
||||
@@ -243,10 +247,10 @@ function sourceTag(value, annotation) {
|
||||
|
||||
function seriesTags(series) {
|
||||
const annotation = series.annotation || {};
|
||||
if (annotation.skipped) {
|
||||
return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }];
|
||||
}
|
||||
const tags = [];
|
||||
if (annotation.skipped) {
|
||||
tags.push({ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" });
|
||||
}
|
||||
if (annotation.plain_ct) {
|
||||
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
|
||||
}
|
||||
@@ -319,7 +323,8 @@ function resetViewState() {
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function selectSeries(seriesUid) {
|
||||
async function selectSeries(seriesUid) {
|
||||
if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid) await confirmSaveIfDirty();
|
||||
const series = app.series.find((item) => item.series_uid === seriesUid);
|
||||
if (!series) return;
|
||||
app.activeSeries = series;
|
||||
@@ -340,7 +345,9 @@ function resetViewer() {
|
||||
$("dicomImage").removeAttribute("src");
|
||||
$("imageEmpty").classList.remove("hidden");
|
||||
$("sliceText").textContent = "0 / 0";
|
||||
$("saveState").textContent = "自动保存";
|
||||
$("saveState").textContent = "已保存";
|
||||
app.dirty = false;
|
||||
app.saving = false;
|
||||
resetViewState();
|
||||
updateOverlay();
|
||||
}
|
||||
@@ -434,7 +441,7 @@ function cloneAnnotation(annotation = {}) {
|
||||
}
|
||||
|
||||
function effectiveParts() {
|
||||
if (!app.draft || app.draft.skipped) return [];
|
||||
if (!app.draft) return [];
|
||||
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]);
|
||||
}
|
||||
|
||||
@@ -444,7 +451,7 @@ function effectivePhase() {
|
||||
}
|
||||
|
||||
function effectivePlainCt() {
|
||||
if (!app.draft || app.draft.skipped) return false;
|
||||
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);
|
||||
@@ -469,22 +476,22 @@ function applyAnnotationControls() {
|
||||
}
|
||||
if (value === "plain_ct") {
|
||||
input.checked = effectivePlainCt();
|
||||
input.disabled = app.draft.skipped;
|
||||
input.disabled = false;
|
||||
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;
|
||||
input.disabled = false;
|
||||
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
|
||||
});
|
||||
|
||||
const phase = effectivePhase();
|
||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||
input.checked = input.value === phase;
|
||||
input.disabled = app.draft.skipped;
|
||||
input.disabled = false;
|
||||
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"));
|
||||
$("phaseBox").classList.toggle("visible", parts.has("upper_abdomen"));
|
||||
}
|
||||
|
||||
function hydrateAnnotation() {
|
||||
@@ -507,17 +514,17 @@ function updateLocalAnnotation(annotation) {
|
||||
}
|
||||
|
||||
function annotationPayload() {
|
||||
const bodyParts = app.draft.skipped ? [] : effectiveParts();
|
||||
const bodyParts = 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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
@@ -526,6 +533,8 @@ function annotationPayload() {
|
||||
|
||||
async function saveAnnotationNow() {
|
||||
if (!app.study || !app.activeSeries || !app.draft) return;
|
||||
if (app.saving) return;
|
||||
app.saving = true;
|
||||
$("saveState").textContent = "保存中";
|
||||
const uid = app.activeSeries.series_uid;
|
||||
try {
|
||||
@@ -534,16 +543,30 @@ async function saveAnnotationNow() {
|
||||
body: JSON.stringify(annotationPayload()),
|
||||
});
|
||||
$("saveState").textContent = "已保存";
|
||||
app.dirty = false;
|
||||
updateLocalAnnotation(data);
|
||||
} catch (err) {
|
||||
$("saveState").textContent = err.message;
|
||||
} finally {
|
||||
app.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function queueSave(delay = 450) {
|
||||
function markDirty() {
|
||||
clearTimeout(app.saveTimer);
|
||||
$("saveState").textContent = "待保存";
|
||||
app.saveTimer = setTimeout(saveAnnotationNow, delay);
|
||||
app.dirty = true;
|
||||
$("saveState").textContent = "未保存";
|
||||
}
|
||||
|
||||
async function confirmSaveIfDirty() {
|
||||
if (!app.dirty) return;
|
||||
const shouldSave = window.confirm("当前标注尚未保存,是否先保存?");
|
||||
if (shouldSave) {
|
||||
await saveAnnotationNow();
|
||||
} else {
|
||||
app.dirty = false;
|
||||
$("saveState").textContent = "未保存";
|
||||
}
|
||||
}
|
||||
|
||||
function handlePartChange(event) {
|
||||
@@ -553,14 +576,6 @@ function handlePartChange(event) {
|
||||
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;
|
||||
}
|
||||
} else if (value === "plain_ct") {
|
||||
if (input.checked) {
|
||||
app.draft.manual_plain_ct = true;
|
||||
@@ -585,7 +600,7 @@ function handlePartChange(event) {
|
||||
app.draft.upper_abdomen_phase = effectivePhase();
|
||||
app.draft.plain_ct = effectivePlainCt();
|
||||
applyAnnotationControls();
|
||||
queueSave();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function handlePhaseChange(event) {
|
||||
@@ -594,7 +609,7 @@ function handlePhaseChange(event) {
|
||||
if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = "";
|
||||
app.draft.upper_abdomen_phase = effectivePhase();
|
||||
applyAnnotationControls();
|
||||
queueSave();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
async function runAI() {
|
||||
@@ -610,6 +625,7 @@ async function runAI() {
|
||||
updateLocalAnnotation(data);
|
||||
$("annotationNotes").value = data.notes || "";
|
||||
$("saveState").textContent = "AI 结果已保存";
|
||||
app.dirty = false;
|
||||
} catch (err) {
|
||||
$("saveState").textContent = err.message;
|
||||
} finally {
|
||||
@@ -618,6 +634,7 @@ async function runAI() {
|
||||
}
|
||||
|
||||
async function openInfo() {
|
||||
await confirmSaveIfDirty();
|
||||
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");
|
||||
@@ -660,6 +677,7 @@ function roleCards(roles) {
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
await confirmSaveIfDirty();
|
||||
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
|
||||
$("settingsContent").innerHTML = `
|
||||
<section class="settings-section">
|
||||
@@ -828,7 +846,13 @@ function wire() {
|
||||
$("annotationNotes").addEventListener("input", () => {
|
||||
if (!app.draft) return;
|
||||
app.draft.notes = $("annotationNotes").value;
|
||||
queueSave(900);
|
||||
markDirty();
|
||||
});
|
||||
$("saveAnnotation").addEventListener("click", saveAnnotationNow);
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!app.dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
document.querySelectorAll("[data-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<div class="annotation-head">
|
||||
<div>
|
||||
<h2>部位标注</h2>
|
||||
<span id="saveState">自动保存</span>
|
||||
<span id="saveState">已保存</span>
|
||||
</div>
|
||||
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
||||
</div>
|
||||
@@ -140,6 +140,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation-actions">
|
||||
<button id="saveAnnotation" class="primary-btn save-btn">保存标注</button>
|
||||
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -785,6 +785,9 @@ button:disabled {
|
||||
}
|
||||
|
||||
.annotation-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 118px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -792,6 +795,10 @@ button:disabled {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user