diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 352e94e..5628d1d 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -317,6 +317,7 @@ def ensure_study_summary_table() -> None: completed boolean NOT NULL DEFAULT false, completed_by text, completed_at timestamptz, + completion_updated_at timestamptz, refreshed_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE public.pacs_dicom_study_summaries @@ -327,6 +328,8 @@ def ensure_study_summary_table() -> None: ADD COLUMN IF NOT EXISTS completed_by text; ALTER TABLE public.pacs_dicom_study_summaries ADD COLUMN IF NOT EXISTS completed_at timestamptz; + ALTER TABLE public.pacs_dicom_study_summaries + ADD COLUMN IF NOT EXISTS completion_updated_at timestamptz; """ pg_scalar(sql) @@ -511,7 +514,8 @@ def annotation_overview_by_study() -> dict[str, dict[str, Any]]: COALESCE( jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb - ) AS body_parts + ) AS body_parts, + max(a.updated_at) FILTER (WHERE COALESCE(a.updated_by, '') <> 'system') AS annotation_updated_at 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 @@ -530,7 +534,7 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]: """ SELECT ct_number, series_count, annotated_series, unannotated_series, - body_parts, completed, completed_by, completed_at, refreshed_at + body_parts, completed, completed_by, completed_at, completion_updated_at, refreshed_at FROM public.pacs_dicom_study_summaries """, timeout=8, @@ -545,7 +549,7 @@ def study_completion_fields(ct_number: str) -> dict[str, Any]: ensure_study_summary_table() rows = pg_json_rows( f""" - SELECT completed, completed_by, completed_at + SELECT completed, completed_by, completed_at, completion_updated_at FROM public.pacs_dicom_study_summaries WHERE ct_number = {sql_literal(ct_number)} LIMIT 1 @@ -559,6 +563,7 @@ def study_completion_fields(ct_number: str) -> dict[str, Any]: "completed": bool(row.get("completed", False)), "completed_by": row.get("completed_by", ""), "completed_at": row.get("completed_at", ""), + "completion_updated_at": row.get("completion_updated_at", ""), } @@ -593,6 +598,12 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li row["completed"] = bool((summary or {}).get("completed", False)) row["completed_by"] = (summary or {}).get("completed_by", "") row["completed_at"] = (summary or {}).get("completed_at", "") + row["completion_updated_at"] = (summary or {}).get("completion_updated_at", "") + row["annotation_updated_at"] = annotation_summary.get("annotation_updated_at", "") + row["modified_at"] = max( + str(row.get("annotation_updated_at") or ""), + str(row.get("completion_updated_at") or ""), + ) row["summary_updated_at"] = (summary or {}).get("refreshed_at", "") return rows @@ -1218,19 +1229,21 @@ def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str = pg_scalar( f""" INSERT INTO public.pacs_dicom_study_summaries ( - ct_number, completed, completed_by, completed_at, refreshed_at + ct_number, completed, completed_by, completed_at, completion_updated_at, refreshed_at ) VALUES ( {sql_literal(ct_number)}, {'true' if data.completed else 'false'}, {sql_literal(user)}, {'now()' if data.completed else 'NULL'}, + now(), now() ) ON CONFLICT (ct_number) DO UPDATE SET completed = EXCLUDED.completed, completed_by = EXCLUDED.completed_by, - completed_at = EXCLUDED.completed_at + completed_at = EXCLUDED.completed_at, + completion_updated_at = EXCLUDED.completion_updated_at """ ) return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)} @@ -1593,7 +1606,9 @@ def save_annotation_payload( "skipped": skipped, "ai_skipped": ai_skipped, "notes": notes, + "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""), + "updated_by": user, } ) diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index bdd6f6c..07e793f 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -24,7 +24,7 @@ const app = { saving: false, pendingImage: null, drag: null, - studySortField: "time", + studySortField: "modified", studySortDir: "desc", studyFilter: "all", studyPartFilter: "all", @@ -117,6 +117,11 @@ function studyTimeKey(study) { return `${date}${time}`; } +function studyModifiedKey(study) { + const raw = String(study.modified_at || study.annotation_updated_at || study.completion_updated_at || study.completed_at || ""); + return raw ? raw.replace(/\D/g, "").padEnd(14, "0") : studyTimeKey(study); +} + function sortArrow(direction) { return direction === "asc" ? "↑" : "↓"; } @@ -240,8 +245,8 @@ function sortStudies(list) { const timeResult = studyTimeKey(a).localeCompare(studyTimeKey(b)) || String(a.ct_number || "").localeCompare(String(b.ct_number || ""), undefined, { numeric: true }); - const unannotatedResult = Number(a.unannotated_series || 0) - Number(b.unannotated_series || 0) || timeResult; - const result = app.studySortField === "unannotated" ? unannotatedResult : timeResult; + const modifiedResult = studyModifiedKey(a).localeCompare(studyModifiedKey(b)) || timeResult; + const result = app.studySortField === "study_time" ? timeResult : modifiedResult; return app.studySortDir === "desc" ? -result : result; }); } @@ -509,16 +514,21 @@ function renderStudies() { function applyStudyCompletion(data) { const completed = Boolean(data.completed); + const modifiedAt = data.completion_updated_at || data.modified_at || data.completed_at || ""; const study = app.studies.find((item) => item.ct_number === data.ct_number); if (study) { study.completed = completed; study.completed_by = data.completed_by || ""; study.completed_at = data.completed_at || ""; + study.completion_updated_at = data.completion_updated_at || study.completion_updated_at || ""; + if (modifiedAt) study.modified_at = modifiedAt; } if (app.study?.ct_number === data.ct_number) { app.study.completed = completed; app.study.completed_by = data.completed_by || ""; app.study.completed_at = data.completed_at || ""; + app.study.completion_updated_at = data.completion_updated_at || app.study.completion_updated_at || ""; + if (modifiedAt) app.study.modified_at = modifiedAt; } } @@ -993,6 +1003,16 @@ function updateLocalAnnotation(annotation, targetSeriesUid = app.activeSeries?.s if (options.updateNotes !== false) $("annotationNotes").value = normalized.notes || ""; } refreshStudyAnnotationSummary(); + const modifiedAt = normalized.updated_at || new Date().toISOString(); + if (app.study && modifiedAt) { + app.study.modified_at = modifiedAt; + app.study.annotation_updated_at = modifiedAt; + const study = app.studies.find((item) => item.ct_number === app.study.ct_number); + if (study) { + study.modified_at = modifiedAt; + study.annotation_updated_at = modifiedAt; + } + } renderSeries(); renderStudies(); if (isActive && applyToActive) applyAnnotationControls(); @@ -1353,7 +1373,11 @@ function changeZoom(multiplier) { function wireImageGestures() { const wrap = document.querySelector(".image-wrap"); - document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation()); + const sliceRail = document.querySelector(".slice-rail"); + ["pointerdown", "pointermove", "pointerup", "pointercancel", "click"].forEach((name) => { + sliceRail.addEventListener(name, (event) => event.stopPropagation()); + }); + sliceRail.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); wrap.addEventListener( "wheel", (event) => { diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 39aa30e..83f375b 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -45,8 +45,8 @@

检查列表

- - + + 0
diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 19b11ad..1993d84 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -308,13 +308,14 @@ button:disabled { .sort-btn { height: 26px; - min-width: 68px; + min-width: 78px; padding: 0 8px; border: 1px solid var(--stroke); border-radius: 7px; background: #0b0f16; color: var(--muted); font-size: 11px; + white-space: nowrap; } .sort-arrow { @@ -501,7 +502,7 @@ button:disabled { } .study-summary-line:has(.study-export-btn) { - grid-template-columns: minmax(0, 1fr) 28px; + grid-template-columns: minmax(0, 1fr) 22px; } .study-summary-line .study-export-btn { @@ -509,29 +510,28 @@ button:disabled { } .study-export-btn { - width: 28px; - min-width: 28px; - height: 28px; + width: 22px; + min-width: 22px; + height: 22px; display: inline-grid; place-items: center; - border: 1px solid var(--stroke); - border-radius: 50%; - background: rgba(21, 28, 39, 0.92); - color: #dbe8ff; + border: 0; + border-radius: 4px; + background: transparent; + color: #9fb1cb; font-size: 11px; font-style: normal; font-weight: 800; } .study-export-btn:hover { - border-color: rgba(25, 212, 194, 0.68); color: #baf8ee; - background: rgba(25, 212, 194, 0.1); + background: transparent; } .study-export-btn svg { - width: 15px; - height: 15px; + width: 18px; + height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; @@ -768,7 +768,7 @@ button:disabled { } #dicomImage { - max-width: calc(100% - 64px); + max-width: calc(100% - 82px); max-height: 100%; object-fit: contain; transform-origin: center center; @@ -822,7 +822,7 @@ button:disabled { .ov-right-top { top: 12px; - right: 54px; + right: 72px; text-align: right; } @@ -832,7 +832,7 @@ button:disabled { } .ov-right-bottom { - right: 54px; + right: 72px; bottom: 12px; text-align: right; } @@ -840,22 +840,25 @@ button:disabled { .slice-rail { position: absolute; top: 16px; - right: 12px; + right: 10px; bottom: 16px; - width: 38px; + z-index: 4; + width: 52px; display: grid; grid-template-rows: 1fr auto; justify-items: center; align-items: center; - padding: 10px 0; + padding: 10px 4px; border: 1px solid rgba(146, 163, 186, 0.24); border-radius: 8px; background: rgba(8, 12, 18, 0.86); backdrop-filter: blur(6px); + cursor: ns-resize; + touch-action: none; } #sliceSlider { - width: 28px; + width: 44px; height: 100%; writing-mode: vertical-lr; direction: ltr; @@ -873,9 +876,9 @@ button:disabled { } #sliceSlider::-webkit-slider-thumb { - width: 16px; - height: 16px; - margin-left: -6px; + width: 22px; + height: 22px; + margin-left: -9px; border: 2px solid #d8fbff; border-radius: 999px; background: var(--cyan); @@ -896,8 +899,8 @@ button:disabled { } #sliceSlider::-moz-range-thumb { - width: 14px; - height: 14px; + width: 20px; + height: 20px; border: 2px solid #d8fbff; border-radius: 999px; background: var(--cyan);