diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 0240a50..8256680 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -116,6 +116,10 @@ class AISettingsIn(BaseModel): enabled: bool = True +class StudyCompletionIn(BaseModel): + completed: bool = False + + class UserIn(BaseModel): username: str password: str @@ -296,8 +300,17 @@ def ensure_study_summary_table() -> None: series_count integer NOT NULL DEFAULT 0, annotated_series integer NOT NULL DEFAULT 0, unannotated_series integer NOT NULL DEFAULT 0, + completed boolean NOT NULL DEFAULT false, + completed_by text, + completed_at timestamptz, refreshed_at timestamptz NOT NULL DEFAULT now() ); + ALTER TABLE public.pacs_dicom_study_summaries + ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false; + ALTER TABLE public.pacs_dicom_study_summaries + ADD COLUMN IF NOT EXISTS completed_by text; + ALTER TABLE public.pacs_dicom_study_summaries + ADD COLUMN IF NOT EXISTS completed_at timestamptz; """ pg_scalar(sql) @@ -492,7 +505,9 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]: ensure_study_summary_table() rows = pg_json_rows( """ - SELECT ct_number, series_count, annotated_series, unannotated_series, refreshed_at + SELECT + ct_number, series_count, annotated_series, unannotated_series, + completed, completed_by, completed_at, refreshed_at FROM public.pacs_dicom_study_summaries """, timeout=8, @@ -502,6 +517,28 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]: return {row["ct_number"]: row for row in rows} +def study_completion_fields(ct_number: str) -> dict[str, Any]: + try: + ensure_study_summary_table() + rows = pg_json_rows( + f""" + SELECT completed, completed_by, completed_at + FROM public.pacs_dicom_study_summaries + WHERE ct_number = {sql_literal(ct_number)} + LIMIT 1 + """, + timeout=8, + ) + except Exception: + rows = [] + row = rows[0] if rows else {} + return { + "completed": bool(row.get("completed", False)), + "completed_by": row.get("completed_by", ""), + "completed_at": row.get("completed_at", ""), + } + + @app.get("/api/studies") def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]: where = "" @@ -528,6 +565,9 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li 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["unannotated_series"] = max(0, total_series - int(row["annotated_series"])) + 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["summary_updated_at"] = (summary or {}).get("refreshed_at", "") return rows @@ -1035,12 +1075,38 @@ def summary_scheduler_loop() -> None: @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"]} + study = {**data["study"], **study_completion_fields(ct_number)} + return {"study": 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)) + return {**study_summary_payload(scan_study(ct_number)), **study_completion_fields(ct_number)} + + +@app.put("/api/studies/{ct_number}/completion") +def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str = Depends(require_auth)) -> dict[str, Any]: + get_study_record(ct_number) + ensure_study_summary_table() + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_study_summaries ( + ct_number, completed, completed_by, completed_at, refreshed_at + ) + VALUES ( + {sql_literal(ct_number)}, + {'true' if data.completed else 'false'}, + {sql_literal(user)}, + {'now()' if data.completed else 'NULL'}, + now() + ) + ON CONFLICT (ct_number) DO UPDATE SET + completed = EXCLUDED.completed, + completed_by = EXCLUDED.completed_by, + completed_at = EXCLUDED.completed_at + """ + ) + return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)} def get_series_files(ct_number: str, series_uid: str) -> list[Path]: diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index e7db332..2950ef2 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -241,10 +241,7 @@ function sortStudies(list) { } function studyStatus(study) { - const total = Number(study.series_count || 0); - const unannotated = Number(study.unannotated_series || 0); - if (total > 0 && unannotated === 0) return "complete"; - return "incomplete"; + return study?.completed ? "complete" : "incomplete"; } function studyStatusLabel(status) { @@ -321,22 +318,60 @@ function renderStudies() { for (const study of studies) { const button = document.createElement("button"); const status = studyStatus(study); + const nextStatusLabel = status === "complete" ? "待处理" : "已完成"; button.className = "study-card"; 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 || "无姓名"; button.innerHTML = ` - ${studyStatusLabel(status)} + ${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)} 张 ${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注 `; button.onclick = () => selectStudy(study.ct_number); + button.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event); list.appendChild(button); } } +function applyStudyCompletion(data) { + const completed = Boolean(data.completed); + 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 || ""; + } + 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 || ""; + } +} + +async function toggleStudyCompletion(ctNumber, event) { + event.stopPropagation(); + const study = app.studies.find((item) => item.ct_number === ctNumber); + if (!study) return; + const previous = Boolean(study.completed); + applyStudyCompletion({ ct_number: ctNumber, completed: !previous }); + renderStudies(); + try { + const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/completion`, { + method: "PUT", + body: JSON.stringify({ completed: !previous }), + }); + applyStudyCompletion(data); + renderStudies(); + } catch (err) { + applyStudyCompletion({ ct_number: ctNumber, completed: previous }); + renderStudies(); + $("saveState").textContent = `状态更新失败:${err.message}`; + } +} + 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 }; @@ -351,6 +386,7 @@ async function selectStudy(ctNumber) { try { const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`); app.study = data.study; + applyStudyCompletion(data.study); setSeries(data.series || []); refreshStudyAnnotationSummary(); renderStudies(); diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 475176d..3863e55 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -385,6 +385,12 @@ button:disabled { font-size: 10px; font-style: normal; font-weight: 800; + cursor: pointer; +} + +.study-status-badge:hover { + border-color: rgba(239, 245, 255, 0.58); + color: var(--text); } .study-status-badge.status-complete {