Improve DICOM UPP relation visualizer
This commit is contained in:
@@ -439,11 +439,18 @@ function downloadSelectedSeries() {
|
||||
triggerDownload(exportDownloadUrl(app.exportDialog.ctNumber, selected));
|
||||
}
|
||||
|
||||
async function loadStudies() {
|
||||
function initialCtNumber() {
|
||||
return new URLSearchParams(location.search).get("ct_number") || "";
|
||||
}
|
||||
|
||||
async function loadStudies(preferredCtNumber = "") {
|
||||
const q = encodeURIComponent($("studySearch").value.trim());
|
||||
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||||
renderStudies();
|
||||
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
|
||||
if (!app.study && app.studies.length) {
|
||||
const preferred = app.studies.find((study) => study.ct_number === preferredCtNumber);
|
||||
await selectStudy((preferred || app.studies[0]).ct_number);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStudies() {
|
||||
@@ -1537,7 +1544,7 @@ async function boot() {
|
||||
if (location.pathname === "/settings" && isAdmin()) {
|
||||
await showSettingsPage(false);
|
||||
} else {
|
||||
await loadStudies();
|
||||
await loadStudies(initialCtNumber());
|
||||
await handleRoute(false);
|
||||
}
|
||||
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 文件、分割名称和分类聚合。
|
||||
|
||||
|
||||
228
数据库Web可视化/app.py
228
数据库Web可视化/app.py
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -23,6 +26,10 @@ PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files")
|
||||
PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries")
|
||||
UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets")
|
||||
UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files")
|
||||
USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users")
|
||||
WEB_ADMIN_USER = os.getenv("VISUALIZER_ADMIN_USER", "admin")
|
||||
WEB_ADMIN_PASSWORD = os.getenv("VISUALIZER_ADMIN_PASSWORD", "123456")
|
||||
PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107")
|
||||
|
||||
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
@@ -37,9 +44,27 @@ PACS_TABLE_SQL = ident(PACS_TABLE)
|
||||
PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
|
||||
UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE)
|
||||
UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE)
|
||||
USER_TABLE_SQL = ident(USER_TABLE)
|
||||
|
||||
app = FastAPI(title="PACS UPP Database Visualizer")
|
||||
app = FastAPI(title="DICOM UPP Database Visualizer")
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
TOKENS: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
class LoginPayload(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserPayload(BaseModel):
|
||||
username: str
|
||||
password: str = ""
|
||||
role: str = "阅片员"
|
||||
status: str = "启用"
|
||||
|
||||
|
||||
class PasswordPayload(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
def pg_env() -> dict[str, str]:
|
||||
@@ -74,6 +99,10 @@ def sql_literal(value: Any) -> str:
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_ct(value: str) -> str:
|
||||
return re.sub(r"\s+", "", str(value or "").upper())
|
||||
|
||||
@@ -86,6 +115,63 @@ def db_available() -> tuple[bool, str]:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def ensure_user_table() -> None:
|
||||
run_psql(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS public.{USER_TABLE_SQL} (
|
||||
username text PRIMARY KEY,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT '阅片员',
|
||||
status text NOT NULL DEFAULT '启用',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status)
|
||||
VALUES ({sql_literal(WEB_ADMIN_USER)}, {sql_literal(password_hash(WEB_ADMIN_PASSWORD))}, '管理员', '启用')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def fetch_user(username: str) -> dict[str, Any] | None:
|
||||
ensure_user_table()
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT username, password_hash, role, status, created_at, updated_at
|
||||
FROM public.{USER_TABLE_SQL}
|
||||
WHERE username = {sql_literal(username)}
|
||||
LIMIT 1
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def current_user(authorization: str | None = Header(default=None)) -> dict[str, str]:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
user = TOKENS.get(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
return user
|
||||
|
||||
|
||||
def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
|
||||
if user.get("role") != "管理员":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return user
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
try:
|
||||
ensure_user_table()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def relation_cte() -> str:
|
||||
return f"""
|
||||
WITH
|
||||
@@ -168,12 +254,12 @@ def relation_cte() -> str:
|
||||
ps.completed,
|
||||
ps.body_parts,
|
||||
to_jsonb(array_remove(ARRAY[
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '肝胆模型' END,
|
||||
CASE WHEN ps.body_parts ? 'chest' THEN '胸外模型' END,
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen'
|
||||
OR ps.body_parts ? 'lower_abdomen'
|
||||
OR ps.body_parts ? 'pelvis' THEN '泌尿模型' END
|
||||
], NULL)) AS dicom_models,
|
||||
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
|
||||
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
|
||||
CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END,
|
||||
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
|
||||
], NULL)) AS dicom_body_parts,
|
||||
u.patient_name AS upp_patient_name,
|
||||
u.patient_sex AS upp_patient_sex,
|
||||
u.patient_age,
|
||||
@@ -217,7 +303,7 @@ def relation_cte() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) -> str:
|
||||
def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -> str:
|
||||
clauses: list[str] = []
|
||||
query = q.strip()
|
||||
if query:
|
||||
@@ -236,6 +322,7 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
|
||||
f"upp_status ILIKE {literal}",
|
||||
f"exam_description ILIKE {literal}",
|
||||
f"study_description ILIKE {literal}",
|
||||
f"COALESCE(dicom_body_parts::text, '') ILIKE {literal}",
|
||||
]
|
||||
).join(("(", ")"))
|
||||
)
|
||||
@@ -249,14 +336,19 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
|
||||
clauses.append("relation_status = 'stl_only'")
|
||||
elif status == "list_only":
|
||||
clauses.append("relation_status = 'list_only'")
|
||||
elif status == "pending_dicom":
|
||||
clauses.append("pacs_present AND COALESCE(completed, false) IS NOT TRUE")
|
||||
elif status == "undetermined":
|
||||
clauses.append("COALESCE(undetermined_series, 0) > 0")
|
||||
elif status != "all":
|
||||
raise HTTPException(status_code=400, detail="invalid status filter")
|
||||
if algorithm_model:
|
||||
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
|
||||
if dicom_model:
|
||||
clauses.append(f"dicom_models ? {sql_literal(dicom_model)}")
|
||||
if dicom_part:
|
||||
valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
|
||||
if dicom_part not in valid_parts:
|
||||
raise HTTPException(status_code=400, detail="invalid DICOM part filter")
|
||||
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
|
||||
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
|
||||
|
||||
@@ -270,8 +362,103 @@ def health() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(payload: LoginPayload) -> dict[str, str]:
|
||||
username = payload.username.strip()
|
||||
try:
|
||||
user = fetch_user(username)
|
||||
except Exception:
|
||||
user = None
|
||||
if not user and username == WEB_ADMIN_USER:
|
||||
user = {"username": WEB_ADMIN_USER, "password_hash": password_hash(WEB_ADMIN_PASSWORD), "role": "管理员", "status": "启用"}
|
||||
if not user or user.get("status") != "启用" or user.get("password_hash") != password_hash(payload.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = secrets.token_urlsafe(32)
|
||||
TOKENS[token] = {"username": user["username"], "role": user.get("role", "阅片员")}
|
||||
return {"token": token, "username": user["username"], "role": user.get("role", "阅片员")}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
|
||||
return user
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT username, role, status, created_at, updated_at
|
||||
FROM public.{USER_TABLE_SQL}
|
||||
ORDER BY username
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return {
|
||||
"user": user,
|
||||
"viewer_url": PACS_VIEWER_URL,
|
||||
"users": rows,
|
||||
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE},
|
||||
"tables": {
|
||||
"dicom": PACS_TABLE,
|
||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||
"upp_assets": UPP_ASSET_TABLE,
|
||||
"upp_stl": UPP_STL_TABLE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/settings/users")
|
||||
def save_user(payload: UserPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
|
||||
username = payload.username.strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="账号不能为空")
|
||||
role = payload.role if payload.role in {"管理员", "阅片员", "访客"} else "阅片员"
|
||||
status = payload.status if payload.status in {"启用", "停用"} else "启用"
|
||||
existing = fetch_user(username)
|
||||
if existing and not payload.password:
|
||||
run_psql(
|
||||
f"""
|
||||
UPDATE public.{USER_TABLE_SQL}
|
||||
SET role = {sql_literal(role)}, status = {sql_literal(status)}, updated_at = now()
|
||||
WHERE username = {sql_literal(username)}
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
else:
|
||||
if not payload.password:
|
||||
raise HTTPException(status_code=400, detail="新账号需要初始密码")
|
||||
run_psql(
|
||||
f"""
|
||||
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status, updated_at)
|
||||
VALUES ({sql_literal(username)}, {sql_literal(password_hash(payload.password))}, {sql_literal(role)}, {sql_literal(status)}, now())
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = now()
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return {"status": "ok", "updated_by": user["username"]}
|
||||
|
||||
|
||||
@app.put("/api/settings/users/{username}/password")
|
||||
def change_password(username: str, payload: PasswordPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
|
||||
if not payload.password:
|
||||
raise HTTPException(status_code=400, detail="密码不能为空")
|
||||
run_psql(
|
||||
f"""
|
||||
UPDATE public.{USER_TABLE_SQL}
|
||||
SET password_hash = {sql_literal(password_hash(payload.password))}, updated_at = now()
|
||||
WHERE username = {sql_literal(username)}
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
return {"status": "ok", "updated_by": user["username"]}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def status() -> dict[str, Any]:
|
||||
def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
ok, message = db_available()
|
||||
counts: dict[str, Any] = {}
|
||||
if ok:
|
||||
@@ -289,7 +476,8 @@ def status() -> dict[str, Any]:
|
||||
count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count,
|
||||
count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_only_count,
|
||||
count(*) FILTER (WHERE relation_status = 'list_only')::int AS list_only_count,
|
||||
COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series
|
||||
count(*) FILTER (WHERE pacs_present AND COALESCE(completed, false) IS NOT TRUE)::int AS pending_dicom_count,
|
||||
COALESCE(sum(CASE WHEN pacs_present THEN COALESCE(undetermined_series, 0) ELSE 0 END), 0)::int AS pending_dicom_series
|
||||
FROM relation
|
||||
""",
|
||||
timeout=24,
|
||||
@@ -297,9 +485,10 @@ def status() -> dict[str, Any]:
|
||||
counts = rows[0] if rows else {}
|
||||
return {
|
||||
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
|
||||
"viewer_url": PACS_VIEWER_URL,
|
||||
"tables": {
|
||||
"pacs": PACS_TABLE,
|
||||
"pacs_summary": PACS_SUMMARY_TABLE,
|
||||
"dicom": PACS_TABLE,
|
||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||
"upp_assets": UPP_ASSET_TABLE,
|
||||
"upp_stl": UPP_STL_TABLE,
|
||||
},
|
||||
@@ -312,10 +501,11 @@ def relations(
|
||||
q: str = "",
|
||||
status: str = "all",
|
||||
algorithm_model: str = "",
|
||||
dicom_model: str = "",
|
||||
dicom_part: str = "",
|
||||
limit: int = Query(default=300, ge=1, le=1000),
|
||||
user: dict[str, str] = Depends(current_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
where = relation_where(q, status, algorithm_model, dicom_model)
|
||||
where = relation_where(q, status, algorithm_model, dicom_part)
|
||||
return pg_json_rows(
|
||||
f"""
|
||||
{relation_cte()}
|
||||
@@ -327,7 +517,7 @@ def relations(
|
||||
study_description, exam_description, algorithm_model, upp_status,
|
||||
pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed,
|
||||
list_present, list_record_count, stl_file_count, stl_file_count_agg,
|
||||
body_parts, dicom_models, segment_categories, segment_families,
|
||||
body_parts, dicom_body_parts, segment_categories, segment_families,
|
||||
pacs_updated_at, upp_updated_at, stl_updated_at
|
||||
FROM relation
|
||||
{where}
|
||||
@@ -344,7 +534,7 @@ def relations(
|
||||
|
||||
|
||||
@app.get("/api/relations/{ct_number}")
|
||||
def relation_detail(ct_number: str) -> dict[str, Any]:
|
||||
def relation_detail(ct_number: str, user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
ct_key = normalize_ct(ct_number)
|
||||
if not ct_key:
|
||||
raise HTTPException(status_code=400, detail="invalid CT number")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user