Improve DICOM UPP relation visualizer

This commit is contained in:
Codex
2026-05-28 08:49:16 +08:00
parent b6149a31eb
commit 4fe6466e20
7 changed files with 831 additions and 119 deletions

View File

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

View File

@@ -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 文件、分割名称和分类聚合。

View File

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

View File

@@ -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]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
@@ -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 `<span class="subtle">无部位标注</span>`;
return parts.map((part) => `<em title="${escapeHtml(part)}">${escapeHtml(part)}</em>`).join("");
}
function renderList() {
const list = $("relationList");
$("resultCount").textContent = String(app.rows.length);
if (!app.rows.length) {
list.innerHTML = `<p class="empty">没有匹配记录</p>`;
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 = `
<div class="card-row">
<strong>${escapeHtml(row.ct_key)}</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(row.algorithm_model || "无")} · DICOM ${escapeHtml(dicomModels)}</small>
<small>${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
<small>${escapeHtml(date || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
<small>重建 ${escapeHtml(row.algorithm_model || "无")}</small>
<div class="inline-tags">${renderPartTags(parts)}</div>
<small>${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""}</small>
`;
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 = `<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 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 `<p class="empty">暂无账号</p>`;
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th><th>更新时间</th><th>重置密码</th></tr></thead>
<tbody>
${users
.map(
(user) => `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.role)}</td>
<td>${escapeHtml(user.status)}</td>
<td>${escapeHtml(fmtDate(user.updated_at))}</td>
<td>
<div class="password-reset">
<input data-password-user="${escapeHtml(user.username)}" placeholder="新密码" type="password" />
<button data-reset-user="${escapeHtml(user.username)}" type="button">更新</button>
</div>
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
async function renderSettingsPage() {
const data = await json("/api/settings");
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title">
<h3>账号创建</h3>
<span>当前登录:${escapeHtml(app.user?.username || "-")}</span>
</div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" />
<input name="password" type="password" placeholder="初始密码" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<select name="status">
<option value="启用">启用</option>
<option value="停用">停用</option>
</select>
<button class="primary-btn" type="submit">保存账号</button>
</form>
${settingsTable(data.users || [])}
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>系统管理</h3><span>跳转与联动</span></div>
<dl class="settings-dl">
<dt>阅片系统</dt><dd>${escapeHtml(data.viewer_url || "-")}</dd>
<dt>数据库</dt><dd>${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}</dd>
<dt>DICOM表</dt><dd>${escapeHtml(data.tables?.dicom || "-")}</dd>
<dt>摘要表</dt><dd>${escapeHtml(data.tables?.dicom_summary || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>权限说明</h3><span>页面级控制</span></div>
<div class="role-grid">
<div class="role-card"><strong>管理员</strong><span>查看关联、系统设置、账号密码管理</span></div>
<div class="role-card"><strong>阅片员</strong><span>查看关联与跳转阅片系统</span></div>
<div class="role-card"><strong>访客</strong><span>只读查看关联信息</span></div>
</div>
</div>
</section>
`;
$("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();

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PACS / STL 数据库关联可视化</title>
<title>DICOM / UPP 数据库关联可视化</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
@@ -11,21 +11,24 @@
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>PACS / STL 数据库关联可视化</h1>
<p>以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产</p>
<h1>DICOM / UPP 数据库关联可视化</h1>
<p>以 CT 号精确关联 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>
<span id="userBadge" class="user-badge">未登录</span>
<button id="settingsBtn" class="dark-btn" type="button">设置</button>
<button id="logoutBtn" class="dark-btn" type="button">退出</button>
</div>
</header>
<main class="layout">
<main id="viewerPage" class="layout">
<aside class="sidebar">
<section class="panel search-panel">
<div class="panel-head">
<h2>CT 索引</h2>
<h2>关联列表</h2>
<span id="resultCount">0</span>
</div>
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述 / 模型" />
@@ -36,21 +39,23 @@
<option value="泌尿模型">泌尿模型</option>
<option value="胸外模型">胸外模型</option>
</select>
<select id="dicomModelFilter">
<option value="">全部DICOM模型</option>
<option value="肝胆模型">肝胆模型</option>
<option value="泌尿模型">泌尿模型</option>
<option value="胸外模型">胸外模型</option>
<select id="dicomPartFilter">
<option value="">全部部位标注</option>
<option value="head_neck">头颈部</option>
<option value="chest">胸部</option>
<option value="upper_abdomen">上腹部</option>
<option value="lower_abdomen">下腹部</option>
<option value="pelvis">盆腔</option>
</select>
</div>
<div class="filter-grid">
<button class="active" data-filter="all">全部</button>
<button data-filter="complete">关联</button>
<button data-filter="complete">完整关联</button>
<button data-filter="no_stl">缺 STL</button>
<button data-filter="pacs_only">PACS</button>
<button data-filter="pacs_only">DICOM</button>
<button data-filter="stl_only">仅 STL</button>
<button data-filter="list_only">列表无STL</button>
<button data-filter="undetermined">判别</button>
<button data-filter="pending_dicom">处理DICOM</button>
</div>
</section>
@@ -66,14 +71,14 @@
<div class="hero-head">
<div>
<h2 id="activeCt">未选择 CT</h2>
<p id="activeSubtitle">从左侧选择一个 CT 号查看 PACS 与 STL 之间的关系</p>
<p id="activeSubtitle">从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系</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>
<span>DICOM 阅片分类系统</span>
<strong>未选择</strong>
<em id="nodePacsMeta">检查与序列</em>
</div>
@@ -81,11 +86,11 @@
<div id="nodeCt" class="link-node ct-node">
<span>CT 号</span>
<strong id="nodeCtValue">-</strong>
<em>规范索引</em>
<em>严格一致匹配</em>
</div>
<div class="link-line"></div>
<div id="nodeStl" class="link-node">
<span>UPP STL 资产</span>
<span>UPP 重建资产</span>
<strong>未选择</strong>
<em id="nodeStlMeta">含UPP列表信息</em>
</div>
@@ -94,7 +99,10 @@
<section class="detail-grid">
<article class="panel detail-card">
<h3>PACS DICOM</h3>
<div class="detail-title">
<h3>DICOM 阅片分类系统</h3>
<button id="openDicomBtn" class="link-btn" type="button">打开阅片系统</button>
</div>
<dl id="pacsDetails"></dl>
</article>
<article class="panel detail-card">
@@ -102,17 +110,34 @@
<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>
<main id="settingsPage" class="settings-page hidden">
<section class="panel settings-shell">
<div class="settings-head">
<div>
<h2>设置</h2>
<p>系统管理、账号密码与 DICOM 阅片系统跳转配置</p>
</div>
<button id="backToViewer" class="dark-btn" type="button">返回</button>
</div>
<div id="settingsContent" class="settings-content"></div>
</section>
</main>
<div id="loginOverlay" class="login-overlay">
<form id="loginForm" class="login-panel">
<span class="brand-mark"></span>
<h2>DICOM / UPP 数据库关联可视化</h2>
<p>请登录后查看数据库关联信息</p>
<input id="username" class="search-input" autocomplete="username" placeholder="账号" />
<input id="password" class="search-input" type="password" autocomplete="current-password" placeholder="密码" />
<button class="primary-btn" type="submit">登录</button>
<span id="loginError" class="error-line"></span>
</form>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

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