diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md index 9688f06..12e7c10 100644 --- a/PACS_DICOM处理/数据处理网页端/README.md +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -36,8 +36,8 @@ uvicorn app:app --host 127.0.0.1 --port 8107 ## 功能 -- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`,支持按检查时间或未标注序列数升降序排序。 -- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序。 +- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`,支持按检查时间或未标注序列数升降序排序、按处理状态筛选,并在后台逐项刷新摘要。 +- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序;序列读取和缩略图未完成时显示加载状态。 - 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。 - 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 - 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 7512806..2482477 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -793,12 +793,38 @@ def scan_study(ct_number: str) -> dict[str, Any]: return cached +def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]: + series_list = data.get("series", []) + total = len(series_list) + annotated = sum( + 1 + for row in series_list + 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("notes") + or row.get("annotation", {}).get("ai_model") + ) + return { + "ct_number": data.get("study", {}).get("ct_number", ""), + "series_count": total, + "annotated_series": annotated, + "unannotated_series": max(0, total - annotated), + "summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + @app.get("/api/studies/{ct_number}/series") def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: data = scan_study(ct_number) return {"study": data["study"], "series": data["series"]} +@app.get("/api/studies/{ct_number}/summary") +def study_summary(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: + return study_summary_payload(scan_study(ct_number)) + + def get_series_files(ct_number: str, series_uid: str) -> list[Path]: data = scan_study(ct_number) files = data["files"].get(series_uid) diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 8ce8131..3d4ff3d 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -22,10 +22,13 @@ const app = { drag: null, studySortField: "time", studySortDir: "desc", + studyFilter: "all", + summaryRun: 0, seriesSortField: "time", seriesSortDir: "asc", showOverlay: true, thumbUrls: new Map(), + imageCache: new Map(), thumbObserver: null, thumbSeries: new WeakMap(), }; @@ -214,6 +217,27 @@ function sortStudies(list) { }); } +function studyStatus(study) { + if (study.summary_loading) return "loading"; + const total = Number(study.series_count || 0); + const unannotated = Number(study.unannotated_series || 0); + if (total > 0 && unannotated === 0) return "complete"; + return "incomplete"; +} + +function studyStatusLabel(status) { + return { complete: "完成", incomplete: "待", loading: "同步" }[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"; + return true; + }); +} + function updateSortButtons() { document.querySelectorAll("[data-study-sort]").forEach((button) => { const active = button.dataset.studySort === app.studySortField; @@ -227,6 +251,9 @@ function updateSortButtons() { const arrow = button.querySelector(".sort-arrow"); if (arrow) arrow.textContent = sortArrow(active ? app.seriesSortDir : "asc"); }); + document.querySelectorAll("[data-study-filter]").forEach((button) => { + button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter); + }); } function setSeries(list) { @@ -256,24 +283,31 @@ function refreshStudyAnnotationSummary() { } async function loadStudies() { + app.summaryRun += 1; const q = encodeURIComponent($("studySearch").value.trim()); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); - $("studyCount").textContent = String(app.studies.length); renderStudies(); - if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number); + if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number); + refreshStudySummaries(); } function renderStudies() { const list = $("studyList"); app.studies = sortStudies(app.studies); updateSortButtons(); + const studies = visibleStudies(); + $("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`; list.innerHTML = ""; - for (const study of app.studies) { + for (const study of studies) { const button = document.createElement("button"); + const status = studyStatus(study); button.className = "study-card"; button.classList.toggle("active", app.study?.ct_number === study.ct_number); + button.classList.toggle("complete", status === "complete"); + button.classList.toggle("loading", status === "loading"); const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; button.innerHTML = ` + ${studyStatusLabel(status)} ${escapeHtml(study.ct_number)} ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")} ${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张 @@ -284,6 +318,25 @@ function renderStudies() { } } +async function refreshStudySummaries() { + const run = app.summaryRun; + for (const study of [...app.studies]) { + if (run !== app.summaryRun) return; + study.summary_loading = true; + renderStudies(); + try { + const summary = await json(`/api/studies/${encodeURIComponent(study.ct_number)}/summary`); + if (run !== app.summaryRun) return; + Object.assign(study, summary, { summary_loading: false, summary_loaded: true }); + if (app.study?.ct_number === study.ct_number) Object.assign(app.study, summary); + } catch (_) { + study.summary_loading = false; + study.summary_error = true; + } + 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 }; @@ -292,7 +345,7 @@ async function selectStudy(ctNumber) { app.draft = null; $("activeStudyLabel").textContent = ctNumber; $("seriesCount").textContent = "读取中"; - $("seriesGrid").innerHTML = ""; + renderSeriesLoading(); resetViewer(); renderStudies(); try { @@ -312,6 +365,12 @@ function sourceTag(value, annotation) { return ""; } +function mergeTagSource(...sources) { + if (sources.includes("manual")) return "manual"; + if (sources.includes("ai")) return "ai"; + return ""; +} + function seriesTags(series) { const annotation = series.annotation || {}; const tags = []; @@ -321,22 +380,33 @@ function seriesTags(series) { if (annotation.plain_ct) { tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" }); } - for (const part of asList(annotation.body_parts)) { - tags.push({ label: partLabel(part), source: sourceTag(part, annotation) }); - } const phase = phaseLabel(annotation.upper_abdomen_phase); - if (phase) { + const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : ""; + const chestWindow = chestWindowLabel(annotation.chest_window); + const chestWindowSource = annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : ""; + for (const part of asList(annotation.body_parts)) { + const partSource = sourceTag(part, annotation); + if (part === "upper_abdomen" && phase && annotation.upper_abdomen_phase !== "unknown") { + tags.push({ label: `${partLabel(part)}-${phase}`, source: mergeTagSource(partSource, phaseSource) }); + continue; + } + if (part === "chest" && chestWindow && annotation.chest_window !== "unknown") { + tags.push({ label: `${partLabel(part)}-${chestWindow}`, source: mergeTagSource(partSource, chestWindowSource) }); + continue; + } + tags.push({ label: partLabel(part), source: partSource }); + } + if (phase && annotation.upper_abdomen_phase === "unknown") { tags.push({ label: phase, - source: annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "", + source: phaseSource, warn: annotation.upper_abdomen_phase === "unknown", }); } - const chestWindow = chestWindowLabel(annotation.chest_window); - if (chestWindow) { + if (chestWindow && annotation.chest_window === "unknown") { tags.push({ label: chestWindow, - source: annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "", + source: chestWindowSource, warn: annotation.chest_window === "unknown", }); } @@ -358,6 +428,7 @@ function renderSeries() { card.innerHTML = `