Move settings to page and cache study summaries

This commit is contained in:
Codex
2026-05-27 13:10:18 +08:00
parent cfd2b379b5
commit 0cf7725dfc
5 changed files with 423 additions and 86 deletions

View File

@@ -1,5 +1,6 @@
const app = {
token: localStorage.getItem("pacs_web_token") || "",
user: null,
studies: [],
study: null,
series: [],
@@ -23,7 +24,7 @@ const app = {
studySortField: "time",
studySortDir: "desc",
studyFilter: "all",
summaryRun: 0,
aiEnabled: true,
seriesSortField: "time",
seriesSortDir: "asc",
showOverlay: true,
@@ -164,7 +165,9 @@ async function login(event) {
body: JSON.stringify({ username: $("username").value, password: $("password").value }),
});
app.token = data.token;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_web_token", app.token);
applyPermissions();
showLogin(false);
await boot();
} catch (_) {
@@ -175,10 +178,27 @@ async function login(event) {
async function logout() {
await confirmSaveIfDirty();
app.token = "";
app.user = null;
localStorage.removeItem("pacs_web_token");
applyPermissions();
showViewerPage(false);
showLogin(true);
}
async function loadCurrentUser() {
if (!app.token) return;
app.user = await json("/api/auth/me");
applyPermissions();
}
function isAdmin() {
return app.user?.role === "管理员";
}
function applyPermissions() {
$("settingsBtn").classList.toggle("hidden", !isAdmin());
}
async function refreshStatus() {
try {
const data = await fetch("/api/status").then((res) => res.json());
@@ -187,6 +207,9 @@ async function refreshStatus() {
pill.title = data.database?.message || "";
pill.classList.toggle("offline", !data.database?.ok);
pill.classList.toggle("online", Boolean(data.database?.ok));
app.aiEnabled = Boolean(data.ai?.enabled && data.ai?.configured);
$("aiClassify").disabled = !app.aiEnabled;
$("aiClassify").title = app.aiEnabled ? "" : "AI 功能未启用或未配置";
} catch (_) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").classList.add("offline");
@@ -218,7 +241,6 @@ function sortStudies(list) {
}
function studyStatus(study) {
if (study.summary_loading) return "loading";
const total = Number(study.series_count || 0);
const unannotated = Number(study.unannotated_series || 0);
if (total > 0 && unannotated === 0) return "complete";
@@ -226,7 +248,7 @@ function studyStatus(study) {
}
function studyStatusLabel(status) {
return { complete: "完成", incomplete: "待", loading: "同步" }[status] || "待";
return status === "complete" ? "已完成" : "待处理";
}
function visibleStudies() {
@@ -283,12 +305,10 @@ function refreshStudyAnnotationSummary() {
}
async function loadStudies() {
app.summaryRun += 1;
const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
renderStudies();
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
refreshStudySummaries();
}
function renderStudies() {
@@ -304,10 +324,9 @@ function renderStudies() {
button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
button.classList.toggle("complete", status === "complete");
button.classList.toggle("loading", status === "loading");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = `
<i class="study-status-badge status-${status}" title="${status === "complete" ? "已处理完毕" : status === "loading" ? "正在同步摘要" : "仍有序列未标注"}">${studyStatusLabel(status)}</i>
<i class="study-status-badge status-${status}" title="${status === "complete" ? "已处理完毕" : "仍有序列未标注"}">${studyStatusLabel(status)}</i>
<strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
@@ -318,25 +337,6 @@ function renderStudies() {
}
}
async function refreshStudySummaries() {
const run = app.summaryRun;
for (const study of [...app.studies]) {
if (run !== app.summaryRun) return;
study.summary_loading = true;
renderStudies();
try {
const summary = await json(`/api/studies/${encodeURIComponent(study.ct_number)}/summary`);
if (run !== app.summaryRun) return;
Object.assign(study, summary, { summary_loading: false, summary_loaded: true });
if (app.study?.ct_number === study.ct_number) Object.assign(app.study, summary);
} catch (_) {
study.summary_loading = false;
study.summary_error = true;
}
renderStudies();
}
}
async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
@@ -983,12 +983,43 @@ function roleCards(roles) {
.join("");
}
async function openSettings() {
function showViewerPage(push = true) {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
if (push && location.pathname !== "/") history.pushState({}, "", "/");
}
async function showSettingsPage(push = true) {
await confirmSaveIfDirty();
if (!isAdmin()) {
if (location.pathname === "/settings") history.replaceState({}, "", "/");
showViewerPage(false);
return;
}
$("viewerPage").classList.add("hidden");
$("settingsPage").classList.remove("hidden");
if (push && location.pathname !== "/settings") history.pushState({}, "", "/settings");
await renderSettingsPage();
}
async function handleRoute(push = false) {
if (location.pathname === "/settings") {
await showSettingsPage(push);
} else {
showViewerPage(push);
}
}
async function openSettings() {
await showSettingsPage(true);
}
async function renderSettingsPage() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
const refresh = settings.summary_refresh || {};
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title"><h3>用户创建</h3><span>当前登录:admin</span></div>
<div class="settings-title"><h3>用户创建</h3><span>当前登录:${escapeHtml(app.user?.username || "-")}</span></div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" autocomplete="off" />
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
@@ -1007,13 +1038,27 @@ async function openSettings() {
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.configured ? "已配置" : "未配置"}</span></div>
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.enabled ? "已启用" : "已关闭"}</span></div>
<dl>
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
<dt>状态</dt><dd>${settings.ai?.configured ? "API 已配置" : "API 未配置"}</dd>
</dl>
<button id="toggleAi" class="settings-action">${settings.ai?.enabled ? "关闭 AI" : "启用 AI"}</button>
</div>
<div>
<div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div>
<dl>
<dt>计划刷新</dt><dd>每天 04:00</dd>
<dt>最近开始</dt><dd>${escapeHtml(refresh.started_at || "-")}</dd>
<dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd>
<dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd>
</dl>
<button id="refreshSummariesNow" class="settings-action"${refresh.running ? " disabled" : ""}>立刻更新检查列表</button>
</div>
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
<dl>
@@ -1025,7 +1070,8 @@ async function openSettings() {
</section>
`;
$("createUserForm").addEventListener("submit", createUser);
$("settingsModal").classList.remove("hidden");
$("toggleAi").addEventListener("click", () => toggleAI(!settings.ai?.enabled));
$("refreshSummariesNow").addEventListener("click", refreshSummariesNow);
}
async function createUser(event) {
@@ -1042,7 +1088,7 @@ async function createUser(event) {
button.textContent = "创建中";
try {
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
await openSettings();
await renderSettingsPage();
} catch (err) {
button.textContent = err.message;
setTimeout(() => {
@@ -1052,6 +1098,20 @@ async function createUser(event) {
}
}
async function toggleAI(enabled) {
await json("/api/settings/ai", { method: "POST", body: JSON.stringify({ enabled }) });
await renderSettingsPage();
await refreshStatus();
}
async function refreshSummariesNow() {
const button = $("refreshSummariesNow");
button.disabled = true;
button.textContent = "已开始刷新";
await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) });
await renderSettingsPage();
}
function changeZoom(multiplier) {
app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
applyTransform();
@@ -1118,6 +1178,7 @@ function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings);
$("backToViewer").addEventListener("click", () => showViewerPage(true));
$("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI);
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
@@ -1187,16 +1248,24 @@ function wire() {
document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
});
window.addEventListener("popstate", () => handleRoute(false));
wireImageGestures();
}
async function boot() {
await loadCurrentUser();
await refreshStatus();
await loadStudies();
if (location.pathname === "/settings" && isAdmin()) {
await showSettingsPage(false);
} else {
await loadStudies();
await handleRoute(false);
}
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
}
wire();
applyPermissions();
refreshStatus();
if (app.token) {
showLogin(false);

View File

@@ -40,7 +40,7 @@
</div>
</header>
<main class="workspace">
<main id="viewerPage" class="workspace">
<aside class="study-pane">
<div class="pane-head">
<h2>检查列表</h2>
@@ -157,6 +157,17 @@
</div>
</section>
</main>
<main id="settingsPage" class="settings-page hidden">
<section class="settings-page-head">
<div>
<h1>设置</h1>
<span>用户、权限、AI 与数据刷新</span>
</div>
<button id="backToViewer" class="dark-btn">返回阅片</button>
</section>
<div id="settingsContent" class="settings-content"></div>
</main>
</div>
<div id="infoModal" class="modal hidden">
@@ -172,19 +183,6 @@
</div>
</div>
<div id="settingsModal" class="modal hidden">
<div class="modal-card settings-card">
<div class="modal-head">
<div>
<h2>设置</h2>
<span>用户、权限、AI 与数据源</span>
</div>
<button class="icon-btn" data-close="settingsModal">×</button>
</div>
<div id="settingsContent" class="settings-content"></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -20,6 +20,10 @@
box-sizing: border-box;
}
.hidden {
display: none !important;
}
body {
margin: 0;
min-width: 1280px;
@@ -370,7 +374,7 @@ button:disabled {
position: absolute;
top: 10px;
right: 10px;
min-width: 24px;
min-width: 48px;
height: 22px;
display: inline-grid;
place-items: center;
@@ -395,13 +399,6 @@ button:disabled {
background: rgba(240, 181, 78, 0.09);
}
.study-status-badge.status-loading {
min-width: 34px;
border-color: rgba(52, 116, 246, 0.56);
color: #bfd5ff;
background: rgba(52, 116, 246, 0.1);
}
.study-card.active {
border-color: rgba(52, 116, 246, 0.82);
background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16);
@@ -947,10 +944,6 @@ button:disabled {
box-shadow: var(--shadow);
}
.settings-card {
width: min(980px, 90vw);
}
.modal-head {
display: flex;
align-items: center;
@@ -1012,11 +1005,41 @@ button:disabled {
}
.settings-content {
max-width: 1180px;
display: grid;
gap: 14px;
margin: 0 auto;
padding: 24px;
}
.settings-page {
min-height: calc(100vh - 66px);
overflow: auto;
background: rgba(6, 8, 12, 0.78);
}
.settings-page-head {
max-width: 1180px;
margin: 0 auto;
padding: 22px 24px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.settings-page-head h1 {
margin: 0;
font-size: 22px;
}
.settings-page-head span {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 13px;
}
.settings-section.split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1044,6 +1067,22 @@ button:disabled {
font-weight: 700;
}
.settings-action {
width: 100%;
height: 36px;
margin-top: 12px;
border: 1px solid rgba(52, 116, 246, 0.45);
border-radius: 8px;
background: rgba(52, 116, 246, 0.16);
color: #1d4ed8;
font-weight: 800;
}
.settings-action:disabled {
cursor: wait;
opacity: 0.58;
}
.settings-table {
width: 100%;
border-collapse: collapse;