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 || "-")}AI 设置
${settings.ai?.configured ? "已配置" : "未配置"}AI 设置
${settings.ai?.enabled ? "已启用" : "已关闭"}
+
检查摘要
${refresh.running ? "刷新中" : "待命"}
+
+
+ 数据库
${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 @@