Add study body part filters

This commit is contained in:
Codex
2026-05-27 13:42:53 +08:00
parent fc681fa08b
commit 6653c147c8
4 changed files with 129 additions and 65 deletions

View File

@@ -27,6 +27,7 @@ const app = {
studySortField: "time",
studySortDir: "desc",
studyFilter: "all",
studyPartFilter: "all",
aiEnabled: true,
seriesSortField: "time",
seriesSortDir: "asc",
@@ -254,8 +255,9 @@ function studyStatusLabel(status) {
function visibleStudies() {
return sortStudies(app.studies).filter((study) => {
const status = studyStatus(study);
if (app.studyFilter === "complete") return status === "complete";
if (app.studyFilter === "incomplete") return status !== "complete";
if (app.studyFilter === "complete" && status !== "complete") return false;
if (app.studyFilter === "incomplete" && status === "complete") return false;
if (app.studyPartFilter !== "all" && !asList(study.body_parts).includes(app.studyPartFilter)) return false;
return true;
});
}
@@ -276,6 +278,9 @@ function updateSortButtons() {
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter);
});
document.querySelectorAll("[data-study-part-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyPartFilter === app.studyPartFilter);
});
}
function setSeries(list) {
@@ -287,17 +292,21 @@ function setSeries(list) {
function isSeriesAnnotated(series) {
const annotation = series.annotation || {};
return Boolean(annotation.skipped || annotation.plain_ct || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model);
return Boolean(annotation.skipped || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model);
}
function refreshStudyAnnotationSummary() {
if (!app.study || !app.series.length) return;
const annotated = app.series.filter(isSeriesAnnotated).length;
const total = app.series.length;
const bodyParts = BODY_PARTS.filter((part) =>
app.series.some((series) => asList(series.annotation?.body_parts).includes(part)),
);
const summary = {
annotated_series: annotated,
unannotated_series: Math.max(0, total - annotated),
series_count: total,
body_parts: bodyParts,
};
Object.assign(app.study, summary);
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
@@ -326,8 +335,15 @@ function renderStudies() {
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
button.classList.toggle("complete", status === "complete");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
const partTags = asList(study.body_parts)
.filter((part) => BODY_PARTS.includes(part))
.map((part) => `<em>${escapeHtml(partLabel(part))}</em>`)
.join("");
button.innerHTML = `
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
<div class="study-card-status-stack">
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
<div class="study-part-tags">${partTags}</div>
</div>
<strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
@@ -416,9 +432,6 @@ function seriesTags(series) {
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" });
}
const phase = phaseLabel(annotation.upper_abdomen_phase);
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
const chestWindow = chestWindowLabel(annotation.chest_window);
@@ -754,13 +767,6 @@ function effectiveChestWindow(draft = app.draft) {
return draft.manual_chest_window || draft.ai_chest_window || "unknown";
}
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) {
const label = input.closest("label");
label.classList.toggle("manual-selected", source === "manual");
@@ -789,12 +795,6 @@ function applyAnnotationControls() {
setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : "");
return;
}
if (value === "plain_ct") {
input.checked = effectivePlainCt();
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 = false;
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
@@ -858,9 +858,9 @@ function annotationPayload(draft = app.draft, notes = $("annotationNotes").value
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,
plain_ct: false,
manual_plain_ct: null,
ai_plain_ct: null,
skipped: Boolean(draft.skipped),
ai_skipped: Boolean(draft.ai_skipped),
notes,
@@ -944,14 +944,6 @@ function handlePartChange(event) {
if (value === "skip") {
app.draft.skipped = input.checked;
if (!input.checked) app.draft.ai_skipped = false;
} else if (value === "plain_ct") {
if (input.checked) {
app.draft.manual_plain_ct = true;
app.draft.ai_plain_ct = null;
} else {
app.draft.manual_plain_ct = false;
app.draft.ai_plain_ct = null;
}
} else if (BODY_PARTS.includes(value)) {
if (input.checked) {
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
@@ -974,7 +966,9 @@ function handlePartChange(event) {
app.draft.body_parts = effectiveParts();
app.draft.upper_abdomen_phase = effectivePhase();
app.draft.chest_window = effectiveChestWindow();
app.draft.plain_ct = effectivePlainCt();
app.draft.plain_ct = false;
app.draft.manual_plain_ct = null;
app.draft.ai_plain_ct = null;
applyAnnotationControls();
markDirty();
}
@@ -1284,6 +1278,12 @@ function wire() {
renderStudies();
});
});
document.querySelectorAll("[data-study-part-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.studyPartFilter = button.dataset.studyPartFilter;
renderStudies();
});
});
document.querySelectorAll("[data-series-sort]").forEach((button) => {
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
});