From 48b0e23d647d53a271468b2544c69d2064fbe011 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 00:46:41 +0800 Subject: [PATCH] Add PACS UPP database visualizer --- 数据库Web可视化/.env.example | 9 + 数据库Web可视化/Dockerfile | 20 ++ 数据库Web可视化/README.md | 23 ++ 数据库Web可视化/app.py | 341 +++++++++++++++++++ 数据库Web可视化/docker-compose.yml | 12 + 数据库Web可视化/requirements.txt | 2 + 数据库Web可视化/static/app.js | 309 +++++++++++++++++ 数据库Web可视化/static/index.html | 114 +++++++ 数据库Web可视化/static/styles.css | 515 +++++++++++++++++++++++++++++ 9 files changed, 1345 insertions(+) create mode 100644 数据库Web可视化/.env.example create mode 100644 数据库Web可视化/Dockerfile create mode 100644 数据库Web可视化/README.md create mode 100644 数据库Web可视化/app.py create mode 100644 数据库Web可视化/docker-compose.yml create mode 100644 数据库Web可视化/requirements.txt create mode 100644 数据库Web可视化/static/app.js create mode 100644 数据库Web可视化/static/index.html create mode 100644 数据库Web可视化/static/styles.css diff --git a/数据库Web可视化/.env.example b/数据库Web可视化/.env.example new file mode 100644 index 0000000..3bbf821 --- /dev/null +++ b/数据库Web可视化/.env.example @@ -0,0 +1,9 @@ +PGHOST=192.168.3.3 +PGPORT=5432 +PGUSER=his_user +PGPASSWORD=change_me +PGDATABASE=pacs_db +PACS_TABLE=pacs_dicom_files +PACS_SUMMARY_TABLE=pacs_dicom_study_summaries +UPP_ASSET_TABLE=upp_exam_assets +UPP_STL_TABLE=upp_stl_files diff --git a/数据库Web可视化/Dockerfile b/数据库Web可视化/Dockerfile new file mode 100644 index 0000000..d008520 --- /dev/null +++ b/数据库Web可视化/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py ./app.py +COPY static ./static + +EXPOSE 8108 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8108"] diff --git a/数据库Web可视化/README.md b/数据库Web可视化/README.md new file mode 100644 index 0000000..f28fa1e --- /dev/null +++ b/数据库Web可视化/README.md @@ -0,0 +1,23 @@ +# PACS / UPP 数据库关联可视化 + +本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查、UPP 列表资产和 UPP STL 文件聚合表放在同一视图中查看。 + +## 启动 + +```bash +cd /home/wkmgc/Desktop/PACS数据处理/数据库Web可视化 +cp .env.example .env +# 编辑 .env,填写本机 PostgreSQL 密码 +docker compose up -d --build +``` + +浏览器打开 `http://127.0.0.1:8108`。 + +## 数据来源 + +- `pacs_dicom_files`:PACS DICOM 检查索引。 +- `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。 +- `upp_exam_assets`:UPP 列表与 STL 资产主索引。 +- `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。 + +CT 号关联时会优先展示原始 CT 号,同时使用去掉开头 `D` 的规范 CT 号进行跨系统匹配。 diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py new file mode 100644 index 0000000..77042c3 --- /dev/null +++ b/数据库Web可视化/app.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + + +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR / "static" + +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") +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") + +IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def ident(name: str) -> str: + if not IDENT_RE.fullmatch(name): + raise RuntimeError(f"invalid SQL identifier: {name}") + return name + + +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) + +app = FastAPI(title="PACS UPP Database Visualizer") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +def pg_env() -> dict[str, str]: + env = os.environ.copy() + env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE}) + return env + + +def run_psql(sql: str, timeout: int = 20) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["psql", "-X", "-q", "-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", sql], + text=True, + capture_output=True, + timeout=timeout, + env=pg_env(), + ) + + +def pg_scalar(sql: str, timeout: int = 20) -> 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 = 24) -> 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 sql_literal(value: Any) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def normalize_ct(value: str) -> str: + return re.sub(r"^D", "", re.sub(r"\s+", "", str(value or "").upper())) + + +def db_available() -> tuple[bool, str]: + try: + pg_scalar("SELECT 1", timeout=4) + return True, "connected" + except Exception as exc: # noqa: BLE001 + return False, str(exc) + + +def relation_cte() -> str: + return f""" + WITH + p AS ( + SELECT + p.*, + regexp_replace(upper(p.ct_number), '^D', '') AS norm_ct + FROM public.{PACS_TABLE_SQL} p + ), + ps AS ( + SELECT * + FROM public.{PACS_SUMMARY_TABLE_SQL} + ), + u_raw AS ( + SELECT + u.*, + regexp_replace(upper(u.ct_number), '^D', '') AS norm_ct + FROM public.{UPP_ASSET_TABLE_SQL} u + ), + u AS ( + SELECT DISTINCT ON (norm_ct) * + FROM u_raw + ORDER BY norm_ct, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST + ), + s_raw AS ( + SELECT + s.*, + regexp_replace(upper(s.ct_number), '^D', '') AS norm_ct + FROM public.{UPP_STL_TABLE_SQL} s + ), + s AS ( + SELECT DISTINCT ON (norm_ct) * + FROM s_raw + ORDER BY norm_ct, file_count DESC, updated_at DESC NULLS LAST + ), + keys AS ( + SELECT norm_ct FROM p + UNION + SELECT norm_ct FROM u + ), + relation AS ( + SELECT + k.norm_ct, + p.ct_number AS pacs_ct_number, + u.ct_number AS upp_ct_number, + s.ct_number AS stl_ct_number, + p.ct_number IS NOT NULL AS pacs_present, + u.ct_number IS NOT NULL AS upp_present, + COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present, + CASE + WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'complete' + WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'no_stl' + WHEN p.ct_number IS NOT NULL THEN 'pacs_only' + WHEN u.ct_number IS NOT NULL THEN 'upp_only' + ELSE 'unknown' + END AS relation_status, + CASE + WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND p.ct_number = u.ct_number THEN 'exact' + WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'normalized' + ELSE '' + END AS match_type, + p.batch_name, + p.source_patient_name, + p.patient_name_dicom, + COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) AS pacs_patient_name, + p.patient_id, + p.patient_sex AS pacs_patient_sex, + p.study_date, + p.study_time, + p.study_description, + p.modality, + p.series_count AS pacs_series_count, + p.dicom_file_count, + p.processed_path, + p.updated_at AS pacs_updated_at, + ps.annotated_series, + ps.undetermined_series, + ps.completed, + ps.body_parts, + u.patient_name AS upp_patient_name, + u.patient_sex AS upp_patient_sex, + u.patient_age, + u.patient_id_masked, + u.exam_date, + u.task_created_at, + u.exam_description, + u.exam_device, + u.algorithm_model, + u.upp_status, + u.list_present, + u.list_record_count, + u.processed_stl_dir, + u.stl_file_count, + u.stl_total_bytes, + u.updated_at AS upp_updated_at, + u.selected_list_record, + u.list_records, + u.stl_candidates, + s.file_count AS stl_file_count_agg, + s.total_bytes AS stl_total_bytes_agg, + s.segment_names, + s.segment_families, + s.segment_categories, + s.file_names, + s.files AS stl_files, + s.updated_at AS stl_updated_at, + CASE + WHEN COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) IS NULL + OR NULLIF(u.patient_name, '') IS NULL THEN '' + WHEN regexp_replace(COALESCE(NULLIF(p.source_patient_name, ''), p.patient_name_dicom), '\\s+', '', 'g') + = regexp_replace(u.patient_name, '\\s+', '', 'g') THEN 'same' + ELSE 'different' + END AS patient_name_match + FROM keys k + LEFT JOIN p ON p.norm_ct = k.norm_ct + LEFT JOIN ps ON ps.ct_number = p.ct_number + LEFT JOIN u ON u.norm_ct = k.norm_ct + LEFT JOIN s ON s.norm_ct = k.norm_ct + ) + """ + + +def relation_where(q: str, status: str) -> str: + clauses: list[str] = [] + query = q.strip() + if query: + like = "%" + query.replace("%", "").replace("_", "") + "%" + literal = sql_literal(like) + clauses.append( + " OR ".join( + [ + f"norm_ct ILIKE {literal}", + f"pacs_ct_number ILIKE {literal}", + f"upp_ct_number ILIKE {literal}", + f"pacs_patient_name ILIKE {literal}", + f"upp_patient_name ILIKE {literal}", + f"patient_id ILIKE {literal}", + f"exam_description ILIKE {literal}", + f"study_description ILIKE {literal}", + ] + ).join(("(", ")")) + ) + if status == "complete": + clauses.append("relation_status = 'complete'") + elif status == "no_stl": + clauses.append("relation_status = 'no_stl'") + elif status == "pacs_only": + clauses.append("relation_status = 'pacs_only'") + elif status == "upp_only": + clauses.append("relation_status = 'upp_only'") + elif status == "normalized": + clauses.append("match_type = 'normalized'") + elif status == "undetermined": + clauses.append("COALESCE(undetermined_series, 0) > 0") + elif status != "all": + raise HTTPException(status_code=400, detail="invalid status filter") + return "WHERE " + " AND ".join(clauses) if clauses else "" + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/health") +def health() -> str: + return "ok" + + +@app.get("/api/status") +def status() -> dict[str, Any]: + ok, message = db_available() + counts: dict[str, Any] = {} + if ok: + rows = pg_json_rows( + f""" + {relation_cte()} + SELECT + count(*)::int AS total_ct, + count(*) FILTER (WHERE pacs_present)::int AS pacs_count, + count(*) FILTER (WHERE upp_present)::int AS upp_count, + count(*) FILTER (WHERE stl_present)::int AS stl_count, + count(*) FILTER (WHERE relation_status = 'complete')::int AS complete_count, + count(*) FILTER (WHERE relation_status = 'no_stl')::int AS no_stl_count, + count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count, + count(*) FILTER (WHERE relation_status = 'upp_only')::int AS upp_only_count, + count(*) FILTER (WHERE match_type = 'normalized')::int AS normalized_match_count, + COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series + FROM relation + """, + timeout=24, + ) + counts = rows[0] if rows else {} + return { + "database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE}, + "tables": { + "pacs": PACS_TABLE, + "pacs_summary": PACS_SUMMARY_TABLE, + "upp_assets": UPP_ASSET_TABLE, + "upp_stl": UPP_STL_TABLE, + }, + "counts": counts, + } + + +@app.get("/api/relations") +def relations(q: str = "", status: str = "all", limit: int = Query(default=300, ge=1, le=1000)) -> list[dict[str, Any]]: + where = relation_where(q, status) + return pg_json_rows( + f""" + {relation_cte()} + SELECT + norm_ct, pacs_ct_number, upp_ct_number, stl_ct_number, + pacs_present, upp_present, stl_present, relation_status, match_type, + pacs_patient_name, upp_patient_name, patient_name_match, patient_id, patient_id_masked, + study_date, study_time, exam_date, task_created_at, + 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, segment_categories, segment_families, + pacs_updated_at, upp_updated_at, stl_updated_at + FROM relation + {where} + ORDER BY + pacs_present DESC, + relation_status, + COALESCE(study_date, '') DESC, + COALESCE(study_time, '') DESC, + norm_ct + LIMIT {int(limit)} + """, + timeout=24, + ) + + +@app.get("/api/relations/{ct_number}") +def relation_detail(ct_number: str) -> dict[str, Any]: + norm_ct = normalize_ct(ct_number) + if not norm_ct: + raise HTTPException(status_code=400, detail="invalid CT number") + rows = pg_json_rows( + f""" + {relation_cte()} + SELECT * + FROM relation + WHERE norm_ct = {sql_literal(norm_ct)} + LIMIT 1 + """, + timeout=24, + ) + if not rows: + raise HTTPException(status_code=404, detail="CT number not found") + return rows[0] diff --git a/数据库Web可视化/docker-compose.yml b/数据库Web可视化/docker-compose.yml new file mode 100644 index 0000000..36c8da6 --- /dev/null +++ b/数据库Web可视化/docker-compose.yml @@ -0,0 +1,12 @@ +name: pacs-database-visualizer + +services: + pacs-database-visualizer: + build: + context: . + container_name: pacs-database-visualizer + restart: unless-stopped + env_file: + - .env + ports: + - "8108:8108" diff --git a/数据库Web可视化/requirements.txt b/数据库Web可视化/requirements.txt new file mode 100644 index 0000000..babedd0 --- /dev/null +++ b/数据库Web可视化/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js new file mode 100644 index 0000000..10415cb --- /dev/null +++ b/数据库Web可视化/static/app.js @@ -0,0 +1,309 @@ +const app = { + rows: [], + active: null, + filter: "all", + search: "", + searchTimer: null, +}; + +const $ = (id) => document.getElementById(id); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function json(path) { + const res = await fetch(path); + 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.json(); +} + +function fmtDate(value) { + const text = String(value || ""); + if (!text) return ""; + if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; + return text.replace("T", " ").slice(0, 19); +} + +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 fmtBytes(value) { + const size = Number(value || 0); + if (!size) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))); + return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; +} + +function asList(value) { + return Array.isArray(value) ? value : []; +} + +function uniq(list) { + return Array.from(new Set(list.filter(Boolean))); +} + +function statusLabel(value) { + return { + complete: "PACS + UPP + STL", + no_stl: "PACS + UPP", + pacs_only: "仅 PACS", + upp_only: "仅 UPP", + unknown: "未知", + }[value] || value || "未知"; +} + +function matchLabel(value) { + return { exact: "CT 号精确一致", normalized: "去 D 前缀匹配" }[value] || "未匹配"; +} + +function partLabel(value) { + return { + head_neck: "头颈部", + chest: "胸部", + upper_abdomen: "上腹部", + lower_abdomen: "下腹部", + pelvis: "盆腔", + }[value] || value; +} + +function setDbStatus(data) { + const pill = $("dbStatus"); + if (data.database?.ok) { + pill.textContent = `${data.database.database} 已连接`; + pill.className = "status-pill online"; + } else { + pill.textContent = "数据库异常"; + pill.className = "status-pill offline"; + } +} + +function renderMetrics(counts = {}) { + const items = [ + ["CT 索引", counts.total_ct], + ["PACS", counts.pacs_count], + ["UPP", counts.upp_count], + ["STL", counts.stl_count], + ["完整关联", counts.complete_count], + ["待判别序列", counts.undetermined_series], + ]; + $("metrics").innerHTML = items + .map(([label, value]) => `
${escapeHtml(label)}${Number(value || 0)}
`) + .join(""); +} + +function cardTitle(row) { + return [ + `规范CT号:${row.norm_ct}`, + `PACS:${row.pacs_ct_number || "无"}`, + `UPP:${row.upp_ct_number || "无"}`, + `状态:${statusLabel(row.relation_status)}`, + `PACS患者:${row.pacs_patient_name || "无"}`, + `UPP患者:${row.upp_patient_name || "无"}`, + ].join("\n"); +} + +function renderList() { + const list = $("relationList"); + $("resultCount").textContent = String(app.rows.length); + if (!app.rows.length) { + list.innerHTML = `

没有匹配记录

`; + return; + } + list.innerHTML = ""; + for (const row of app.rows) { + const card = document.createElement("button"); + card.className = "relation-card"; + card.classList.toggle("active", app.active?.norm_ct === row.norm_ct); + 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); + card.innerHTML = ` +
+ ${escapeHtml(row.norm_ct)} + ${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(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""} + `; + card.onclick = () => selectRelation(row.norm_ct); + list.appendChild(card); + } +} + +function renderDl(id, entries) { + const html = entries + .map(([key, value]) => { + const text = value || value === 0 ? String(value) : "-"; + return `
${escapeHtml(key)}
${escapeHtml(text)}
`; + }) + .join(""); + $(id).innerHTML = html; +} + +function markNode(id, state, strong, meta) { + const node = $(id); + node.classList.remove("ok", "warn", "missing"); + node.classList.add(state); + node.querySelector("strong").textContent = strong; + 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 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 renderDetail(row) { + app.active = row; + $("activeCt").textContent = row.norm_ct || "未选择 CT"; + $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.upp_ct_number || "无 UPP"} · ${matchLabel(row.match_type)}`; + const badge = $("relationBadge"); + badge.className = `relation-badge ${row.relation_status || "idle"} ${row.match_type === "normalized" ? "normalized" : ""}`; + badge.textContent = statusLabel(row.relation_status); + + $("nodeCtValue").textContent = row.norm_ct || "-"; + markNode("nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列`); + markNode("nodeUpp", row.upp_present ? "ok" : "missing", row.upp_ct_number || "缺失", row.upp_status || row.algorithm_model || "无任务状态"); + markNode("nodeStl", row.stl_present ? "ok" : "warn", row.stl_present ? "已生成" : "未生成", `${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL · ${fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)}`); + + renderDl("pacsDetails", [ + ["CT号", row.pacs_ct_number], + ["患者", row.pacs_patient_name], + ["患者ID", row.patient_id], + ["检查时间", `${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)} 待判别`], + ["完成", row.completed ? "已完成" : "待处理"], + ["部位", asList(row.body_parts).map(partLabel).join("、")], + ]); + + renderDl("uppDetails", [ + ["CT号", row.upp_ct_number], + ["患者", row.upp_patient_name], + ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], + ["检查时间", fmtDate(row.exam_date)], + ["任务时间", fmtDate(row.task_created_at)], + ["算法模型", row.algorithm_model], + ["状态", row.upp_status], + ["列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"], + ["患者名", row.patient_name_match === "same" ? "两侧一致" : row.patient_name_match === "different" ? "两侧不同" : "无法判断"], + ]); + + renderDl("stlDetails", [ + ["STL状态", row.stl_present ? "存在" : "缺失"], + ["文件数", 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("、")], + ["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)], + ]); + + renderSegments(row); + renderList(); +} + +async function selectRelation(normCt) { + const row = await json(`/api/relations/${encodeURIComponent(normCt)}`); + renderDetail(row); +} + +async function loadStatus() { + const data = await json("/api/status"); + setDbStatus(data); + renderMetrics(data.counts || {}); +} + +async function loadRelations() { + const params = new URLSearchParams(); + params.set("status", app.filter); + params.set("limit", "500"); + if (app.search) params.set("q", app.search); + app.rows = await json(`/api/relations?${params.toString()}`); + if (app.rows.length && !app.rows.some((row) => row.norm_ct === app.active?.norm_ct)) { + await selectRelation(app.rows[0].norm_ct); + } else { + renderList(); + if (!app.active && app.rows.length) await selectRelation(app.rows[0].norm_ct); + } +} + +async function refreshAll() { + try { + await loadStatus(); + await loadRelations(); + } catch (err) { + $("dbStatus").textContent = err.message; + $("dbStatus").className = "status-pill offline"; + } +} + +function wire() { + $("refreshBtn").addEventListener("click", refreshAll); + $("searchInput").addEventListener("input", () => { + clearTimeout(app.searchTimer); + app.searchTimer = setTimeout(() => { + app.search = $("searchInput").value.trim(); + loadRelations(); + }, 250); + }); + document.querySelectorAll("[data-filter]").forEach((button) => { + button.addEventListener("click", () => { + app.filter = button.dataset.filter; + document.querySelectorAll("[data-filter]").forEach((item) => item.classList.toggle("active", item === button)); + loadRelations(); + }); + }); +} + +wire(); +refreshAll(); diff --git a/数据库Web可视化/static/index.html b/数据库Web可视化/static/index.html new file mode 100644 index 0000000..af27f46 --- /dev/null +++ b/数据库Web可视化/static/index.html @@ -0,0 +1,114 @@ + + + + + + PACS / UPP 数据库关联可视化 + + + +
+
+ +
+

PACS / UPP 数据库关联可视化

+

以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产

+
+
+
+ 数据库 + +
+
+ +
+ + +
+
+ +
+
+
+

未选择 CT

+

从左侧选择一个 CT 号查看两个系统之间的关系

+
+ 等待选择 +
+ + +
+ +
+
+

PACS DICOM

+
+
+
+

UPP 列表

+
+
+
+

UPP STL

+
+
+
+ +
+
+

STL 分割结构

+ 0 个 +
+
+
+
+
+ + + + diff --git a/数据库Web可视化/static/styles.css b/数据库Web可视化/static/styles.css new file mode 100644 index 0000000..9c0b08b --- /dev/null +++ b/数据库Web可视化/static/styles.css @@ -0,0 +1,515 @@ +:root { + color-scheme: dark; + --bg: #070a0f; + --panel: #101722; + --panel-2: #0b111b; + --line: #1f2c3c; + --line-strong: #345070; + --text: #e8f0fb; + --muted: #8ea2bd; + --blue: #3276f6; + --cyan: #19d4c2; + --green: #16b981; + --amber: #f0b54e; + --red: #fb7185; + --shadow: 0 18px 42px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + overflow: hidden; + background: + linear-gradient(90deg, rgba(52, 118, 246, 0.07) 1px, transparent 1px), + linear-gradient(180deg, rgba(25, 212, 194, 0.055) 1px, transparent 1px), + radial-gradient(circle at 12% 0%, rgba(25, 212, 194, 0.13), transparent 28%), + var(--bg); + background-size: 72px 72px, 72px 72px, auto; + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.topbar { + height: 66px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 0 22px; + border-bottom: 1px solid var(--line); + background: rgba(7, 10, 15, 0.92); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-mark { + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: 8px; + background: linear-gradient(135deg, var(--cyan), var(--blue)); + box-shadow: 0 0 24px rgba(25, 212, 194, 0.24); +} + +.brand h1 { + margin: 0; + font-size: 18px; + line-height: 1.2; +} + +.brand p { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; +} + +.top-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.status-pill, +.relation-badge { + min-width: 82px; + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.status-pill.online, +.relation-badge.complete { + border-color: rgba(22, 185, 129, 0.46); + color: #a5f5d4; + background: rgba(22, 185, 129, 0.08); +} + +.status-pill.offline, +.relation-badge.pacs_only, +.relation-badge.upp_only { + border-color: rgba(251, 113, 133, 0.46); + color: #fecdd3; + background: rgba(251, 113, 133, 0.08); +} + +.relation-badge.no_stl, +.relation-badge.normalized { + border-color: rgba(240, 181, 78, 0.5); + color: #ffe0a3; + background: rgba(240, 181, 78, 0.08); +} + +.dark-btn { + height: 34px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + color: var(--text); +} + +.dark-btn:hover, +.filter-grid button:hover { + border-color: var(--line-strong); + background: #172233; +} + +.layout { + height: calc(100vh - 66px); + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + gap: 14px; + padding: 14px; +} + +.sidebar, +.content { + min-height: 0; + display: grid; + gap: 12px; +} + +.sidebar { + grid-template-rows: auto minmax(0, 1fr); +} + +.content { + grid-template-rows: auto auto auto minmax(0, 1fr); +} + +.panel { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(16, 23, 34, 0.9); + box-shadow: var(--shadow); +} + +.search-panel, +.list-panel, +.hero-panel, +.segment-panel { + padding: 14px; +} + +.panel-head, +.hero-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-head h2, +.hero-head h2 { + margin: 0; + font-size: 16px; +} + +.panel-head span, +.hero-head p { + color: var(--muted); + font-size: 12px; +} + +.hero-head p { + margin: 4px 0 0; +} + +.search-input { + width: 100%; + height: 40px; + margin-top: 12px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + background: #080d15; + color: var(--text); +} + +.search-input:focus { + border-color: rgba(52, 118, 246, 0.76); +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + margin-top: 10px; +} + +.filter-grid button { + height: 30px; + border: 1px solid var(--line); + border-radius: 7px; + background: #0b111b; + color: var(--muted); + font-size: 12px; +} + +.filter-grid button.active { + border-color: var(--blue); + color: white; + background: rgba(52, 118, 246, 0.26); +} + +.relation-list { + height: 100%; + overflow: auto; + padding-right: 3px; +} + +.relation-card { + width: 100%; + display: grid; + gap: 7px; + margin-bottom: 10px; + padding: 12px; + border: 1px solid transparent; + border-radius: 8px; + background: #0b111b; + color: var(--text); + text-align: left; +} + +.relation-card.active { + border-color: rgba(25, 212, 194, 0.82); + background: #0e1823; +} + +.relation-card strong, +.relation-card span, +.relation-card small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.relation-card strong { + font-size: 15px; +} + +.relation-card span, +.relation-card small { + color: var(--muted); + font-size: 12px; +} + +.card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.mini-badge { + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 11px; +} + +.mini-badge.complete { + border-color: rgba(22, 185, 129, 0.5); + color: #a5f5d4; +} + +.mini-badge.no_stl, +.mini-badge.normalized { + border-color: rgba(240, 181, 78, 0.5); + color: #ffe0a3; +} + +.mini-badge.pacs_only, +.mini-badge.upp_only { + border-color: rgba(251, 113, 133, 0.48); + color: #fecdd3; +} + +.metrics { + display: grid; + grid-template-columns: repeat(6, minmax(116px, 1fr)); + gap: 10px; +} + +.metric { + min-height: 76px; + padding: 13px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(11, 17, 27, 0.92); +} + +.metric span { + color: var(--muted); + font-size: 12px; +} + +.metric strong { + display: block; + margin-top: 8px; + font-size: 24px; +} + +.link-map { + display: grid; + grid-template-columns: minmax(150px, 1fr) 48px minmax(150px, 0.8fr) 48px minmax(150px, 1fr) 48px minmax(150px, 1fr); + align-items: center; + gap: 8px; + margin-top: 16px; +} + +.link-node { + min-height: 104px; + display: grid; + align-content: center; + gap: 7px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0b111b; +} + +.link-node.ok { + border-color: rgba(25, 212, 194, 0.55); + background: rgba(25, 212, 194, 0.07); +} + +.link-node.warn { + border-color: rgba(240, 181, 78, 0.48); + background: rgba(240, 181, 78, 0.06); +} + +.link-node.missing { + border-color: rgba(251, 113, 133, 0.45); + background: rgba(251, 113, 133, 0.06); +} + +.link-node span, +.link-node em { + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.link-node strong { + overflow: hidden; + font-size: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ct-node { + border-color: rgba(52, 118, 246, 0.68); + background: rgba(52, 118, 246, 0.09); +} + +.link-line { + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, var(--line), rgba(25, 212, 194, 0.72), var(--line)); +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.detail-card { + padding: 14px; +} + +.detail-card h3 { + margin: 0 0 12px; + font-size: 15px; +} + +dl { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 8px 10px; + margin: 0; +} + +dt { + color: var(--muted); + font-size: 12px; +} + +dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--text); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.segment-panel { + min-height: 0; + overflow: hidden; +} + +.segment-groups { + max-height: 100%; + overflow: auto; + margin-top: 12px; +} + +.segment-group { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + gap: 10px; + padding: 10px 0; + border-top: 1px solid var(--line); +} + +.segment-group:first-child { + border-top: 0; +} + +.segment-group strong { + color: #cfe0f5; + font-size: 13px; +} + +.segment-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.segment-tags em { + max-width: 190px; + padding: 4px 8px; + overflow: hidden; + border: 1px solid rgba(25, 212, 194, 0.32); + border-radius: 999px; + color: #baf8ee; + background: rgba(25, 212, 194, 0.07); + font-size: 12px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty { + margin: 18px 0 0; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +@media (max-width: 1180px) { + body { + overflow: auto; + } + + .layout { + height: auto; + grid-template-columns: 1fr; + } + + .metrics, + .detail-grid, + .link-map { + grid-template-columns: 1fr; + } + + .link-line { + width: 2px; + height: 22px; + margin: 0 auto; + } +}