Add Docker PACS DICOM web viewer
This commit is contained in:
7
PACS_DICOM处理/数据处理网页端/.dockerignore
Normal file
7
PACS_DICOM处理/数据处理网页端/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
15
PACS_DICOM处理/数据处理网页端/.env.example
Normal file
15
PACS_DICOM处理/数据处理网页端/.env.example
Normal file
@@ -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
|
||||||
20
PACS_DICOM处理/数据处理网页端/Dockerfile
Normal file
20
PACS_DICOM处理/数据处理网页端/Dockerfile
Normal file
@@ -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"]
|
||||||
51
PACS_DICOM处理/数据处理网页端/README.md
Normal file
51
PACS_DICOM处理/数据处理网页端/README.md
Normal file
@@ -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` 中。
|
||||||
866
PACS_DICOM处理/数据处理网页端/app.py
Normal file
866
PACS_DICOM处理/数据处理网页端/app.py
Normal file
@@ -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")
|
||||||
20
PACS_DICOM处理/数据处理网页端/docker-compose.yml
Normal file
20
PACS_DICOM处理/数据处理网页端/docker-compose.yml
Normal file
@@ -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
|
||||||
5
PACS_DICOM处理/数据处理网页端/requirements.txt
Normal file
5
PACS_DICOM处理/数据处理网页端/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.116.1
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
pydicom==2.4.5
|
||||||
|
numpy>=2.0
|
||||||
|
pillow>=11.0
|
||||||
613
PACS_DICOM处理/数据处理网页端/static/app.js
Normal file
613
PACS_DICOM处理/数据处理网页端/static/app.js
Normal file
@@ -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 = `
|
||||||
|
<strong>${escapeHtml(study.ct_number)}</strong>
|
||||||
|
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
|
||||||
|
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
|
||||||
|
<span>${Number(study.annotated_series || 0)} 个序列已标注</span>
|
||||||
|
`;
|
||||||
|
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 = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="thumb">
|
||||||
|
<img alt="" />
|
||||||
|
<b>${Number(series.count || 0)} 张</b>
|
||||||
|
${skipped ? "<i>略过</i>" : ""}
|
||||||
|
</div>
|
||||||
|
<div class="series-copy">
|
||||||
|
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||||||
|
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
||||||
|
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
||||||
|
<div class="tag-line">${tags.length ? tags.map((tag) => `<em>${escapeHtml(tag)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<h3>${escapeHtml(title)}</h3>${Object.entries(fields)
|
||||||
|
.map(([key, value]) => `<p class="info-row"><span>${escapeHtml(key)}</span><b>${escapeHtml(value || "-")}</b></p>`)
|
||||||
|
.join("")}`;
|
||||||
|
content.appendChild(section);
|
||||||
|
}
|
||||||
|
$("infoModal").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsTable(users) {
|
||||||
|
return `
|
||||||
|
<table class="settings-table">
|
||||||
|
<thead><tr><th>账号</th><th>角色</th><th>状态</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${(users || [])
|
||||||
|
.map((user) => `<tr><td>${escapeHtml(user.username)}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.status)}</td></tr>`)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleCards(roles) {
|
||||||
|
return (roles || [])
|
||||||
|
.map(
|
||||||
|
(role) => `
|
||||||
|
<div class="role-card">
|
||||||
|
<strong>${escapeHtml(role.name)}</strong>
|
||||||
|
<span>${(role.permissions || []).map(escapeHtml).join(" / ")}</span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings() {
|
||||||
|
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
|
||||||
|
$("settingsContent").innerHTML = `
|
||||||
|
<section class="settings-section">
|
||||||
|
<div class="settings-title"><h3>用户创建</h3><span>当前登录:admin</span></div>
|
||||||
|
<form id="createUserForm" class="settings-form">
|
||||||
|
<input name="username" placeholder="新账号" autocomplete="off" />
|
||||||
|
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
|
||||||
|
<select name="role">
|
||||||
|
<option value="阅片员">阅片员</option>
|
||||||
|
<option value="管理员">管理员</option>
|
||||||
|
<option value="访客">访客</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">创建</button>
|
||||||
|
</form>
|
||||||
|
${settingsTable(settings.users)}
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<div class="settings-title"><h3>权限控制</h3><span>按角色控制查看、标注、AI 与系统设置</span></div>
|
||||||
|
<div class="role-grid">${roleCards(settings.roles)}</div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section split">
|
||||||
|
<div>
|
||||||
|
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.configured ? "已配置" : "未配置"}</span></div>
|
||||||
|
<dl>
|
||||||
|
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
|
||||||
|
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
|
||||||
|
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
|
||||||
|
<dl>
|
||||||
|
<dt>主机</dt><dd>${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}</dd>
|
||||||
|
<dt>库表</dt><dd>${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}</dd>
|
||||||
|
<dt>DICOM</dt><dd>${escapeHtml(settings.dicom?.processed_root || "-")}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
$("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);
|
||||||
|
}
|
||||||
156
PACS_DICOM处理/数据处理网页端/static/index.html
Normal file
156
PACS_DICOM处理/数据处理网页端/static/index.html
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>PACS DICOM Viewer</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loginOverlay" class="login-overlay">
|
||||||
|
<form id="loginForm" class="login-panel">
|
||||||
|
<div class="brand-mark">PACS</div>
|
||||||
|
<h1>DICOM 阅片标注</h1>
|
||||||
|
<label>
|
||||||
|
<span>账号</span>
|
||||||
|
<input id="username" autocomplete="username" value="admin" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<input id="password" type="password" autocomplete="current-password" value="123456" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
<p id="loginError"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="product">
|
||||||
|
<div class="logo-dot"></div>
|
||||||
|
<div>
|
||||||
|
<strong>PACS DICOM Viewer</strong>
|
||||||
|
<span id="activeStudyLabel">未选择检查</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<div id="dbStatus" class="status-pill offline">数据库</div>
|
||||||
|
<button id="settingsBtn" class="ghost-btn">设置</button>
|
||||||
|
<button id="logoutBtn" class="dark-btn">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<aside class="study-pane">
|
||||||
|
<div class="pane-head">
|
||||||
|
<h2>检查列表</h2>
|
||||||
|
<span id="studyCount">0</span>
|
||||||
|
</div>
|
||||||
|
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
||||||
|
<div id="studyList" class="study-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="series-pane">
|
||||||
|
<div class="pane-head">
|
||||||
|
<h2>序列</h2>
|
||||||
|
<span id="seriesCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div id="seriesGrid" class="series-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="viewer-pane">
|
||||||
|
<div class="viewer-toolbar">
|
||||||
|
<div class="segmented" data-group="plane">
|
||||||
|
<button data-plane="axial" class="active">横断面</button>
|
||||||
|
<button data-plane="sagittal">矢状面</button>
|
||||||
|
<button data-plane="coronal">冠状面</button>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" data-group="window">
|
||||||
|
<button data-window="default" class="active">默认</button>
|
||||||
|
<button data-window="bone">骨窗</button>
|
||||||
|
<button data-window="soft">软组织</button>
|
||||||
|
<button data-window="contrast">高对比</button>
|
||||||
|
</div>
|
||||||
|
<div class="tool-row">
|
||||||
|
<button id="rotateLeft">↶ 左转</button>
|
||||||
|
<button id="rotateRight">↷ 右转</button>
|
||||||
|
<button id="zoomOut">− 缩小</button>
|
||||||
|
<button id="zoomIn">+ 放大</button>
|
||||||
|
<button id="resetView">↺ 复位</button>
|
||||||
|
<button id="infoBtn">DICOM 信息</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-stage">
|
||||||
|
<div class="image-wrap">
|
||||||
|
<img id="dicomImage" alt="" />
|
||||||
|
<div id="imageEmpty" class="image-empty"></div>
|
||||||
|
<div class="slice-rail">
|
||||||
|
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
|
||||||
|
<span id="sliceText">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="annotation-panel">
|
||||||
|
<div class="annotation-head">
|
||||||
|
<div>
|
||||||
|
<h2>部位标注</h2>
|
||||||
|
<span id="saveState">未保存</span>
|
||||||
|
</div>
|
||||||
|
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
||||||
|
</div>
|
||||||
|
<div class="part-grid">
|
||||||
|
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
|
||||||
|
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
||||||
|
<label><input type="checkbox" value="chest" />胸部</label>
|
||||||
|
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
|
||||||
|
<label><input type="checkbox" value="lower_abdomen" />下腹部</label>
|
||||||
|
<label><input type="checkbox" value="pelvis" />盆腔</label>
|
||||||
|
</div>
|
||||||
|
<div id="phaseBox" class="phase-box">
|
||||||
|
<span>上腹部期相</span>
|
||||||
|
<div class="phase-options">
|
||||||
|
<label><input name="phase" type="radio" value="arterial" />动脉期</label>
|
||||||
|
<label><input name="phase" type="radio" value="portal_venous" />门静脉期</label>
|
||||||
|
<label><input name="phase" type="radio" value="unknown" />无法判别</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="annotation-actions">
|
||||||
|
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
||||||
|
<button id="saveAnnotation" class="primary-btn">保存标注</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="infoModal" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2>DICOM 详细信息</h2>
|
||||||
|
<span>基础元数据、图像矩阵、空间距离</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-close="infoModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="infoContent" class="info-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settingsModal" class="modal hidden">
|
||||||
|
<div class="modal-card settings-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2>设置</h2>
|
||||||
|
<span>用户、权限、AI 与数据源</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-close="settingsModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="settingsContent" class="settings-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
824
PACS_DICOM处理/数据处理网页端/static/styles.css
Normal file
824
PACS_DICOM处理/数据处理网页端/static/styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user