Use exact CT links for PACS STL view
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# PACS / UPP 数据库关联可视化
|
# PACS / STL 数据库关联可视化
|
||||||
|
|
||||||
本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查、UPP 列表资产和 UPP STL 文件聚合表放在同一视图中查看。
|
本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查与 UPP STL 重建资产放在同一视图中查看。UPP 列表字段作为 STL 资产的补充信息展示,不作为独立系统节点。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
- `pacs_dicom_files`:PACS DICOM 检查索引。
|
- `pacs_dicom_files`:PACS DICOM 检查索引。
|
||||||
- `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。
|
- `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。
|
||||||
- `upp_exam_assets`:UPP 列表与 STL 资产主索引。
|
- `upp_exam_assets`:UPP 列表与 STL 资产主索引,为 STL 资产提供患者、任务、算法模型、状态等补充信息。
|
||||||
- `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。
|
- `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。
|
||||||
|
|
||||||
CT 号关联时会优先展示原始 CT 号,同时使用去掉开头 `D` 的规范 CT 号进行跨系统匹配。
|
CT 号关联使用严格一致匹配,`DCT...` 与 `CT...` 会被视为两个不同 CT 号。
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def sql_literal(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_ct(value: str) -> str:
|
def normalize_ct(value: str) -> str:
|
||||||
return re.sub(r"^D", "", re.sub(r"\s+", "", str(value or "").upper()))
|
return re.sub(r"\s+", "", str(value or "").upper())
|
||||||
|
|
||||||
|
|
||||||
def db_available() -> tuple[bool, str]:
|
def db_available() -> tuple[bool, str]:
|
||||||
@@ -92,7 +92,7 @@ def relation_cte() -> str:
|
|||||||
p AS (
|
p AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
regexp_replace(upper(p.ct_number), '^D', '') AS norm_ct
|
upper(p.ct_number) AS ct_key
|
||||||
FROM public.{PACS_TABLE_SQL} p
|
FROM public.{PACS_TABLE_SQL} p
|
||||||
),
|
),
|
||||||
ps AS (
|
ps AS (
|
||||||
@@ -102,49 +102,51 @@ def relation_cte() -> str:
|
|||||||
u_raw AS (
|
u_raw AS (
|
||||||
SELECT
|
SELECT
|
||||||
u.*,
|
u.*,
|
||||||
regexp_replace(upper(u.ct_number), '^D', '') AS norm_ct
|
upper(u.ct_number) AS ct_key
|
||||||
FROM public.{UPP_ASSET_TABLE_SQL} u
|
FROM public.{UPP_ASSET_TABLE_SQL} u
|
||||||
),
|
),
|
||||||
u AS (
|
u AS (
|
||||||
SELECT DISTINCT ON (norm_ct) *
|
SELECT DISTINCT ON (ct_key) *
|
||||||
FROM u_raw
|
FROM u_raw
|
||||||
ORDER BY norm_ct, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST
|
ORDER BY ct_key, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST
|
||||||
),
|
),
|
||||||
s_raw AS (
|
s_raw AS (
|
||||||
SELECT
|
SELECT
|
||||||
s.*,
|
s.*,
|
||||||
regexp_replace(upper(s.ct_number), '^D', '') AS norm_ct
|
upper(s.ct_number) AS ct_key
|
||||||
FROM public.{UPP_STL_TABLE_SQL} s
|
FROM public.{UPP_STL_TABLE_SQL} s
|
||||||
),
|
),
|
||||||
s AS (
|
s AS (
|
||||||
SELECT DISTINCT ON (norm_ct) *
|
SELECT DISTINCT ON (ct_key) *
|
||||||
FROM s_raw
|
FROM s_raw
|
||||||
ORDER BY norm_ct, file_count DESC, updated_at DESC NULLS LAST
|
ORDER BY ct_key, file_count DESC, updated_at DESC NULLS LAST
|
||||||
),
|
),
|
||||||
keys AS (
|
keys AS (
|
||||||
SELECT norm_ct FROM p
|
SELECT ct_key FROM p
|
||||||
UNION
|
UNION
|
||||||
SELECT norm_ct FROM u
|
SELECT ct_key FROM u
|
||||||
),
|
),
|
||||||
relation AS (
|
relation AS (
|
||||||
SELECT
|
SELECT
|
||||||
k.norm_ct,
|
k.ct_key,
|
||||||
p.ct_number AS pacs_ct_number,
|
p.ct_number AS pacs_ct_number,
|
||||||
|
u.ct_number AS stl_ct_number,
|
||||||
u.ct_number AS upp_ct_number,
|
u.ct_number AS upp_ct_number,
|
||||||
s.ct_number AS stl_ct_number,
|
s.ct_number AS stl_file_ct_number,
|
||||||
p.ct_number IS NOT NULL AS pacs_present,
|
p.ct_number IS NOT NULL AS pacs_present,
|
||||||
|
u.ct_number IS NOT NULL AS stl_asset_present,
|
||||||
u.ct_number IS NOT NULL AS upp_present,
|
u.ct_number IS NOT NULL AS upp_present,
|
||||||
COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present,
|
COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present,
|
||||||
CASE
|
CASE
|
||||||
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'complete'
|
WHEN p.ct_number IS NOT NULL AND (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'complete'
|
||||||
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'no_stl'
|
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'no_stl'
|
||||||
WHEN p.ct_number IS NOT NULL THEN 'pacs_only'
|
WHEN p.ct_number IS NOT NULL THEN 'pacs_only'
|
||||||
WHEN u.ct_number IS NOT NULL THEN 'upp_only'
|
WHEN (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'stl_only'
|
||||||
|
WHEN u.ct_number IS NOT NULL THEN 'list_only'
|
||||||
ELSE 'unknown'
|
ELSE 'unknown'
|
||||||
END AS relation_status,
|
END AS relation_status,
|
||||||
CASE
|
CASE
|
||||||
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND p.ct_number = u.ct_number THEN 'exact'
|
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND p.ct_number = u.ct_number THEN 'exact'
|
||||||
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'normalized'
|
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END AS match_type,
|
END AS match_type,
|
||||||
p.batch_name,
|
p.batch_name,
|
||||||
@@ -165,6 +167,13 @@ def relation_cte() -> str:
|
|||||||
ps.undetermined_series,
|
ps.undetermined_series,
|
||||||
ps.completed,
|
ps.completed,
|
||||||
ps.body_parts,
|
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,
|
||||||
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,
|
||||||
@@ -200,15 +209,15 @@ def relation_cte() -> str:
|
|||||||
ELSE 'different'
|
ELSE 'different'
|
||||||
END AS patient_name_match
|
END AS patient_name_match
|
||||||
FROM keys k
|
FROM keys k
|
||||||
LEFT JOIN p ON p.norm_ct = k.norm_ct
|
LEFT JOIN p ON p.ct_key = k.ct_key
|
||||||
LEFT JOIN ps ON ps.ct_number = p.ct_number
|
LEFT JOIN ps ON ps.ct_number = p.ct_number
|
||||||
LEFT JOIN u ON u.norm_ct = k.norm_ct
|
LEFT JOIN u ON u.ct_key = k.ct_key
|
||||||
LEFT JOIN s ON s.norm_ct = k.norm_ct
|
LEFT JOIN s ON s.ct_key = k.ct_key
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def relation_where(q: str, status: str) -> str:
|
def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) -> str:
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
query = q.strip()
|
query = q.strip()
|
||||||
if query:
|
if query:
|
||||||
@@ -217,12 +226,14 @@ def relation_where(q: str, status: str) -> str:
|
|||||||
clauses.append(
|
clauses.append(
|
||||||
" OR ".join(
|
" OR ".join(
|
||||||
[
|
[
|
||||||
f"norm_ct ILIKE {literal}",
|
f"ct_key ILIKE {literal}",
|
||||||
f"pacs_ct_number ILIKE {literal}",
|
f"pacs_ct_number ILIKE {literal}",
|
||||||
f"upp_ct_number ILIKE {literal}",
|
f"stl_ct_number ILIKE {literal}",
|
||||||
f"pacs_patient_name ILIKE {literal}",
|
f"pacs_patient_name ILIKE {literal}",
|
||||||
f"upp_patient_name ILIKE {literal}",
|
f"upp_patient_name ILIKE {literal}",
|
||||||
f"patient_id ILIKE {literal}",
|
f"patient_id ILIKE {literal}",
|
||||||
|
f"algorithm_model 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}",
|
||||||
]
|
]
|
||||||
@@ -234,14 +245,18 @@ def relation_where(q: str, status: str) -> str:
|
|||||||
clauses.append("relation_status = 'no_stl'")
|
clauses.append("relation_status = 'no_stl'")
|
||||||
elif status == "pacs_only":
|
elif status == "pacs_only":
|
||||||
clauses.append("relation_status = 'pacs_only'")
|
clauses.append("relation_status = 'pacs_only'")
|
||||||
elif status == "upp_only":
|
elif status == "stl_only":
|
||||||
clauses.append("relation_status = 'upp_only'")
|
clauses.append("relation_status = 'stl_only'")
|
||||||
elif status == "normalized":
|
elif status == "list_only":
|
||||||
clauses.append("match_type = 'normalized'")
|
clauses.append("relation_status = 'list_only'")
|
||||||
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:
|
||||||
|
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
|
||||||
|
if dicom_model:
|
||||||
|
clauses.append(f"dicom_models ? {sql_literal(dicom_model)}")
|
||||||
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
|
||||||
|
|
||||||
@@ -266,13 +281,14 @@ def status() -> dict[str, Any]:
|
|||||||
SELECT
|
SELECT
|
||||||
count(*)::int AS total_ct,
|
count(*)::int AS total_ct,
|
||||||
count(*) FILTER (WHERE pacs_present)::int AS pacs_count,
|
count(*) FILTER (WHERE pacs_present)::int AS pacs_count,
|
||||||
count(*) FILTER (WHERE upp_present)::int AS upp_count,
|
count(*) FILTER (WHERE stl_asset_present)::int AS stl_asset_count,
|
||||||
|
count(*) FILTER (WHERE list_present)::int AS list_count,
|
||||||
count(*) FILTER (WHERE stl_present)::int AS stl_count,
|
count(*) FILTER (WHERE stl_present)::int AS stl_count,
|
||||||
count(*) FILTER (WHERE relation_status = 'complete')::int AS complete_count,
|
count(*) FILTER (WHERE relation_status = 'complete')::int AS complete_count,
|
||||||
count(*) FILTER (WHERE relation_status = 'no_stl')::int AS no_stl_count,
|
count(*) FILTER (WHERE relation_status = 'no_stl')::int AS no_stl_count,
|
||||||
count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count,
|
count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count,
|
||||||
count(*) FILTER (WHERE relation_status = 'upp_only')::int AS upp_only_count,
|
count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_only_count,
|
||||||
count(*) FILTER (WHERE match_type = 'normalized')::int AS normalized_match_count,
|
count(*) FILTER (WHERE relation_status = 'list_only')::int AS list_only_count,
|
||||||
COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series
|
COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series
|
||||||
FROM relation
|
FROM relation
|
||||||
""",
|
""",
|
||||||
@@ -292,20 +308,26 @@ def status() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/relations")
|
@app.get("/api/relations")
|
||||||
def relations(q: str = "", status: str = "all", limit: int = Query(default=300, ge=1, le=1000)) -> list[dict[str, Any]]:
|
def relations(
|
||||||
where = relation_where(q, status)
|
q: str = "",
|
||||||
|
status: str = "all",
|
||||||
|
algorithm_model: str = "",
|
||||||
|
dicom_model: str = "",
|
||||||
|
limit: int = Query(default=300, ge=1, le=1000),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
where = relation_where(q, status, algorithm_model, dicom_model)
|
||||||
return pg_json_rows(
|
return pg_json_rows(
|
||||||
f"""
|
f"""
|
||||||
{relation_cte()}
|
{relation_cte()}
|
||||||
SELECT
|
SELECT
|
||||||
norm_ct, pacs_ct_number, upp_ct_number, stl_ct_number,
|
ct_key, pacs_ct_number, stl_ct_number,
|
||||||
pacs_present, upp_present, stl_present, relation_status, match_type,
|
pacs_present, stl_asset_present, list_present, stl_present, relation_status, match_type,
|
||||||
pacs_patient_name, upp_patient_name, patient_name_match, patient_id, patient_id_masked,
|
pacs_patient_name, upp_patient_name, patient_name_match, patient_id, patient_id_masked,
|
||||||
study_date, study_time, exam_date, task_created_at,
|
study_date, study_time, exam_date, task_created_at,
|
||||||
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, segment_categories, segment_families,
|
body_parts, dicom_models, 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}
|
||||||
@@ -314,7 +336,7 @@ def relations(q: str = "", status: str = "all", limit: int = Query(default=300,
|
|||||||
relation_status,
|
relation_status,
|
||||||
COALESCE(study_date, '') DESC,
|
COALESCE(study_date, '') DESC,
|
||||||
COALESCE(study_time, '') DESC,
|
COALESCE(study_time, '') DESC,
|
||||||
norm_ct
|
ct_key
|
||||||
LIMIT {int(limit)}
|
LIMIT {int(limit)}
|
||||||
""",
|
""",
|
||||||
timeout=24,
|
timeout=24,
|
||||||
@@ -323,15 +345,15 @@ def relations(q: str = "", status: str = "all", limit: int = Query(default=300,
|
|||||||
|
|
||||||
@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) -> dict[str, Any]:
|
||||||
norm_ct = normalize_ct(ct_number)
|
ct_key = normalize_ct(ct_number)
|
||||||
if not norm_ct:
|
if not ct_key:
|
||||||
raise HTTPException(status_code=400, detail="invalid CT number")
|
raise HTTPException(status_code=400, detail="invalid CT number")
|
||||||
rows = pg_json_rows(
|
rows = pg_json_rows(
|
||||||
f"""
|
f"""
|
||||||
{relation_cte()}
|
{relation_cte()}
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM relation
|
FROM relation
|
||||||
WHERE norm_ct = {sql_literal(norm_ct)}
|
WHERE ct_key = {sql_literal(ct_key)}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
timeout=24,
|
timeout=24,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const app = {
|
|||||||
active: null,
|
active: null,
|
||||||
filter: "all",
|
filter: "all",
|
||||||
search: "",
|
search: "",
|
||||||
|
algorithmModel: "",
|
||||||
|
dicomModel: "",
|
||||||
searchTimer: null,
|
searchTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,16 +65,17 @@ function uniq(list) {
|
|||||||
|
|
||||||
function statusLabel(value) {
|
function statusLabel(value) {
|
||||||
return {
|
return {
|
||||||
complete: "PACS + UPP + STL",
|
complete: "PACS + STL",
|
||||||
no_stl: "PACS + UPP",
|
no_stl: "PACS + 列表",
|
||||||
pacs_only: "仅 PACS",
|
pacs_only: "仅 PACS",
|
||||||
upp_only: "仅 UPP",
|
stl_only: "仅 STL",
|
||||||
|
list_only: "列表无STL",
|
||||||
unknown: "未知",
|
unknown: "未知",
|
||||||
}[value] || value || "未知";
|
}[value] || value || "未知";
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchLabel(value) {
|
function matchLabel(value) {
|
||||||
return { exact: "CT 号精确一致", normalized: "去 D 前缀匹配" }[value] || "未匹配";
|
return { exact: "CT 号一致" }[value] || "未匹配";
|
||||||
}
|
}
|
||||||
|
|
||||||
function partLabel(value) {
|
function partLabel(value) {
|
||||||
@@ -100,7 +103,7 @@ function renderMetrics(counts = {}) {
|
|||||||
const items = [
|
const items = [
|
||||||
["CT 索引", counts.total_ct],
|
["CT 索引", counts.total_ct],
|
||||||
["PACS", counts.pacs_count],
|
["PACS", counts.pacs_count],
|
||||||
["UPP", counts.upp_count],
|
["STL资产", counts.stl_asset_count],
|
||||||
["STL", counts.stl_count],
|
["STL", counts.stl_count],
|
||||||
["完整关联", counts.complete_count],
|
["完整关联", counts.complete_count],
|
||||||
["待判别序列", counts.undetermined_series],
|
["待判别序列", counts.undetermined_series],
|
||||||
@@ -112,12 +115,14 @@ function renderMetrics(counts = {}) {
|
|||||||
|
|
||||||
function cardTitle(row) {
|
function cardTitle(row) {
|
||||||
return [
|
return [
|
||||||
`规范CT号:${row.norm_ct}`,
|
`CT号:${row.ct_key}`,
|
||||||
`PACS:${row.pacs_ct_number || "无"}`,
|
`PACS:${row.pacs_ct_number || "无"}`,
|
||||||
`UPP:${row.upp_ct_number || "无"}`,
|
`STL:${row.stl_ct_number || "无"}`,
|
||||||
`状态:${statusLabel(row.relation_status)}`,
|
`状态:${statusLabel(row.relation_status)}`,
|
||||||
`PACS患者:${row.pacs_patient_name || "无"}`,
|
`PACS患者:${row.pacs_patient_name || "无"}`,
|
||||||
`UPP患者:${row.upp_patient_name || "无"}`,
|
`STL列表患者:${row.upp_patient_name || "无"}`,
|
||||||
|
`重建模型:${row.algorithm_model || "无"}`,
|
||||||
|
`DICOM模型:${asList(row.dicom_models).join("、") || "无"}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,20 +137,22 @@ function renderList() {
|
|||||||
for (const row of app.rows) {
|
for (const row of app.rows) {
|
||||||
const card = document.createElement("button");
|
const card = document.createElement("button");
|
||||||
card.className = "relation-card";
|
card.className = "relation-card";
|
||||||
card.classList.toggle("active", app.active?.norm_ct === row.norm_ct);
|
card.classList.toggle("active", app.active?.ct_key === row.ct_key);
|
||||||
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("、") || "无";
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-row">
|
<div class="card-row">
|
||||||
<strong>${escapeHtml(row.norm_ct)}</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 || "无时间")} · 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(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
|
||||||
`;
|
`;
|
||||||
card.onclick = () => selectRelation(row.norm_ct);
|
card.onclick = () => selectRelation(row.ct_key);
|
||||||
list.appendChild(card);
|
list.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,16 +210,15 @@ function renderSegments(row) {
|
|||||||
|
|
||||||
function renderDetail(row) {
|
function renderDetail(row) {
|
||||||
app.active = row;
|
app.active = row;
|
||||||
$("activeCt").textContent = row.norm_ct || "未选择 CT";
|
$("activeCt").textContent = row.ct_key || "未选择 CT";
|
||||||
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.upp_ct_number || "无 UPP"} · ${matchLabel(row.match_type)}`;
|
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
|
||||||
const badge = $("relationBadge");
|
const badge = $("relationBadge");
|
||||||
badge.className = `relation-badge ${row.relation_status || "idle"} ${row.match_type === "normalized" ? "normalized" : ""}`;
|
badge.className = `relation-badge ${row.relation_status || "idle"}`;
|
||||||
badge.textContent = statusLabel(row.relation_status);
|
badge.textContent = statusLabel(row.relation_status);
|
||||||
|
|
||||||
$("nodeCtValue").textContent = row.norm_ct || "-";
|
$("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("nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列`);
|
||||||
markNode("nodeUpp", row.upp_present ? "ok" : "missing", row.upp_ct_number || "缺失", row.upp_status || row.algorithm_model || "无任务状态");
|
markNode("nodeStl", row.stl_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("nodeStl", row.stl_present ? "ok" : "warn", row.stl_present ? "已生成" : "未生成", `${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL · ${fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)}`);
|
|
||||||
|
|
||||||
renderDl("pacsDetails", [
|
renderDl("pacsDetails", [
|
||||||
["CT号", row.pacs_ct_number],
|
["CT号", row.pacs_ct_number],
|
||||||
@@ -224,22 +230,19 @@ function renderDetail(row) {
|
|||||||
["标注", `${Number(row.annotated_series || 0)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`],
|
["标注", `${Number(row.annotated_series || 0)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`],
|
||||||
["完成", row.completed ? "已完成" : "待处理"],
|
["完成", row.completed ? "已完成" : "待处理"],
|
||||||
["部位", asList(row.body_parts).map(partLabel).join("、")],
|
["部位", asList(row.body_parts).map(partLabel).join("、")],
|
||||||
|
["DICOM模型", asList(row.dicom_models).join("、")],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderDl("uppDetails", [
|
renderDl("stlDetails", [
|
||||||
["CT号", row.upp_ct_number],
|
["CT号", row.stl_ct_number],
|
||||||
|
["STL状态", row.stl_present ? "存在" : "缺失"],
|
||||||
|
["重建模型", row.algorithm_model],
|
||||||
|
["UPP状态", row.upp_status],
|
||||||
["患者", row.upp_patient_name],
|
["患者", row.upp_patient_name],
|
||||||
["年龄/性别", [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.algorithm_model],
|
|
||||||
["状态", row.upp_status],
|
|
||||||
["列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"],
|
["列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"],
|
||||||
["患者名", row.patient_name_match === "same" ? "两侧一致" : row.patient_name_match === "different" ? "两侧不同" : "无法判断"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
renderDl("stlDetails", [
|
|
||||||
["STL状态", row.stl_present ? "存在" : "缺失"],
|
|
||||||
["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)],
|
["文件数", 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],
|
||||||
@@ -252,8 +255,8 @@ function renderDetail(row) {
|
|||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectRelation(normCt) {
|
async function selectRelation(ctKey) {
|
||||||
const row = await json(`/api/relations/${encodeURIComponent(normCt)}`);
|
const row = await json(`/api/relations/${encodeURIComponent(ctKey)}`);
|
||||||
renderDetail(row);
|
renderDetail(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,12 +271,14 @@ async function loadRelations() {
|
|||||||
params.set("status", app.filter);
|
params.set("status", app.filter);
|
||||||
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.dicomModel) params.set("dicom_model", app.dicomModel);
|
||||||
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.norm_ct === app.active?.norm_ct)) {
|
if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) {
|
||||||
await selectRelation(app.rows[0].norm_ct);
|
await selectRelation(app.rows[0].ct_key);
|
||||||
} else {
|
} else {
|
||||||
renderList();
|
renderList();
|
||||||
if (!app.active && app.rows.length) await selectRelation(app.rows[0].norm_ct);
|
if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +301,14 @@ function wire() {
|
|||||||
loadRelations();
|
loadRelations();
|
||||||
}, 250);
|
}, 250);
|
||||||
});
|
});
|
||||||
|
$("algorithmFilter").addEventListener("change", () => {
|
||||||
|
app.algorithmModel = $("algorithmFilter").value;
|
||||||
|
loadRelations();
|
||||||
|
});
|
||||||
|
$("dicomModelFilter").addEventListener("change", () => {
|
||||||
|
app.dicomModel = $("dicomModelFilter").value;
|
||||||
|
loadRelations();
|
||||||
|
});
|
||||||
document.querySelectorAll("[data-filter]").forEach((button) => {
|
document.querySelectorAll("[data-filter]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
app.filter = button.dataset.filter;
|
app.filter = button.dataset.filter;
|
||||||
|
|||||||
@@ -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 / UPP 数据库关联可视化</title>
|
<title>PACS / STL 数据库关联可视化</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark"></span>
|
<span class="brand-mark"></span>
|
||||||
<div>
|
<div>
|
||||||
<h1>PACS / UPP 数据库关联可视化</h1>
|
<h1>PACS / STL 数据库关联可视化</h1>
|
||||||
<p>以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产</p>
|
<p>以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
@@ -28,14 +28,28 @@
|
|||||||
<h2>CT 索引</h2>
|
<h2>CT 索引</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 / 描述 / 模型" />
|
||||||
|
<div class="model-filters">
|
||||||
|
<select id="algorithmFilter">
|
||||||
|
<option value="">全部重建模型</option>
|
||||||
|
<option value="肝胆模型">肝胆模型</option>
|
||||||
|
<option value="泌尿模型">泌尿模型</option>
|
||||||
|
<option value="胸外模型">胸外模型</option>
|
||||||
|
</select>
|
||||||
|
<select id="dicomModelFilter">
|
||||||
|
<option value="">全部DICOM模型</option>
|
||||||
|
<option value="肝胆模型">肝胆模型</option>
|
||||||
|
<option value="泌尿模型">泌尿模型</option>
|
||||||
|
<option value="胸外模型">胸外模型</option>
|
||||||
|
</select>
|
||||||
|
</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">仅 PACS</button>
|
||||||
<button data-filter="upp_only">仅 UPP</button>
|
<button data-filter="stl_only">仅 STL</button>
|
||||||
<button data-filter="normalized">D 前缀</button>
|
<button data-filter="list_only">列表无STL</button>
|
||||||
<button data-filter="undetermined">待判别</button>
|
<button data-filter="undetermined">待判别</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -52,7 +66,7 @@
|
|||||||
<div class="hero-head">
|
<div class="hero-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="activeCt">未选择 CT</h2>
|
<h2 id="activeCt">未选择 CT</h2>
|
||||||
<p id="activeSubtitle">从左侧选择一个 CT 号查看两个系统之间的关系</p>
|
<p id="activeSubtitle">从左侧选择一个 CT 号查看 PACS 与 STL 之间的关系</p>
|
||||||
</div>
|
</div>
|
||||||
<span id="relationBadge" class="relation-badge idle">等待选择</span>
|
<span id="relationBadge" class="relation-badge idle">等待选择</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,16 +84,10 @@
|
|||||||
<em>规范索引</em>
|
<em>规范索引</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-line"></div>
|
<div class="link-line"></div>
|
||||||
<div id="nodeUpp" class="link-node">
|
|
||||||
<span>UPP 列表</span>
|
|
||||||
<strong>未选择</strong>
|
|
||||||
<em id="nodeUppMeta">任务与状态</em>
|
|
||||||
</div>
|
|
||||||
<div class="link-line"></div>
|
|
||||||
<div id="nodeStl" class="link-node">
|
<div id="nodeStl" class="link-node">
|
||||||
<span>STL 资产</span>
|
<span>UPP STL 资产</span>
|
||||||
<strong>未选择</strong>
|
<strong>未选择</strong>
|
||||||
<em id="nodeStlMeta">分割文件</em>
|
<em id="nodeStlMeta">含UPP列表信息</em>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -89,10 +97,6 @@
|
|||||||
<h3>PACS DICOM</h3>
|
<h3>PACS DICOM</h3>
|
||||||
<dl id="pacsDetails"></dl>
|
<dl id="pacsDetails"></dl>
|
||||||
</article>
|
</article>
|
||||||
<article class="panel detail-card">
|
|
||||||
<h3>UPP 列表</h3>
|
|
||||||
<dl id="uppDetails"></dl>
|
|
||||||
</article>
|
|
||||||
<article class="panel detail-card">
|
<article class="panel detail-card">
|
||||||
<h3>UPP STL</h3>
|
<h3>UPP STL</h3>
|
||||||
<dl id="stlDetails"></dl>
|
<dl id="stlDetails"></dl>
|
||||||
|
|||||||
@@ -112,14 +112,14 @@ button {
|
|||||||
|
|
||||||
.status-pill.offline,
|
.status-pill.offline,
|
||||||
.relation-badge.pacs_only,
|
.relation-badge.pacs_only,
|
||||||
.relation-badge.upp_only {
|
.relation-badge.stl_only,
|
||||||
|
.relation-badge.list_only {
|
||||||
border-color: rgba(251, 113, 133, 0.46);
|
border-color: rgba(251, 113, 133, 0.46);
|
||||||
color: #fecdd3;
|
color: #fecdd3;
|
||||||
background: rgba(251, 113, 133, 0.08);
|
background: rgba(251, 113, 133, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.relation-badge.no_stl,
|
.relation-badge.no_stl {
|
||||||
.relation-badge.normalized {
|
|
||||||
border-color: rgba(240, 181, 78, 0.5);
|
border-color: rgba(240, 181, 78, 0.5);
|
||||||
color: #ffe0a3;
|
color: #ffe0a3;
|
||||||
background: rgba(240, 181, 78, 0.08);
|
background: rgba(240, 181, 78, 0.08);
|
||||||
@@ -218,6 +218,28 @@ button {
|
|||||||
border-color: rgba(52, 118, 246, 0.76);
|
border-color: rgba(52, 118, 246, 0.76);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-filters select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 0;
|
||||||
|
background: #080d15;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-filters select:focus {
|
||||||
|
border-color: rgba(52, 118, 246, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -303,14 +325,14 @@ button {
|
|||||||
color: #a5f5d4;
|
color: #a5f5d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-badge.no_stl,
|
.mini-badge.no_stl {
|
||||||
.mini-badge.normalized {
|
|
||||||
border-color: rgba(240, 181, 78, 0.5);
|
border-color: rgba(240, 181, 78, 0.5);
|
||||||
color: #ffe0a3;
|
color: #ffe0a3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-badge.pacs_only,
|
.mini-badge.pacs_only,
|
||||||
.mini-badge.upp_only {
|
.mini-badge.stl_only,
|
||||||
|
.mini-badge.list_only {
|
||||||
border-color: rgba(251, 113, 133, 0.48);
|
border-color: rgba(251, 113, 133, 0.48);
|
||||||
color: #fecdd3;
|
color: #fecdd3;
|
||||||
}
|
}
|
||||||
@@ -342,7 +364,7 @@ button {
|
|||||||
|
|
||||||
.link-map {
|
.link-map {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(150px, 1fr) 48px minmax(150px, 0.8fr) 48px minmax(150px, 1fr) 48px minmax(150px, 1fr);
|
grid-template-columns: minmax(180px, 1fr) 56px minmax(160px, 0.72fr) 56px minmax(180px, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
@@ -401,7 +423,7 @@ button {
|
|||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user