Add study body part filters
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
@@ -55,6 +55,14 @@
|
||||
<button data-study-filter="incomplete">待处理</button>
|
||||
<button data-study-filter="complete">已完成</button>
|
||||
</div>
|
||||
<div class="study-part-filter">
|
||||
<button class="active" data-study-part-filter="all">全部部位</button>
|
||||
<button data-study-part-filter="head_neck">头颈部</button>
|
||||
<button data-study-part-filter="chest">胸部</button>
|
||||
<button data-study-part-filter="upper_abdomen">上腹部</button>
|
||||
<button data-study-part-filter="lower_abdomen">下腹部</button>
|
||||
<button data-study-part-filter="pelvis">盆腔</button>
|
||||
</div>
|
||||
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
||||
<div id="studyList" class="study-list"></div>
|
||||
</aside>
|
||||
@@ -125,7 +133,6 @@
|
||||
</div>
|
||||
<div class="part-grid">
|
||||
<label class="skip-option"><input type="checkbox" value="skip" />略过/不采用</label>
|
||||
<label class="plain-option"><input type="checkbox" value="plain_ct" />平扫CT</label>
|
||||
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
||||
<label><input type="checkbox" value="chest" />胸部</label>
|
||||
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
|
||||
|
||||
@@ -262,7 +262,7 @@ button:disabled {
|
||||
.series-pane {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -336,8 +336,17 @@ button:disabled {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.study-filter button {
|
||||
.study-part-filter {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.study-filter button,
|
||||
.study-part-filter button {
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 7px;
|
||||
background: #0b0f16;
|
||||
@@ -345,7 +354,13 @@ button:disabled {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.study-filter button.active {
|
||||
.study-part-filter button {
|
||||
min-width: 66px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.study-filter button.active,
|
||||
.study-part-filter button.active {
|
||||
border-color: rgba(25, 212, 194, 0.62);
|
||||
color: #baf8ee;
|
||||
background: rgba(25, 212, 194, 0.08);
|
||||
@@ -361,7 +376,7 @@ button:disabled {
|
||||
.study-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 12px 42px 12px 12px;
|
||||
padding: 12px 112px 12px 12px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
@@ -370,10 +385,17 @@ button:disabled {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.study-status-badge {
|
||||
.study-card-status-stack {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
max-width: 96px;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.study-status-badge {
|
||||
min-width: 48px;
|
||||
height: 22px;
|
||||
display: inline-grid;
|
||||
@@ -388,6 +410,28 @@ button:disabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.study-part-tags {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.study-part-tags em {
|
||||
max-width: 92px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(25, 212, 194, 0.34);
|
||||
border-radius: 999px;
|
||||
color: #baf8ee;
|
||||
background: rgba(25, 212, 194, 0.07);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.study-status-badge:hover {
|
||||
border-color: rgba(239, 245, 255, 0.58);
|
||||
color: var(--text);
|
||||
@@ -874,11 +918,6 @@ button:disabled {
|
||||
background: rgba(240, 181, 78, 0.12);
|
||||
}
|
||||
|
||||
.part-grid .plain-option:has(input:checked) {
|
||||
border-color: rgba(52, 116, 246, 0.72);
|
||||
background: rgba(52, 116, 246, 0.12);
|
||||
}
|
||||
|
||||
.part-grid input:disabled + * {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user