Add PACS UPP database visualizer

This commit is contained in:
Codex
2026-05-28 00:46:41 +08:00
parent f34e40ec0a
commit 48b0e23d64
9 changed files with 1345 additions and 0 deletions

View File

@@ -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

View 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 8108
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8108"]

View File

@@ -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 号进行跨系统匹配。

View File

@@ -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]

View File

@@ -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"

View File

@@ -0,0 +1,2 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0

View File

@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
.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 = `<p class="empty">没有匹配记录</p>`;
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 = `
<div class="card-row">
<strong>${escapeHtml(row.norm_ct)}</strong>
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
</div>
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
<small>${escapeHtml(date || "无时间")} · PACS ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
<small>${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
`;
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 `<dt>${escapeHtml(key)}</dt><dd title="${escapeHtml(text)}">${escapeHtml(text)}</dd>`;
})
.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 = `<p class="empty">没有 STL 分割文件</p>`;
return;
}
$("segmentGroups").innerHTML = Array.from(groups.entries())
.map(
([group, names]) => `
<div class="segment-group">
<strong>${escapeHtml(group)}</strong>
<div class="segment-tags">${names.map((name) => `<em title="${escapeHtml(name)}">${escapeHtml(name)}</em>`).join("")}</div>
</div>
`,
)
.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();

View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PACS / UPP 数据库关联可视化</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>PACS / UPP 数据库关联可视化</h1>
<p>以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产</p>
</div>
</div>
<div class="top-actions">
<span id="dbStatus" class="status-pill">数据库</span>
<button id="refreshBtn" class="dark-btn" type="button">刷新</button>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<section class="panel search-panel">
<div class="panel-head">
<h2>CT 索引</h2>
<span id="resultCount">0</span>
</div>
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述" />
<div class="filter-grid">
<button class="active" data-filter="all">全部</button>
<button data-filter="complete">已关联</button>
<button data-filter="no_stl">缺 STL</button>
<button data-filter="pacs_only">仅 PACS</button>
<button data-filter="upp_only">仅 UPP</button>
<button data-filter="normalized">D 前缀</button>
<button data-filter="undetermined">待判别</button>
</div>
</section>
<section class="panel list-panel">
<div id="relationList" class="relation-list"></div>
</section>
</aside>
<section class="content">
<section class="metrics" id="metrics"></section>
<section class="panel hero-panel">
<div class="hero-head">
<div>
<h2 id="activeCt">未选择 CT</h2>
<p id="activeSubtitle">从左侧选择一个 CT 号查看两个系统之间的关系</p>
</div>
<span id="relationBadge" class="relation-badge idle">等待选择</span>
</div>
<div class="link-map">
<div id="nodePacs" class="link-node">
<span>PACS DICOM</span>
<strong>未选择</strong>
<em id="nodePacsMeta">检查与序列</em>
</div>
<div class="link-line"></div>
<div id="nodeCt" class="link-node ct-node">
<span>CT 号</span>
<strong id="nodeCtValue">-</strong>
<em>规范索引</em>
</div>
<div class="link-line"></div>
<div id="nodeUpp" class="link-node">
<span>UPP 列表</span>
<strong>未选择</strong>
<em id="nodeUppMeta">任务与状态</em>
</div>
<div class="link-line"></div>
<div id="nodeStl" class="link-node">
<span>STL 资产</span>
<strong>未选择</strong>
<em id="nodeStlMeta">分割文件</em>
</div>
</div>
</section>
<section class="detail-grid">
<article class="panel detail-card">
<h3>PACS DICOM</h3>
<dl id="pacsDetails"></dl>
</article>
<article class="panel detail-card">
<h3>UPP 列表</h3>
<dl id="uppDetails"></dl>
</article>
<article class="panel detail-card">
<h3>UPP STL</h3>
<dl id="stlDetails"></dl>
</article>
</section>
<section class="panel segment-panel">
<div class="panel-head">
<h2>STL 分割结构</h2>
<span id="segmentCount">0 个</span>
</div>
<div id="segmentGroups" class="segment-groups"></div>
</section>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -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;
}
}