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 = `
+ 加载中 ${Number(series.count || 0)} 张 ${skipped ? "略过/不采用" : ""}
@@ -374,24 +445,71 @@ function renderSeries() { } } +function renderSeriesLoading() { + $("seriesGrid").innerHTML = Array.from({ length: 4 }) + .map( + () => ` +
+
序列读取中
+
+ 读取中 + 正在同步序列信息 + 请稍候 +
+
+ `, + ) + .join(""); +} + function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) { return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`; } +function cacheImageUrl(key, url) { + app.imageCache.set(key, url); + while (app.imageCache.size > 90) { + const oldestKey = app.imageCache.keys().next().value; + const oldestUrl = app.imageCache.get(oldestKey); + URL.revokeObjectURL(oldestUrl); + app.imageCache.delete(oldestKey); + } +} + +function showImageMessage(message) { + const empty = $("imageEmpty"); + empty.textContent = message; + empty.classList.remove("hidden"); +} + +function hideImageMessage() { + $("imageEmpty").classList.add("hidden"); +} + async function loadThumb(series, img) { + const thumb = img.closest(".thumb"); + const state = thumb?.querySelector(".thumb-state"); try { const key = thumbKey(series); if (app.thumbUrls.has(key)) { img.src = app.thumbUrls.get(key); + thumb?.classList.add("thumb-ready"); return; } + thumb?.classList.add("thumb-loading"); + if (state) state.textContent = "加载中"; const index = Math.floor((Number(series.count) || 1) / 2); const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob()); const url = URL.createObjectURL(blob); app.thumbUrls.set(key, url); img.src = url; + thumb?.classList.remove("thumb-loading"); + thumb?.classList.add("thumb-ready"); } catch (_) { img.removeAttribute("src"); + thumb?.classList.remove("thumb-loading"); + thumb?.classList.add("thumb-failed"); + if (state) state.textContent = "未加载"; } } @@ -403,6 +521,7 @@ function queueThumb(series, img) { const key = thumbKey(series); if (app.thumbUrls.has(key)) { img.src = app.thumbUrls.get(key); + img.closest(".thumb")?.classList.add("thumb-ready"); return; } if (!("IntersectionObserver" in window)) { @@ -448,6 +567,9 @@ async function selectSeries(seriesUid) { if (!series) return; app.activeSeries = series; app.slice = Math.floor(maxSlice() / 2); + app.pendingImage = null; + $("dicomImage").removeAttribute("src"); + showImageMessage("图像加载中"); resetViewState(); $("sliceSlider").max = String(maxSlice()); $("sliceSlider").value = String(app.slice); @@ -457,12 +579,11 @@ async function selectSeries(seriesUid) { } function resetViewer() { - if (app.imageUrl) URL.revokeObjectURL(app.imageUrl); app.imageUrl = ""; app.pendingImage = null; app.activeSeries = null; $("dicomImage").removeAttribute("src"); - $("imageEmpty").classList.remove("hidden"); + hideImageMessage(); $("sliceText").textContent = "0 / 0"; $("saveState").textContent = "已保存"; clearAnnotationControls(); @@ -515,22 +636,49 @@ async function updateImage() { $("sliceSlider").max = String(max); $("sliceSlider").value = String(app.slice); $("sliceText").textContent = `${app.slice + 1} / ${max + 1}`; - $("imageEmpty").classList.add("hidden"); + const url = imageUrlFor(); + if (app.imageCache.has(url)) { + app.imageUrl = app.imageCache.get(url); + $("dicomImage").src = app.imageUrl; + hideImageMessage(); + updateOverlay(); + applyTransform(); + preloadNeighborImages(); + return; + } + showImageMessage("图像加载中"); updateOverlay(); const ticket = Symbol("image"); app.pendingImage = ticket; try { - const blob = await request(imageUrlFor()).then((res) => res.blob()); + const blob = await request(url).then((res) => res.blob()); if (app.pendingImage !== ticket) return; - if (app.imageUrl) URL.revokeObjectURL(app.imageUrl); app.imageUrl = URL.createObjectURL(blob); + cacheImageUrl(url, app.imageUrl); $("dicomImage").src = app.imageUrl; + hideImageMessage(); applyTransform(); + preloadNeighborImages(); } catch (err) { + showImageMessage("图像读取失败"); $("saveState").textContent = `图像读取失败:${err.message}`; } } +function preloadNeighborImages() { + if (!app.study || !app.activeSeries) return; + const max = maxSlice(); + const indexes = [app.slice - 2, app.slice - 1, app.slice + 1, app.slice + 2].filter((index) => index >= 0 && index <= max); + for (const index of indexes) { + const url = imageUrlFor(app.activeSeries, index, app.window, app.plane); + if (app.imageCache.has(url)) continue; + request(url) + .then((res) => res.blob()) + .then((blob) => cacheImageUrl(url, URL.createObjectURL(blob))) + .catch(() => {}); + } +} + function cloneAnnotation(annotation = {}) { return { body_parts: asList(annotation.body_parts), @@ -981,6 +1129,12 @@ function wire() { document.querySelectorAll("[data-study-sort]").forEach((button) => { button.addEventListener("click", () => setStudySort(button.dataset.studySort)); }); + document.querySelectorAll("[data-study-filter]").forEach((button) => { + button.addEventListener("click", () => { + app.studyFilter = button.dataset.studyFilter; + renderStudies(); + }); + }); document.querySelectorAll("[data-series-sort]").forEach((button) => { button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort)); }); diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 34a3789..80abd44 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -50,6 +50,11 @@ 0 +
+ + + +
@@ -99,7 +104,7 @@
-
+
0 / 0 diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index b310463..5f56981 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -258,10 +258,14 @@ button:disabled { .series-pane { padding: 14px; display: grid; - grid-template-rows: auto auto 1fr; + grid-template-rows: auto auto auto 1fr; gap: 12px; } +.series-pane { + grid-template-rows: auto 1fr; +} + .pane-head, .annotation-head, .settings-title { @@ -322,6 +326,27 @@ button:disabled { color: var(--text); } +.study-filter { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.study-filter button { + height: 30px; + border: 1px solid var(--stroke); + border-radius: 7px; + background: #0b0f16; + color: var(--muted); + font-size: 12px; +} + +.study-filter button.active { + border-color: rgba(25, 212, 194, 0.62); + color: #baf8ee; + background: rgba(25, 212, 194, 0.08); +} + .study-list, .series-grid { min-height: 0; @@ -330,8 +355,9 @@ button:disabled { } .study-card { + position: relative; width: 100%; - padding: 12px; + padding: 12px 42px 12px 12px; margin-bottom: 10px; border: 1px solid transparent; border-radius: 8px; @@ -340,6 +366,42 @@ button:disabled { text-align: left; } +.study-status-badge { + position: absolute; + top: 10px; + right: 10px; + min-width: 24px; + height: 22px; + display: inline-grid; + place-items: center; + padding: 0 6px; + border: 1px solid rgba(146, 163, 186, 0.25); + border-radius: 999px; + color: var(--muted); + font-size: 10px; + font-style: normal; + font-weight: 800; +} + +.study-status-badge.status-complete { + border-color: rgba(18, 185, 129, 0.62); + color: #9af4cf; + background: rgba(18, 185, 129, 0.09); +} + +.study-status-badge.status-incomplete { + border-color: rgba(240, 181, 78, 0.56); + color: #ffe0a3; + background: rgba(240, 181, 78, 0.09); +} + +.study-status-badge.status-loading { + min-width: 34px; + border-color: rgba(52, 116, 246, 0.56); + color: #bfd5ff; + background: rgba(52, 116, 246, 0.1); +} + .study-card.active { border-color: rgba(52, 116, 246, 0.82); background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16); @@ -410,6 +472,24 @@ button:disabled { object-fit: contain; } +.thumb-state { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--muted); + font-size: 12px; + background: rgba(2, 3, 6, 0.62); +} + +.thumb-ready .thumb-state { + display: none; +} + +.thumb-failed .thumb-state { + color: #fecdd3; +} + .thumb b, .thumb i { position: absolute; @@ -440,6 +520,11 @@ button:disabled { gap: 7px; } +.loading-card { + pointer-events: none; + opacity: 0.78; +} + .series-copy strong { overflow: hidden; text-overflow: ellipsis; @@ -555,6 +640,19 @@ button:disabled { } .image-empty { + position: absolute; + inset: 0; + z-index: 1; + display: grid; + place-items: center; + color: var(--muted); + font-size: 13px; + letter-spacing: 0; + pointer-events: none; + background: rgba(0, 0, 0, 0.22); +} + +.image-empty.hidden { display: none; }