Improve DICOM UPP relation visualizer
This commit is contained in:
@@ -439,11 +439,18 @@ function downloadSelectedSeries() {
|
|||||||
triggerDownload(exportDownloadUrl(app.exportDialog.ctNumber, selected));
|
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());
|
const q = encodeURIComponent($("studySearch").value.trim());
|
||||||
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||||||
renderStudies();
|
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() {
|
function renderStudies() {
|
||||||
@@ -1537,7 +1544,7 @@ async function boot() {
|
|||||||
if (location.pathname === "/settings" && isAdmin()) {
|
if (location.pathname === "/settings" && isAdmin()) {
|
||||||
await showSettingsPage(false);
|
await showSettingsPage(false);
|
||||||
} else {
|
} else {
|
||||||
await loadStudies();
|
await loadStudies(initialCtNumber());
|
||||||
await handleRoute(false);
|
await handleRoute(false);
|
||||||
}
|
}
|
||||||
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
|
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ PACS_TABLE=pacs_dicom_files
|
|||||||
PACS_SUMMARY_TABLE=pacs_dicom_study_summaries
|
PACS_SUMMARY_TABLE=pacs_dicom_study_summaries
|
||||||
UPP_ASSET_TABLE=upp_exam_assets
|
UPP_ASSET_TABLE=upp_exam_assets
|
||||||
UPP_STL_TABLE=upp_stl_files
|
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_files`:DICOM 检查索引。
|
||||||
- `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。
|
- `pacs_dicom_study_summaries`:DICOM 标注/完成状态汇总。
|
||||||
- `upp_exam_assets`:UPP 列表与 STL 资产主索引,为 STL 资产提供患者、任务、算法模型、状态等补充信息。
|
- `upp_exam_assets`:UPP 列表与 STL 资产主索引,为 STL 资产提供患者、任务、算法模型、状态等补充信息。
|
||||||
- `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。
|
- `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。
|
||||||
|
|
||||||
|
|||||||
228
数据库Web可视化/app.py
228
数据库Web可视化/app.py
@@ -1,13 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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")
|
PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries")
|
||||||
UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets")
|
UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets")
|
||||||
UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files")
|
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_]*$")
|
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)
|
PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
|
||||||
UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE)
|
UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE)
|
||||||
UPP_STL_TABLE_SQL = ident(UPP_STL_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")
|
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]:
|
def pg_env() -> dict[str, str]:
|
||||||
@@ -74,6 +99,10 @@ def sql_literal(value: Any) -> str:
|
|||||||
return "'" + str(value).replace("'", "''") + "'"
|
return "'" + str(value).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def password_hash(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def normalize_ct(value: str) -> str:
|
def normalize_ct(value: str) -> str:
|
||||||
return re.sub(r"\s+", "", str(value or "").upper())
|
return re.sub(r"\s+", "", str(value or "").upper())
|
||||||
|
|
||||||
@@ -86,6 +115,63 @@ def db_available() -> tuple[bool, str]:
|
|||||||
return False, str(exc)
|
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:
|
def relation_cte() -> str:
|
||||||
return f"""
|
return f"""
|
||||||
WITH
|
WITH
|
||||||
@@ -168,12 +254,12 @@ def relation_cte() -> str:
|
|||||||
ps.completed,
|
ps.completed,
|
||||||
ps.body_parts,
|
ps.body_parts,
|
||||||
to_jsonb(array_remove(ARRAY[
|
to_jsonb(array_remove(ARRAY[
|
||||||
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '肝胆模型' END,
|
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
|
||||||
CASE WHEN ps.body_parts ? 'chest' THEN '胸外模型' END,
|
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
|
||||||
CASE WHEN ps.body_parts ? 'upper_abdomen'
|
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
|
||||||
OR ps.body_parts ? 'lower_abdomen'
|
CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END,
|
||||||
OR ps.body_parts ? 'pelvis' THEN '泌尿模型' END
|
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
|
||||||
], NULL)) AS dicom_models,
|
], NULL)) AS dicom_body_parts,
|
||||||
u.patient_name AS upp_patient_name,
|
u.patient_name AS upp_patient_name,
|
||||||
u.patient_sex AS upp_patient_sex,
|
u.patient_sex AS upp_patient_sex,
|
||||||
u.patient_age,
|
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] = []
|
clauses: list[str] = []
|
||||||
query = q.strip()
|
query = q.strip()
|
||||||
if query:
|
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"upp_status ILIKE {literal}",
|
||||||
f"exam_description ILIKE {literal}",
|
f"exam_description ILIKE {literal}",
|
||||||
f"study_description ILIKE {literal}",
|
f"study_description ILIKE {literal}",
|
||||||
|
f"COALESCE(dicom_body_parts::text, '') ILIKE {literal}",
|
||||||
]
|
]
|
||||||
).join(("(", ")"))
|
).join(("(", ")"))
|
||||||
)
|
)
|
||||||
@@ -249,14 +336,19 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
|
|||||||
clauses.append("relation_status = 'stl_only'")
|
clauses.append("relation_status = 'stl_only'")
|
||||||
elif status == "list_only":
|
elif status == "list_only":
|
||||||
clauses.append("relation_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":
|
elif status == "undetermined":
|
||||||
clauses.append("COALESCE(undetermined_series, 0) > 0")
|
clauses.append("COALESCE(undetermined_series, 0) > 0")
|
||||||
elif status != "all":
|
elif status != "all":
|
||||||
raise HTTPException(status_code=400, detail="invalid status filter")
|
raise HTTPException(status_code=400, detail="invalid status filter")
|
||||||
if algorithm_model:
|
if algorithm_model:
|
||||||
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
|
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
|
||||||
if dicom_model:
|
if dicom_part:
|
||||||
clauses.append(f"dicom_models ? {sql_literal(dicom_model)}")
|
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 ""
|
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
|
||||||
|
|
||||||
@@ -270,8 +362,103 @@ def health() -> str:
|
|||||||
return "ok"
|
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")
|
@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()
|
ok, message = db_available()
|
||||||
counts: dict[str, Any] = {}
|
counts: dict[str, Any] = {}
|
||||||
if ok:
|
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 = 'pacs_only')::int AS pacs_only_count,
|
||||||
count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_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,
|
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
|
FROM relation
|
||||||
""",
|
""",
|
||||||
timeout=24,
|
timeout=24,
|
||||||
@@ -297,9 +485,10 @@ def status() -> dict[str, Any]:
|
|||||||
counts = rows[0] if rows else {}
|
counts = rows[0] if rows else {}
|
||||||
return {
|
return {
|
||||||
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
|
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
|
||||||
|
"viewer_url": PACS_VIEWER_URL,
|
||||||
"tables": {
|
"tables": {
|
||||||
"pacs": PACS_TABLE,
|
"dicom": PACS_TABLE,
|
||||||
"pacs_summary": PACS_SUMMARY_TABLE,
|
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||||
"upp_assets": UPP_ASSET_TABLE,
|
"upp_assets": UPP_ASSET_TABLE,
|
||||||
"upp_stl": UPP_STL_TABLE,
|
"upp_stl": UPP_STL_TABLE,
|
||||||
},
|
},
|
||||||
@@ -312,10 +501,11 @@ def relations(
|
|||||||
q: str = "",
|
q: str = "",
|
||||||
status: str = "all",
|
status: str = "all",
|
||||||
algorithm_model: str = "",
|
algorithm_model: str = "",
|
||||||
dicom_model: str = "",
|
dicom_part: str = "",
|
||||||
limit: int = Query(default=300, ge=1, le=1000),
|
limit: int = Query(default=300, ge=1, le=1000),
|
||||||
|
user: dict[str, str] = Depends(current_user),
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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(
|
return pg_json_rows(
|
||||||
f"""
|
f"""
|
||||||
{relation_cte()}
|
{relation_cte()}
|
||||||
@@ -327,7 +517,7 @@ def relations(
|
|||||||
study_description, exam_description, algorithm_model, upp_status,
|
study_description, exam_description, algorithm_model, upp_status,
|
||||||
pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed,
|
pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed,
|
||||||
list_present, list_record_count, stl_file_count, stl_file_count_agg,
|
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
|
pacs_updated_at, upp_updated_at, stl_updated_at
|
||||||
FROM relation
|
FROM relation
|
||||||
{where}
|
{where}
|
||||||
@@ -344,7 +534,7 @@ def relations(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/relations/{ct_number}")
|
@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)
|
ct_key = normalize_ct(ct_number)
|
||||||
if not ct_key:
|
if not ct_key:
|
||||||
raise HTTPException(status_code=400, detail="invalid CT number")
|
raise HTTPException(status_code=400, detail="invalid CT number")
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
const app = {
|
const app = {
|
||||||
|
token: localStorage.getItem("pacs_relation_token") || "",
|
||||||
|
user: null,
|
||||||
rows: [],
|
rows: [],
|
||||||
active: null,
|
active: null,
|
||||||
filter: "all",
|
filter: "all",
|
||||||
search: "",
|
search: "",
|
||||||
algorithmModel: "",
|
algorithmModel: "",
|
||||||
dicomModel: "",
|
dicomPart: "",
|
||||||
|
viewerUrl: "http://127.0.0.1:8107",
|
||||||
searchTimer: null,
|
searchTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,8 +22,15 @@ function escapeHtml(value) {
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function json(path) {
|
async function request(path, options = {}) {
|
||||||
const res = await fetch(path);
|
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) {
|
if (!res.ok) {
|
||||||
let detail = res.statusText;
|
let detail = res.statusText;
|
||||||
try {
|
try {
|
||||||
@@ -31,9 +41,39 @@ async function json(path) {
|
|||||||
}
|
}
|
||||||
throw new Error(detail);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json(path, options = {}) {
|
||||||
|
const res = await request(path, options);
|
||||||
return res.json();
|
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) {
|
function fmtDate(value) {
|
||||||
const text = String(value || "");
|
const text = String(value || "");
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
@@ -65,9 +105,9 @@ function uniq(list) {
|
|||||||
|
|
||||||
function statusLabel(value) {
|
function statusLabel(value) {
|
||||||
return {
|
return {
|
||||||
complete: "PACS + STL",
|
complete: "DICOM + STL",
|
||||||
no_stl: "PACS + 列表",
|
no_stl: "DICOM + UPP",
|
||||||
pacs_only: "仅 PACS",
|
pacs_only: "仅 DICOM",
|
||||||
stl_only: "仅 STL",
|
stl_only: "仅 STL",
|
||||||
list_only: "列表无STL",
|
list_only: "列表无STL",
|
||||||
unknown: "未知",
|
unknown: "未知",
|
||||||
@@ -88,7 +128,18 @@ function partLabel(value) {
|
|||||||
}[value] || 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) {
|
function setDbStatus(data) {
|
||||||
|
app.viewerUrl = data.viewer_url || app.viewerUrl;
|
||||||
const pill = $("dbStatus");
|
const pill = $("dbStatus");
|
||||||
if (data.database?.ok) {
|
if (data.database?.ok) {
|
||||||
pill.textContent = `${data.database.database} 已连接`;
|
pill.textContent = `${data.database.database} 已连接`;
|
||||||
@@ -101,12 +152,11 @@ function setDbStatus(data) {
|
|||||||
|
|
||||||
function renderMetrics(counts = {}) {
|
function renderMetrics(counts = {}) {
|
||||||
const items = [
|
const items = [
|
||||||
["CT 索引", counts.total_ct],
|
|
||||||
["PACS", counts.pacs_count],
|
|
||||||
["STL资产", counts.stl_asset_count],
|
|
||||||
["STL", counts.stl_count],
|
|
||||||
["完整关联", counts.complete_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
|
$("metrics").innerHTML = items
|
||||||
.map(([label, value]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
|
.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) {
|
function cardTitle(row) {
|
||||||
return [
|
return [
|
||||||
`CT号:${row.ct_key}`,
|
`CT号:${row.ct_key}`,
|
||||||
`PACS:${row.pacs_ct_number || "无"}`,
|
`DICOM:${row.pacs_ct_number || "无"}`,
|
||||||
`STL:${row.stl_ct_number || "无"}`,
|
`STL:${row.stl_ct_number || "无"}`,
|
||||||
`状态:${statusLabel(row.relation_status)}`,
|
`状态:${statusLabel(row.relation_status)}`,
|
||||||
`PACS患者:${row.pacs_patient_name || "无"}`,
|
`DICOM患者:${row.pacs_patient_name || "无"}`,
|
||||||
`STL列表患者:${row.upp_patient_name || "无"}`,
|
`UPP患者:${row.upp_patient_name || "无"}`,
|
||||||
`重建模型:${row.algorithm_model || "无"}`,
|
`重建模型:${row.algorithm_model || "无"}`,
|
||||||
`DICOM模型:${asList(row.dicom_models).join("、") || "无"}`,
|
`部位标注:${dicomParts(row).join("、") || "无"}`,
|
||||||
].join("\n");
|
].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() {
|
function renderList() {
|
||||||
const list = $("relationList");
|
const list = $("relationList");
|
||||||
$("resultCount").textContent = String(app.rows.length);
|
$("resultCount").textContent = String(app.rows.length);
|
||||||
if (!app.rows.length) {
|
if (!app.rows.length) {
|
||||||
list.innerHTML = `<p class="empty">没有匹配记录</p>`;
|
list.innerHTML = `<p class="empty">没有匹配记录</p>`;
|
||||||
|
clearDetail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
@@ -141,16 +197,18 @@ function renderList() {
|
|||||||
card.title = cardTitle(row);
|
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 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 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 = `
|
card.innerHTML = `
|
||||||
<div class="card-row">
|
<div class="card-row">
|
||||||
<strong>${escapeHtml(row.ct_key)}</strong>
|
<strong>${escapeHtml(row.ct_key)}</strong>
|
||||||
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
|
<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(date || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
|
||||||
<small>重建 ${escapeHtml(row.algorithm_model || "无")} · DICOM ${escapeHtml(dicomModels)}</small>
|
<small>重建 ${escapeHtml(row.algorithm_model || "无")}</small>
|
||||||
<small>${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
|
<div class="inline-tags">${renderPartTags(parts)}</div>
|
||||||
|
<small>${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""}</small>
|
||||||
`;
|
`;
|
||||||
card.onclick = () => selectRelation(row.ct_key);
|
card.onclick = () => selectRelation(row.ct_key);
|
||||||
list.appendChild(card);
|
list.appendChild(card);
|
||||||
@@ -175,50 +233,54 @@ function markNode(id, state, strong, meta) {
|
|||||||
node.querySelector("em").textContent = meta;
|
node.querySelector("em").textContent = meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupSegments(row) {
|
function dicomViewerUrl(row = app.active) {
|
||||||
const names = asList(row.segment_names);
|
if (!row?.pacs_ct_number) return "";
|
||||||
const families = asList(row.segment_families);
|
return `${app.viewerUrl.replace(/\/$/, "")}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`;
|
||||||
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) {
|
function updateOpenDicomButton() {
|
||||||
const groups = groupSegments(row);
|
const button = $("openDicomBtn");
|
||||||
const count = asList(row.segment_names).length;
|
const url = dicomViewerUrl();
|
||||||
$("segmentCount").textContent = `${count} 个`;
|
button.disabled = !url;
|
||||||
if (!count) {
|
button.title = url || "当前记录没有 DICOM 检查号";
|
||||||
$("segmentGroups").innerHTML = `<p class="empty">没有 STL 分割文件</p>`;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
$("segmentGroups").innerHTML = Array.from(groups.entries())
|
|
||||||
.map(
|
function clearDetail() {
|
||||||
([group, names]) => `
|
app.active = null;
|
||||||
<div class="segment-group">
|
$("activeCt").textContent = "未选择 CT";
|
||||||
<strong>${escapeHtml(group)}</strong>
|
$("activeSubtitle").textContent = "从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系";
|
||||||
<div class="segment-tags">${names.map((name) => `<em title="${escapeHtml(name)}">${escapeHtml(name)}</em>`).join("")}</div>
|
$("relationBadge").className = "relation-badge idle";
|
||||||
</div>
|
$("relationBadge").textContent = "等待选择";
|
||||||
`,
|
markNode("nodePacs", "missing", "未选择", "检查与序列");
|
||||||
)
|
$("nodeCtValue").textContent = "-";
|
||||||
.join("");
|
markNode("nodeStl", "missing", "未选择", "含UPP列表信息");
|
||||||
|
renderDl("pacsDetails", []);
|
||||||
|
renderDl("stlDetails", []);
|
||||||
|
updateOpenDicomButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(row) {
|
function renderDetail(row) {
|
||||||
app.active = row;
|
app.active = row;
|
||||||
$("activeCt").textContent = row.ct_key || "未选择 CT";
|
$("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");
|
const badge = $("relationBadge");
|
||||||
badge.className = `relation-badge ${row.relation_status || "idle"}`;
|
badge.className = `relation-badge ${row.relation_status || "idle"}`;
|
||||||
badge.textContent = statusLabel(row.relation_status);
|
badge.textContent = statusLabel(row.relation_status);
|
||||||
|
|
||||||
|
const pending = pendingDICOM(row);
|
||||||
$("nodeCtValue").textContent = row.ct_key || "-";
|
$("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(
|
||||||
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`);
|
"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", [
|
renderDl("pacsDetails", [
|
||||||
["CT号", row.pacs_ct_number],
|
["CT号", row.pacs_ct_number],
|
||||||
@@ -227,10 +289,9 @@ function renderDetail(row) {
|
|||||||
["检查时间", `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}`.trim()],
|
["检查时间", `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}`.trim()],
|
||||||
["描述", row.study_description],
|
["描述", row.study_description],
|
||||||
["序列/文件", `${Number(row.pacs_series_count || 0)} / ${Number(row.dicom_file_count || 0)}`],
|
["序列/文件", `${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 ? "已完成" : "待处理"],
|
["完成", row.completed ? "已完成" : "待处理"],
|
||||||
["部位", asList(row.body_parts).map(partLabel).join("、")],
|
["部位标注", dicomParts(row).join("、")],
|
||||||
["DICOM模型", asList(row.dicom_models).join("、")],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderDl("stlDetails", [
|
renderDl("stlDetails", [
|
||||||
@@ -242,16 +303,15 @@ function renderDetail(row) {
|
|||||||
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
|
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
|
||||||
["检查时间", fmtDate(row.exam_date)],
|
["检查时间", fmtDate(row.exam_date)],
|
||||||
["任务时间", fmtDate(row.task_created_at)],
|
["任务时间", 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)],
|
["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)],
|
||||||
["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)],
|
["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)],
|
||||||
["目录", row.processed_stl_dir],
|
["目录", row.processed_stl_dir],
|
||||||
["分类", uniq(asList(row.segment_categories)).join("、")],
|
["分割分类", uniq(asList(row.segment_categories)).join("、")],
|
||||||
["Family", uniq(asList(row.segment_families)).join("、")],
|
|
||||||
["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)],
|
["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderSegments(row);
|
updateOpenDicomButton();
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,13 +332,14 @@ async function loadRelations() {
|
|||||||
params.set("limit", "500");
|
params.set("limit", "500");
|
||||||
if (app.search) params.set("q", app.search);
|
if (app.search) params.set("q", app.search);
|
||||||
if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel);
|
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()}`);
|
app.rows = await json(`/api/relations?${params.toString()}`);
|
||||||
if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) {
|
if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) {
|
||||||
await selectRelation(app.rows[0].ct_key);
|
await selectRelation(app.rows[0].ct_key);
|
||||||
} else {
|
} else {
|
||||||
renderList();
|
renderList();
|
||||||
if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key);
|
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() {
|
function wire() {
|
||||||
|
$("loginForm").addEventListener("submit", login);
|
||||||
|
$("logoutBtn").addEventListener("click", logout);
|
||||||
|
$("settingsBtn").addEventListener("click", showSettings);
|
||||||
|
$("backToViewer").addEventListener("click", showViewer);
|
||||||
$("refreshBtn").addEventListener("click", refreshAll);
|
$("refreshBtn").addEventListener("click", refreshAll);
|
||||||
|
$("openDicomBtn").addEventListener("click", openDicomViewer);
|
||||||
$("searchInput").addEventListener("input", () => {
|
$("searchInput").addEventListener("input", () => {
|
||||||
clearTimeout(app.searchTimer);
|
clearTimeout(app.searchTimer);
|
||||||
app.searchTimer = setTimeout(() => {
|
app.searchTimer = setTimeout(() => {
|
||||||
@@ -305,8 +532,8 @@ function wire() {
|
|||||||
app.algorithmModel = $("algorithmFilter").value;
|
app.algorithmModel = $("algorithmFilter").value;
|
||||||
loadRelations();
|
loadRelations();
|
||||||
});
|
});
|
||||||
$("dicomModelFilter").addEventListener("change", () => {
|
$("dicomPartFilter").addEventListener("change", () => {
|
||||||
app.dicomModel = $("dicomModelFilter").value;
|
app.dicomPart = $("dicomPartFilter").value;
|
||||||
loadRelations();
|
loadRelations();
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-filter]").forEach((button) => {
|
document.querySelectorAll("[data-filter]").forEach((button) => {
|
||||||
@@ -318,5 +545,12 @@ function wire() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
clearDetail();
|
||||||
|
applyAuthUi();
|
||||||
wire();
|
wire();
|
||||||
refreshAll();
|
const ok = await loadCurrentUser();
|
||||||
|
if (ok) await refreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -11,21 +11,24 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark"></span>
|
<span class="brand-mark"></span>
|
||||||
<div>
|
<div>
|
||||||
<h1>PACS / STL 数据库关联可视化</h1>
|
<h1>DICOM / UPP 数据库关联可视化</h1>
|
||||||
<p>以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产</p>
|
<p>以 CT 号精确关联 DICOM 阅片分类与 UPP STL 重建资产</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span id="dbStatus" class="status-pill">数据库</span>
|
<span id="dbStatus" class="status-pill">数据库</span>
|
||||||
<button id="refreshBtn" class="dark-btn" type="button">刷新</button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout">
|
<main id="viewerPage" class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<section class="panel search-panel">
|
<section class="panel search-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>CT 索引</h2>
|
<h2>关联列表</h2>
|
||||||
<span id="resultCount">0</span>
|
<span id="resultCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述 / 模型" />
|
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述 / 模型" />
|
||||||
@@ -36,21 +39,23 @@
|
|||||||
<option value="泌尿模型">泌尿模型</option>
|
<option value="泌尿模型">泌尿模型</option>
|
||||||
<option value="胸外模型">胸外模型</option>
|
<option value="胸外模型">胸外模型</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="dicomModelFilter">
|
<select id="dicomPartFilter">
|
||||||
<option value="">全部DICOM模型</option>
|
<option value="">全部部位标注</option>
|
||||||
<option value="肝胆模型">肝胆模型</option>
|
<option value="head_neck">头颈部</option>
|
||||||
<option value="泌尿模型">泌尿模型</option>
|
<option value="chest">胸部</option>
|
||||||
<option value="胸外模型">胸外模型</option>
|
<option value="upper_abdomen">上腹部</option>
|
||||||
|
<option value="lower_abdomen">下腹部</option>
|
||||||
|
<option value="pelvis">盆腔</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<button class="active" data-filter="all">全部</button>
|
<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="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="stl_only">仅 STL</button>
|
||||||
<button data-filter="list_only">列表无STL</button>
|
<button data-filter="list_only">列表无STL</button>
|
||||||
<button data-filter="undetermined">待判别</button>
|
<button data-filter="pending_dicom">待处理DICOM</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -66,14 +71,14 @@
|
|||||||
<div class="hero-head">
|
<div class="hero-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="activeCt">未选择 CT</h2>
|
<h2 id="activeCt">未选择 CT</h2>
|
||||||
<p id="activeSubtitle">从左侧选择一个 CT 号查看 PACS 与 STL 之间的关系</p>
|
<p id="activeSubtitle">从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系</p>
|
||||||
</div>
|
</div>
|
||||||
<span id="relationBadge" class="relation-badge idle">等待选择</span>
|
<span id="relationBadge" class="relation-badge idle">等待选择</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="link-map">
|
<div class="link-map">
|
||||||
<div id="nodePacs" class="link-node">
|
<div id="nodePacs" class="link-node">
|
||||||
<span>PACS DICOM</span>
|
<span>DICOM 阅片分类系统</span>
|
||||||
<strong>未选择</strong>
|
<strong>未选择</strong>
|
||||||
<em id="nodePacsMeta">检查与序列</em>
|
<em id="nodePacsMeta">检查与序列</em>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,11 +86,11 @@
|
|||||||
<div id="nodeCt" class="link-node ct-node">
|
<div id="nodeCt" class="link-node ct-node">
|
||||||
<span>CT 号</span>
|
<span>CT 号</span>
|
||||||
<strong id="nodeCtValue">-</strong>
|
<strong id="nodeCtValue">-</strong>
|
||||||
<em>规范索引</em>
|
<em>严格一致匹配</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-line"></div>
|
<div class="link-line"></div>
|
||||||
<div id="nodeStl" class="link-node">
|
<div id="nodeStl" class="link-node">
|
||||||
<span>UPP STL 资产</span>
|
<span>UPP 重建资产</span>
|
||||||
<strong>未选择</strong>
|
<strong>未选择</strong>
|
||||||
<em id="nodeStlMeta">含UPP列表信息</em>
|
<em id="nodeStlMeta">含UPP列表信息</em>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +99,10 @@
|
|||||||
|
|
||||||
<section class="detail-grid">
|
<section class="detail-grid">
|
||||||
<article class="panel detail-card">
|
<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>
|
<dl id="pacsDetails"></dl>
|
||||||
</article>
|
</article>
|
||||||
<article class="panel detail-card">
|
<article class="panel detail-card">
|
||||||
@@ -102,17 +110,34 @@
|
|||||||
<dl id="stlDetails"></dl>
|
<dl id="stlDetails"></dl>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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>
|
</section>
|
||||||
</main>
|
</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>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,19 @@ button {
|
|||||||
gap: 10px;
|
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,
|
.status-pill,
|
||||||
.relation-badge {
|
.relation-badge {
|
||||||
min-width: 82px;
|
min-width: 82px;
|
||||||
@@ -134,6 +148,30 @@ button {
|
|||||||
color: var(--text);
|
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,
|
.dark-btn:hover,
|
||||||
.filter-grid button:hover {
|
.filter-grid button:hover {
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
@@ -160,7 +198,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -337,9 +375,35 @@ button {
|
|||||||
color: #fecdd3;
|
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 {
|
.metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(116px, 1fr));
|
grid-template-columns: repeat(5, minmax(116px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,11 +495,24 @@ button {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card h3 {
|
.detail-card h3,
|
||||||
|
.detail-title h3 {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 15px;
|
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 {
|
dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 96px minmax(0, 1fr);
|
grid-template-columns: 96px minmax(0, 1fr);
|
||||||
@@ -513,6 +590,176 @@ dd {
|
|||||||
text-align: center;
|
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) {
|
@media (max-width: 1180px) {
|
||||||
body {
|
body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -525,10 +772,15 @@ dd {
|
|||||||
|
|
||||||
.metrics,
|
.metrics,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
|
.settings-section.split,
|
||||||
.link-map {
|
.link-map {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.link-line {
|
.link-line {
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
Reference in New Issue
Block a user