diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index c5206fe..bf45fc7 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -439,11 +439,18 @@ function downloadSelectedSeries() { triggerDownload(exportDownloadUrl(app.exportDialog.ctNumber, selected)); } -async function loadStudies() { +function initialCtNumber() { + return new URLSearchParams(location.search).get("ct_number") || ""; +} + +async function loadStudies(preferredCtNumber = "") { const q = encodeURIComponent($("studySearch").value.trim()); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); renderStudies(); - if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number); + if (!app.study && app.studies.length) { + const preferred = app.studies.find((study) => study.ct_number === preferredCtNumber); + await selectStudy((preferred || app.studies[0]).ct_number); + } } function renderStudies() { @@ -1537,7 +1544,7 @@ async function boot() { if (location.pathname === "/settings" && isAdmin()) { await showSettingsPage(false); } else { - await loadStudies(); + await loadStudies(initialCtNumber()); await handleRoute(false); } if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000); diff --git a/数据库Web可视化/.env.example b/数据库Web可视化/.env.example index 3bbf821..d51c4ce 100644 --- a/数据库Web可视化/.env.example +++ b/数据库Web可视化/.env.example @@ -7,3 +7,7 @@ PACS_TABLE=pacs_dicom_files PACS_SUMMARY_TABLE=pacs_dicom_study_summaries UPP_ASSET_TABLE=upp_exam_assets UPP_STL_TABLE=upp_stl_files +VISUALIZER_USER_TABLE=db_visualizer_users +VISUALIZER_ADMIN_USER=admin +VISUALIZER_ADMIN_PASSWORD=123456 +PACS_VIEWER_URL=http://127.0.0.1:8107 diff --git a/数据库Web可视化/README.md b/数据库Web可视化/README.md index a7962c8..7442bfa 100644 --- a/数据库Web可视化/README.md +++ b/数据库Web可视化/README.md @@ -1,6 +1,6 @@ -# PACS / STL 数据库关联可视化 +# DICOM / UPP 数据库关联可视化 -本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查与 UPP STL 重建资产放在同一视图中查看。UPP 列表字段作为 STL 资产的补充信息展示,不作为独立系统节点。 +本网页端以 CT 号严格一致匹配,把 PostgreSQL 中的 DICOM 阅片分类结果与 UPP STL 重建资产放在同一视图中查看。UPP 列表字段作为 STL 资产的补充信息展示,不作为独立系统节点。 ## 启动 @@ -15,8 +15,8 @@ docker compose up -d --build ## 数据来源 -- `pacs_dicom_files`:PACS DICOM 检查索引。 -- `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。 +- `pacs_dicom_files`:DICOM 检查索引。 +- `pacs_dicom_study_summaries`:DICOM 标注/完成状态汇总。 - `upp_exam_assets`:UPP 列表与 STL 资产主索引,为 STL 资产提供患者、任务、算法模型、状态等补充信息。 - `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。 diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py index 1c743fb..1352601 100644 --- a/数据库Web可视化/app.py +++ b/数据库Web可视化/app.py @@ -1,13 +1,16 @@ from __future__ import annotations import json +import hashlib import os import re +import secrets import subprocess from pathlib import Path from typing import Any -from fastapi import FastAPI, HTTPException, Query +from fastapi import Depends, FastAPI, Header, HTTPException, Query +from pydantic import BaseModel from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -23,6 +26,10 @@ PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files") PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries") UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets") UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files") +USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users") +WEB_ADMIN_USER = os.getenv("VISUALIZER_ADMIN_USER", "admin") +WEB_ADMIN_PASSWORD = os.getenv("VISUALIZER_ADMIN_PASSWORD", "123456") +PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107") IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -37,9 +44,27 @@ PACS_TABLE_SQL = ident(PACS_TABLE) PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE) UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE) UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE) +USER_TABLE_SQL = ident(USER_TABLE) -app = FastAPI(title="PACS UPP Database Visualizer") +app = FastAPI(title="DICOM UPP Database Visualizer") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +TOKENS: dict[str, dict[str, str]] = {} + + +class LoginPayload(BaseModel): + username: str + password: str + + +class UserPayload(BaseModel): + username: str + password: str = "" + role: str = "阅片员" + status: str = "启用" + + +class PasswordPayload(BaseModel): + password: str def pg_env() -> dict[str, str]: @@ -74,6 +99,10 @@ def sql_literal(value: Any) -> str: return "'" + str(value).replace("'", "''") + "'" +def password_hash(password: str) -> str: + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + def normalize_ct(value: str) -> str: return re.sub(r"\s+", "", str(value or "").upper()) @@ -86,6 +115,63 @@ def db_available() -> tuple[bool, str]: return False, str(exc) +def ensure_user_table() -> None: + run_psql( + f""" + CREATE TABLE IF NOT EXISTS public.{USER_TABLE_SQL} ( + 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.{USER_TABLE_SQL} (username, password_hash, role, status) + VALUES ({sql_literal(WEB_ADMIN_USER)}, {sql_literal(password_hash(WEB_ADMIN_PASSWORD))}, '管理员', '启用') + ON CONFLICT (username) DO NOTHING; + """, + timeout=10, + ) + + +def fetch_user(username: str) -> dict[str, Any] | None: + ensure_user_table() + rows = pg_json_rows( + f""" + SELECT username, password_hash, role, status, created_at, updated_at + FROM public.{USER_TABLE_SQL} + WHERE username = {sql_literal(username)} + LIMIT 1 + """, + timeout=10, + ) + return rows[0] if rows else None + + +def current_user(authorization: str | None = Header(default=None)) -> dict[str, str]: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="unauthorized") + token = authorization.removeprefix("Bearer ").strip() + user = TOKENS.get(token) + if not user: + raise HTTPException(status_code=401, detail="unauthorized") + return user + + +def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]: + if user.get("role") != "管理员": + raise HTTPException(status_code=403, detail="forbidden") + return user + + +@app.on_event("startup") +def startup() -> None: + try: + ensure_user_table() + except Exception: + pass + + def relation_cte() -> str: return f""" WITH @@ -168,12 +254,12 @@ def relation_cte() -> str: ps.completed, ps.body_parts, to_jsonb(array_remove(ARRAY[ - CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '肝胆模型' END, - CASE WHEN ps.body_parts ? 'chest' THEN '胸外模型' END, - CASE WHEN ps.body_parts ? 'upper_abdomen' - OR ps.body_parts ? 'lower_abdomen' - OR ps.body_parts ? 'pelvis' THEN '泌尿模型' END - ], NULL)) AS dicom_models, + CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END, + CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END, + CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END, + CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END, + CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END + ], NULL)) AS dicom_body_parts, u.patient_name AS upp_patient_name, u.patient_sex AS upp_patient_sex, u.patient_age, @@ -217,7 +303,7 @@ def relation_cte() -> str: """ -def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) -> str: +def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -> str: clauses: list[str] = [] query = q.strip() if query: @@ -236,6 +322,7 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) f"upp_status ILIKE {literal}", f"exam_description ILIKE {literal}", f"study_description ILIKE {literal}", + f"COALESCE(dicom_body_parts::text, '') ILIKE {literal}", ] ).join(("(", ")")) ) @@ -249,14 +336,19 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) clauses.append("relation_status = 'stl_only'") elif status == "list_only": clauses.append("relation_status = 'list_only'") + elif status == "pending_dicom": + clauses.append("pacs_present AND COALESCE(completed, false) IS NOT TRUE") elif status == "undetermined": clauses.append("COALESCE(undetermined_series, 0) > 0") elif status != "all": raise HTTPException(status_code=400, detail="invalid status filter") if algorithm_model: clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}") - if dicom_model: - clauses.append(f"dicom_models ? {sql_literal(dicom_model)}") + if dicom_part: + valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} + if dicom_part not in valid_parts: + raise HTTPException(status_code=400, detail="invalid DICOM part filter") + clauses.append(f"body_parts ? {sql_literal(dicom_part)}") return "WHERE " + " AND ".join(clauses) if clauses else "" @@ -270,8 +362,103 @@ def health() -> str: return "ok" +@app.post("/api/auth/login") +def login(payload: LoginPayload) -> dict[str, str]: + username = payload.username.strip() + try: + user = fetch_user(username) + except Exception: + user = None + if not user and username == WEB_ADMIN_USER: + user = {"username": WEB_ADMIN_USER, "password_hash": password_hash(WEB_ADMIN_PASSWORD), "role": "管理员", "status": "启用"} + if not user or user.get("status") != "启用" or user.get("password_hash") != password_hash(payload.password): + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = secrets.token_urlsafe(32) + TOKENS[token] = {"username": user["username"], "role": user.get("role", "阅片员")} + return {"token": token, "username": user["username"], "role": user.get("role", "阅片员")} + + +@app.get("/api/auth/me") +def me(user: dict[str, str] = Depends(current_user)) -> dict[str, str]: + return user + + +@app.get("/api/settings") +def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]: + rows = pg_json_rows( + f""" + SELECT username, role, status, created_at, updated_at + FROM public.{USER_TABLE_SQL} + ORDER BY username + """, + timeout=10, + ) + return { + "user": user, + "viewer_url": PACS_VIEWER_URL, + "users": rows, + "database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE}, + "tables": { + "dicom": PACS_TABLE, + "dicom_summary": PACS_SUMMARY_TABLE, + "upp_assets": UPP_ASSET_TABLE, + "upp_stl": UPP_STL_TABLE, + }, + } + + +@app.post("/api/settings/users") +def save_user(payload: UserPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]: + username = payload.username.strip() + if not username: + raise HTTPException(status_code=400, detail="账号不能为空") + role = payload.role if payload.role in {"管理员", "阅片员", "访客"} else "阅片员" + status = payload.status if payload.status in {"启用", "停用"} else "启用" + existing = fetch_user(username) + if existing and not payload.password: + run_psql( + f""" + UPDATE public.{USER_TABLE_SQL} + SET role = {sql_literal(role)}, status = {sql_literal(status)}, updated_at = now() + WHERE username = {sql_literal(username)} + """, + timeout=10, + ) + else: + if not payload.password: + raise HTTPException(status_code=400, detail="新账号需要初始密码") + run_psql( + f""" + INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status, updated_at) + VALUES ({sql_literal(username)}, {sql_literal(password_hash(payload.password))}, {sql_literal(role)}, {sql_literal(status)}, now()) + ON CONFLICT (username) DO UPDATE + SET password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + status = EXCLUDED.status, + updated_at = now() + """, + timeout=10, + ) + return {"status": "ok", "updated_by": user["username"]} + + +@app.put("/api/settings/users/{username}/password") +def change_password(username: str, payload: PasswordPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]: + if not payload.password: + raise HTTPException(status_code=400, detail="密码不能为空") + run_psql( + f""" + UPDATE public.{USER_TABLE_SQL} + SET password_hash = {sql_literal(password_hash(payload.password))}, updated_at = now() + WHERE username = {sql_literal(username)} + """, + timeout=10, + ) + return {"status": "ok", "updated_by": user["username"]} + + @app.get("/api/status") -def status() -> dict[str, Any]: +def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]: ok, message = db_available() counts: dict[str, Any] = {} if ok: @@ -289,7 +476,8 @@ def status() -> dict[str, Any]: count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count, count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_only_count, count(*) FILTER (WHERE relation_status = 'list_only')::int AS list_only_count, - COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series + count(*) FILTER (WHERE pacs_present AND COALESCE(completed, false) IS NOT TRUE)::int AS pending_dicom_count, + COALESCE(sum(CASE WHEN pacs_present THEN COALESCE(undetermined_series, 0) ELSE 0 END), 0)::int AS pending_dicom_series FROM relation """, timeout=24, @@ -297,9 +485,10 @@ def status() -> dict[str, Any]: counts = rows[0] if rows else {} return { "database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE}, + "viewer_url": PACS_VIEWER_URL, "tables": { - "pacs": PACS_TABLE, - "pacs_summary": PACS_SUMMARY_TABLE, + "dicom": PACS_TABLE, + "dicom_summary": PACS_SUMMARY_TABLE, "upp_assets": UPP_ASSET_TABLE, "upp_stl": UPP_STL_TABLE, }, @@ -312,10 +501,11 @@ def relations( q: str = "", status: str = "all", algorithm_model: str = "", - dicom_model: str = "", + dicom_part: str = "", limit: int = Query(default=300, ge=1, le=1000), + user: dict[str, str] = Depends(current_user), ) -> list[dict[str, Any]]: - where = relation_where(q, status, algorithm_model, dicom_model) + where = relation_where(q, status, algorithm_model, dicom_part) return pg_json_rows( f""" {relation_cte()} @@ -327,7 +517,7 @@ def relations( study_description, exam_description, algorithm_model, upp_status, pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed, list_present, list_record_count, stl_file_count, stl_file_count_agg, - body_parts, dicom_models, segment_categories, segment_families, + body_parts, dicom_body_parts, segment_categories, segment_families, pacs_updated_at, upp_updated_at, stl_updated_at FROM relation {where} @@ -344,7 +534,7 @@ def relations( @app.get("/api/relations/{ct_number}") -def relation_detail(ct_number: str) -> dict[str, Any]: +def relation_detail(ct_number: str, user: dict[str, str] = Depends(current_user)) -> dict[str, Any]: ct_key = normalize_ct(ct_number) if not ct_key: raise HTTPException(status_code=400, detail="invalid CT number") diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js index 54e96e9..04ad8c1 100644 --- a/数据库Web可视化/static/app.js +++ b/数据库Web可视化/static/app.js @@ -1,10 +1,13 @@ const app = { + token: localStorage.getItem("pacs_relation_token") || "", + user: null, rows: [], active: null, filter: "all", search: "", algorithmModel: "", - dicomModel: "", + dicomPart: "", + viewerUrl: "http://127.0.0.1:8107", searchTimer: null, }; @@ -19,8 +22,15 @@ function escapeHtml(value) { .replaceAll("'", "'"); } -async function json(path) { - const res = await fetch(path); +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 { @@ -31,9 +41,39 @@ async function json(path) { } 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 showViewer() { + $("viewerPage").classList.remove("hidden"); + $("settingsPage").classList.add("hidden"); +} + +async function showSettings() { + if (!isAdmin()) return; + $("viewerPage").classList.add("hidden"); + $("settingsPage").classList.remove("hidden"); + await renderSettingsPage(); +} + +function isAdmin() { + return app.user?.role === "管理员"; +} + +function applyAuthUi() { + $("userBadge").textContent = app.user ? `${app.user.username} · ${app.user.role}` : "未登录"; + $("settingsBtn").classList.toggle("hidden", !isAdmin()); +} + function fmtDate(value) { const text = String(value || ""); if (!text) return ""; @@ -65,9 +105,9 @@ function uniq(list) { function statusLabel(value) { return { - complete: "PACS + STL", - no_stl: "PACS + 列表", - pacs_only: "仅 PACS", + complete: "DICOM + STL", + no_stl: "DICOM + UPP", + pacs_only: "仅 DICOM", stl_only: "仅 STL", list_only: "列表无STL", unknown: "未知", @@ -88,7 +128,18 @@ function partLabel(value) { }[value] || value; } +function dicomParts(row) { + const labels = asList(row.dicom_body_parts); + if (labels.length) return labels; + return asList(row.body_parts).map(partLabel); +} + +function pendingDICOM(row) { + return Number(row.undetermined_series || 0); +} + function setDbStatus(data) { + app.viewerUrl = data.viewer_url || app.viewerUrl; const pill = $("dbStatus"); if (data.database?.ok) { pill.textContent = `${data.database.database} 已连接`; @@ -101,12 +152,11 @@ function setDbStatus(data) { function renderMetrics(counts = {}) { const items = [ - ["CT 索引", counts.total_ct], - ["PACS", counts.pacs_count], - ["STL资产", counts.stl_asset_count], - ["STL", counts.stl_count], ["完整关联", counts.complete_count], - ["待判别序列", counts.undetermined_series], + ["DICOM", counts.pacs_count], + ["缺 STL", counts.no_stl_count], + ["仅 DICOM", counts.pacs_only_count], + ["待处理DICOM", counts.pending_dicom_count], ]; $("metrics").innerHTML = items .map(([label, value]) => `
没有匹配记录
`; + clearDetail(); return; } list.innerHTML = ""; @@ -141,16 +197,18 @@ function renderList() { card.title = cardTitle(row); const date = row.study_date ? `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}` : fmtDate(row.exam_date || row.task_created_at); const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0); - const dicomModels = asList(row.dicom_models).join("、") || "无"; + const parts = dicomParts(row); + const pending = pendingDICOM(row); card.innerHTML = `没有 STL 分割文件
`; - return; - } - $("segmentGroups").innerHTML = Array.from(groups.entries()) - .map( - ([group, names]) => ` -暂无账号
`; + return ` +| 账号 | 角色 | 状态 | 更新时间 | 重置密码 |
|---|---|---|---|---|
| ${escapeHtml(user.username)} | +${escapeHtml(user.role)} | +${escapeHtml(user.status)} | +${escapeHtml(fmtDate(user.updated_at))} | +
+
+
+
+
+ |
+
以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产
+以 CT 号精确关联 DICOM 阅片分类与 UPP STL 重建资产