From 40e18ed45d422cd4047b07a7bc0f1c55f866c3c8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 30 May 2026 13:29:31 +0800 Subject: [PATCH] Update PACS DICOM navigation and summaries --- DICOM_and_UPP配准/app.py | 23 ++- .../preprocess_pacs_dicom_batch.py | 182 +++++++++++++++++- PACS_DICOM处理/数据处理网页端/README.md | 2 +- PACS_DICOM处理/数据处理网页端/app.py | 72 +++++-- PACS_DICOM处理/数据处理网页端/static/app.js | 37 +++- .../数据处理网页端/static/index.html | 8 +- .../数据处理网页端/static/styles.css | 16 ++ 数据库Web可视化/app.py | 18 +- 数据库Web可视化/static/app.js | 3 +- 数据库Web可视化/static/index.html | 3 +- 10 files changed, 307 insertions(+), 57 deletions(-) diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py index d5124b5..8e3d5c8 100644 --- a/DICOM_and_UPP配准/app.py +++ b/DICOM_and_UPP配准/app.py @@ -594,14 +594,20 @@ def annotation_labels_sql() -> str: return f""" SELECT a.ct_number, - COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys, + COALESCE(jsonb_agg(DISTINCT + CASE + WHEN part.value IN ('lower_abdomen', 'pelvis') THEN 'abdomen_pelvis' + ELSE part.value + END + ) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys, COALESCE(jsonb_agg(DISTINCT CASE part.value WHEN 'head_neck' THEN '头颈部' WHEN 'chest' THEN '胸部' WHEN 'upper_abdomen' THEN '上腹部' - WHEN 'lower_abdomen' THEN '下腹部' - WHEN 'pelvis' THEN '盆腔' + WHEN 'abdomen_pelvis' THEN '腹盆部' + WHEN 'lower_abdomen' THEN '腹盆部' + WHEN 'pelvis' THEN '腹盆部' ELSE NULL END ) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels @@ -688,7 +694,7 @@ def cases( ) if status_filter in {"registered", "unregistered"}: clauses.append(f"registration_status = {sql_literal(status_filter)}") - if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}: + if body_part in {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"}: clauses.append(f"body_part_keys ? {sql_literal(body_part)}") if algorithm_model.strip(): clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}") @@ -808,6 +814,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path: return root target_folder = str(study.get("target_folder_name") or "") if target_folder and PROCESSED_ROOT.exists(): + direct = PROCESSED_ROOT / target_folder + if direct.exists(): + return direct found = next(PROCESSED_ROOT.rglob(target_folder), None) if found: return found @@ -843,10 +852,8 @@ def annotation_labels(row: dict[str, Any]) -> list[str]: elif part == "upper_abdomen": phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别") labels.append(f"上腹部-{phase}") - elif part == "lower_abdomen": - labels.append("下腹部") - elif part == "pelvis": - labels.append("盆腔") + elif part in {"abdomen_pelvis", "lower_abdomen", "pelvis"}: + labels.append("腹盆部") return labels diff --git a/PACS_DICOM处理/数据处理工作区/preprocess_pacs_dicom_batch.py b/PACS_DICOM处理/数据处理工作区/preprocess_pacs_dicom_batch.py index a5ba21f..31f38ee 100644 --- a/PACS_DICOM处理/数据处理工作区/preprocess_pacs_dicom_batch.py +++ b/PACS_DICOM处理/数据处理工作区/preprocess_pacs_dicom_batch.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse import csv +import errno import json import os import re @@ -42,6 +43,8 @@ DICOM_TAGS = [ "SOPInstanceUID", ] +CT_NUMBER_RE = re.compile(r"\bD?CT\d{6,}\b", re.IGNORECASE) + @dataclass class StudyRow: @@ -100,8 +103,22 @@ def parse_export_folder_name(name: str) -> tuple[str, str]: return ct_number.strip(), patient_name.strip() +def normalize_ct_text(value: str) -> str: + return value.strip().upper() + + +def extract_ct_numbers(*values: object) -> list[str]: + candidates: list[str] = [] + for value in values: + for match in CT_NUMBER_RE.findall(text(value)): + normalized = normalize_ct_text(match) + if normalized not in candidates: + candidates.append(normalized) + return candidates + + def canonical_ct_number(accession_number: str) -> str: - accession_number = accession_number.strip() + accession_number = normalize_ct_text(accession_number) match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number) if match: return match.group(1) @@ -164,6 +181,13 @@ def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path) raw_accession_numbers = sorted(counters["AccessionNumber"]) accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value}) + fallback_ct_numbers = extract_ct_numbers( + source_ct_number, + folder.name, + *counters["StudyID"].keys(), + *counters["AccessionNumber"].keys(), + *counters["PatientID"].keys(), + ) study_instance_uids = sorted(counters["StudyInstanceUID"]) status = "ok" @@ -174,9 +198,13 @@ def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path) if raw_accession_numbers and raw_accession_numbers != accession_numbers: notes.append("normalized_accession_suffixes") if not accession_numbers: - status = "error" - notes.append("missing_accession_number") - ct_number = source_ct_number + if fallback_ct_numbers: + notes.append("missing_accession_number_used_folder_or_dicom_fallback") + ct_number = fallback_ct_numbers[0] + else: + status = "error" + notes.append("missing_accession_number") + ct_number = source_ct_number elif len(accession_numbers) > 1: status = "error" notes.append("multiple_accession_numbers") @@ -251,19 +279,153 @@ def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]: continue except OSError: pass - if target.stat().st_size != source.stat().st_size: + if target.stat().st_size == source.stat().st_size: + already_ok += 1 + continue + else: raise RuntimeError(f"target exists with different size: {target}") - target.unlink() try: os.link(source, target) except OSError as exc: - if exc.errno != 18: # EXDEV + if exc.errno not in {errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP}: raise - shutil.copy2(source, target) + shutil.copyfile(source, target) + try: + shutil.copystat(source, target) + except OSError as stat_exc: + if stat_exc.errno not in {errno.EPERM, errno.EOPNOTSUPP}: + raise linked += 1 return linked, already_ok +def write_sync_sql(path: Path, manifest_path: Path) -> None: + sql = f"""BEGIN; + +CREATE TABLE IF NOT EXISTS public.pacs_dicom_files ( + ct_number text PRIMARY KEY, + batch_name text NOT NULL, + source_folder_name text, + source_ct_number text, + source_patient_name text, + target_folder_name text, + needs_ct_number_fix boolean NOT NULL DEFAULT false, + patient_name_dicom text, + patient_id text, + patient_birth_date text, + patient_sex text, + study_date text, + study_time text, + study_id text, + modality text, + body_part_examined text, + protocol_name text, + study_description text, + accession_numbers text, + raw_accession_numbers text, + study_instance_uids text, + series_count integer, + dicom_file_count integer, + total_file_count integer, + total_bytes bigint, + source_path text, + processed_path text, + status text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TEMP TABLE pacs_dicom_files_stage ( + batch_name text, + source_folder_name text, + source_ct_number text, + source_patient_name text, + ct_number text, + target_folder_name text, + needs_ct_number_fix text, + patient_name_dicom text, + patient_id text, + patient_birth_date text, + patient_sex text, + study_date text, + study_time text, + study_id text, + modality text, + body_part_examined text, + protocol_name text, + study_description text, + accession_numbers text, + raw_accession_numbers text, + study_instance_uids text, + series_count text, + dicom_file_count text, + total_file_count text, + total_bytes text, + source_path text, + processed_path text, + status text, + notes text +) ON COMMIT DROP; + +\\copy pacs_dicom_files_stage FROM '{manifest_path}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8') + +INSERT INTO public.pacs_dicom_files ( + ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name, + target_folder_name, needs_ct_number_fix, patient_name_dicom, patient_id, + patient_birth_date, patient_sex, study_date, study_time, study_id, modality, + body_part_examined, protocol_name, study_description, accession_numbers, + raw_accession_numbers, study_instance_uids, series_count, dicom_file_count, + total_file_count, total_bytes, source_path, processed_path, status, notes, updated_at +) +SELECT + ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name, + target_folder_name, COALESCE(NULLIF(needs_ct_number_fix, '')::boolean, false), + patient_name_dicom, patient_id, patient_birth_date, patient_sex, study_date, + study_time, study_id, modality, body_part_examined, protocol_name, + study_description, accession_numbers, raw_accession_numbers, study_instance_uids, + NULLIF(series_count, '')::integer, + NULLIF(dicom_file_count, '')::integer, + NULLIF(total_file_count, '')::integer, + NULLIF(total_bytes, '')::bigint, + source_path, processed_path, status, notes, now() +FROM pacs_dicom_files_stage +ON CONFLICT (ct_number) DO UPDATE SET + batch_name = EXCLUDED.batch_name, + source_folder_name = EXCLUDED.source_folder_name, + source_ct_number = EXCLUDED.source_ct_number, + source_patient_name = EXCLUDED.source_patient_name, + target_folder_name = EXCLUDED.target_folder_name, + needs_ct_number_fix = EXCLUDED.needs_ct_number_fix, + patient_name_dicom = EXCLUDED.patient_name_dicom, + patient_id = EXCLUDED.patient_id, + patient_birth_date = EXCLUDED.patient_birth_date, + patient_sex = EXCLUDED.patient_sex, + study_date = EXCLUDED.study_date, + study_time = EXCLUDED.study_time, + study_id = EXCLUDED.study_id, + modality = EXCLUDED.modality, + body_part_examined = EXCLUDED.body_part_examined, + protocol_name = EXCLUDED.protocol_name, + study_description = EXCLUDED.study_description, + accession_numbers = EXCLUDED.accession_numbers, + raw_accession_numbers = EXCLUDED.raw_accession_numbers, + study_instance_uids = EXCLUDED.study_instance_uids, + series_count = EXCLUDED.series_count, + dicom_file_count = EXCLUDED.dicom_file_count, + total_file_count = EXCLUDED.total_file_count, + total_bytes = EXCLUDED.total_bytes, + source_path = EXCLUDED.source_path, + processed_path = EXCLUDED.processed_path, + status = EXCLUDED.status, + notes = EXCLUDED.notes, + updated_at = now(); + +COMMIT; +""" + path.write_text(sql, encoding="utf-8") + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source", type=Path, required=True) @@ -272,11 +434,12 @@ def main() -> int: parser.add_argument("--batch-name", default="") parser.add_argument("--apply", action="store_true", help="create processed hardlink tree") parser.add_argument("--write-file-manifest", action="store_true") + parser.add_argument("--legacy-batch-layout", action="store_true", help="store processed studies under processed-root/batch-name") args = parser.parse_args() source_root = args.source.resolve() batch_name = args.batch_name or source_root.name - processed_batch_root = (args.processed_root / batch_name).resolve() + processed_batch_root = (args.processed_root / batch_name if args.legacy_batch_layout else args.processed_root).absolute() result_dir = (args.results_root / batch_name).resolve() result_dir.mkdir(parents=True, exist_ok=True) @@ -311,6 +474,7 @@ def main() -> int: study_fieldnames = list(asdict(study_rows[0]).keys()) if study_rows else list(StudyRow.__dataclass_fields__) write_csv(result_dir / "study_manifest.csv", study_dicts, study_fieldnames) + write_sync_sql(result_dir / "sync_pacs_dicom_files.sql", (result_dir / "study_manifest.csv").resolve()) write_csv( result_dir / "rename_plan.csv", [ diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md index c51272d..8dd003d 100644 --- a/PACS_DICOM处理/数据处理网页端/README.md +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -43,7 +43,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107 - 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 - 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。 -- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 +- 序列标注:选择略过/不采用、头颈部、胸部、上腹部、腹盆部;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 - AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。 - 设置:管理员独立页面,支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 475f5ff..ff96434 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -15,7 +15,7 @@ import urllib.error import urllib.request import zipfile from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -32,6 +32,15 @@ from starlette.background import BackgroundTask APP_DIR = Path(__file__).resolve().parent PACS_ROOT = APP_DIR.parent STATIC_DIR = APP_DIR / "static" +LOCAL_TZ = timezone(timedelta(hours=8), "Asia/Shanghai") + + +def local_now() -> datetime: + return datetime.now(LOCAL_TZ) + + +def local_time_text() -> str: + return local_now().strftime("%Y-%m-%d %H:%M:%S") def load_env_file() -> None: @@ -68,16 +77,16 @@ WINDOWS = { "contrast": (90.0, 140.0), } -BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"] +BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "abdomen_pelvis"] BODY_PARTS = set(BODY_PART_ORDER) +BODY_PART_ALIASES = {"lower_abdomen": "abdomen_pelvis", "pelvis": "abdomen_pelvis"} PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} BODY_PART_LABELS = { "head_neck": "头颈部", "chest": "胸部", "upper_abdomen": "上腹部", - "lower_abdomen": "下腹部", - "pelvis": "盆腔", + "abdomen_pelvis": "腹盆部", } PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"} CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"} @@ -471,6 +480,11 @@ def settings_page() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") +@app.get("/favicon.ico") +def favicon() -> Response: + return Response(status_code=204) + + @app.post("/api/auth/login") def login(data: LoginIn) -> dict[str, str]: if authenticate_web_user(data.username, data.password): @@ -498,7 +512,7 @@ def status() -> dict[str, Any]: "database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count}, "dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()}, "ai": {"configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL}, - "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), + "server_time": local_time_text(), } @@ -646,6 +660,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path: target_folder = str(study.get("target_folder_name") or "") if target_folder: + direct = PROCESSED_ROOT / target_folder + if direct.exists(): + return direct direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}")) if direct_matches: return direct_matches[0] @@ -751,7 +768,7 @@ def build_series_export_zip(ct_number: str, series_uids: list[str]) -> tuple[str remove_file(temp_path) raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc - filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{time.strftime('%Y%m%d_%H%M%S')}.zip" + filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{local_now().strftime('%Y%m%d_%H%M%S')}.zip" return temp_path, filename @@ -811,7 +828,12 @@ def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: def valid_parts(parts: Any) -> list[str]: if not isinstance(parts, list): return [] - return [part for part in parts if part in BODY_PARTS] + normalized: list[str] = [] + for value in parts: + part = BODY_PART_ALIASES.get(str(value), str(value)) + if part in BODY_PARTS and part not in normalized: + normalized.append(part) + return normalized def valid_phase(value: Any) -> str: @@ -1162,7 +1184,7 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]: "unannotated_series": max(0, total - annotated), "undetermined_series": undetermined, "body_parts": body_parts, - "summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "summary_updated_at": local_time_text(), } @@ -1197,12 +1219,17 @@ def save_study_summary(summary: dict[str, Any]) -> None: pass -def all_ct_numbers() -> list[str]: +def all_ct_numbers(missing_summary_only: bool = False) -> list[str]: + where = "" + if missing_summary_only: + where = "WHERE s.ct_number IS NULL OR COALESCE(s.series_count, 0) = 0" rows = pg_json_rows( f""" - SELECT ct_number - FROM public.{PGTABLE} - ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number + SELECT p.ct_number + FROM public.{PGTABLE} p + LEFT JOIN public.pacs_dicom_study_summaries s USING (ct_number) + {where} + ORDER BY p.study_date DESC NULLS LAST, p.study_time DESC NULLS LAST, p.ct_number """, timeout=20, ) @@ -1212,7 +1239,7 @@ def all_ct_numbers() -> list[str]: def refresh_all_study_summaries(reason: str = "manual") -> None: if not SUMMARY_REFRESH_LOCK.acquire(blocking=False): return - SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"}) + SUMMARY_REFRESH_STATE.update({"running": True, "started_at": local_time_text(), "finished_at": "", "message": f"{reason} 刷新中"}) try: numbers = all_ct_numbers() for index, ct_number in enumerate(numbers, start=1): @@ -1223,16 +1250,16 @@ def refresh_all_study_summaries(reason: str = "manual") -> None: SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}" except Exception as exc: # noqa: BLE001 SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}" - SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"}) + SUMMARY_REFRESH_STATE.update({"finished_at": local_time_text(), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"}) except Exception as exc: # noqa: BLE001 - SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"}) + SUMMARY_REFRESH_STATE.update({"finished_at": local_time_text(), "message": f"{reason} 刷新失败:{exc}"}) finally: SUMMARY_REFRESH_STATE["running"] = False SUMMARY_REFRESH_LOCK.release() def next_four_am() -> datetime: - now = datetime.now() + now = local_now() target = now.replace(hour=4, minute=0, second=0, microsecond=0) if target <= now: target += timedelta(days=1) @@ -1241,7 +1268,7 @@ def next_four_am() -> datetime: def summary_scheduler_loop() -> None: while True: - sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds()) + sleep_seconds = max(60.0, (next_four_am() - local_now()).total_seconds()) time.sleep(sleep_seconds) refresh_all_study_summaries("凌晨4点定时") @@ -1642,7 +1669,7 @@ def save_annotation_payload( "skipped": skipped, "ai_skipped": ai_skipped, "notes": notes, - "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "updated_at": local_now().strftime("%Y-%m-%dT%H:%M:%S"), "ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""), "updated_by": user, } @@ -1741,7 +1768,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen "text": ( "请根据这组CT序列的代表图像判断该序列所属部位。" "可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), " - "lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" + "abdomen_pelvis(腹盆部)。一个序列可包含多个部位。" "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。" "如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。" "如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。" @@ -1824,13 +1851,18 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen @app.get("/api/settings") def settings(_: str = Depends(require_admin)) -> dict[str, Any]: + summary_refresh = { + **SUMMARY_REFRESH_STATE, + "timezone": "Asia/Shanghai", + "next_scheduled_at": next_four_am().strftime("%Y-%m-%d %H:%M:%S"), + } return { "users": web_users(), "roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()], "database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE}, "ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "url": KIMI_API_URL}, "dicom": {"processed_root": str(PROCESSED_ROOT)}, - "summary_refresh": SUMMARY_REFRESH_STATE, + "summary_refresh": summary_refresh, } diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index bf45fc7..3037fa7 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -41,7 +41,8 @@ const app = { const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); -const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"]; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "abdomen_pelvis"]; const WINDOW_PRESETS = { bone: { wl: 500, ww: 1800 }, soft: { wl: 50, ww: 360 }, @@ -139,8 +140,7 @@ function partLabel(value) { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", - lower_abdomen: "下腹部", - pelvis: "盆腔", + abdomen_pelvis: "腹盆部", }[value] || value; } @@ -443,6 +443,23 @@ function initialCtNumber() { return new URLSearchParams(location.search).get("ct_number") || ""; } +function siblingSystemUrl(port, params = {}) { + const url = new URL(window.location.href); + url.port = String(port); + url.pathname = "/"; + url.search = ""; + Object.entries(params).forEach(([key, value]) => { + if (value) url.searchParams.set(key, value); + }); + return url.toString(); +} + +function openSiblingSystem(kind) { + const ctNumber = app.study?.ct_number || initialCtNumber(); + const port = kind === "registration" ? 8109 : 8108; + window.open(siblingSystemUrl(port, { ct_number: ctNumber }), "_blank"); +} + async function loadStudies(preferredCtNumber = "") { const q = encodeURIComponent($("studySearch").value.trim()); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); @@ -1323,7 +1340,8 @@ async function renderSettingsPage() {

检查摘要

${refresh.running ? "刷新中" : "待命"}
-
计划刷新
每天 04:00
+
计划刷新
每天 04:00(北京时间,全量)
+
下次刷新
${escapeHtml(refresh.next_scheduled_at || "-")}
最近开始
${escapeHtml(refresh.started_at || "-")}
最近完成
${escapeHtml(refresh.finished_at || "-")}
状态
${escapeHtml(refresh.message || "-")}
@@ -1382,6 +1400,15 @@ async function refreshSummariesNow() { button.disabled = true; button.textContent = "已开始刷新"; await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) }); + let refresh = {}; + for (let i = 0; i < 300; i += 1) { + await sleep(2000); + const settings = await json("/api/settings"); + refresh = settings.summary_refresh || {}; + button.textContent = refresh.running ? refresh.message || "刷新中" : "刷新完成"; + if (!refresh.running) break; + } + await loadStudies(app.study?.ct_number || initialCtNumber()); await renderSettingsPage(); } @@ -1455,6 +1482,8 @@ function wire() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", logout); $("settingsBtn").addEventListener("click", openSettings); + $("registrationLinkBtn").addEventListener("click", () => openSiblingSystem("registration")); + $("relationLinkBtn").addEventListener("click", () => openSiblingSystem("relation")); $("backToViewer").addEventListener("click", () => showViewerPage(true)); $("infoBtn").addEventListener("click", openInfo); $("aiClassify").addEventListener("click", runAI); diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 83f375b..81bbbcb 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -34,6 +34,8 @@
+ +
数据库
@@ -60,8 +62,7 @@ - - +
@@ -136,8 +137,7 @@ - - +
上腹部期相 diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 1993d84..5e4d549 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -242,6 +242,22 @@ button:disabled { background: #070a10; } +.nav-btn { + min-width: 118px; + color: #d8e8ff; +} + +.nav-btn.accent { + border-color: rgba(25, 212, 194, 0.42); + color: #c7fff7; + background: rgba(25, 212, 194, 0.08); +} + +.nav-btn.accent:hover { + border-color: rgba(25, 212, 194, 0.72); + background: rgba(25, 212, 194, 0.14); +} + .workspace { height: calc(100vh - 66px); display: grid; diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py index 381be6a..d567c94 100644 --- a/数据库Web可视化/app.py +++ b/数据库Web可视化/app.py @@ -351,16 +351,18 @@ def relation_cte() -> str: WHEN 'delayed' THEN '延迟期' ELSE '无法判别' END - WHEN 'lower_abdomen' THEN '下腹部' - WHEN 'pelvis' THEN '盆腔' + WHEN 'abdomen_pelvis' THEN '腹盆部' + WHEN 'lower_abdomen' THEN '腹盆部' + WHEN 'pelvis' THEN '腹盆部' ELSE NULL END AS label, CASE part.value WHEN 'head_neck' THEN 1 WHEN 'chest' THEN 2 WHEN 'upper_abdomen' THEN 3 + WHEN 'abdomen_pelvis' THEN 4 WHEN 'lower_abdomen' THEN 4 - WHEN 'pelvis' THEN 5 + WHEN 'pelvis' THEN 4 ELSE 99 END AS sort_key FROM public.{PACS_ANNOTATION_TABLE_SQL} a @@ -587,8 +589,7 @@ def relation_cte() -> str: CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END, CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END, CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END, - CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END, - CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END + CASE WHEN ps.body_parts ? 'abdomen_pelvis' OR ps.body_parts ? 'lower_abdomen' OR ps.body_parts ? 'pelvis' THEN '腹盆部' END ], NULL)) AS dicom_body_parts, u.patient_name AS upp_patient_name, COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names, @@ -683,10 +684,13 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) - if algorithm_model: clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}") if dicom_part: - valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} + valid_parts = {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"} if dicom_part not in valid_parts: raise HTTPException(status_code=400, detail="invalid DICOM part filter") - clauses.append(f"body_parts ? {sql_literal(dicom_part)}") + if dicom_part == "abdomen_pelvis": + clauses.append("(body_parts ? 'abdomen_pelvis' OR body_parts ? 'lower_abdomen' OR body_parts ? 'pelvis')") + else: + clauses.append(f"body_parts ? {sql_literal(dicom_part)}") return "WHERE " + " AND ".join(clauses) if clauses else "" diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js index 4f084e1..b91a93c 100644 --- a/数据库Web可视化/static/app.js +++ b/数据库Web可视化/static/app.js @@ -130,8 +130,7 @@ function partLabel(value) { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", - lower_abdomen: "下腹部", - pelvis: "盆腔", + abdomen_pelvis: "腹盆部", }[value] || value; } diff --git a/数据库Web可视化/static/index.html b/数据库Web可视化/static/index.html index 4aadcb0..292df3b 100644 --- a/数据库Web可视化/static/index.html +++ b/数据库Web可视化/static/index.html @@ -47,8 +47,7 @@ - - +