From 9c478ed3920977f4fe522cae7ad3e21e74b2fe12 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 08:43:33 +0800 Subject: [PATCH] Add Docker PACS DICOM web viewer --- PACS_DICOM处理/数据处理网页端/.dockerignore | 7 + PACS_DICOM处理/数据处理网页端/.env.example | 15 + PACS_DICOM处理/数据处理网页端/Dockerfile | 20 + PACS_DICOM处理/数据处理网页端/README.md | 51 ++ PACS_DICOM处理/数据处理网页端/app.py | 866 ++++++++++++++++++ .../数据处理网页端/docker-compose.yml | 20 + .../数据处理网页端/requirements.txt | 5 + PACS_DICOM处理/数据处理网页端/static/app.js | 613 +++++++++++++ .../数据处理网页端/static/index.html | 156 ++++ .../数据处理网页端/static/styles.css | 824 +++++++++++++++++ 10 files changed, 2577 insertions(+) create mode 100644 PACS_DICOM处理/数据处理网页端/.dockerignore create mode 100644 PACS_DICOM处理/数据处理网页端/.env.example create mode 100644 PACS_DICOM处理/数据处理网页端/Dockerfile create mode 100644 PACS_DICOM处理/数据处理网页端/README.md create mode 100644 PACS_DICOM处理/数据处理网页端/app.py create mode 100644 PACS_DICOM处理/数据处理网页端/docker-compose.yml create mode 100644 PACS_DICOM处理/数据处理网页端/requirements.txt create mode 100644 PACS_DICOM处理/数据处理网页端/static/app.js create mode 100644 PACS_DICOM处理/数据处理网页端/static/index.html create mode 100644 PACS_DICOM处理/数据处理网页端/static/styles.css diff --git a/PACS_DICOM处理/数据处理网页端/.dockerignore b/PACS_DICOM处理/数据处理网页端/.dockerignore new file mode 100644 index 0000000..23f84d2 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/.dockerignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +data/ +logs/ +*.log diff --git a/PACS_DICOM处理/数据处理网页端/.env.example b/PACS_DICOM处理/数据处理网页端/.env.example new file mode 100644 index 0000000..a26ac0f --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/.env.example @@ -0,0 +1,15 @@ +PGHOST=192.168.3.3 +PGPORT=5432 +PGUSER=his_user +PGPASSWORD=change_me +PGDATABASE=pacs_db +PGTABLE=pacs_dicom_files +PG_ANNOTATION_TABLE=pacs_dicom_series_annotations +PACS_PROCESSED_ROOT=/dicom/已处理_DICOM数据 +PACS_BATCH_NAME=2026_5_27_PACS下载数据 +PACS_WEB_USER=admin +PACS_WEB_PASSWORD=123456 +KIMI_API_NAME=HIS_Check +KIMI_API_KEY=change_me +KIMI_API_URL=https://api.moonshot.cn/v1/chat/completions +KIMI_MODEL=kimi-latest diff --git a/PACS_DICOM处理/数据处理网页端/Dockerfile b/PACS_DICOM处理/数据处理网页端/Dockerfile new file mode 100644 index 0000000..8a43d5b --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py ./app.py +COPY static ./static + +EXPOSE 8107 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8107"] diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md new file mode 100644 index 0000000..996a5ff --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -0,0 +1,51 @@ +# PACS DICOM 网页端 + +本目录提供本地 DICOM 检查浏览、序列查看和序列部位标注界面。 + +## Docker 启动 + +本网页端推荐用 Docker Compose 启动。DICOM 数据通过只读 volume 挂载到容器,不会打包进镜像。 + +```bash +cd /home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/数据处理网页端 +cp .env.example .env +# 编辑 .env,填写本机 PostgreSQL 密码和可选 Kimi API Key +docker compose up -d --build +``` + +浏览器打开 `http://127.0.0.1:8107`。 + +默认登录账号为 `admin`,密码为 `123456`。可以通过 `.env` 或 `docker-compose.yml` 中的 `PACS_WEB_USER`、`PACS_WEB_PASSWORD` 覆盖。 + +## 本机直接启动 + +先在当前 shell 设置 PostgreSQL 连接信息,再启动 FastAPI: + +```bash +export PGHOST=192.168.3.3 +export PGPORT=5432 +export PGUSER=his_user +export PGPASSWORD='请在本机填写' +export PGDATABASE=pacs_db +export PGTABLE=pacs_dicom_files +export PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据 + +cd /home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/数据处理网页端 +uvicorn app:app --host 127.0.0.1 --port 8107 +``` + +## 功能 + +- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`。 +- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。 +- 右侧查看器:支持横断面、矢状面、冠状面,窗宽窗位、旋转、切片进度条。 +- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 +- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 +- 序列标注:选择略过、头颈部、胸部、上腹部、下腹部、盆腔;上腹部需继续选择动脉期、门静脉期或无法判别。 +- AI 识别:配置 Kimi 后,可把序列张数及横断面、矢状面、冠状面代表图像传入 AI,自动给出部位、期相和备注建议。 +- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。 +- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`。 + +## 数据安全 + +网页端代码不提交 DICOM 原始数据、已处理数据、处理结果和 `.env`。数据库密码请只放在本机环境变量或本机 `.env` 中。 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py new file mode 100644 index 0000000..71711f1 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -0,0 +1,866 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import secrets +import subprocess +import time +import urllib.error +import urllib.request +from collections import defaultdict +from pathlib import Path +from typing import Any + +import numpy as np +import pydicom +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Response +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from PIL import Image + + +APP_DIR = Path(__file__).resolve().parent +PACS_ROOT = APP_DIR.parent +STATIC_DIR = APP_DIR / "static" + + +def load_env_file() -> None: + env_file = APP_DIR / ".env" + if not env_file.exists(): + return + for line in env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +load_env_file() + +PGHOST = os.getenv("PGHOST", "192.168.3.3") +PGPORT = os.getenv("PGPORT", "5432") +PGUSER = os.getenv("PGUSER", "his_user") +PGDATABASE = os.getenv("PGDATABASE", "pacs_db") +PGTABLE = os.getenv("PGTABLE", "pacs_dicom_files") +WEB_USER = os.getenv("PACS_WEB_USER", "admin") +WEB_PASSWORD = os.getenv("PACS_WEB_PASSWORD", "123456") +PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", str(PACS_ROOT / "已处理_DICOM数据"))).resolve() +KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") +KIMI_API_NAME = os.getenv("KIMI_API_NAME", "HIS_Check") +KIMI_API_URL = os.getenv("KIMI_API_URL", "https://api.moonshot.cn/v1/chat/completions") +KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-latest") + +WINDOWS = { + "default": None, + "bone": (500.0, 1800.0), + "soft": (50.0, 360.0), + "contrast": (90.0, 140.0), +} + +BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} +PHASES = {"arterial", "portal_venous", "unknown", ""} +ROLES = { + "管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"], + "阅片员": ["查看DICOM", "编辑标注", "AI识别"], + "访客": ["查看DICOM"], +} + +app = FastAPI(title="PACS DICOM Viewer") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +TOKENS: dict[str, str] = {} +STUDY_CACHE: dict[str, dict[str, Any]] = {} +STACK_CACHE: dict[str, tuple[float, np.ndarray]] = {} + + +class LoginIn(BaseModel): + username: str + password: str + + +class AnnotationIn(BaseModel): + body_parts: list[str] = [] + upper_abdomen_phase: str = "" + notes: str = "" + skipped: bool = False + + +class AIRequest(BaseModel): + sample_count: int = 3 + + +class UserIn(BaseModel): + username: str + password: str + role: str = "阅片员" + status: str = "启用" + + +def pg_env() -> dict[str, str]: + env = os.environ.copy() + if os.getenv("PGPASSWORD"): + env["PGPASSWORD"] = os.environ["PGPASSWORD"] + return env + + +def sql_literal(value: Any) -> str: + if value is None: + return "NULL" + return "'" + str(value).replace("'", "''") + "'" + + +def run_psql(sql: str, timeout: int = 12) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "psql", + "-h", + PGHOST, + "-p", + PGPORT, + "-U", + PGUSER, + "-d", + PGDATABASE, + "-X", + "-q", + "-t", + "-A", + "-c", + sql, + ], + text=True, + capture_output=True, + timeout=timeout, + env=pg_env(), + ) + + +def pg_scalar(sql: str, timeout: int = 12) -> str: + result = run_psql(sql, timeout=timeout) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip()) + return result.stdout.strip() + + +def pg_json_rows(select_sql: str, timeout: int = 20) -> list[dict[str, Any]]: + payload = pg_scalar( + f"SELECT COALESCE(json_agg(row_to_json(q)), '[]'::json)::text FROM ({select_sql}) q", + timeout=timeout, + ) + return json.loads(payload or "[]") + + +def db_available() -> tuple[bool, str]: + if not os.getenv("PGPASSWORD"): + return False, "PGPASSWORD 未设置" + try: + pg_scalar("SELECT 1", timeout=4) + return True, "connected" + except Exception as exc: # noqa: BLE001 + return False, str(exc) + + +def ensure_annotation_table() -> None: + sql = """ + CREATE TABLE IF NOT EXISTS public.pacs_dicom_series_annotations ( + ct_number text NOT NULL, + study_instance_uid text, + series_instance_uid text NOT NULL, + series_description text, + body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, + upper_abdomen_phase text NOT NULL DEFAULT '', + skipped boolean NOT NULL DEFAULT false, + notes text NOT NULL DEFAULT '', + ai_result jsonb, + ai_model text, + updated_by text NOT NULL DEFAULT 'admin', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (ct_number, series_instance_uid) + ); + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS skipped boolean NOT NULL DEFAULT false; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_result jsonb; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_model text; + """ + pg_scalar(sql) + + +def password_hash(password: str) -> str: + return hashlib.sha256(("pacs-dicom-web:" + password).encode("utf-8")).hexdigest() + + +def ensure_user_table() -> None: + sql = f""" + CREATE TABLE IF NOT EXISTS public.pacs_web_users ( + username text PRIMARY KEY, + password_hash text NOT NULL, + role text NOT NULL DEFAULT '阅片员', + status text NOT NULL DEFAULT '启用', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + INSERT INTO public.pacs_web_users (username, password_hash, role, status) + VALUES ({sql_literal(WEB_USER)}, {sql_literal(password_hash(WEB_PASSWORD))}, '管理员', '启用') + ON CONFLICT (username) DO NOTHING; + """ + pg_scalar(sql) + + +def web_users() -> list[dict[str, Any]]: + try: + ensure_user_table() + return pg_json_rows( + """ + SELECT username, role, status, created_at, updated_at + FROM public.pacs_web_users + ORDER BY username + """ + ) + except Exception: + return [{"username": WEB_USER, "role": "管理员", "status": "启用"}] + + +def authenticate_web_user(username: str, password: str) -> bool: + try: + ok, _ = db_available() + if ok: + ensure_user_table() + rows = pg_json_rows( + f""" + SELECT username, password_hash, status + FROM public.pacs_web_users + WHERE username = {sql_literal(username)} + LIMIT 1 + """ + ) + if rows: + row = rows[0] + return row.get("status") == "启用" and row.get("password_hash") == password_hash(password) + except Exception: + pass + return username == WEB_USER and password == WEB_PASSWORD + + +@app.on_event("startup") +def startup() -> None: + ok, _ = db_available() + if ok: + ensure_annotation_table() + ensure_user_table() + + +def require_auth(authorization: str | None = Header(default=None), access_token: str = Query(default="")) -> str: + token = access_token.strip() + if not token and authorization and authorization.startswith("Bearer "): + token = authorization.removeprefix("Bearer ").strip() + if not token: + raise HTTPException(status_code=401, detail="unauthorized") + user = TOKENS.get(token) + if not user: + raise HTTPException(status_code=401, detail="unauthorized") + return user + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.post("/api/auth/login") +def login(data: LoginIn) -> dict[str, str]: + if authenticate_web_user(data.username, data.password): + token = secrets.token_urlsafe(32) + TOKENS[token] = data.username + return {"token": token, "username": data.username} + raise HTTPException(status_code=401, detail="invalid credentials") + + +@app.get("/api/status") +def status() -> dict[str, Any]: + db_ok, db_message = db_available() + table_count = None + if db_ok: + try: + table_count = int(pg_scalar(f"SELECT count(*) FROM public.{PGTABLE}")) + except Exception: + table_count = None + return { + "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), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL}, + "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + +@app.get("/api/studies") +def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]: + where = "" + if q: + like = "%" + q.replace("%", "").replace("_", "") + "%" + where = f"WHERE ct_number ILIKE {sql_literal(like)} OR source_patient_name ILIKE {sql_literal(like)} OR patient_name_dicom ILIKE {sql_literal(like)}" + rows = pg_json_rows( + f""" + SELECT + ct_number, batch_name, target_folder_name, source_patient_name, patient_name_dicom, + patient_id, study_date, study_time, modality, dicom_file_count, + processed_path, needs_ct_number_fix, status + FROM public.{PGTABLE} + {where} + ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number + LIMIT {int(limit)} + """ + ) + annotations = pg_json_rows( + """ + SELECT ct_number, count(*)::int AS annotated_series + FROM public.pacs_dicom_series_annotations + WHERE skipped IS NOT TRUE AND jsonb_array_length(body_parts) > 0 + GROUP BY ct_number + """, + timeout=8, + ) + annotation_map = {row["ct_number"]: row["annotated_series"] for row in annotations} + for row in rows: + row["annotated_series"] = annotation_map.get(row["ct_number"], 0) + return rows + + +def get_study_record(ct_number: str) -> dict[str, Any]: + rows = pg_json_rows( + f""" + SELECT * FROM public.{PGTABLE} + WHERE ct_number = {sql_literal(ct_number)} + LIMIT 1 + """ + ) + if not rows: + raise HTTPException(status_code=404, detail="study not found") + return rows[0] + + +def resolve_study_root(study: dict[str, Any]) -> Path: + root = Path(study.get("processed_path") or "") + if root.exists(): + return root + + target_folder = str(study.get("target_folder_name") or "") + if target_folder: + direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}")) + if direct_matches: + return direct_matches[0] + recursive = next(PROCESSED_ROOT.rglob(target_folder), None) + if recursive: + return recursive + + ct_number = str(study.get("ct_number") or "") + recursive = next(PROCESSED_ROOT.rglob(f"{ct_number}-*"), None) + if recursive: + return recursive + return root + + +def read_header(path: Path) -> dict[str, str]: + tags = [ + "SeriesInstanceUID", + "StudyInstanceUID", + "SeriesNumber", + "SeriesDescription", + "InstanceNumber", + "SliceLocation", + "ImagePositionPatient", + "AcquisitionTime", + "ContentTime", + "SeriesTime", + "StudyTime", + "Modality", + "BodyPartExamined", + "Manufacturer", + "Rows", + "Columns", + "PixelSpacing", + "SliceThickness", + "SpacingBetweenSlices", + "WindowCenter", + "WindowWidth", + ] + ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True, specific_tags=tags + ["SpecificCharacterSet"]) + return {tag: str(getattr(ds, tag, "")).strip() for tag in tags} + + +def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: + path, meta = item + instance = float(meta.get("InstanceNumber") or 0) + position = meta.get("ImagePositionPatient", "") + z = 0.0 + if position: + try: + z = float(str(position).strip("[]").split(",")[-1]) + except Exception: + z = 0.0 + if meta.get("SliceLocation"): + try: + z = float(meta["SliceLocation"]) + except Exception: + pass + return (z, instance, str(path)) + + +def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: + try: + rows = pg_json_rows( + f""" + SELECT series_instance_uid, body_parts, upper_abdomen_phase, skipped, notes, updated_at, ai_model + FROM public.pacs_dicom_series_annotations + WHERE ct_number = {sql_literal(ct_number)} + """ + ) + except Exception: + return {} + return {row["series_instance_uid"]: row for row in rows} + + +def scan_study(ct_number: str) -> dict[str, Any]: + cached = STUDY_CACHE.get(ct_number) + if cached and time.time() - cached["cached_at"] < 600: + return cached + + study = get_study_record(ct_number) + root = resolve_study_root(study) + if not root.exists(): + raise HTTPException(status_code=404, detail=f"DICOM path not found: {root}") + + grouped: dict[str, list[tuple[Path, dict[str, str]]]] = defaultdict(list) + for path in root.rglob("*.dcm"): + try: + meta = read_header(path) + except Exception: + continue + uid = meta.get("SeriesInstanceUID") or path.parent.name + grouped[uid].append((path, meta)) + + annotations = get_annotations(ct_number) + series_list = [] + file_map = {} + for uid, items in grouped.items(): + items.sort(key=sort_key) + first = items[0][1] + last = items[-1][1] + file_map[uid] = [path for path, _ in items] + annotation = annotations.get(uid, {}) + series_list.append( + { + "ct_number": ct_number, + "series_uid": uid, + "study_uid": first.get("StudyInstanceUID", ""), + "series_number": first.get("SeriesNumber", ""), + "description": first.get("SeriesDescription", "") or "未命名序列", + "count": len(items), + "modality": first.get("Modality", ""), + "body_part_dicom": first.get("BodyPartExamined", ""), + "study_time": first.get("StudyTime", ""), + "series_time": first.get("SeriesTime", "") or first.get("AcquisitionTime", "") or first.get("ContentTime", ""), + "first_time": first.get("AcquisitionTime", "") or first.get("ContentTime", ""), + "last_time": last.get("AcquisitionTime", "") or last.get("ContentTime", ""), + "manufacturer": first.get("Manufacturer", ""), + "rows": first.get("Rows", ""), + "columns": first.get("Columns", ""), + "pixel_spacing": first.get("PixelSpacing", ""), + "slice_thickness": first.get("SliceThickness", ""), + "spacing_between_slices": first.get("SpacingBetweenSlices", ""), + "annotation": { + "body_parts": annotation.get("body_parts", []), + "upper_abdomen_phase": annotation.get("upper_abdomen_phase", ""), + "skipped": bool(annotation.get("skipped", False)), + "notes": annotation.get("notes", ""), + "updated_at": annotation.get("updated_at", ""), + "ai_model": annotation.get("ai_model", ""), + }, + } + ) + + def numeric(value: str) -> int: + try: + return int(float(value)) + except Exception: + return 999999 + + series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, numeric(row["series_number"]), row["description"])) + cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map} + STUDY_CACHE[ct_number] = cached + return cached + + +@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"]} + + +def get_series_files(ct_number: str, series_uid: str) -> list[Path]: + data = scan_study(ct_number) + files = data["files"].get(series_uid) + if not files: + raise HTTPException(status_code=404, detail="series not found") + return files + + +def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]: + if preset in WINDOWS and WINDOWS[preset]: + return WINDOWS[preset] # type: ignore[return-value] + center = getattr(ds, "WindowCenter", 50) + width = getattr(ds, "WindowWidth", 360) + if isinstance(center, pydicom.multival.MultiValue): + center = center[0] + if isinstance(width, pydicom.multival.MultiValue): + width = width[0] + try: + return float(center), float(width) + except Exception: + return 50.0, 360.0 + + +def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray: + arr = ds.pixel_array.astype(np.float32) + slope = float(getattr(ds, "RescaleSlope", 1) or 1) + intercept = float(getattr(ds, "RescaleIntercept", 0) or 0) + return arr * slope + intercept + + +def render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, rotate: int = 0, max_size: int = 900) -> bytes: + low = center - width / 2.0 + high = center + width / 2.0 + img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8) + if invert: + img = 255 - img + pil = Image.fromarray(img) + if rotate: + pil = pil.rotate(-rotate, expand=True) + if max(pil.size) > max_size: + pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR) + output = io.BytesIO() + pil.save(output, format="PNG", optimize=True) + return output.getvalue() + + +def load_stack(ct_number: str, series_uid: str) -> np.ndarray: + key = f"{ct_number}|{series_uid}" + cached = STACK_CACHE.get(key) + if cached: + STACK_CACHE[key] = (time.time(), cached[1]) + return cached[1] + files = get_series_files(ct_number, series_uid) + arrays = [] + for path in files: + ds = pydicom.dcmread(str(path), force=True) + arrays.append(dicom_to_hu(ds)) + stack = np.stack(arrays, axis=0) + STACK_CACHE[key] = (time.time(), stack) + if len(STACK_CACHE) > 2: + oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0] + STACK_CACHE.pop(oldest, None) + return stack + + +@app.get("/api/image") +def image( + ct_number: str, + series_uid: str, + index: int = 0, + plane: str = "axial", + window: str = "default", + rotate: int = 0, + _: str = Depends(require_auth), +) -> Response: + files = get_series_files(ct_number, series_uid) + index = max(0, index) + if plane == "axial" or len(files) < 2: + index = min(index, len(files) - 1) + ds = pydicom.dcmread(str(files[index]), force=True) + center, width = window_values(ds, window) + payload = render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", rotate) + return Response(payload, media_type="image/png") + + stack = load_stack(ct_number, series_uid) + sample_ds = pydicom.dcmread(str(files[min(len(files) - 1, len(files) // 2)]), stop_before_pixels=True, force=True) + center, width = window_values(sample_ds, window) + if plane == "coronal": + index = min(index, stack.shape[1] - 1) + arr = stack[:, index, :] + elif plane == "sagittal": + index = min(index, stack.shape[2] - 1) + arr = stack[:, :, index] + else: + raise HTTPException(status_code=400, detail="invalid plane") + payload = render_array(np.flipud(arr), center, width, False, rotate) + return Response(payload, media_type="image/png") + + +@app.get("/api/dicom-info") +def dicom_info(ct_number: str, series_uid: str, index: int = 0, _: str = Depends(require_auth)) -> dict[str, Any]: + files = get_series_files(ct_number, series_uid) + index = min(max(0, index), len(files) - 1) + path = files[index] + ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True) + fields = { + "patient": { + "患者姓名": str(getattr(ds, "PatientName", "")), + "患者ID": str(getattr(ds, "PatientID", "")), + "检查号": str(getattr(ds, "AccessionNumber", "")), + "检查日期": str(getattr(ds, "StudyDate", "")), + "检查时间": str(getattr(ds, "StudyTime", "")), + "设备厂商": str(getattr(ds, "Manufacturer", "")), + }, + "series": { + "序列描述": str(getattr(ds, "SeriesDescription", "")), + "序列号": str(getattr(ds, "SeriesNumber", "")), + "文件数量": len(files), + "当前文件": path.name, + "DICOM路径": str(path), + }, + "image": { + "Rows": str(getattr(ds, "Rows", "")), + "Columns": str(getattr(ds, "Columns", "")), + "BitsAllocated": str(getattr(ds, "BitsAllocated", "")), + "WindowCenter": str(getattr(ds, "WindowCenter", "")), + "WindowWidth": str(getattr(ds, "WindowWidth", "")), + "Rescale": f"{getattr(ds, 'RescaleSlope', '')} / {getattr(ds, 'RescaleIntercept', '')}", + }, + "spacing": { + "像素间距": str(getattr(ds, "PixelSpacing", "")), + "切片厚度": str(getattr(ds, "SliceThickness", "")), + "SpacingBetweenSlices": str(getattr(ds, "SpacingBetweenSlices", "")), + "ImagePositionPatient": str(getattr(ds, "ImagePositionPatient", "")), + }, + } + return {"path": str(path), "fields": fields} + + +@app.put("/api/series/{ct_number}/{series_uid}/annotation") +def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]: + body_parts = [] if data.skipped else [part for part in data.body_parts if part in BODY_PARTS] + phase = data.upper_abdomen_phase if data.upper_abdomen_phase in PHASES else "" + if "upper_abdomen" not in body_parts: + phase = "" + study = scan_study(ct_number) + series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) + if not series_row: + raise HTTPException(status_code=404, detail="series not found") + ensure_annotation_table() + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_series_annotations ( + ct_number, study_instance_uid, series_instance_uid, series_description, + body_parts, upper_abdomen_phase, skipped, notes, updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(series_row.get('study_uid', ''))}, + {sql_literal(series_uid)}, + {sql_literal(series_row.get('description', ''))}, + {sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb, + {sql_literal(phase)}, + {'true' if data.skipped else 'false'}, + {sql_literal(data.notes)}, + {sql_literal(user)}, + now() + ) + ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET + study_instance_uid = EXCLUDED.study_instance_uid, + series_description = EXCLUDED.series_description, + body_parts = EXCLUDED.body_parts, + upper_abdomen_phase = EXCLUDED.upper_abdomen_phase, + skipped = EXCLUDED.skipped, + notes = EXCLUDED.notes, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """ + ) + STUDY_CACHE.pop(ct_number, None) + return {"ok": True, "body_parts": body_parts, "upper_abdomen_phase": phase, "skipped": data.skipped} + + +def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, bytes]]: + files = get_series_files(ct_number, series_uid) + axial_index = max(0, min(len(files) - 1, len(files) // 2)) + ds = pydicom.dcmread(str(files[axial_index]), force=True) + center, width = window_values(ds, "soft") + images = [("横断面", render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", max_size=720))] + + if len(files) >= 3: + try: + stack = load_stack(ct_number, series_uid) + sample_ds = pydicom.dcmread(str(files[axial_index]), stop_before_pixels=True, force=True) + center, width = window_values(sample_ds, "soft") + coronal = np.flipud(stack[:, stack.shape[1] // 2, :]) + sagittal = np.flipud(stack[:, :, stack.shape[2] // 2]) + images.append(("矢状面", render_array(sagittal, center, width, False, max_size=720))) + images.append(("冠状面", render_array(coronal, center, width, False, max_size=720))) + except Exception: + pass + return images + + +def parse_ai_json(content: str) -> dict[str, Any]: + text = content.strip() + if text.startswith("```"): + text = text.strip("`") + if text.startswith("json"): + text = text[4:] + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + text = text[start : end + 1] + try: + return json.loads(text) + except Exception: + return {"body_parts": [], "upper_abdomen_phase": "", "skipped": False, "notes": content[:800]} + + +@app.post("/api/series/{ct_number}/{series_uid}/ai") +def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]: + if not KIMI_API_KEY: + raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置") + study = scan_study(ct_number) + series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) + if not series_row: + raise HTTPException(status_code=404, detail="series not found") + + content: list[dict[str, Any]] = [ + { + "type": "text", + "text": ( + "请根据这组CT序列的代表图像判断该序列所属部位。" + "可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), " + "lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" + "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。" + "如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。" + "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"skipped\":false,\"notes\":\"\"}。" + f"PACS张数: {series_row.get('count', 0)}。" + f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。" + ), + } + ] + for label, png in representative_images(ct_number, series_uid): + content.append({"type": "text", "text": label}) + content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode("ascii")}}) + + payload = { + "model": KIMI_MODEL, + "messages": [ + {"role": "system", "content": "你是医学影像序列分拣助手,只做部位和期相粗分类,不输出诊断。"}, + {"role": "user", "content": content}, + ], + "temperature": 0.1, + } + request = urllib.request.Request( + KIMI_API_URL, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {KIMI_API_KEY}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:800] + raise HTTPException(status_code=502, detail=f"Kimi API 错误: {detail}") from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Kimi API 调用失败: {exc}") from exc + + data = json.loads(raw) + message = data.get("choices", [{}])[0].get("message", {}).get("content", "") + suggestion = parse_ai_json(message) + body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS] + skipped = bool(suggestion.get("skipped", False)) + phase = suggestion.get("upper_abdomen_phase", "") + if phase not in PHASES or "upper_abdomen" not in body_parts: + phase = "" + ensure_annotation_table() + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_series_annotations ( + ct_number, study_instance_uid, series_instance_uid, series_description, + body_parts, upper_abdomen_phase, skipped, notes, ai_result, ai_model, updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(series_row.get('study_uid', ''))}, + {sql_literal(series_uid)}, + {sql_literal(series_row.get('description', ''))}, + '[]'::jsonb, + '', + false, + '', + {sql_literal(json.dumps(suggestion, ensure_ascii=False))}::jsonb, + {sql_literal(KIMI_MODEL)}, + {sql_literal(user)}, + now() + ) + ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET + ai_result = EXCLUDED.ai_result, + ai_model = EXCLUDED.ai_model, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """ + ) + STUDY_CACHE.pop(ct_number, None) + return { + "ok": True, + "provider": "Kimi", + "name": KIMI_API_NAME, + "model": KIMI_MODEL, + "body_parts": [] if skipped else body_parts, + "upper_abdomen_phase": "" if skipped else phase, + "skipped": skipped, + "notes": str(suggestion.get("notes", "")), + "raw": suggestion, + } + + +@app.get("/api/settings") +def settings(_: str = Depends(require_auth)) -> dict[str, Any]: + 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), "url": KIMI_API_URL}, + "dicom": {"processed_root": str(PROCESSED_ROOT)}, + } + + +@app.post("/api/settings/users") +def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]: + if user != WEB_USER: + raise HTTPException(status_code=403, detail="仅管理员可创建用户") + username = data.username.strip() + if not username or len(username) > 64: + raise HTTPException(status_code=400, detail="账号格式不正确") + if len(data.password) < 6: + raise HTTPException(status_code=400, detail="密码至少 6 位") + role = data.role if data.role in ROLES else "阅片员" + status = data.status if data.status in {"启用", "停用"} else "启用" + ensure_user_table() + exists = int(pg_scalar(f"SELECT count(*) FROM public.pacs_web_users WHERE username = {sql_literal(username)}") or "0") + if exists: + raise HTTPException(status_code=409, detail="账号已存在") + pg_scalar( + f""" + INSERT INTO public.pacs_web_users (username, password_hash, role, status, updated_at) + VALUES ({sql_literal(username)}, {sql_literal(password_hash(data.password))}, {sql_literal(role)}, {sql_literal(status)}, now()) + """ + ) + return {"ok": True, "username": username, "role": role, "status": status} + + +@app.get("/health") +def health() -> Response: + return Response("ok", media_type="text/plain") diff --git a/PACS_DICOM处理/数据处理网页端/docker-compose.yml b/PACS_DICOM处理/数据处理网页端/docker-compose.yml new file mode 100644 index 0000000..e4a84be --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/docker-compose.yml @@ -0,0 +1,20 @@ +name: pacs-dicom-web + +services: + pacs-dicom-web: + build: + context: . + container_name: pacs-dicom-web + restart: unless-stopped + env_file: + - .env + environment: + PACS_PROCESSED_ROOT: /dicom/已处理_DICOM数据 + PACS_BATCH_NAME: 2026_5_27_PACS下载数据 + PACS_WEB_USER: admin + PACS_WEB_PASSWORD: "123456" + PG_ANNOTATION_TABLE: pacs_dicom_series_annotations + ports: + - "8107:8107" + volumes: + - /home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据:/dicom/已处理_DICOM数据:ro diff --git a/PACS_DICOM处理/数据处理网页端/requirements.txt b/PACS_DICOM处理/数据处理网页端/requirements.txt new file mode 100644 index 0000000..5c2bdb8 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pydicom==2.4.5 +numpy>=2.0 +pillow>=11.0 diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js new file mode 100644 index 0000000..18648b5 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -0,0 +1,613 @@ +const app = { + token: localStorage.getItem("pacs_web_token") || "", + studies: [], + study: null, + series: [], + activeSeries: null, + slice: 0, + plane: "axial", + window: "default", + rotate: 0, + zoom: 1, + panX: 0, + panY: 0, + imageUrl: "", + searchTimer: null, + statusTimer: null, + pendingImage: null, + drag: null, +}; + +const $ = (id) => document.getElementById(id); +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function request(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (app.token) headers.Authorization = `Bearer ${app.token}`; + if (options.body) headers["Content-Type"] = "application/json"; + const res = await fetch(path, { ...options, headers }); + if (res.status === 401) { + showLogin(true); + throw new Error("unauthorized"); + } + if (!res.ok) { + let detail = res.statusText; + try { + const data = await res.json(); + detail = data.detail || detail; + } catch (_) { + detail = await res.text(); + } + throw new Error(detail); + } + return res; +} + +async function json(path, options = {}) { + const res = await request(path, options); + return res.json(); +} + +function showLogin(show) { + $("loginOverlay").classList.toggle("hidden", !show); +} + +function fmtDate(value) { + const text = String(value || ""); + if (text.length !== 8) return text; + return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; +} + +function fmtTime(value) { + const digits = String(value || "").replace(/\D/g, "").slice(0, 6); + if (digits.length < 6) return value || ""; + return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`; +} + +function timeRange(series) { + const first = fmtTime(series.first_time || series.series_time || series.study_time); + const last = fmtTime(series.last_time); + if (first && last && first !== last) return `${first}-${last}`; + return first || last || "未记录"; +} + +function phaseLabel(value) { + return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || ""; +} + +function partLabel(value) { + return { + head_neck: "头颈部", + chest: "胸部", + upper_abdomen: "上腹部", + lower_abdomen: "下腹部", + pelvis: "盆腔", + }[value] || value; +} + +async function login(event) { + event.preventDefault(); + $("loginError").textContent = ""; + try { + const data = await json("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: $("username").value, password: $("password").value }), + }); + app.token = data.token; + localStorage.setItem("pacs_web_token", app.token); + showLogin(false); + await boot(); + } catch (_) { + $("loginError").textContent = "登录失败"; + } +} + +function logout() { + app.token = ""; + localStorage.removeItem("pacs_web_token"); + showLogin(true); +} + +async function refreshStatus() { + try { + const data = await fetch("/api/status").then((res) => res.json()); + const pill = $("dbStatus"); + pill.textContent = data.database?.ok ? `数据库 ${data.database.rows ?? ""}` : "数据库异常"; + pill.title = data.database?.message || ""; + pill.classList.toggle("offline", !data.database?.ok); + pill.classList.toggle("online", Boolean(data.database?.ok)); + } catch (_) { + $("dbStatus").textContent = "数据库异常"; + $("dbStatus").classList.add("offline"); + $("dbStatus").classList.remove("online"); + } +} + +async function loadStudies() { + const q = encodeURIComponent($("studySearch").value.trim()); + app.studies = 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); +} + +function renderStudies() { + const list = $("studyList"); + list.innerHTML = ""; + for (const study of app.studies) { + const button = document.createElement("button"); + button.className = "study-card"; + button.classList.toggle("active", app.study?.ct_number === study.ct_number); + const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; + button.innerHTML = ` + ${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)} 个序列已标注 + `; + button.onclick = () => selectStudy(study.ct_number); + list.appendChild(button); + } +} + +async function selectStudy(ctNumber) { + app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber }; + app.series = []; + app.activeSeries = null; + $("activeStudyLabel").textContent = ctNumber; + $("seriesCount").textContent = "读取中"; + $("seriesGrid").innerHTML = ""; + resetViewer(); + renderStudies(); + try { + const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`); + app.study = data.study; + app.series = data.series || []; + $("seriesCount").textContent = String(app.series.length); + renderSeries(); + if (app.series.length) selectSeries(app.series[0].series_uid); + } catch (err) { + $("seriesGrid").innerHTML = `

${escapeHtml(err.message)}

`; + } +} + +function renderSeries() { + const grid = $("seriesGrid"); + grid.innerHTML = ""; + for (const series of app.series) { + const skipped = Boolean(series.annotation?.skipped); + const parts = series.annotation?.body_parts || []; + const tags = skipped + ? ["略过"] + : [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean); + const card = document.createElement("button"); + card.className = "series-card"; + card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid); + card.classList.toggle("skipped", skipped); + card.innerHTML = ` +
+ + ${Number(series.count || 0)} 张 + ${skipped ? "略过" : ""} +
+
+ ${escapeHtml(series.description || "未命名序列")} + 拍摄 ${escapeHtml(timeRange(series))} + 序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")} +
${tags.length ? tags.map((tag) => `${escapeHtml(tag)}`).join("") : "未标注"}
+
+ `; + card.onclick = () => selectSeries(series.series_uid); + grid.appendChild(card); + loadThumb(series, card.querySelector("img")); + } +} + +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`; +} + +async function loadThumb(series, img) { + try { + const index = Math.floor((Number(series.count) || 1) / 2); + const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob()); + img.src = URL.createObjectURL(blob); + } catch (_) { + img.removeAttribute("src"); + } +} + +function maxSlice() { + if (!app.activeSeries) return 0; + if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1); + if (app.plane === "coronal") return Math.max(0, Number(app.activeSeries.rows || 1) - 1); + return Math.max(0, Number(app.activeSeries.count || 1) - 1); +} + +function resetViewState() { + app.rotate = 0; + app.zoom = 1; + app.panX = 0; + app.panY = 0; + applyTransform(); +} + +function selectSeries(seriesUid) { + const series = app.series.find((item) => item.series_uid === seriesUid); + if (!series) return; + app.activeSeries = series; + app.slice = Math.floor(maxSlice() / 2); + resetViewState(); + $("sliceSlider").max = String(maxSlice()); + $("sliceSlider").value = String(app.slice); + hydrateAnnotation(); + renderSeries(); + updateImage(); +} + +function resetViewer() { + if (app.imageUrl) URL.revokeObjectURL(app.imageUrl); + app.imageUrl = ""; + app.pendingImage = null; + app.activeSeries = null; + $("dicomImage").removeAttribute("src"); + $("imageEmpty").classList.remove("hidden"); + $("sliceText").textContent = "0 / 0"; + $("saveState").textContent = "未保存"; + resetViewState(); +} + +function applyTransform() { + $("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`; +} + +async function updateImage() { + if (!app.study || !app.activeSeries) return; + const max = maxSlice(); + app.slice = clamp(app.slice, 0, max); + $("sliceSlider").max = String(max); + $("sliceSlider").value = String(app.slice); + $("sliceText").textContent = `${app.slice + 1} / ${max + 1}`; + $("imageEmpty").classList.add("hidden"); + const ticket = Symbol("image"); + app.pendingImage = ticket; + try { + const blob = await request(imageUrlFor()).then((res) => res.blob()); + if (app.pendingImage !== ticket) return; + if (app.imageUrl) URL.revokeObjectURL(app.imageUrl); + app.imageUrl = URL.createObjectURL(blob); + $("dicomImage").src = app.imageUrl; + applyTransform(); + } catch (err) { + $("saveState").textContent = `图像读取失败:${err.message}`; + } +} + +function isSkippedChecked() { + return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked); +} + +function selectedParts() { + return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value); +} + +function syncPartState() { + const skipped = isSkippedChecked(); + document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => { + input.disabled = skipped; + if (skipped) input.checked = false; + }); + if (skipped) { + document.querySelectorAll("input[name=phase]").forEach((input) => { + input.checked = false; + }); + } + const show = !skipped && selectedParts().includes("upper_abdomen"); + $("phaseBox").classList.toggle("visible", show); +} + +function hydrateAnnotation() { + const annotation = app.activeSeries?.annotation || {}; + const parts = new Set(annotation.body_parts || []); + document.querySelectorAll(".part-grid input").forEach((input) => { + if (input.value === "skip") { + input.checked = Boolean(annotation.skipped); + return; + } + input.checked = !annotation.skipped && parts.has(input.value); + input.disabled = Boolean(annotation.skipped); + }); + document.querySelectorAll("input[name=phase]").forEach((input) => { + input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || ""); + }); + $("annotationNotes").value = annotation.notes || ""; + syncPartState(); +} + +async function reloadCurrentStudySeries(keepUid) { + const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`); + app.study = data.study; + app.series = data.series || []; + app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null; + $("seriesCount").textContent = String(app.series.length); + renderSeries(); + hydrateAnnotation(); +} + +async function saveAnnotation() { + if (!app.study || !app.activeSeries) return; + const skipped = isSkippedChecked(); + const parts = skipped ? [] : selectedParts(); + let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || ""; + if (parts.includes("upper_abdomen") && !phase) { + $("saveState").textContent = "请选择上腹部期相"; + return; + } + if (!parts.includes("upper_abdomen")) phase = ""; + $("saveState").textContent = "保存中"; + const uid = app.activeSeries.series_uid; + try { + await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, { + method: "PUT", + body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }), + }); + $("saveState").textContent = "已保存"; + await reloadCurrentStudySeries(uid); + } catch (err) { + $("saveState").textContent = err.message; + } +} + +function applyAiSuggestion(data) { + const parts = new Set(data.body_parts || []); + document.querySelectorAll(".part-grid input").forEach((input) => { + if (input.value === "skip") { + input.checked = Boolean(data.skipped); + return; + } + input.checked = !data.skipped && parts.has(input.value); + }); + document.querySelectorAll("input[name=phase]").forEach((input) => { + input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || ""); + }); + if (data.notes) $("annotationNotes").value = data.notes; + syncPartState(); +} + +async function runAI() { + if (!app.study || !app.activeSeries) return; + const button = $("aiClassify"); + button.disabled = true; + $("saveState").textContent = "AI 识别中"; + try { + const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, { + method: "POST", + body: JSON.stringify({ sample_count: 3 }), + }); + applyAiSuggestion(data); + $("saveState").textContent = "AI 建议已填入,确认后保存"; + } catch (err) { + $("saveState").textContent = err.message; + } finally { + button.disabled = false; + } +} + +async function openInfo() { + if (!app.study || !app.activeSeries) return; + const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`); + const content = $("infoContent"); + content.innerHTML = ""; + for (const [title, fields] of Object.entries(data.fields || {})) { + const section = document.createElement("section"); + section.className = "info-card"; + section.innerHTML = `

${escapeHtml(title)}

${Object.entries(fields) + .map(([key, value]) => `

${escapeHtml(key)}${escapeHtml(value || "-")}

`) + .join("")}`; + content.appendChild(section); + } + $("infoModal").classList.remove("hidden"); +} + +function settingsTable(users) { + return ` + + + + ${(users || []) + .map((user) => ``) + .join("")} + +
账号角色状态
${escapeHtml(user.username)}${escapeHtml(user.role)}${escapeHtml(user.status)}
+ `; +} + +function roleCards(roles) { + return (roles || []) + .map( + (role) => ` +
+ ${escapeHtml(role.name)} + ${(role.permissions || []).map(escapeHtml).join(" / ")} +
+ `, + ) + .join(""); +} + +async function openSettings() { + const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]); + $("settingsContent").innerHTML = ` +
+

用户创建

当前登录:admin
+
+ + + + +
+ ${settingsTable(settings.users)} +
+
+

权限控制

按角色控制查看、标注、AI 与系统设置
+
${roleCards(settings.roles)}
+
+
+
+

AI 设置

${settings.ai?.configured ? "已配置" : "未配置"}
+
+
供应商
${escapeHtml(settings.ai?.provider || "-")}
+
名称
${escapeHtml(settings.ai?.name || "-")}
+
模型
${escapeHtml(settings.ai?.model || "-")}
+
+
+
+

数据库

${status.database?.ok ? "已连接" : "异常"}
+
+
主机
${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}
+
库表
${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}
+
DICOM
${escapeHtml(settings.dicom?.processed_root || "-")}
+
+
+
+ `; + $("createUserForm").addEventListener("submit", createUser); + $("settingsModal").classList.remove("hidden"); +} + +async function createUser(event) { + event.preventDefault(); + const button = event.currentTarget.querySelector("button"); + const form = new FormData(event.currentTarget); + const payload = { + username: String(form.get("username") || "").trim(), + password: String(form.get("password") || ""), + role: String(form.get("role") || "阅片员"), + }; + if (!payload.username || !payload.password) return; + button.disabled = true; + button.textContent = "创建中"; + try { + await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) }); + await openSettings(); + } catch (err) { + button.textContent = err.message; + setTimeout(() => { + button.disabled = false; + button.textContent = "创建"; + }, 1800); + } +} + +function changeZoom(multiplier) { + app.zoom = clamp(app.zoom * multiplier, 0.25, 6); + applyTransform(); +} + +function wireImageGestures() { + const wrap = document.querySelector(".image-wrap"); + document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation()); + wrap.addEventListener( + "wheel", + (event) => { + if (!app.activeSeries) return; + event.preventDefault(); + changeZoom(event.deltaY < 0 ? 1.12 : 1 / 1.12); + }, + { passive: false }, + ); + wrap.addEventListener("pointerdown", (event) => { + if (!app.activeSeries) return; + app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY }; + wrap.classList.add("dragging"); + wrap.setPointerCapture(event.pointerId); + }); + wrap.addEventListener("pointermove", (event) => { + if (!app.drag) return; + app.panX = app.drag.panX + event.clientX - app.drag.x; + app.panY = app.drag.panY + event.clientY - app.drag.y; + applyTransform(); + }); + ["pointerup", "pointercancel", "pointerleave"].forEach((name) => { + wrap.addEventListener(name, () => { + app.drag = null; + wrap.classList.remove("dragging"); + }); + }); +} + +function wire() { + $("loginForm").addEventListener("submit", login); + $("logoutBtn").addEventListener("click", logout); + $("settingsBtn").addEventListener("click", openSettings); + $("infoBtn").addEventListener("click", openInfo); + $("aiClassify").addEventListener("click", runAI); + $("zoomIn").addEventListener("click", () => changeZoom(1.18)); + $("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18)); + $("studySearch").addEventListener("input", () => { + clearTimeout(app.searchTimer); + app.searchTimer = setTimeout(loadStudies, 250); + }); + $("sliceSlider").addEventListener("input", (event) => { + app.slice = Number(event.target.value); + updateImage(); + }); + document.querySelectorAll("[data-plane]").forEach((button) => { + button.addEventListener("click", () => { + app.plane = button.dataset.plane; + app.slice = Math.floor(maxSlice() / 2); + document.querySelectorAll("[data-plane]").forEach((b) => b.classList.toggle("active", b === button)); + updateImage(); + }); + }); + document.querySelectorAll("[data-window]").forEach((button) => { + button.addEventListener("click", () => { + app.window = button.dataset.window; + document.querySelectorAll("[data-window]").forEach((b) => b.classList.toggle("active", b === button)); + updateImage(); + }); + }); + $("rotateLeft").addEventListener("click", () => { + app.rotate = (app.rotate - 90 + 360) % 360; + applyTransform(); + }); + $("rotateRight").addEventListener("click", () => { + app.rotate = (app.rotate + 90) % 360; + applyTransform(); + }); + $("resetView").addEventListener("click", resetViewState); + document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", syncPartState)); + $("saveAnnotation").addEventListener("click", saveAnnotation); + document.querySelectorAll("[data-close]").forEach((button) => { + button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden")); + }); + wireImageGestures(); +} + +async function boot() { + await refreshStatus(); + await loadStudies(); + if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000); +} + +wire(); +refreshStatus(); +if (app.token) { + showLogin(false); + boot().catch(() => showLogin(true)); +} else { + showLogin(true); +} diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html new file mode 100644 index 0000000..3d0b319 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -0,0 +1,156 @@ + + + + + + PACS DICOM Viewer + + + +
+ +
+ +
+
+
+
+
+ PACS DICOM Viewer + 未选择检查 +
+
+
+
数据库
+ + +
+
+ +
+ + +
+
+

序列

+ 0 +
+
+
+ +
+
+
+ + + +
+
+ + + + +
+
+ + + + + + +
+
+ +
+
+ +
+
+ + 0 / 0 +
+
+ + +
+
+
+
+ + + + + + + + diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css new file mode 100644 index 0000000..a404cd7 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -0,0 +1,824 @@ +:root { + color-scheme: dark; + --bg: #06080c; + --panel: #10151d; + --panel-2: #151c27; + --panel-3: #202b3a; + --stroke: #2a3444; + --stroke-strong: #3a475c; + --muted: #92a3ba; + --text: #eff5ff; + --blue: #3474f6; + --cyan: #19d4c2; + --green: #12b981; + --amber: #f0b54e; + --red: #fb7185; + --shadow: 0 18px 58px rgba(0, 0, 0, 0.38); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 1280px; + min-height: 100vh; + background: + linear-gradient(90deg, rgba(25, 212, 194, 0.05) 0 1px, transparent 1px 100%), + linear-gradient(180deg, rgba(52, 116, 246, 0.04) 0 1px, transparent 1px 100%), + #06080c; + background-size: 42px 42px; + color: var(--text); + font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +button:disabled { + cursor: wait; + opacity: 0.66; +} + +.login-overlay { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + background: rgba(6, 8, 12, 0.86); + backdrop-filter: blur(14px); +} + +.login-overlay.hidden { + display: none; +} + +.login-panel { + width: 380px; + padding: 28px; + border: 1px solid var(--stroke); + border-radius: 10px; + background: linear-gradient(180deg, rgba(21, 28, 39, 0.98), rgba(8, 11, 16, 0.98)); + box-shadow: var(--shadow); +} + +.brand-mark { + width: 54px; + height: 54px; + display: grid; + place-items: center; + border-radius: 8px; + background: var(--blue); + font-weight: 800; +} + +.login-panel h1 { + margin: 18px 0 24px; + font-size: 24px; +} + +.login-panel label { + display: block; + margin-bottom: 14px; + color: var(--muted); +} + +.login-panel span { + display: block; + margin-bottom: 7px; + font-size: 13px; +} + +.login-panel input, +.search, +.note-input, +.settings-form input, +.settings-form select { + width: 100%; + border: 1px solid var(--stroke); + border-radius: 8px; + outline: none; + background: #080c12; + color: var(--text); +} + +.login-panel input, +.search, +.note-input, +.settings-form input, +.settings-form select { + height: 38px; + padding: 0 12px; +} + +.login-panel button, +.primary-btn { + height: 40px; + border-radius: 8px; + background: var(--blue); + color: white; + font-weight: 700; +} + +.login-panel button, +.primary-btn { + width: 100%; +} + +#loginError { + min-height: 18px; + color: var(--red); + font-size: 13px; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-rows: 66px 1fr; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 22px; + border-bottom: 1px solid var(--stroke); + background: rgba(8, 11, 17, 0.95); + backdrop-filter: blur(12px); +} + +.product { + display: flex; + align-items: center; + gap: 14px; +} + +.logo-dot { + width: 34px; + height: 34px; + border-radius: 8px; + background: linear-gradient(135deg, var(--cyan), var(--blue)); +} + +.product strong { + display: block; + font-size: 17px; +} + +.product span { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; +} + +.top-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.status-pill { + min-width: 118px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid var(--stroke); + border-radius: 999px; + color: var(--muted); + font-size: 12px; +} + +.status-pill.online { + border-color: rgba(18, 185, 129, 0.35); + color: #9af4cf; + background: rgba(18, 185, 129, 0.08); +} + +.status-pill.offline { + border-color: rgba(251, 113, 133, 0.35); + color: #fecdd3; + background: rgba(251, 113, 133, 0.08); +} + +.ghost-btn, +.dark-btn, +.tool-row button, +.segmented button, +.icon-btn { + height: 34px; + padding: 0 13px; + border: 1px solid var(--stroke); + border-radius: 8px; + background: var(--panel-2); + color: var(--text); +} + +.ghost-btn:hover, +.dark-btn:hover, +.tool-row button:hover, +.segmented button:hover { + border-color: var(--stroke-strong); + background: #1b2532; +} + +.dark-btn { + background: #070a10; +} + +.workspace { + height: calc(100vh - 66px); + display: grid; + grid-template-columns: 300px 450px minmax(650px, 1fr); + gap: 14px; + padding: 14px; +} + +.study-pane, +.series-pane, +.viewer-pane { + min-height: 0; + border: 1px solid var(--stroke); + border-radius: 8px; + background: rgba(16, 21, 29, 0.9); + box-shadow: var(--shadow); +} + +.study-pane, +.series-pane { + padding: 14px; + display: grid; + grid-template-rows: auto auto 1fr; + gap: 12px; +} + +.pane-head, +.annotation-head, +.settings-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pane-head h2, +.annotation-head h2 { + margin: 0; + font-size: 15px; +} + +.pane-head span, +.annotation-head span, +.settings-title span { + color: var(--muted); + font-size: 12px; +} + +.study-list, +.series-grid { + min-height: 0; + overflow: auto; + padding-right: 4px; +} + +.study-card { + width: 100%; + padding: 12px; + margin-bottom: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: #0b0f16; + color: var(--text); + text-align: left; +} + +.study-card.active { + border-color: rgba(52, 116, 246, 0.82); + background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16); +} + +.study-card strong, +.series-card strong { + display: block; + font-size: 14px; +} + +.study-card span { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.series-grid { + display: grid; + grid-template-columns: 1fr; + align-content: start; + gap: 12px; +} + +.series-card { + width: 100%; + min-height: 156px; + display: grid; + grid-template-columns: 142px minmax(0, 1fr); + gap: 12px; + padding: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: #0b0f16; + color: var(--text); + text-align: left; +} + +.series-card.active { + border-color: rgba(25, 212, 194, 0.72); + background: #0d141c; +} + +.series-card.skipped { + border-color: rgba(240, 181, 78, 0.36); + background: linear-gradient(180deg, rgba(240, 181, 78, 0.1), #0b0f16 42%); +} + +.thumb { + position: relative; + width: 132px; + height: 132px; + align-self: start; + overflow: hidden; + border: 1px solid #1d2734; + border-radius: 6px; + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.025) 75%), + #020306; +} + +.thumb img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.thumb b, +.thumb i { + position: absolute; + right: 8px; + padding: 3px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.74); + color: #f8fafc; + font-size: 11px; + font-style: normal; +} + +.thumb b { + bottom: 8px; +} + +.thumb i { + top: 8px; + background: rgba(240, 181, 78, 0.86); + color: #1b1305; + font-weight: 700; +} + +.series-copy { + min-width: 0; + display: grid; + align-content: start; + gap: 7px; +} + +.series-copy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shot-time, +.series-copy small { + color: var(--muted); + font-size: 12px; +} + +.tag-line { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tag-line em { + max-width: 100%; + padding: 3px 7px; + overflow: hidden; + border: 1px solid rgba(146, 163, 186, 0.18); + border-radius: 999px; + color: #c8d6ea; + font-size: 11px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-pane { + min-width: 0; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; +} + +.viewer-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-bottom: 1px solid var(--stroke); + background: rgba(8, 11, 17, 0.86); +} + +.segmented { + display: flex; + gap: 5px; + padding: 5px; + border: 1px solid var(--stroke); + border-radius: 9px; + background: var(--panel); +} + +.segmented button.active { + border-color: var(--blue); + background: var(--blue); +} + +.tool-row { + display: flex; + gap: 6px; + margin-left: auto; +} + +.viewer-stage { + min-height: 0; + display: grid; + grid-template-rows: minmax(360px, 1fr) auto; +} + +.image-wrap { + position: relative; + min-height: 0; + display: grid; + place-items: center; + overflow: hidden; + background: #000; + cursor: grab; +} + +.image-wrap.dragging { + cursor: grabbing; +} + +#dicomImage { + max-width: calc(100% - 64px); + max-height: 100%; + object-fit: contain; + transform-origin: center center; + will-change: transform; + user-select: none; +} + +.image-empty { + display: none; +} + +.slice-rail { + position: absolute; + top: 16px; + right: 12px; + bottom: 16px; + width: 38px; + display: grid; + grid-template-rows: 1fr auto; + justify-items: center; + align-items: center; + padding: 10px 0; + border: 1px solid rgba(146, 163, 186, 0.24); + border-radius: 8px; + background: rgba(8, 12, 18, 0.86); + backdrop-filter: blur(6px); +} + +#sliceSlider { + width: 28px; + height: 100%; + writing-mode: vertical-lr; + direction: rtl; + accent-color: var(--cyan); +} + +#sliceText { + margin-top: 8px; + color: var(--muted); + font-size: 11px; + writing-mode: vertical-rl; +} + +.annotation-panel { + min-width: 0; + padding: 12px; + border-top: 1px solid var(--stroke); + background: var(--panel); +} + +.annotation-head { + margin-bottom: 10px; +} + +.ai-btn { + min-width: 86px; + color: #baf8ee; +} + +.part-grid { + display: grid; + grid-template-columns: repeat(6, minmax(82px, 1fr)); + gap: 8px; +} + +.part-grid label, +.phase-options label { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--stroke); + border-radius: 8px; + color: var(--text); + background: #0b0f16; + white-space: nowrap; +} + +.part-grid label:has(input:checked), +.phase-options label:has(input:checked) { + border-color: rgba(25, 212, 194, 0.58); + background: rgba(25, 212, 194, 0.1); +} + +.part-grid .skip-option:has(input:checked) { + border-color: rgba(240, 181, 78, 0.72); + background: rgba(240, 181, 78, 0.12); +} + +.part-grid input:disabled + * { + color: var(--muted); +} + +.phase-box { + display: none; + margin-top: 10px; +} + +.phase-box.visible { + display: grid; + grid-template-columns: 96px 1fr; + align-items: center; + gap: 10px; +} + +.phase-box > span { + color: var(--muted); + font-size: 12px; +} + +.phase-options { + display: grid; + grid-template-columns: repeat(3, minmax(110px, 1fr)); + gap: 8px; +} + +.annotation-actions { + display: grid; + grid-template-columns: minmax(220px, 1fr) 160px; + gap: 10px; + margin-top: 10px; +} + +.note-input { + height: 38px; +} + +.modal { + position: fixed; + inset: 0; + z-index: 12; + display: grid; + place-items: center; + background: rgba(6, 8, 12, 0.68); + backdrop-filter: blur(12px); +} + +.modal.hidden { + display: none; +} + +.modal-card { + width: min(900px, 86vw); + max-height: 82vh; + overflow: auto; + border: 1px solid #d7e1ef; + border-radius: 10px; + background: #f8fafc; + color: #132033; + box-shadow: var(--shadow); +} + +.settings-card { + width: min(980px, 90vw); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px; + border-bottom: 1px solid #e2e8f0; +} + +.modal-head h2 { + margin: 0 0 4px; + font-size: 18px; +} + +.modal-head span { + color: #7890ad; + font-size: 13px; +} + +.icon-btn { + width: 36px; + padding: 0; + background: #eef2f7; + color: #18304f; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 24px; +} + +.info-card, +.settings-section { + padding: 16px; + border-radius: 8px; + background: #f1f5f9; +} + +.info-card h3, +.settings-title h3 { + margin: 0 0 12px; + font-size: 14px; +} + +.info-row { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 5px 0; + color: #7b8da7; + font-size: 13px; +} + +.info-row b { + color: #14233a; + text-align: right; + overflow-wrap: anywhere; +} + +.settings-content { + display: grid; + gap: 14px; + padding: 24px; +} + +.settings-section.split { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.settings-form { + display: grid; + grid-template-columns: 1fr 1fr 140px 88px; + gap: 8px; + margin: 10px 0 14px; +} + +.settings-form input, +.settings-form select { + border-color: #cdd8e7; + background: white; + color: #132033; +} + +.settings-form button { + border-radius: 8px; + background: #1d5ff0; + color: white; + font-weight: 700; +} + +.settings-table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 8px; + background: white; + font-size: 13px; +} + +.settings-table th, +.settings-table td { + padding: 9px 10px; + border-bottom: 1px solid #e5edf7; + text-align: left; +} + +.settings-table th { + color: #6f819a; + font-weight: 700; +} + +.role-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.role-card { + min-height: 78px; + padding: 12px; + border: 1px solid #dce5f1; + border-radius: 8px; + background: white; +} + +.role-card strong { + display: block; + margin-bottom: 8px; +} + +.role-card span, +.settings-section dd, +.settings-section dt { + color: #6f819a; + font-size: 13px; +} + +.settings-section dl { + display: grid; + grid-template-columns: 70px minmax(0, 1fr); + gap: 8px 12px; + margin: 12px 0 0; +} + +.settings-section dt, +.settings-section dd { + margin: 0; +} + +.settings-section dd { + color: #17263d; + overflow-wrap: anywhere; +} + +.error-line { + color: var(--red); + font-size: 13px; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 999px; + background: var(--panel-3); + background-clip: padding-box; +}