Add study body part filters
This commit is contained in:
@@ -65,7 +65,8 @@ WINDOWS = {
|
|||||||
"contrast": (90.0, 140.0),
|
"contrast": (90.0, 140.0),
|
||||||
}
|
}
|
||||||
|
|
||||||
BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
|
BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"]
|
||||||
|
BODY_PARTS = set(BODY_PART_ORDER)
|
||||||
PHASES = {"arterial", "portal_venous", "delayed", "unknown", ""}
|
PHASES = {"arterial", "portal_venous", "delayed", "unknown", ""}
|
||||||
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
|
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
|
||||||
ROLES = {
|
ROLES = {
|
||||||
@@ -300,11 +301,14 @@ def ensure_study_summary_table() -> None:
|
|||||||
series_count integer NOT NULL DEFAULT 0,
|
series_count integer NOT NULL DEFAULT 0,
|
||||||
annotated_series integer NOT NULL DEFAULT 0,
|
annotated_series integer NOT NULL DEFAULT 0,
|
||||||
unannotated_series integer NOT NULL DEFAULT 0,
|
unannotated_series integer NOT NULL DEFAULT 0,
|
||||||
|
body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
completed boolean NOT NULL DEFAULT false,
|
completed boolean NOT NULL DEFAULT false,
|
||||||
completed_by text,
|
completed_by text,
|
||||||
completed_at timestamptz,
|
completed_at timestamptz,
|
||||||
refreshed_at timestamptz NOT NULL DEFAULT now()
|
refreshed_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
ALTER TABLE public.pacs_dicom_study_summaries
|
||||||
|
ADD COLUMN IF NOT EXISTS body_parts jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
ALTER TABLE public.pacs_dicom_study_summaries
|
ALTER TABLE public.pacs_dicom_study_summaries
|
||||||
ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
|
||||||
ALTER TABLE public.pacs_dicom_study_summaries
|
ALTER TABLE public.pacs_dicom_study_summaries
|
||||||
@@ -480,24 +484,31 @@ def status() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def annotation_counts_by_study() -> dict[str, int]:
|
def annotation_overview_by_study() -> dict[str, dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
annotations = pg_json_rows(
|
annotations = pg_json_rows(
|
||||||
"""
|
"""
|
||||||
SELECT ct_number, count(*)::int AS annotated_series
|
SELECT
|
||||||
FROM public.pacs_dicom_series_annotations
|
a.ct_number,
|
||||||
WHERE skipped IS TRUE
|
count(DISTINCT a.series_instance_uid) FILTER (
|
||||||
OR jsonb_array_length(body_parts) > 0
|
WHERE a.skipped IS TRUE
|
||||||
OR plain_ct IS TRUE
|
OR jsonb_array_length(a.body_parts) > 0
|
||||||
OR NULLIF(notes, '') IS NOT NULL
|
OR NULLIF(a.notes, '') IS NOT NULL
|
||||||
OR ai_result IS NOT NULL
|
OR a.ai_result IS NOT NULL
|
||||||
GROUP BY ct_number
|
)::int AS annotated_series,
|
||||||
|
COALESCE(
|
||||||
|
jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL),
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS body_parts
|
||||||
|
FROM public.pacs_dicom_series_annotations a
|
||||||
|
LEFT JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value) ON true
|
||||||
|
GROUP BY a.ct_number
|
||||||
""",
|
""",
|
||||||
timeout=8,
|
timeout=8,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
return {row["ct_number"]: int(row["annotated_series"] or 0) for row in annotations}
|
return {row["ct_number"]: row for row in annotations}
|
||||||
|
|
||||||
|
|
||||||
def summary_rows_by_study() -> dict[str, dict[str, Any]]:
|
def summary_rows_by_study() -> dict[str, dict[str, Any]]:
|
||||||
@@ -507,7 +518,7 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
ct_number, series_count, annotated_series, unannotated_series,
|
ct_number, series_count, annotated_series, unannotated_series,
|
||||||
completed, completed_by, completed_at, refreshed_at
|
body_parts, completed, completed_by, completed_at, refreshed_at
|
||||||
FROM public.pacs_dicom_study_summaries
|
FROM public.pacs_dicom_study_summaries
|
||||||
""",
|
""",
|
||||||
timeout=8,
|
timeout=8,
|
||||||
@@ -557,14 +568,16 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
|
|||||||
LIMIT {int(limit)}
|
LIMIT {int(limit)}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
annotation_map = annotation_counts_by_study()
|
annotation_map = annotation_overview_by_study()
|
||||||
summary_map = summary_rows_by_study()
|
summary_map = summary_rows_by_study()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
summary = summary_map.get(row["ct_number"])
|
summary = summary_map.get(row["ct_number"])
|
||||||
|
annotation_summary = annotation_map.get(row["ct_number"], {})
|
||||||
total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0)
|
total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0)
|
||||||
row["series_count"] = total_series
|
row["series_count"] = total_series
|
||||||
row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_map.get(row["ct_number"], 0)))
|
row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_summary.get("annotated_series", 0)))
|
||||||
row["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
|
row["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
|
||||||
|
row["body_parts"] = valid_parts((summary or {}).get("body_parts") or annotation_summary.get("body_parts") or [])
|
||||||
row["completed"] = bool((summary or {}).get("completed", False))
|
row["completed"] = bool((summary or {}).get("completed", False))
|
||||||
row["completed_by"] = (summary or {}).get("completed_by", "")
|
row["completed_by"] = (summary or {}).get("completed_by", "")
|
||||||
row["completed_at"] = (summary or {}).get("completed_at", "")
|
row["completed_at"] = (summary or {}).get("completed_at", "")
|
||||||
@@ -978,11 +991,15 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
|
def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
series_list = data.get("series", [])
|
series_list = data.get("series", [])
|
||||||
total = len(series_list)
|
total = len(series_list)
|
||||||
|
body_parts = [
|
||||||
|
part
|
||||||
|
for part in BODY_PART_ORDER
|
||||||
|
if any(part in valid_parts(row.get("annotation", {}).get("body_parts") or []) for row in series_list)
|
||||||
|
]
|
||||||
annotated = sum(
|
annotated = sum(
|
||||||
1
|
1
|
||||||
for row in series_list
|
for row in series_list
|
||||||
if row.get("annotation", {}).get("skipped")
|
if row.get("annotation", {}).get("skipped")
|
||||||
or row.get("annotation", {}).get("plain_ct")
|
|
||||||
or row.get("annotation", {}).get("body_parts")
|
or row.get("annotation", {}).get("body_parts")
|
||||||
or row.get("annotation", {}).get("notes")
|
or row.get("annotation", {}).get("notes")
|
||||||
or row.get("annotation", {}).get("ai_model")
|
or row.get("annotation", {}).get("ai_model")
|
||||||
@@ -992,6 +1009,7 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"series_count": total,
|
"series_count": total,
|
||||||
"annotated_series": annotated,
|
"annotated_series": annotated,
|
||||||
"unannotated_series": max(0, total - annotated),
|
"unannotated_series": max(0, total - annotated),
|
||||||
|
"body_parts": body_parts,
|
||||||
"summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,19 +1020,21 @@ def save_study_summary(summary: dict[str, Any]) -> None:
|
|||||||
pg_scalar(
|
pg_scalar(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO public.pacs_dicom_study_summaries (
|
INSERT INTO public.pacs_dicom_study_summaries (
|
||||||
ct_number, series_count, annotated_series, unannotated_series, refreshed_at
|
ct_number, series_count, annotated_series, unannotated_series, body_parts, refreshed_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
{sql_literal(summary.get('ct_number', ''))},
|
{sql_literal(summary.get('ct_number', ''))},
|
||||||
{int(summary.get('series_count') or 0)},
|
{int(summary.get('series_count') or 0)},
|
||||||
{int(summary.get('annotated_series') or 0)},
|
{int(summary.get('annotated_series') or 0)},
|
||||||
{int(summary.get('unannotated_series') or 0)},
|
{int(summary.get('unannotated_series') or 0)},
|
||||||
|
{sql_literal(json.dumps(valid_parts(summary.get('body_parts') or []), ensure_ascii=False))}::jsonb,
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
ON CONFLICT (ct_number) DO UPDATE SET
|
ON CONFLICT (ct_number) DO UPDATE SET
|
||||||
series_count = EXCLUDED.series_count,
|
series_count = EXCLUDED.series_count,
|
||||||
annotated_series = EXCLUDED.annotated_series,
|
annotated_series = EXCLUDED.annotated_series,
|
||||||
unannotated_series = EXCLUDED.unannotated_series,
|
unannotated_series = EXCLUDED.unannotated_series,
|
||||||
|
body_parts = EXCLUDED.body_parts,
|
||||||
refreshed_at = now()
|
refreshed_at = now()
|
||||||
""",
|
""",
|
||||||
timeout=8,
|
timeout=8,
|
||||||
@@ -1539,7 +1559,7 @@ def parse_ai_json(content: str) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"body_parts": [], "upper_abdomen_phase": "", "chest_window": "", "plain_ct": False, "skipped": False, "notes": content[:800]}
|
return {"body_parts": [], "upper_abdomen_phase": "", "chest_window": "", "skipped": False, "notes": content[:800]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/series/{ct_number}/{series_uid}/ai")
|
@app.post("/api/series/{ct_number}/{series_uid}/ai")
|
||||||
@@ -1559,10 +1579,9 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
|||||||
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
|
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
|
||||||
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
||||||
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
||||||
"请同时判断 plain_ct 是否为平扫CT。"
|
|
||||||
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
|
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
|
||||||
"如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。"
|
"如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。"
|
||||||
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"chest_window\":\"\",\"plain_ct\":false,\"skipped\":false,\"notes\":\"\"}。"
|
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"chest_window\":\"\",\"skipped\":false,\"notes\":\"\"}。"
|
||||||
f"PACS张数: {series_row.get('count', 0)}。"
|
f"PACS张数: {series_row.get('count', 0)}。"
|
||||||
f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。"
|
f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。"
|
||||||
),
|
),
|
||||||
@@ -1600,7 +1619,6 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
|||||||
suggestion = parse_ai_json(message)
|
suggestion = parse_ai_json(message)
|
||||||
body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS]
|
body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS]
|
||||||
skipped = bool(suggestion.get("skipped", False))
|
skipped = bool(suggestion.get("skipped", False))
|
||||||
plain_ct = bool(suggestion.get("plain_ct", False))
|
|
||||||
phase = suggestion.get("upper_abdomen_phase", "")
|
phase = suggestion.get("upper_abdomen_phase", "")
|
||||||
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
||||||
phase = ""
|
phase = ""
|
||||||
@@ -1620,8 +1638,8 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
|||||||
ai_phase="" if skipped else phase,
|
ai_phase="" if skipped else phase,
|
||||||
manual_chest_window=current.get("manual_chest_window", ""),
|
manual_chest_window=current.get("manual_chest_window", ""),
|
||||||
ai_chest_window="" if skipped else chest_window,
|
ai_chest_window="" if skipped else chest_window,
|
||||||
manual_plain_ct=current.get("manual_plain_ct"),
|
manual_plain_ct=None,
|
||||||
ai_plain_ct=None if skipped else plain_ct,
|
ai_plain_ct=None,
|
||||||
skipped=skipped,
|
skipped=skipped,
|
||||||
ai_skipped=skipped,
|
ai_skipped=skipped,
|
||||||
notes=str(suggestion.get("notes", "")) or current.get("notes", ""),
|
notes=str(suggestion.get("notes", "")) or current.get("notes", ""),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const app = {
|
|||||||
studySortField: "time",
|
studySortField: "time",
|
||||||
studySortDir: "desc",
|
studySortDir: "desc",
|
||||||
studyFilter: "all",
|
studyFilter: "all",
|
||||||
|
studyPartFilter: "all",
|
||||||
aiEnabled: true,
|
aiEnabled: true,
|
||||||
seriesSortField: "time",
|
seriesSortField: "time",
|
||||||
seriesSortDir: "asc",
|
seriesSortDir: "asc",
|
||||||
@@ -254,8 +255,9 @@ function studyStatusLabel(status) {
|
|||||||
function visibleStudies() {
|
function visibleStudies() {
|
||||||
return sortStudies(app.studies).filter((study) => {
|
return sortStudies(app.studies).filter((study) => {
|
||||||
const status = studyStatus(study);
|
const status = studyStatus(study);
|
||||||
if (app.studyFilter === "complete") return status === "complete";
|
if (app.studyFilter === "complete" && status !== "complete") return false;
|
||||||
if (app.studyFilter === "incomplete") return status !== "complete";
|
if (app.studyFilter === "incomplete" && status === "complete") return false;
|
||||||
|
if (app.studyPartFilter !== "all" && !asList(study.body_parts).includes(app.studyPartFilter)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,9 @@ function updateSortButtons() {
|
|||||||
document.querySelectorAll("[data-study-filter]").forEach((button) => {
|
document.querySelectorAll("[data-study-filter]").forEach((button) => {
|
||||||
button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter);
|
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) {
|
function setSeries(list) {
|
||||||
@@ -287,17 +292,21 @@ function setSeries(list) {
|
|||||||
|
|
||||||
function isSeriesAnnotated(series) {
|
function isSeriesAnnotated(series) {
|
||||||
const annotation = series.annotation || {};
|
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() {
|
function refreshStudyAnnotationSummary() {
|
||||||
if (!app.study || !app.series.length) return;
|
if (!app.study || !app.series.length) return;
|
||||||
const annotated = app.series.filter(isSeriesAnnotated).length;
|
const annotated = app.series.filter(isSeriesAnnotated).length;
|
||||||
const total = app.series.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 = {
|
const summary = {
|
||||||
annotated_series: annotated,
|
annotated_series: annotated,
|
||||||
unannotated_series: Math.max(0, total - annotated),
|
unannotated_series: Math.max(0, total - annotated),
|
||||||
series_count: total,
|
series_count: total,
|
||||||
|
body_parts: bodyParts,
|
||||||
};
|
};
|
||||||
Object.assign(app.study, summary);
|
Object.assign(app.study, summary);
|
||||||
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
|
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("active", app.study?.ct_number === study.ct_number);
|
||||||
button.classList.toggle("complete", status === "complete");
|
button.classList.toggle("complete", status === "complete");
|
||||||
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
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 = `
|
button.innerHTML = `
|
||||||
|
<div class="study-card-status-stack">
|
||||||
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
|
<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>
|
<strong>${escapeHtml(study.ct_number)}</strong>
|
||||||
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
|
<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>
|
<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) {
|
if (annotation.skipped) {
|
||||||
tags.push({ label: "略过/不采用", source: annotation.ai_skipped ? "ai" : "manual" });
|
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 phase = phaseLabel(annotation.upper_abdomen_phase);
|
||||||
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
|
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
|
||||||
const chestWindow = chestWindowLabel(annotation.chest_window);
|
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";
|
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) {
|
function setLabelSource(input, source) {
|
||||||
const label = input.closest("label");
|
const label = input.closest("label");
|
||||||
label.classList.toggle("manual-selected", source === "manual");
|
label.classList.toggle("manual-selected", source === "manual");
|
||||||
@@ -789,12 +795,6 @@ function applyAnnotationControls() {
|
|||||||
setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : "");
|
setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : "");
|
||||||
return;
|
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.checked = parts.has(value);
|
||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
|
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),
|
chest_window: effectiveChestWindow(draft),
|
||||||
manual_chest_window: draft.manual_chest_window,
|
manual_chest_window: draft.manual_chest_window,
|
||||||
ai_chest_window: draft.ai_chest_window,
|
ai_chest_window: draft.ai_chest_window,
|
||||||
plain_ct: effectivePlainCt(draft),
|
plain_ct: false,
|
||||||
manual_plain_ct: draft.manual_plain_ct,
|
manual_plain_ct: null,
|
||||||
ai_plain_ct: draft.ai_plain_ct,
|
ai_plain_ct: null,
|
||||||
skipped: Boolean(draft.skipped),
|
skipped: Boolean(draft.skipped),
|
||||||
ai_skipped: Boolean(draft.ai_skipped),
|
ai_skipped: Boolean(draft.ai_skipped),
|
||||||
notes,
|
notes,
|
||||||
@@ -944,14 +944,6 @@ function handlePartChange(event) {
|
|||||||
if (value === "skip") {
|
if (value === "skip") {
|
||||||
app.draft.skipped = input.checked;
|
app.draft.skipped = input.checked;
|
||||||
if (!input.checked) app.draft.ai_skipped = false;
|
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)) {
|
} else if (BODY_PARTS.includes(value)) {
|
||||||
if (input.checked) {
|
if (input.checked) {
|
||||||
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
|
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.body_parts = effectiveParts();
|
||||||
app.draft.upper_abdomen_phase = effectivePhase();
|
app.draft.upper_abdomen_phase = effectivePhase();
|
||||||
app.draft.chest_window = effectiveChestWindow();
|
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();
|
applyAnnotationControls();
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
@@ -1284,6 +1278,12 @@ function wire() {
|
|||||||
renderStudies();
|
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) => {
|
document.querySelectorAll("[data-series-sort]").forEach((button) => {
|
||||||
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
|
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,14 @@
|
|||||||
<button data-study-filter="incomplete">待处理</button>
|
<button data-study-filter="incomplete">待处理</button>
|
||||||
<button data-study-filter="complete">已完成</button>
|
<button data-study-filter="complete">已完成</button>
|
||||||
</div>
|
</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="搜索检查号 / 姓名" />
|
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
||||||
<div id="studyList" class="study-list"></div>
|
<div id="studyList" class="study-list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -125,7 +133,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="part-grid">
|
<div class="part-grid">
|
||||||
<label class="skip-option"><input type="checkbox" value="skip" />略过/不采用</label>
|
<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="head_neck" />头颈部</label>
|
||||||
<label><input type="checkbox" value="chest" />胸部</label>
|
<label><input type="checkbox" value="chest" />胸部</label>
|
||||||
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
|
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ button:disabled {
|
|||||||
.series-pane {
|
.series-pane {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto 1fr;
|
grid-template-rows: auto auto auto auto 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,8 +336,17 @@ button:disabled {
|
|||||||
gap: 6px;
|
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;
|
height: 30px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: #0b0f16;
|
background: #0b0f16;
|
||||||
@@ -345,7 +354,13 @@ button:disabled {
|
|||||||
font-size: 12px;
|
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);
|
border-color: rgba(25, 212, 194, 0.62);
|
||||||
color: #baf8ee;
|
color: #baf8ee;
|
||||||
background: rgba(25, 212, 194, 0.08);
|
background: rgba(25, 212, 194, 0.08);
|
||||||
@@ -361,7 +376,7 @@ button:disabled {
|
|||||||
.study-card {
|
.study-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 42px 12px 12px;
|
padding: 12px 112px 12px 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -370,10 +385,17 @@ button:disabled {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.study-status-badge {
|
.study-card-status-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
|
max-width: 96px;
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-status-badge {
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
@@ -388,6 +410,28 @@ button:disabled {
|
|||||||
cursor: pointer;
|
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 {
|
.study-status-badge:hover {
|
||||||
border-color: rgba(239, 245, 255, 0.58);
|
border-color: rgba(239, 245, 255, 0.58);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -874,11 +918,6 @@ button:disabled {
|
|||||||
background: rgba(240, 181, 78, 0.12);
|
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 + * {
|
.part-grid input:disabled + * {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user