diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md index 12e7c10..c51272d 100644 --- a/PACS_DICOM处理/数据处理网页端/README.md +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -36,7 +36,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107 ## 功能 -- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`,支持按检查时间或未标注序列数升降序排序、按处理状态筛选,并在后台逐项刷新摘要。 +- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files` 与检查摘要缓存,支持按检查时间或未标注序列数升降序排序、按处理状态筛选;摘要每天 04:00 后台刷新,也会在进入单个检查时刷新该检查。 - 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序;序列读取和缩略图未完成时显示加载状态。 - 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。 - 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 @@ -44,8 +44,8 @@ uvicorn app:app --host 127.0.0.1 --port 8107 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 - 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。 - 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 -- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。 -- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。 +- AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。 +- 设置:管理员独立页面,支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 ## 数据安全 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 2482477..0240a50 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -8,10 +8,12 @@ import json import os import secrets import subprocess +import threading import time import urllib.error import urllib.request from collections import defaultdict +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -78,6 +80,9 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") TOKENS: dict[str, str] = {} STUDY_CACHE: dict[str, dict[str, Any]] = {} STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} +SUMMARY_REFRESH_LOCK = threading.Lock() +SUMMARY_REFRESH_STATE: dict[str, Any] = {"running": False, "started_at": "", "finished_at": "", "message": "未运行"} +SUMMARY_SCHEDULER_STARTED = False class LoginIn(BaseModel): @@ -107,6 +112,10 @@ class AIRequest(BaseModel): sample_count: int = 3 +class AISettingsIn(BaseModel): + enabled: bool = True + + class UserIn(BaseModel): username: str password: str @@ -266,6 +275,66 @@ def ensure_user_table() -> None: pg_scalar(sql) +def ensure_settings_table() -> None: + sql = """ + CREATE TABLE IF NOT EXISTS public.pacs_web_settings ( + key text PRIMARY KEY, + value text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() + ); + INSERT INTO public.pacs_web_settings (key, value) + VALUES ('ai_enabled', 'true') + ON CONFLICT (key) DO NOTHING; + """ + pg_scalar(sql) + + +def ensure_study_summary_table() -> None: + sql = """ + CREATE TABLE IF NOT EXISTS public.pacs_dicom_study_summaries ( + ct_number text PRIMARY KEY, + series_count integer NOT NULL DEFAULT 0, + annotated_series integer NOT NULL DEFAULT 0, + unannotated_series integer NOT NULL DEFAULT 0, + refreshed_at timestamptz NOT NULL DEFAULT now() + ); + """ + pg_scalar(sql) + + +def setting_value(key: str, default: str = "") -> str: + try: + ensure_settings_table() + rows = pg_json_rows( + f""" + SELECT value + FROM public.pacs_web_settings + WHERE key = {sql_literal(key)} + LIMIT 1 + """ + ) + return str(rows[0]["value"]) if rows else default + except Exception: + return default + + +def set_setting_value(key: str, value: str) -> None: + ensure_settings_table() + pg_scalar( + f""" + INSERT INTO public.pacs_web_settings (key, value, updated_at) + VALUES ({sql_literal(key)}, {sql_literal(value)}, now()) + ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = now() + """ + ) + + +def ai_enabled() -> bool: + return setting_value("ai_enabled", "true").lower() in {"true", "1", "yes", "on"} + + def web_users() -> list[dict[str, Any]]: try: ensure_user_table() @@ -280,6 +349,30 @@ def web_users() -> list[dict[str, Any]]: return [{"username": WEB_USER, "role": "管理员", "status": "启用"}] +def web_user_record(username: str) -> dict[str, Any]: + try: + ensure_user_table() + rows = pg_json_rows( + f""" + SELECT username, role, status + FROM public.pacs_web_users + WHERE username = {sql_literal(username)} + LIMIT 1 + """ + ) + if rows: + return rows[0] + except Exception: + pass + if username == WEB_USER: + return {"username": WEB_USER, "role": "管理员", "status": "启用"} + return {"username": username, "role": "访客", "status": "启用"} + + +def user_role(username: str) -> str: + return str(web_user_record(username).get("role") or "访客") + + def authenticate_web_user(username: str, password: str) -> bool: try: ok, _ = db_available() @@ -303,10 +396,16 @@ def authenticate_web_user(username: str, password: str) -> bool: @app.on_event("startup") def startup() -> None: + global SUMMARY_SCHEDULER_STARTED ok, _ = db_available() if ok: ensure_annotation_table() ensure_user_table() + ensure_settings_table() + ensure_study_summary_table() + if not SUMMARY_SCHEDULER_STARTED: + SUMMARY_SCHEDULER_STARTED = True + threading.Thread(target=summary_scheduler_loop, daemon=True).start() def require_auth(authorization: str | None = Header(default=None), access_token: str = Query(default="")) -> str: @@ -321,20 +420,36 @@ def require_auth(authorization: str | None = Header(default=None), access_token: return user +def require_admin(user: str = Depends(require_auth)) -> str: + if user_role(user) != "管理员": + raise HTTPException(status_code=403, detail="仅管理员可访问设置") + return user + + @app.get("/") def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") +@app.get("/settings") +def settings_page() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + @app.post("/api/auth/login") def login(data: LoginIn) -> dict[str, str]: if authenticate_web_user(data.username, data.password): token = secrets.token_urlsafe(32) TOKENS[token] = data.username - return {"token": token, "username": data.username} + return {"token": token, "username": data.username, "role": user_role(data.username)} raise HTTPException(status_code=401, detail="invalid credentials") +@app.get("/api/auth/me") +def me(user: str = Depends(require_auth)) -> dict[str, str]: + return {"username": user, "role": user_role(user)} + + @app.get("/api/status") def status() -> dict[str, Any]: db_ok, db_message = db_available() @@ -347,11 +462,46 @@ def status() -> dict[str, Any]: return { "database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count}, "dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()}, - "ai": {"configured": bool(KIMI_API_KEY), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL}, + "ai": {"configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL}, "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), } +def annotation_counts_by_study() -> dict[str, int]: + try: + annotations = pg_json_rows( + """ + SELECT ct_number, count(*)::int AS annotated_series + FROM public.pacs_dicom_series_annotations + WHERE skipped IS TRUE + OR jsonb_array_length(body_parts) > 0 + OR plain_ct IS TRUE + OR NULLIF(notes, '') IS NOT NULL + OR ai_result IS NOT NULL + GROUP BY ct_number + """, + timeout=8, + ) + except Exception: + return {} + return {row["ct_number"]: int(row["annotated_series"] or 0) for row in annotations} + + +def summary_rows_by_study() -> dict[str, dict[str, Any]]: + try: + ensure_study_summary_table() + rows = pg_json_rows( + """ + SELECT ct_number, series_count, annotated_series, unannotated_series, refreshed_at + FROM public.pacs_dicom_study_summaries + """, + timeout=8, + ) + except Exception: + return {} + return {row["ct_number"]: row for row in rows} + + @app.get("/api/studies") def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]: where = "" @@ -370,24 +520,15 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li LIMIT {int(limit)} """ ) - annotations = pg_json_rows( - """ - SELECT ct_number, count(*)::int AS annotated_series - FROM public.pacs_dicom_series_annotations - WHERE skipped IS TRUE - OR jsonb_array_length(body_parts) > 0 - OR plain_ct IS TRUE - OR NULLIF(notes, '') IS NOT NULL - OR ai_result IS NOT NULL - GROUP BY ct_number - """, - timeout=8, - ) - annotation_map = {row["ct_number"]: row["annotated_series"] for row in annotations} + annotation_map = annotation_counts_by_study() + summary_map = summary_rows_by_study() for row in rows: - total_series = int(row.get("series_count") or 0) - row["annotated_series"] = min(total_series, int(annotation_map.get(row["ct_number"], 0))) + summary = summary_map.get(row["ct_number"]) + total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0) + row["series_count"] = total_series + row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_map.get(row["ct_number"], 0))) row["unannotated_series"] = max(0, total_series - int(row["annotated_series"])) + row["summary_updated_at"] = (summary or {}).get("refreshed_at", "") return rows @@ -790,6 +931,7 @@ def scan_study(ct_number: str) -> dict[str, Any]: series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, *series_time_key(row))) cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map} STUDY_CACHE[ct_number] = cached + save_study_summary(study_summary_payload(cached)) return cached @@ -814,6 +956,82 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]: } +def save_study_summary(summary: dict[str, Any]) -> None: + try: + ensure_study_summary_table() + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_study_summaries ( + ct_number, series_count, annotated_series, unannotated_series, refreshed_at + ) + VALUES ( + {sql_literal(summary.get('ct_number', ''))}, + {int(summary.get('series_count') or 0)}, + {int(summary.get('annotated_series') or 0)}, + {int(summary.get('unannotated_series') or 0)}, + now() + ) + ON CONFLICT (ct_number) DO UPDATE SET + series_count = EXCLUDED.series_count, + annotated_series = EXCLUDED.annotated_series, + unannotated_series = EXCLUDED.unannotated_series, + refreshed_at = now() + """, + timeout=8, + ) + except Exception: + pass + + +def all_ct_numbers() -> list[str]: + rows = pg_json_rows( + f""" + SELECT ct_number + FROM public.{PGTABLE} + ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number + """, + timeout=20, + ) + return [str(row["ct_number"]) for row in rows if row.get("ct_number")] + + +def refresh_all_study_summaries(reason: str = "manual") -> None: + if not SUMMARY_REFRESH_LOCK.acquire(blocking=False): + return + SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"}) + try: + numbers = all_ct_numbers() + for index, ct_number in enumerate(numbers, start=1): + STUDY_CACHE.pop(ct_number, None) + try: + data = scan_study(ct_number) + save_study_summary(study_summary_payload(data)) + SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}" + except Exception as exc: # noqa: BLE001 + SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}" + SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"}) + except Exception as exc: # noqa: BLE001 + SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"}) + finally: + SUMMARY_REFRESH_STATE["running"] = False + SUMMARY_REFRESH_LOCK.release() + + +def next_four_am() -> datetime: + now = datetime.now() + target = now.replace(hour=4, minute=0, second=0, microsecond=0) + if target <= now: + target += timedelta(days=1) + return target + + +def summary_scheduler_loop() -> None: + while True: + sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds()) + time.sleep(sleep_seconds) + refresh_all_study_summaries("凌晨4点定时") + + @app.get("/api/studies/{ct_number}/series") def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: data = scan_study(ct_number) @@ -1260,8 +1478,8 @@ def parse_ai_json(content: str) -> dict[str, Any]: @app.post("/api/series/{ct_number}/{series_uid}/ai") def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]: - if not KIMI_API_KEY: - raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置") + if not KIMI_API_KEY or not ai_enabled(): + raise HTTPException(status_code=400, detail="AI 功能未启用或 KIMI_API_KEY 未配置") study = scan_study(ct_number) series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) if not series_row: @@ -1357,20 +1575,33 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen @app.get("/api/settings") -def settings(_: str = Depends(require_auth)) -> dict[str, Any]: +def settings(_: str = Depends(require_admin)) -> dict[str, Any]: return { "users": web_users(), "roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()], "database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE}, - "ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "url": KIMI_API_URL}, + "ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "url": KIMI_API_URL}, "dicom": {"processed_root": str(PROCESSED_ROOT)}, + "summary_refresh": SUMMARY_REFRESH_STATE, } +@app.post("/api/settings/ai") +def update_ai_settings(data: AISettingsIn, _: str = Depends(require_admin)) -> dict[str, Any]: + set_setting_value("ai_enabled", "true" if data.enabled else "false") + return {"ok": True, "enabled": data.enabled} + + +@app.post("/api/settings/refresh-summaries") +def refresh_summaries_now(_: str = Depends(require_admin)) -> dict[str, Any]: + if SUMMARY_REFRESH_STATE.get("running"): + return {"ok": True, **SUMMARY_REFRESH_STATE} + threading.Thread(target=refresh_all_study_summaries, args=("手动",), daemon=True).start() + return {"ok": True, "running": True, "message": "已开始后台刷新"} + + @app.post("/api/settings/users") -def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]: - if user != WEB_USER: - raise HTTPException(status_code=403, detail="仅管理员可创建用户") +def create_user(data: UserIn, user: str = Depends(require_admin)) -> dict[str, Any]: username = data.username.strip() if not username or len(username) > 64: raise HTTPException(status_code=400, detail="账号格式不正确") diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 3d4ff3d..e7db332 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -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 = ` - ${studyStatusLabel(status)} + ${studyStatusLabel(status)} ${escapeHtml(study.ct_number)} ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")} ${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张 @@ -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 = `
-

用户创建

当前登录:admin
+

用户创建

当前登录:${escapeHtml(app.user?.username || "-")}
@@ -1007,13 +1038,27 @@ async function openSettings() {
-

AI 设置

${settings.ai?.configured ? "已配置" : "未配置"}
+

AI 设置

${settings.ai?.enabled ? "已启用" : "已关闭"}
供应商
${escapeHtml(settings.ai?.provider || "-")}
名称
${escapeHtml(settings.ai?.name || "-")}
模型
${escapeHtml(settings.ai?.model || "-")}
+
状态
${settings.ai?.configured ? "API 已配置" : "API 未配置"}
+
+
+

检查摘要

${refresh.running ? "刷新中" : "待命"}
+
+
计划刷新
每天 04:00
+
最近开始
${escapeHtml(refresh.started_at || "-")}
+
最近完成
${escapeHtml(refresh.finished_at || "-")}
+
状态
${escapeHtml(refresh.message || "-")}
+
+ +
+
+

数据库

${status.database?.ok ? "已连接" : "异常"}
@@ -1025,7 +1070,8 @@ async function openSettings() {
`; $("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); diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 80abd44..efba69d 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -40,7 +40,7 @@ -
+
+ +
+
+
+

设置

+ 用户、权限、AI 与数据刷新 +
+ +
+
+
- - diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 5f56981..475176d 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -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;