Move settings to page and cache study summaries
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user