From 4fe6466e20a0481368bfdceca414877848eca1f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 08:49:16 +0800 Subject: [PATCH] Improve DICOM UPP relation visualizer --- PACS_DICOM处理/数据处理网页端/static/app.js | 13 +- 数据库Web可视化/.env.example | 4 + 数据库Web可视化/README.md | 8 +- 数据库Web可视化/app.py | 228 +++++++++++-- 数据库Web可视化/static/app.js | 360 ++++++++++++++++---- 数据库Web可视化/static/index.html | 77 +++-- 数据库Web可视化/static/styles.css | 260 +++++++++++++- 7 files changed, 831 insertions(+), 119 deletions(-) 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]) => `
${escapeHtml(label)}${Number(value || 0)}
`) @@ -116,21 +166,27 @@ function renderMetrics(counts = {}) { function cardTitle(row) { return [ `CT号:${row.ct_key}`, - `PACS:${row.pacs_ct_number || "无"}`, + `DICOM:${row.pacs_ct_number || "无"}`, `STL:${row.stl_ct_number || "无"}`, `状态:${statusLabel(row.relation_status)}`, - `PACS患者:${row.pacs_patient_name || "无"}`, - `STL列表患者:${row.upp_patient_name || "无"}`, + `DICOM患者:${row.pacs_patient_name || "无"}`, + `UPP患者:${row.upp_patient_name || "无"}`, `重建模型:${row.algorithm_model || "无"}`, - `DICOM模型:${asList(row.dicom_models).join("、") || "无"}`, + `部位标注:${dicomParts(row).join("、") || "无"}`, ].join("\n"); } +function renderPartTags(parts) { + if (!parts.length) return `无部位标注`; + return parts.map((part) => `${escapeHtml(part)}`).join(""); +} + function renderList() { const list = $("relationList"); $("resultCount").textContent = String(app.rows.length); if (!app.rows.length) { list.innerHTML = `

没有匹配记录

`; + 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 = `
${escapeHtml(row.ct_key)} ${escapeHtml(statusLabel(row.relation_status))}
${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")} - ${escapeHtml(date || "无时间")} · PACS ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个 - 重建 ${escapeHtml(row.algorithm_model || "无")} · DICOM ${escapeHtml(dicomModels)} - ${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""} + ${escapeHtml(date || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个 + 重建 ${escapeHtml(row.algorithm_model || "无")} +
${renderPartTags(parts)}
+ ${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""} `; card.onclick = () => selectRelation(row.ct_key); list.appendChild(card); @@ -175,50 +233,54 @@ function markNode(id, state, strong, meta) { node.querySelector("em").textContent = meta; } -function groupSegments(row) { - const names = asList(row.segment_names); - const families = asList(row.segment_families); - const categories = asList(row.segment_categories); - const groups = new Map(); - names.forEach((name, index) => { - const key = categories[index] || families[index] || "未分类"; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(name); - }); - return groups; +function dicomViewerUrl(row = app.active) { + if (!row?.pacs_ct_number) return ""; + return `${app.viewerUrl.replace(/\/$/, "")}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`; } -function renderSegments(row) { - const groups = groupSegments(row); - const count = asList(row.segment_names).length; - $("segmentCount").textContent = `${count} 个`; - if (!count) { - $("segmentGroups").innerHTML = `

没有 STL 分割文件

`; - return; - } - $("segmentGroups").innerHTML = Array.from(groups.entries()) - .map( - ([group, names]) => ` -
- ${escapeHtml(group)} -
${names.map((name) => `${escapeHtml(name)}`).join("")}
-
- `, - ) - .join(""); +function updateOpenDicomButton() { + const button = $("openDicomBtn"); + const url = dicomViewerUrl(); + button.disabled = !url; + button.title = url || "当前记录没有 DICOM 检查号"; +} + +function clearDetail() { + app.active = null; + $("activeCt").textContent = "未选择 CT"; + $("activeSubtitle").textContent = "从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系"; + $("relationBadge").className = "relation-badge idle"; + $("relationBadge").textContent = "等待选择"; + markNode("nodePacs", "missing", "未选择", "检查与序列"); + $("nodeCtValue").textContent = "-"; + markNode("nodeStl", "missing", "未选择", "含UPP列表信息"); + renderDl("pacsDetails", []); + renderDl("stlDetails", []); + updateOpenDicomButton(); } function renderDetail(row) { app.active = row; $("activeCt").textContent = row.ct_key || "未选择 CT"; - $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`; + $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"} ↔ ${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`; const badge = $("relationBadge"); badge.className = `relation-badge ${row.relation_status || "idle"}`; badge.textContent = statusLabel(row.relation_status); + const pending = pendingDICOM(row); $("nodeCtValue").textContent = row.ct_key || "-"; - markNode("nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列`); - markNode("nodeStl", row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing", row.stl_ct_number || "缺失", `${row.algorithm_model || "无模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`); + markNode( + "nodePacs", + row.pacs_present ? "ok" : "missing", + row.pacs_ct_number || "缺失", + `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列${pending ? ` · ${pending} 待处理DICOM` : ""}`, + ); + markNode( + "nodeStl", + row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing", + row.stl_ct_number || "缺失", + `${row.algorithm_model || "无重建模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`, + ); renderDl("pacsDetails", [ ["CT号", row.pacs_ct_number], @@ -227,10 +289,9 @@ function renderDetail(row) { ["检查时间", `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}`.trim()], ["描述", row.study_description], ["序列/文件", `${Number(row.pacs_series_count || 0)} / ${Number(row.dicom_file_count || 0)}`], - ["标注", `${Number(row.annotated_series || 0)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`], + ["标注", `${Number(row.annotated_series || 0)} 已标注 · ${pending} 待处理DICOM`], ["完成", row.completed ? "已完成" : "待处理"], - ["部位", asList(row.body_parts).map(partLabel).join("、")], - ["DICOM模型", asList(row.dicom_models).join("、")], + ["部位标注", dicomParts(row).join("、")], ]); renderDl("stlDetails", [ @@ -242,16 +303,15 @@ function renderDetail(row) { ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], ["检查时间", fmtDate(row.exam_date)], ["任务时间", fmtDate(row.task_created_at)], - ["列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"], + ["UPP列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"], ["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)], ["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)], ["目录", row.processed_stl_dir], - ["分类", uniq(asList(row.segment_categories)).join("、")], - ["Family", uniq(asList(row.segment_families)).join("、")], + ["分割分类", uniq(asList(row.segment_categories)).join("、")], ["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)], ]); - renderSegments(row); + updateOpenDicomButton(); renderList(); } @@ -272,13 +332,14 @@ async function loadRelations() { params.set("limit", "500"); if (app.search) params.set("q", app.search); if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel); - if (app.dicomModel) params.set("dicom_model", app.dicomModel); + if (app.dicomPart) params.set("dicom_part", app.dicomPart); app.rows = await json(`/api/relations?${params.toString()}`); if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) { await selectRelation(app.rows[0].ct_key); } else { renderList(); if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key); + if (!app.rows.length) clearDetail(); } } @@ -292,8 +353,174 @@ async function refreshAll() { } } +async function login(event) { + event.preventDefault(); + $("loginError").textContent = ""; + try { + const data = await json("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }), + }); + app.token = data.token; + app.user = { username: data.username, role: data.role }; + localStorage.setItem("pacs_relation_token", app.token); + applyAuthUi(); + showLogin(false); + showViewer(); + await refreshAll(); + } catch (_) { + $("loginError").textContent = "登录失败"; + } +} + +function logout() { + app.token = ""; + app.user = null; + app.rows = []; + app.active = null; + localStorage.removeItem("pacs_relation_token"); + applyAuthUi(); + showViewer(); + clearDetail(); + showLogin(true); +} + +async function loadCurrentUser() { + if (!app.token) { + showLogin(true); + return false; + } + try { + app.user = await json("/api/auth/me"); + applyAuthUi(); + showLogin(false); + return true; + } catch (_) { + app.token = ""; + localStorage.removeItem("pacs_relation_token"); + applyAuthUi(); + showLogin(true); + return false; + } +} + +function settingsTable(users) { + if (!users?.length) return `

暂无账号

`; + return ` + + + + ${users + .map( + (user) => ` + + + + + + + + `, + ) + .join("")} + +
账号角色状态更新时间重置密码
${escapeHtml(user.username)}${escapeHtml(user.role)}${escapeHtml(user.status)}${escapeHtml(fmtDate(user.updated_at))} +
+ + +
+
+ `; +} + +async function renderSettingsPage() { + const data = await json("/api/settings"); + $("settingsContent").innerHTML = ` +
+
+

账号创建

+ 当前登录:${escapeHtml(app.user?.username || "-")} +
+
+ + + + + +
+ ${settingsTable(data.users || [])} +
+
+
+

系统管理

跳转与联动
+
+
阅片系统
${escapeHtml(data.viewer_url || "-")}
+
数据库
${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}
+
DICOM表
${escapeHtml(data.tables?.dicom || "-")}
+
摘要表
${escapeHtml(data.tables?.dicom_summary || "-")}
+
+
+
+

权限说明

页面级控制
+
+
管理员查看关联、系统设置、账号密码管理
+
阅片员查看关联与跳转阅片系统
+
访客只读查看关联信息
+
+
+
+ `; + $("createUserForm").addEventListener("submit", saveUser); + document.querySelectorAll("[data-reset-user]").forEach((button) => { + button.addEventListener("click", () => resetPassword(button.dataset.resetUser)); + }); +} + +async function saveUser(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + await json("/api/settings/users", { + method: "POST", + body: JSON.stringify({ + username: String(form.get("username") || "").trim(), + password: String(form.get("password") || ""), + role: String(form.get("role") || "阅片员"), + status: String(form.get("status") || "启用"), + }), + }); + await renderSettingsPage(); +} + +async function resetPassword(username) { + const input = Array.from(document.querySelectorAll("[data-password-user]")).find((item) => item.dataset.passwordUser === username); + const password = input?.value || ""; + if (!password) return; + await json(`/api/settings/users/${encodeURIComponent(username)}/password`, { + method: "PUT", + body: JSON.stringify({ password }), + }); + await renderSettingsPage(); +} + +function openDicomViewer() { + const url = dicomViewerUrl(); + if (url) window.open(url, "_blank", "noopener"); +} + function wire() { + $("loginForm").addEventListener("submit", login); + $("logoutBtn").addEventListener("click", logout); + $("settingsBtn").addEventListener("click", showSettings); + $("backToViewer").addEventListener("click", showViewer); $("refreshBtn").addEventListener("click", refreshAll); + $("openDicomBtn").addEventListener("click", openDicomViewer); $("searchInput").addEventListener("input", () => { clearTimeout(app.searchTimer); app.searchTimer = setTimeout(() => { @@ -305,8 +532,8 @@ function wire() { app.algorithmModel = $("algorithmFilter").value; loadRelations(); }); - $("dicomModelFilter").addEventListener("change", () => { - app.dicomModel = $("dicomModelFilter").value; + $("dicomPartFilter").addEventListener("change", () => { + app.dicomPart = $("dicomPartFilter").value; loadRelations(); }); document.querySelectorAll("[data-filter]").forEach((button) => { @@ -318,5 +545,12 @@ function wire() { }); } -wire(); -refreshAll(); +async function boot() { + clearDetail(); + applyAuthUi(); + wire(); + const ok = await loadCurrentUser(); + if (ok) await refreshAll(); +} + +boot(); diff --git a/数据库Web可视化/static/index.html b/数据库Web可视化/static/index.html index b35908b..ba46408 100644 --- a/数据库Web可视化/static/index.html +++ b/数据库Web可视化/static/index.html @@ -3,7 +3,7 @@ - PACS / STL 数据库关联可视化 + DICOM / UPP 数据库关联可视化 @@ -11,21 +11,24 @@
-

PACS / STL 数据库关联可视化

-

以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产

+

DICOM / UPP 数据库关联可视化

+

以 CT 号精确关联 DICOM 阅片分类与 UPP STL 重建资产

数据库 + 未登录 + +
-
+
+
+
+
+
+

设置

+

系统管理、账号密码与 DICOM 阅片系统跳转配置

+
+ +
+
+
+
+ + + diff --git a/数据库Web可视化/static/styles.css b/数据库Web可视化/static/styles.css index 8947ff1..242b6e8 100644 --- a/数据库Web可视化/static/styles.css +++ b/数据库Web可视化/static/styles.css @@ -34,7 +34,8 @@ body { } button, -input { +input, +select { font: inherit; } @@ -87,6 +88,19 @@ button { gap: 10px; } +.hidden { + display: none !important; +} + +.user-badge { + max-width: 170px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + .status-pill, .relation-badge { min-width: 82px; @@ -134,6 +148,30 @@ button { color: var(--text); } +.primary-btn, +.link-btn { + height: 34px; + padding: 0 14px; + border: 1px solid rgba(52, 118, 246, 0.72); + border-radius: 8px; + background: var(--blue); + color: white; + font-weight: 700; +} + +.link-btn { + height: 30px; + border-color: rgba(25, 212, 194, 0.42); + background: rgba(25, 212, 194, 0.12); + color: #baf8ee; + font-size: 12px; +} + +.link-btn:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .dark-btn:hover, .filter-grid button:hover { border-color: var(--line-strong); @@ -160,7 +198,7 @@ button { } .content { - grid-template-rows: auto auto auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); } .panel { @@ -337,9 +375,35 @@ button { color: #fecdd3; } +.inline-tags { + display: flex; + min-width: 0; + gap: 6px; + overflow: hidden; +} + +.inline-tags em { + max-width: 96px; + padding: 2px 7px; + overflow: hidden; + border: 1px solid rgba(25, 212, 194, 0.42); + border-radius: 999px; + color: #baf8ee; + background: rgba(25, 212, 194, 0.08); + font-size: 11px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subtle { + color: var(--muted); + font-size: 12px; +} + .metrics { display: grid; - grid-template-columns: repeat(6, minmax(116px, 1fr)); + grid-template-columns: repeat(5, minmax(116px, 1fr)); gap: 10px; } @@ -431,11 +495,24 @@ button { padding: 14px; } -.detail-card h3 { +.detail-card h3, +.detail-title h3 { margin: 0 0 12px; font-size: 15px; } +.detail-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.detail-title h3 { + margin: 0; +} + dl { display: grid; grid-template-columns: 96px minmax(0, 1fr); @@ -513,6 +590,176 @@ dd { text-align: center; } +.error-line { + color: #fecdd3; + font-size: 12px; +} + +.login-overlay { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 22px; + background: + radial-gradient(circle at 30% 20%, rgba(25, 212, 194, 0.2), transparent 30%), + rgba(7, 10, 15, 0.86); + backdrop-filter: blur(8px); +} + +.login-panel { + width: min(420px, 100%); + display: grid; + gap: 12px; + padding: 26px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: rgba(11, 17, 27, 0.96); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.45); +} + +.login-panel h2 { + margin: 4px 0 0; + font-size: 20px; +} + +.login-panel p { + margin: 0 0 4px; + color: var(--muted); + font-size: 13px; +} + +.settings-page { + height: calc(100vh - 66px); + padding: 14px; + overflow: auto; +} + +.settings-shell { + max-width: 1180px; + margin: 0 auto; + padding: 18px; +} + +.settings-head, +.settings-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.settings-head h2, +.settings-title h3 { + margin: 0; +} + +.settings-head p, +.settings-title span { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; +} + +.settings-content { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.settings-section { + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(7, 10, 15, 0.55); +} + +.settings-section.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.settings-form { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 130px 110px 110px; + gap: 8px; + margin: 14px 0; +} + +.settings-form input, +.settings-form select, +.password-reset input { + min-width: 0; + height: 34px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + background: #080d15; + color: var(--text); +} + +.settings-table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 8px; + background: #0b111b; +} + +.settings-table th, +.settings-table td { + padding: 10px; + border-bottom: 1px solid var(--line); + color: var(--text); + font-size: 12px; + text-align: left; +} + +.settings-table th { + color: #b8c7dc; + background: rgba(52, 118, 246, 0.08); +} + +.password-reset { + display: grid; + grid-template-columns: minmax(0, 1fr) 64px; + gap: 6px; +} + +.password-reset button { + border: 1px solid rgba(25, 212, 194, 0.42); + border-radius: 8px; + background: rgba(25, 212, 194, 0.1); + color: #baf8ee; +} + +.settings-dl { + margin-top: 12px; +} + +.role-grid { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.role-card { + display: grid; + gap: 5px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0b111b; +} + +.role-card span { + color: var(--muted); + font-size: 12px; +} + @media (max-width: 1180px) { body { overflow: auto; @@ -525,10 +772,15 @@ dd { .metrics, .detail-grid, + .settings-section.split, .link-map { grid-template-columns: 1fr; } + .settings-form { + grid-template-columns: 1fr; + } + .link-line { width: 2px; height: 22px;