Improve DICOM UPP relation visualizer

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

View File

@@ -1,10 +1,13 @@
const app = {
token: localStorage.getItem("pacs_relation_token") || "",
user: null,
rows: [],
active: null,
filter: "all",
search: "",
algorithmModel: "",
dicomModel: "",
dicomPart: "",
viewerUrl: "http://127.0.0.1:8107",
searchTimer: null,
};
@@ -19,8 +22,15 @@ function escapeHtml(value) {
.replaceAll("'", "'");
}
async function json(path) {
const res = await fetch(path);
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
showLogin(true);
throw new Error("unauthorized");
}
if (!res.ok) {
let detail = res.statusText;
try {
@@ -31,9 +41,39 @@ async function json(path) {
}
throw new Error(detail);
}
return res;
}
async function json(path, options = {}) {
const res = await request(path, options);
return res.json();
}
function showLogin(show) {
$("loginOverlay").classList.toggle("hidden", !show);
}
function showViewer() {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
}
async function showSettings() {
if (!isAdmin()) return;
$("viewerPage").classList.add("hidden");
$("settingsPage").classList.remove("hidden");
await renderSettingsPage();
}
function isAdmin() {
return app.user?.role === "管理员";
}
function applyAuthUi() {
$("userBadge").textContent = app.user ? `${app.user.username} · ${app.user.role}` : "未登录";
$("settingsBtn").classList.toggle("hidden", !isAdmin());
}
function fmtDate(value) {
const text = String(value || "");
if (!text) return "";
@@ -65,9 +105,9 @@ function uniq(list) {
function statusLabel(value) {
return {
complete: "PACS + STL",
no_stl: "PACS + 列表",
pacs_only: "仅 PACS",
complete: "DICOM + STL",
no_stl: "DICOM + UPP",
pacs_only: "仅 DICOM",
stl_only: "仅 STL",
list_only: "列表无STL",
unknown: "未知",
@@ -88,7 +128,18 @@ function partLabel(value) {
}[value] || value;
}
function dicomParts(row) {
const labels = asList(row.dicom_body_parts);
if (labels.length) return labels;
return asList(row.body_parts).map(partLabel);
}
function pendingDICOM(row) {
return Number(row.undetermined_series || 0);
}
function setDbStatus(data) {
app.viewerUrl = data.viewer_url || app.viewerUrl;
const pill = $("dbStatus");
if (data.database?.ok) {
pill.textContent = `${data.database.database} 已连接`;
@@ -101,12 +152,11 @@ function setDbStatus(data) {
function renderMetrics(counts = {}) {
const items = [
["CT 索引", counts.total_ct],
["PACS", counts.pacs_count],
["STL资产", counts.stl_asset_count],
["STL", counts.stl_count],
["完整关联", counts.complete_count],
["待判别序列", counts.undetermined_series],
["DICOM", counts.pacs_count],
["缺 STL", counts.no_stl_count],
["仅 DICOM", counts.pacs_only_count],
["待处理DICOM", counts.pending_dicom_count],
];
$("metrics").innerHTML = items
.map(([label, value]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
@@ -116,21 +166,27 @@ function renderMetrics(counts = {}) {
function cardTitle(row) {
return [
`CT号${row.ct_key}`,
`PACS${row.pacs_ct_number || "无"}`,
`DICOM${row.pacs_ct_number || "无"}`,
`STL${row.stl_ct_number || "无"}`,
`状态:${statusLabel(row.relation_status)}`,
`PACS患者:${row.pacs_patient_name || "无"}`,
`STL列表患者:${row.upp_patient_name || "无"}`,
`DICOM患者:${row.pacs_patient_name || "无"}`,
`UPP患者:${row.upp_patient_name || "无"}`,
`重建模型:${row.algorithm_model || "无"}`,
`DICOM模型${asList(row.dicom_models).join("、") || "无"}`,
`部位标注:${dicomParts(row).join("、") || "无"}`,
].join("\n");
}
function renderPartTags(parts) {
if (!parts.length) return `<span class="subtle">无部位标注</span>`;
return parts.map((part) => `<em title="${escapeHtml(part)}">${escapeHtml(part)}</em>`).join("");
}
function renderList() {
const list = $("relationList");
$("resultCount").textContent = String(app.rows.length);
if (!app.rows.length) {
list.innerHTML = `<p class="empty">没有匹配记录</p>`;
clearDetail();
return;
}
list.innerHTML = "";
@@ -141,16 +197,18 @@ function renderList() {
card.title = cardTitle(row);
const date = row.study_date ? `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}` : fmtDate(row.exam_date || row.task_created_at);
const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0);
const dicomModels = asList(row.dicom_models).join("、") || "无";
const parts = dicomParts(row);
const pending = pendingDICOM(row);
card.innerHTML = `
<div class="card-row">
<strong>${escapeHtml(row.ct_key)}</strong>
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
</div>
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
<small>${escapeHtml(date || "无时间")} · PACS ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
<small>重建 ${escapeHtml(row.algorithm_model || "无")} · DICOM ${escapeHtml(dicomModels)}</small>
<small>${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""}</small>
<small>${escapeHtml(date || "无时间")} · DICOM ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个</small>
<small>重建 ${escapeHtml(row.algorithm_model || "无")}</small>
<div class="inline-tags">${renderPartTags(parts)}</div>
<small>${escapeHtml(matchLabel(row.match_type))}${pending ? ` · ${pending} 待处理DICOM` : ""}</small>
`;
card.onclick = () => selectRelation(row.ct_key);
list.appendChild(card);
@@ -175,50 +233,54 @@ function markNode(id, state, strong, meta) {
node.querySelector("em").textContent = meta;
}
function groupSegments(row) {
const names = asList(row.segment_names);
const families = asList(row.segment_families);
const categories = asList(row.segment_categories);
const groups = new Map();
names.forEach((name, index) => {
const key = categories[index] || families[index] || "未分类";
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(name);
});
return groups;
function dicomViewerUrl(row = app.active) {
if (!row?.pacs_ct_number) return "";
return `${app.viewerUrl.replace(/\/$/, "")}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`;
}
function renderSegments(row) {
const groups = groupSegments(row);
const count = asList(row.segment_names).length;
$("segmentCount").textContent = `${count}`;
if (!count) {
$("segmentGroups").innerHTML = `<p class="empty">没有 STL 分割文件</p>`;
return;
}
$("segmentGroups").innerHTML = Array.from(groups.entries())
.map(
([group, names]) => `
<div class="segment-group">
<strong>${escapeHtml(group)}</strong>
<div class="segment-tags">${names.map((name) => `<em title="${escapeHtml(name)}">${escapeHtml(name)}</em>`).join("")}</div>
</div>
`,
)
.join("");
function updateOpenDicomButton() {
const button = $("openDicomBtn");
const url = dicomViewerUrl();
button.disabled = !url;
button.title = url || "当前记录没有 DICOM 检查号";
}
function clearDetail() {
app.active = null;
$("activeCt").textContent = "未选择 CT";
$("activeSubtitle").textContent = "从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系";
$("relationBadge").className = "relation-badge idle";
$("relationBadge").textContent = "等待选择";
markNode("nodePacs", "missing", "未选择", "检查与序列");
$("nodeCtValue").textContent = "-";
markNode("nodeStl", "missing", "未选择", "含UPP列表信息");
renderDl("pacsDetails", []);
renderDl("stlDetails", []);
updateOpenDicomButton();
}
function renderDetail(row) {
app.active = row;
$("activeCt").textContent = row.ct_key || "未选择 CT";
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"}${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"}${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`;
const badge = $("relationBadge");
badge.className = `relation-badge ${row.relation_status || "idle"}`;
badge.textContent = statusLabel(row.relation_status);
const pending = pendingDICOM(row);
$("nodeCtValue").textContent = row.ct_key || "-";
markNode("nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列`);
markNode("nodeStl", row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing", row.stl_ct_number || "缺失", `${row.algorithm_model || "无模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`);
markNode(
"nodePacs",
row.pacs_present ? "ok" : "missing",
row.pacs_ct_number || "缺失",
`${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列${pending ? ` · ${pending} 待处理DICOM` : ""}`,
);
markNode(
"nodeStl",
row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing",
row.stl_ct_number || "缺失",
`${row.algorithm_model || "无重建模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`,
);
renderDl("pacsDetails", [
["CT号", row.pacs_ct_number],
@@ -227,10 +289,9 @@ function renderDetail(row) {
["检查时间", `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}`.trim()],
["描述", row.study_description],
["序列/文件", `${Number(row.pacs_series_count || 0)} / ${Number(row.dicom_file_count || 0)}`],
["标注", `${Number(row.annotated_series || 0)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`],
["标注", `${Number(row.annotated_series || 0)} 已标注 · ${pending} 待处理DICOM`],
["完成", row.completed ? "已完成" : "待处理"],
["部位", asList(row.body_parts).map(partLabel).join("、")],
["DICOM模型", asList(row.dicom_models).join("、")],
["部位标注", dicomParts(row).join("、")],
]);
renderDl("stlDetails", [
@@ -242,16 +303,15 @@ function renderDetail(row) {
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
["检查时间", fmtDate(row.exam_date)],
["任务时间", fmtDate(row.task_created_at)],
["列表记录", row.list_present ? `${Number(row.list_record_count || 0)}` : "无"],
["UPP列表记录", row.list_present ? `${Number(row.list_record_count || 0)}` : "无"],
["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)],
["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)],
["目录", row.processed_stl_dir],
["分类", uniq(asList(row.segment_categories)).join("、")],
["Family", uniq(asList(row.segment_families)).join("、")],
["分割分类", uniq(asList(row.segment_categories)).join("、")],
["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)],
]);
renderSegments(row);
updateOpenDicomButton();
renderList();
}
@@ -272,13 +332,14 @@ async function loadRelations() {
params.set("limit", "500");
if (app.search) params.set("q", app.search);
if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel);
if (app.dicomModel) params.set("dicom_model", app.dicomModel);
if (app.dicomPart) params.set("dicom_part", app.dicomPart);
app.rows = await json(`/api/relations?${params.toString()}`);
if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) {
await selectRelation(app.rows[0].ct_key);
} else {
renderList();
if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key);
if (!app.rows.length) clearDetail();
}
}
@@ -292,8 +353,174 @@ async function refreshAll() {
}
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const data = await json("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
app.token = data.token;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_relation_token", app.token);
applyAuthUi();
showLogin(false);
showViewer();
await refreshAll();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
function logout() {
app.token = "";
app.user = null;
app.rows = [];
app.active = null;
localStorage.removeItem("pacs_relation_token");
applyAuthUi();
showViewer();
clearDetail();
showLogin(true);
}
async function loadCurrentUser() {
if (!app.token) {
showLogin(true);
return false;
}
try {
app.user = await json("/api/auth/me");
applyAuthUi();
showLogin(false);
return true;
} catch (_) {
app.token = "";
localStorage.removeItem("pacs_relation_token");
applyAuthUi();
showLogin(true);
return false;
}
}
function settingsTable(users) {
if (!users?.length) return `<p class="empty">暂无账号</p>`;
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th><th>更新时间</th><th>重置密码</th></tr></thead>
<tbody>
${users
.map(
(user) => `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.role)}</td>
<td>${escapeHtml(user.status)}</td>
<td>${escapeHtml(fmtDate(user.updated_at))}</td>
<td>
<div class="password-reset">
<input data-password-user="${escapeHtml(user.username)}" placeholder="新密码" type="password" />
<button data-reset-user="${escapeHtml(user.username)}" type="button">更新</button>
</div>
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
async function renderSettingsPage() {
const data = await json("/api/settings");
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title">
<h3>账号创建</h3>
<span>当前登录:${escapeHtml(app.user?.username || "-")}</span>
</div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" />
<input name="password" type="password" placeholder="初始密码" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<select name="status">
<option value="启用">启用</option>
<option value="停用">停用</option>
</select>
<button class="primary-btn" type="submit">保存账号</button>
</form>
${settingsTable(data.users || [])}
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>系统管理</h3><span>跳转与联动</span></div>
<dl class="settings-dl">
<dt>阅片系统</dt><dd>${escapeHtml(data.viewer_url || "-")}</dd>
<dt>数据库</dt><dd>${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}</dd>
<dt>DICOM表</dt><dd>${escapeHtml(data.tables?.dicom || "-")}</dd>
<dt>摘要表</dt><dd>${escapeHtml(data.tables?.dicom_summary || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>权限说明</h3><span>页面级控制</span></div>
<div class="role-grid">
<div class="role-card"><strong>管理员</strong><span>查看关联、系统设置、账号密码管理</span></div>
<div class="role-card"><strong>阅片员</strong><span>查看关联与跳转阅片系统</span></div>
<div class="role-card"><strong>访客</strong><span>只读查看关联信息</span></div>
</div>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", saveUser);
document.querySelectorAll("[data-reset-user]").forEach((button) => {
button.addEventListener("click", () => resetPassword(button.dataset.resetUser));
});
}
async function saveUser(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
await json("/api/settings/users", {
method: "POST",
body: JSON.stringify({
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
status: String(form.get("status") || "启用"),
}),
});
await renderSettingsPage();
}
async function resetPassword(username) {
const input = Array.from(document.querySelectorAll("[data-password-user]")).find((item) => item.dataset.passwordUser === username);
const password = input?.value || "";
if (!password) return;
await json(`/api/settings/users/${encodeURIComponent(username)}/password`, {
method: "PUT",
body: JSON.stringify({ password }),
});
await renderSettingsPage();
}
function openDicomViewer() {
const url = dicomViewerUrl();
if (url) window.open(url, "_blank", "noopener");
}
function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", showSettings);
$("backToViewer").addEventListener("click", showViewer);
$("refreshBtn").addEventListener("click", refreshAll);
$("openDicomBtn").addEventListener("click", openDicomViewer);
$("searchInput").addEventListener("input", () => {
clearTimeout(app.searchTimer);
app.searchTimer = setTimeout(() => {
@@ -305,8 +532,8 @@ function wire() {
app.algorithmModel = $("algorithmFilter").value;
loadRelations();
});
$("dicomModelFilter").addEventListener("change", () => {
app.dicomModel = $("dicomModelFilter").value;
$("dicomPartFilter").addEventListener("change", () => {
app.dicomPart = $("dicomPartFilter").value;
loadRelations();
});
document.querySelectorAll("[data-filter]").forEach((button) => {
@@ -318,5 +545,12 @@ function wire() {
});
}
wire();
refreshAll();
async function boot() {
clearDetail();
applyAuthUi();
wire();
const ok = await loadCurrentUser();
if (ok) await refreshAll();
}
boot();

View File

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

View File

@@ -34,7 +34,8 @@ body {
}
button,
input {
input,
select {
font: inherit;
}
@@ -87,6 +88,19 @@ button {
gap: 10px;
}
.hidden {
display: none !important;
}
.user-badge {
max-width: 170px;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-pill,
.relation-badge {
min-width: 82px;
@@ -134,6 +148,30 @@ button {
color: var(--text);
}
.primary-btn,
.link-btn {
height: 34px;
padding: 0 14px;
border: 1px solid rgba(52, 118, 246, 0.72);
border-radius: 8px;
background: var(--blue);
color: white;
font-weight: 700;
}
.link-btn {
height: 30px;
border-color: rgba(25, 212, 194, 0.42);
background: rgba(25, 212, 194, 0.12);
color: #baf8ee;
font-size: 12px;
}
.link-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.dark-btn:hover,
.filter-grid button:hover {
border-color: var(--line-strong);
@@ -160,7 +198,7 @@ button {
}
.content {
grid-template-rows: auto auto auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
}
.panel {
@@ -337,9 +375,35 @@ button {
color: #fecdd3;
}
.inline-tags {
display: flex;
min-width: 0;
gap: 6px;
overflow: hidden;
}
.inline-tags em {
max-width: 96px;
padding: 2px 7px;
overflow: hidden;
border: 1px solid rgba(25, 212, 194, 0.42);
border-radius: 999px;
color: #baf8ee;
background: rgba(25, 212, 194, 0.08);
font-size: 11px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtle {
color: var(--muted);
font-size: 12px;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(116px, 1fr));
grid-template-columns: repeat(5, minmax(116px, 1fr));
gap: 10px;
}
@@ -431,11 +495,24 @@ button {
padding: 14px;
}
.detail-card h3 {
.detail-card h3,
.detail-title h3 {
margin: 0 0 12px;
font-size: 15px;
}
.detail-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.detail-title h3 {
margin: 0;
}
dl {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
@@ -513,6 +590,176 @@ dd {
text-align: center;
}
.error-line {
color: #fecdd3;
font-size: 12px;
}
.login-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 22px;
background:
radial-gradient(circle at 30% 20%, rgba(25, 212, 194, 0.2), transparent 30%),
rgba(7, 10, 15, 0.86);
backdrop-filter: blur(8px);
}
.login-panel {
width: min(420px, 100%);
display: grid;
gap: 12px;
padding: 26px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: rgba(11, 17, 27, 0.96);
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.45);
}
.login-panel h2 {
margin: 4px 0 0;
font-size: 20px;
}
.login-panel p {
margin: 0 0 4px;
color: var(--muted);
font-size: 13px;
}
.settings-page {
height: calc(100vh - 66px);
padding: 14px;
overflow: auto;
}
.settings-shell {
max-width: 1180px;
margin: 0 auto;
padding: 18px;
}
.settings-head,
.settings-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-head h2,
.settings-title h3 {
margin: 0;
}
.settings-head p,
.settings-title span {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.settings-content {
display: grid;
gap: 14px;
margin-top: 18px;
}
.settings-section {
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(7, 10, 15, 0.55);
}
.settings-section.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
}
.settings-form {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 130px 110px 110px;
gap: 8px;
margin: 14px 0;
}
.settings-form input,
.settings-form select,
.password-reset input {
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 8px;
outline: 0;
background: #080d15;
color: var(--text);
}
.settings-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 8px;
background: #0b111b;
}
.settings-table th,
.settings-table td {
padding: 10px;
border-bottom: 1px solid var(--line);
color: var(--text);
font-size: 12px;
text-align: left;
}
.settings-table th {
color: #b8c7dc;
background: rgba(52, 118, 246, 0.08);
}
.password-reset {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 6px;
}
.password-reset button {
border: 1px solid rgba(25, 212, 194, 0.42);
border-radius: 8px;
background: rgba(25, 212, 194, 0.1);
color: #baf8ee;
}
.settings-dl {
margin-top: 12px;
}
.role-grid {
display: grid;
gap: 8px;
margin-top: 12px;
}
.role-card {
display: grid;
gap: 5px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #0b111b;
}
.role-card span {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1180px) {
body {
overflow: auto;
@@ -525,10 +772,15 @@ dd {
.metrics,
.detail-grid,
.settings-section.split,
.link-map {
grid-template-columns: 1fr;
}
.settings-form {
grid-template-columns: 1fr;
}
.link-line {
width: 2px;
height: 22px;