Refine DICOM UPP relation loading
This commit is contained in:
@@ -5,6 +5,7 @@ PGPASSWORD=change_me
|
|||||||
PGDATABASE=pacs_db
|
PGDATABASE=pacs_db
|
||||||
PACS_TABLE=pacs_dicom_files
|
PACS_TABLE=pacs_dicom_files
|
||||||
PACS_SUMMARY_TABLE=pacs_dicom_study_summaries
|
PACS_SUMMARY_TABLE=pacs_dicom_study_summaries
|
||||||
|
PACS_ANNOTATION_TABLE=pacs_dicom_series_annotations
|
||||||
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_USER_TABLE=db_visualizer_users
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ PGUSER = os.getenv("PGUSER", "his_user")
|
|||||||
PGDATABASE = os.getenv("PGDATABASE", "pacs_db")
|
PGDATABASE = os.getenv("PGDATABASE", "pacs_db")
|
||||||
PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files")
|
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")
|
||||||
|
PACS_ANNOTATION_TABLE = os.getenv("PACS_ANNOTATION_TABLE", "pacs_dicom_series_annotations")
|
||||||
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")
|
USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users")
|
||||||
@@ -42,6 +43,7 @@ def ident(name: str) -> str:
|
|||||||
|
|
||||||
PACS_TABLE_SQL = ident(PACS_TABLE)
|
PACS_TABLE_SQL = ident(PACS_TABLE)
|
||||||
PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
|
PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
|
||||||
|
PACS_ANNOTATION_TABLE_SQL = ident(PACS_ANNOTATION_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)
|
USER_TABLE_SQL = ident(USER_TABLE)
|
||||||
@@ -185,6 +187,52 @@ def relation_cte() -> str:
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM public.{PACS_SUMMARY_TABLE_SQL}
|
FROM public.{PACS_SUMMARY_TABLE_SQL}
|
||||||
),
|
),
|
||||||
|
a_label_raw AS (
|
||||||
|
SELECT
|
||||||
|
a.ct_number,
|
||||||
|
CASE part.value
|
||||||
|
WHEN 'head_neck' THEN '头颈部'
|
||||||
|
WHEN 'chest' THEN '胸部-' || CASE COALESCE(NULLIF(a.chest_window, ''), 'unknown')
|
||||||
|
WHEN 'lung' THEN '肺窗'
|
||||||
|
WHEN 'mediastinal' THEN '纵隔窗'
|
||||||
|
ELSE '无法判别'
|
||||||
|
END
|
||||||
|
WHEN 'upper_abdomen' THEN '上腹部-' || CASE COALESCE(NULLIF(a.upper_abdomen_phase, ''), 'unknown')
|
||||||
|
WHEN 'plain' THEN '平扫'
|
||||||
|
WHEN 'arterial' THEN '动脉期'
|
||||||
|
WHEN 'portal_venous' THEN '门脉期'
|
||||||
|
WHEN 'delayed' THEN '延迟期'
|
||||||
|
ELSE '无法判别'
|
||||||
|
END
|
||||||
|
WHEN 'lower_abdomen' THEN '下腹部'
|
||||||
|
WHEN 'pelvis' THEN '盆腔'
|
||||||
|
ELSE NULL
|
||||||
|
END AS label,
|
||||||
|
CASE part.value
|
||||||
|
WHEN 'head_neck' THEN 1
|
||||||
|
WHEN 'chest' THEN 2
|
||||||
|
WHEN 'upper_abdomen' THEN 3
|
||||||
|
WHEN 'lower_abdomen' THEN 4
|
||||||
|
WHEN 'pelvis' THEN 5
|
||||||
|
ELSE 99
|
||||||
|
END AS sort_key
|
||||||
|
FROM public.{PACS_ANNOTATION_TABLE_SQL} a
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value)
|
||||||
|
WHERE COALESCE(a.skipped, false) IS NOT TRUE
|
||||||
|
),
|
||||||
|
a_label_distinct AS (
|
||||||
|
SELECT ct_number, label, min(sort_key) AS sort_key
|
||||||
|
FROM a_label_raw
|
||||||
|
WHERE label IS NOT NULL
|
||||||
|
GROUP BY ct_number, label
|
||||||
|
),
|
||||||
|
a_labels AS (
|
||||||
|
SELECT
|
||||||
|
ct_number,
|
||||||
|
to_jsonb(array_agg(label ORDER BY sort_key, label)) AS dicom_annotation_labels
|
||||||
|
FROM a_label_distinct
|
||||||
|
GROUP BY ct_number
|
||||||
|
),
|
||||||
u_raw AS (
|
u_raw AS (
|
||||||
SELECT
|
SELECT
|
||||||
u.*,
|
u.*,
|
||||||
@@ -253,6 +301,7 @@ def relation_cte() -> str:
|
|||||||
ps.undetermined_series,
|
ps.undetermined_series,
|
||||||
ps.completed,
|
ps.completed,
|
||||||
ps.body_parts,
|
ps.body_parts,
|
||||||
|
COALESCE(a_labels.dicom_annotation_labels, '[]'::jsonb) AS dicom_annotation_labels,
|
||||||
to_jsonb(array_remove(ARRAY[
|
to_jsonb(array_remove(ARRAY[
|
||||||
CASE WHEN ps.body_parts ? 'head_neck' 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,
|
||||||
@@ -297,6 +346,7 @@ def relation_cte() -> str:
|
|||||||
FROM keys k
|
FROM keys k
|
||||||
LEFT JOIN p ON p.ct_key = k.ct_key
|
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 a_labels ON a_labels.ct_number = p.ct_number
|
||||||
LEFT JOIN u ON u.ct_key = k.ct_key
|
LEFT JOIN u ON u.ct_key = k.ct_key
|
||||||
LEFT JOIN s ON s.ct_key = k.ct_key
|
LEFT JOIN s ON s.ct_key = k.ct_key
|
||||||
)
|
)
|
||||||
@@ -401,6 +451,7 @@ def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
|
|||||||
"tables": {
|
"tables": {
|
||||||
"dicom": PACS_TABLE,
|
"dicom": PACS_TABLE,
|
||||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||||
|
"dicom_annotations": PACS_ANNOTATION_TABLE,
|
||||||
"upp_assets": UPP_ASSET_TABLE,
|
"upp_assets": UPP_ASSET_TABLE,
|
||||||
"upp_stl": UPP_STL_TABLE,
|
"upp_stl": UPP_STL_TABLE,
|
||||||
},
|
},
|
||||||
@@ -489,6 +540,7 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
|||||||
"tables": {
|
"tables": {
|
||||||
"dicom": PACS_TABLE,
|
"dicom": PACS_TABLE,
|
||||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||||
|
"dicom_annotations": PACS_ANNOTATION_TABLE,
|
||||||
"upp_assets": UPP_ASSET_TABLE,
|
"upp_assets": UPP_ASSET_TABLE,
|
||||||
"upp_stl": UPP_STL_TABLE,
|
"upp_stl": UPP_STL_TABLE,
|
||||||
},
|
},
|
||||||
@@ -503,6 +555,7 @@ def relations(
|
|||||||
algorithm_model: str = "",
|
algorithm_model: str = "",
|
||||||
dicom_part: str = "",
|
dicom_part: str = "",
|
||||||
limit: int = Query(default=300, ge=1, le=1000),
|
limit: int = Query(default=300, ge=1, le=1000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
user: dict[str, str] = Depends(current_user),
|
user: dict[str, str] = Depends(current_user),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
where = relation_where(q, status, algorithm_model, dicom_part)
|
where = relation_where(q, status, algorithm_model, dicom_part)
|
||||||
@@ -517,7 +570,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_body_parts, segment_categories, segment_families,
|
body_parts, dicom_body_parts, dicom_annotation_labels, 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}
|
||||||
@@ -528,6 +581,7 @@ def relations(
|
|||||||
COALESCE(study_time, '') DESC,
|
COALESCE(study_time, '') DESC,
|
||||||
ct_key
|
ct_key
|
||||||
LIMIT {int(limit)}
|
LIMIT {int(limit)}
|
||||||
|
OFFSET {int(offset)}
|
||||||
""",
|
""",
|
||||||
timeout=24,
|
timeout=24,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const app = {
|
|||||||
dicomPart: "",
|
dicomPart: "",
|
||||||
viewerUrl: "http://127.0.0.1:8107",
|
viewerUrl: "http://127.0.0.1:8107",
|
||||||
searchTimer: null,
|
searchTimer: null,
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
hasMore: true,
|
||||||
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -53,6 +57,12 @@ function showLogin(show) {
|
|||||||
$("loginOverlay").classList.toggle("hidden", !show);
|
$("loginOverlay").classList.toggle("hidden", !show);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setLoading(show) {
|
||||||
|
app.loading = show;
|
||||||
|
$("topLoading").classList.toggle("hidden", !show);
|
||||||
|
$("listLoading").classList.toggle("hidden", !show || !app.rows.length);
|
||||||
|
}
|
||||||
|
|
||||||
function showViewer() {
|
function showViewer() {
|
||||||
$("viewerPage").classList.remove("hidden");
|
$("viewerPage").classList.remove("hidden");
|
||||||
$("settingsPage").classList.add("hidden");
|
$("settingsPage").classList.add("hidden");
|
||||||
@@ -109,15 +119,11 @@ function statusLabel(value) {
|
|||||||
no_stl: "DICOM + UPP",
|
no_stl: "DICOM + UPP",
|
||||||
pacs_only: "仅 DICOM",
|
pacs_only: "仅 DICOM",
|
||||||
stl_only: "仅 STL",
|
stl_only: "仅 STL",
|
||||||
list_only: "列表无STL",
|
list_only: "仅 UPP",
|
||||||
unknown: "未知",
|
unknown: "未知",
|
||||||
}[value] || value || "未知";
|
}[value] || value || "未知";
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchLabel(value) {
|
|
||||||
return { exact: "CT 号一致" }[value] || "未匹配";
|
|
||||||
}
|
|
||||||
|
|
||||||
function partLabel(value) {
|
function partLabel(value) {
|
||||||
return {
|
return {
|
||||||
head_neck: "头颈部",
|
head_neck: "头颈部",
|
||||||
@@ -134,8 +140,9 @@ function dicomParts(row) {
|
|||||||
return asList(row.body_parts).map(partLabel);
|
return asList(row.body_parts).map(partLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingDICOM(row) {
|
function annotationLabels(row) {
|
||||||
return Number(row.undetermined_series || 0);
|
const labels = asList(row.dicom_annotation_labels);
|
||||||
|
return labels.length ? labels : dicomParts(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDbStatus(data) {
|
function setDbStatus(data) {
|
||||||
@@ -154,9 +161,8 @@ function renderMetrics(counts = {}) {
|
|||||||
const items = [
|
const items = [
|
||||||
["完整关联", counts.complete_count],
|
["完整关联", counts.complete_count],
|
||||||
["DICOM", counts.pacs_count],
|
["DICOM", counts.pacs_count],
|
||||||
["缺 STL", counts.no_stl_count],
|
|
||||||
["仅 DICOM", counts.pacs_only_count],
|
["仅 DICOM", counts.pacs_only_count],
|
||||||
["待处理DICOM", counts.pending_dicom_count],
|
["仅 STL", counts.stl_only_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>`)
|
||||||
@@ -172,7 +178,7 @@ function cardTitle(row) {
|
|||||||
`DICOM患者:${row.pacs_patient_name || "无"}`,
|
`DICOM患者:${row.pacs_patient_name || "无"}`,
|
||||||
`UPP患者:${row.upp_patient_name || "无"}`,
|
`UPP患者:${row.upp_patient_name || "无"}`,
|
||||||
`重建模型:${row.algorithm_model || "无"}`,
|
`重建模型:${row.algorithm_model || "无"}`,
|
||||||
`部位标注:${dicomParts(row).join("、") || "无"}`,
|
`部位标注:${annotationLabels(row).join("、") || "无"}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +191,8 @@ 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">${app.loading ? "正在加载..." : "没有匹配记录"}</p>`;
|
||||||
clearDetail();
|
if (!app.loading) clearDetail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
@@ -195,20 +201,23 @@ function renderList() {
|
|||||||
card.className = "relation-card";
|
card.className = "relation-card";
|
||||||
card.classList.toggle("active", app.active?.ct_key === row.ct_key);
|
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 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 parts = dicomParts(row);
|
const labels = annotationLabels(row);
|
||||||
const pending = pendingDICOM(row);
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-row">
|
<div class="card-layout">
|
||||||
|
<div class="card-main">
|
||||||
<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>
|
|
||||||
</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 || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
|
<small>DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount} 个` : ""}</small>
|
||||||
<small>重建 ${escapeHtml(row.algorithm_model || "无")}</small>
|
</div>
|
||||||
<div class="inline-tags">${renderPartTags(parts)}</div>
|
<div class="card-side">
|
||||||
<small>${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""}</small>
|
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
||||||
|
<div class="inline-tags important-tags">
|
||||||
|
${row.algorithm_model ? `<em title="${escapeHtml(row.algorithm_model)}">${escapeHtml(row.algorithm_model)}</em>` : ""}
|
||||||
|
${renderPartTags(labels)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
card.onclick = () => selectRelation(row.ct_key);
|
card.onclick = () => selectRelation(row.ct_key);
|
||||||
list.appendChild(card);
|
list.appendChild(card);
|
||||||
@@ -262,18 +271,18 @@ function clearDetail() {
|
|||||||
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 || "无 DICOM"} ↔ ${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
|
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"} ↔ ${row.stl_ct_number || "无 STL"}`;
|
||||||
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);
|
const labels = annotationLabels(row);
|
||||||
$("nodeCtValue").textContent = row.ct_key || "-";
|
$("nodeCtValue").textContent = row.ct_key || "-";
|
||||||
markNode(
|
markNode(
|
||||||
"nodePacs",
|
"nodePacs",
|
||||||
row.pacs_present ? "ok" : "missing",
|
row.pacs_present ? "ok" : "missing",
|
||||||
row.pacs_ct_number || "缺失",
|
row.pacs_ct_number || "缺失",
|
||||||
`${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列${pending ? ` · ${pending} 待处理DICOM` : ""}`,
|
`${Number(row.pacs_series_count || 0)} 序列 · ${labels.join("、") || "未标注"}`,
|
||||||
);
|
);
|
||||||
markNode(
|
markNode(
|
||||||
"nodeStl",
|
"nodeStl",
|
||||||
@@ -289,9 +298,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)} 已标注 · ${pending} 待处理DICOM`],
|
["标注", `${Number(row.annotated_series || 0)} 已标注`],
|
||||||
["完成", row.completed ? "已完成" : "待处理"],
|
["完成", row.completed ? "已完成" : "待处理"],
|
||||||
["部位标注", dicomParts(row).join("、")],
|
["部位标注", labels.join("、")],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderDl("stlDetails", [
|
renderDl("stlDetails", [
|
||||||
@@ -303,7 +312,6 @@ 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)],
|
||||||
["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],
|
||||||
@@ -326,30 +334,48 @@ async function loadStatus() {
|
|||||||
renderMetrics(data.counts || {});
|
renderMetrics(data.counts || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRelations() {
|
async function loadRelations(reset = true) {
|
||||||
|
if (app.loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
if (reset) {
|
||||||
|
app.offset = 0;
|
||||||
|
app.hasMore = true;
|
||||||
|
app.rows = [];
|
||||||
|
app.active = null;
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
if (!app.hasMore) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("status", app.filter);
|
params.set("status", app.filter);
|
||||||
params.set("limit", "500");
|
params.set("limit", String(app.limit));
|
||||||
|
params.set("offset", String(app.offset));
|
||||||
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.dicomPart) params.set("dicom_part", app.dicomPart);
|
if (app.dicomPart) params.set("dicom_part", app.dicomPart);
|
||||||
app.rows = await json(`/api/relations?${params.toString()}`);
|
try {
|
||||||
if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) {
|
const rows = await json(`/api/relations?${params.toString()}`);
|
||||||
await selectRelation(app.rows[0].ct_key);
|
app.rows = reset ? rows : [...app.rows, ...rows.filter((row) => !app.rows.some((item) => item.ct_key === row.ct_key))];
|
||||||
} else {
|
app.offset += rows.length;
|
||||||
|
app.hasMore = rows.length === app.limit;
|
||||||
renderList();
|
renderList();
|
||||||
if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key);
|
if (reset && app.rows.length) await selectRelation(app.rows[0].ct_key);
|
||||||
if (!app.rows.length) clearDetail();
|
if (reset && !app.rows.length) clearDetail();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
try {
|
try {
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
await loadRelations();
|
await loadRelations(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("dbStatus").textContent = err.message;
|
$("dbStatus").textContent = err.message;
|
||||||
$("dbStatus").className = "status-pill offline";
|
$("dbStatus").className = "status-pill offline";
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,13 +540,22 @@ function openDicomViewer() {
|
|||||||
if (url) window.open(url, "_blank", "noopener");
|
if (url) window.open(url, "_blank", "noopener");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openViewerHome() {
|
||||||
|
window.open(app.viewerUrl.replace(/\/$/, ""), "_blank", "noopener");
|
||||||
|
}
|
||||||
|
|
||||||
function wire() {
|
function wire() {
|
||||||
$("loginForm").addEventListener("submit", login);
|
$("loginForm").addEventListener("submit", login);
|
||||||
$("logoutBtn").addEventListener("click", logout);
|
$("logoutBtn").addEventListener("click", logout);
|
||||||
$("settingsBtn").addEventListener("click", showSettings);
|
$("settingsBtn").addEventListener("click", showSettings);
|
||||||
$("backToViewer").addEventListener("click", showViewer);
|
$("backToViewer").addEventListener("click", showViewer);
|
||||||
$("refreshBtn").addEventListener("click", refreshAll);
|
$("refreshBtn").addEventListener("click", refreshAll);
|
||||||
|
$("viewerHomeBtn").addEventListener("click", openViewerHome);
|
||||||
$("openDicomBtn").addEventListener("click", openDicomViewer);
|
$("openDicomBtn").addEventListener("click", openDicomViewer);
|
||||||
|
$("relationList").addEventListener("scroll", () => {
|
||||||
|
const list = $("relationList");
|
||||||
|
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 140) loadRelations(false);
|
||||||
|
});
|
||||||
$("searchInput").addEventListener("input", () => {
|
$("searchInput").addEventListener("input", () => {
|
||||||
clearTimeout(app.searchTimer);
|
clearTimeout(app.searchTimer);
|
||||||
app.searchTimer = setTimeout(() => {
|
app.searchTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
|
<button id="viewerHomeBtn" class="dark-btn" type="button">进入阅片系统</button>
|
||||||
<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>
|
<span id="userBadge" class="user-badge">未登录</span>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<button id="logoutBtn" class="dark-btn" type="button">退出</button>
|
<button id="logoutBtn" class="dark-btn" type="button">退出</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="topLoading" class="top-loading hidden"><span></span></div>
|
||||||
|
|
||||||
<main id="viewerPage" class="layout">
|
<main id="viewerPage" class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
@@ -51,16 +53,14 @@
|
|||||||
<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="pacs_only">仅 DICOM</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="pending_dicom">待处理DICOM</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel list-panel">
|
<section class="panel list-panel">
|
||||||
<div id="relationList" class="relation-list"></div>
|
<div id="relationList" class="relation-list"></div>
|
||||||
|
<div id="listLoading" class="list-loading hidden">加载中...</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,37 @@ button {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-loading {
|
||||||
|
position: fixed;
|
||||||
|
top: 66px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 15;
|
||||||
|
height: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(31, 44, 60, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-loading span {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 36%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent);
|
||||||
|
animation: loading-sweep 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-sweep {
|
||||||
|
from {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(310%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -279,13 +310,17 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-grid button {
|
.filter-grid button {
|
||||||
|
min-width: 112px;
|
||||||
|
flex: 0 0 auto;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -304,6 +339,26 @@ button {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 3px;
|
padding-right: 3px;
|
||||||
|
padding-bottom: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-loading {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 14px;
|
||||||
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(25, 212, 194, 0.32);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #baf8ee;
|
||||||
|
background: rgba(7, 10, 15, 0.88);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relation-card {
|
.relation-card {
|
||||||
@@ -342,11 +397,28 @@ button {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-row {
|
.card-layout {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: minmax(0, 1fr) minmax(112px, 0.72fr);
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
gap: 8px;
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-main,
|
||||||
|
.card-side {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-side {
|
||||||
|
justify-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-tags {
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-badge {
|
.mini-badge {
|
||||||
@@ -402,12 +474,16 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(5, minmax(116px, 1fr));
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
|
min-width: 140px;
|
||||||
|
flex: 1 0 140px;
|
||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user