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

@@ -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 头信息,支持按时间或张数升降序排序;序列读取和缩略图未完成时显示加载状态。 - 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序;序列读取和缩略图未完成时显示加载状态。
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。 - 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 - 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
@@ -44,8 +44,8 @@ uvicorn app:app --host 127.0.0.1 --port 8107
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。 - 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。
- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 - 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。 - AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。
- 设置:支持用户创建、角色权限展示、数据库状态AI 配置查看 - 设置:管理员独立页面,支持用户创建、角色权限展示、数据库状态AI 开关、检查摘要立刻更新
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
## 数据安全 ## 数据安全

View File

@@ -8,10 +8,12 @@ import json
import os import os
import secrets import secrets
import subprocess import subprocess
import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -78,6 +80,9 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, str] = {} TOKENS: dict[str, str] = {}
STUDY_CACHE: dict[str, dict[str, Any]] = {} STUDY_CACHE: dict[str, dict[str, Any]] = {}
STACK_CACHE: dict[str, tuple[float, 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): class LoginIn(BaseModel):
@@ -107,6 +112,10 @@ class AIRequest(BaseModel):
sample_count: int = 3 sample_count: int = 3
class AISettingsIn(BaseModel):
enabled: bool = True
class UserIn(BaseModel): class UserIn(BaseModel):
username: str username: str
password: str password: str
@@ -266,6 +275,66 @@ def ensure_user_table() -> None:
pg_scalar(sql) 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]]: def web_users() -> list[dict[str, Any]]:
try: try:
ensure_user_table() ensure_user_table()
@@ -280,6 +349,30 @@ def web_users() -> list[dict[str, Any]]:
return [{"username": WEB_USER, "role": "管理员", "status": "启用"}] 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: def authenticate_web_user(username: str, password: str) -> bool:
try: try:
ok, _ = db_available() ok, _ = db_available()
@@ -303,10 +396,16 @@ def authenticate_web_user(username: str, password: str) -> bool:
@app.on_event("startup") @app.on_event("startup")
def startup() -> None: def startup() -> None:
global SUMMARY_SCHEDULER_STARTED
ok, _ = db_available() ok, _ = db_available()
if ok: if ok:
ensure_annotation_table() ensure_annotation_table()
ensure_user_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: 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 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("/") @app.get("/")
def index() -> FileResponse: def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")
@app.get("/settings")
def settings_page() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.post("/api/auth/login") @app.post("/api/auth/login")
def login(data: LoginIn) -> dict[str, str]: def login(data: LoginIn) -> dict[str, str]:
if authenticate_web_user(data.username, data.password): if authenticate_web_user(data.username, data.password):
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
TOKENS[token] = data.username 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") 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") @app.get("/api/status")
def status() -> dict[str, Any]: def status() -> dict[str, Any]:
db_ok, db_message = db_available() db_ok, db_message = db_available()
@@ -347,11 +462,46 @@ def status() -> dict[str, Any]:
return { return {
"database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count}, "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()}, "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"), "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") @app.get("/api/studies")
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]: def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
where = "" where = ""
@@ -370,24 +520,15 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
LIMIT {int(limit)} LIMIT {int(limit)}
""" """
) )
annotations = pg_json_rows( annotation_map = annotation_counts_by_study()
""" summary_map = summary_rows_by_study()
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}
for row in rows: for row in rows:
total_series = int(row.get("series_count") or 0) summary = summary_map.get(row["ct_number"])
row["annotated_series"] = min(total_series, int(annotation_map.get(row["ct_number"], 0))) 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["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
row["summary_updated_at"] = (summary or {}).get("refreshed_at", "")
return rows 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))) 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} cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map}
STUDY_CACHE[ct_number] = cached STUDY_CACHE[ct_number] = cached
save_study_summary(study_summary_payload(cached))
return 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") @app.get("/api/studies/{ct_number}/series")
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
data = scan_study(ct_number) 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") @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]: def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]:
if not KIMI_API_KEY: if not KIMI_API_KEY or not ai_enabled():
raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置") raise HTTPException(status_code=400, detail="AI 功能未启用或 KIMI_API_KEY 未配置")
study = scan_study(ct_number) study = scan_study(ct_number)
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
if not series_row: 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") @app.get("/api/settings")
def settings(_: str = Depends(require_auth)) -> dict[str, Any]: def settings(_: str = Depends(require_admin)) -> dict[str, Any]:
return { return {
"users": web_users(), "users": web_users(),
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()], "roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE}, "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)}, "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") @app.post("/api/settings/users")
def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]: def create_user(data: UserIn, user: str = Depends(require_admin)) -> dict[str, Any]:
if user != WEB_USER:
raise HTTPException(status_code=403, detail="仅管理员可创建用户")
username = data.username.strip() username = data.username.strip()
if not username or len(username) > 64: if not username or len(username) > 64:
raise HTTPException(status_code=400, detail="账号格式不正确") raise HTTPException(status_code=400, detail="账号格式不正确")

View File

@@ -1,5 +1,6 @@
const app = { const app = {
token: localStorage.getItem("pacs_web_token") || "", token: localStorage.getItem("pacs_web_token") || "",
user: null,
studies: [], studies: [],
study: null, study: null,
series: [], series: [],
@@ -23,7 +24,7 @@ const app = {
studySortField: "time", studySortField: "time",
studySortDir: "desc", studySortDir: "desc",
studyFilter: "all", studyFilter: "all",
summaryRun: 0, aiEnabled: true,
seriesSortField: "time", seriesSortField: "time",
seriesSortDir: "asc", seriesSortDir: "asc",
showOverlay: true, showOverlay: true,
@@ -164,7 +165,9 @@ async function login(event) {
body: JSON.stringify({ username: $("username").value, password: $("password").value }), body: JSON.stringify({ username: $("username").value, password: $("password").value }),
}); });
app.token = data.token; app.token = data.token;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_web_token", app.token); localStorage.setItem("pacs_web_token", app.token);
applyPermissions();
showLogin(false); showLogin(false);
await boot(); await boot();
} catch (_) { } catch (_) {
@@ -175,10 +178,27 @@ async function login(event) {
async function logout() { async function logout() {
await confirmSaveIfDirty(); await confirmSaveIfDirty();
app.token = ""; app.token = "";
app.user = null;
localStorage.removeItem("pacs_web_token"); localStorage.removeItem("pacs_web_token");
applyPermissions();
showViewerPage(false);
showLogin(true); 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() { async function refreshStatus() {
try { try {
const data = await fetch("/api/status").then((res) => res.json()); const data = await fetch("/api/status").then((res) => res.json());
@@ -187,6 +207,9 @@ async function refreshStatus() {
pill.title = data.database?.message || ""; pill.title = data.database?.message || "";
pill.classList.toggle("offline", !data.database?.ok); pill.classList.toggle("offline", !data.database?.ok);
pill.classList.toggle("online", Boolean(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 (_) { } catch (_) {
$("dbStatus").textContent = "数据库异常"; $("dbStatus").textContent = "数据库异常";
$("dbStatus").classList.add("offline"); $("dbStatus").classList.add("offline");
@@ -218,7 +241,6 @@ function sortStudies(list) {
} }
function studyStatus(study) { function studyStatus(study) {
if (study.summary_loading) return "loading";
const total = Number(study.series_count || 0); const total = Number(study.series_count || 0);
const unannotated = Number(study.unannotated_series || 0); const unannotated = Number(study.unannotated_series || 0);
if (total > 0 && unannotated === 0) return "complete"; if (total > 0 && unannotated === 0) return "complete";
@@ -226,7 +248,7 @@ function studyStatus(study) {
} }
function studyStatusLabel(status) { function studyStatusLabel(status) {
return { complete: "完成", incomplete: "待", loading: "同步" }[status] || "待"; return status === "complete" ? "已完成" : "待处理";
} }
function visibleStudies() { function visibleStudies() {
@@ -283,12 +305,10 @@ function refreshStudyAnnotationSummary() {
} }
async function loadStudies() { async function loadStudies() {
app.summaryRun += 1;
const q = encodeURIComponent($("studySearch").value.trim()); const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
renderStudies(); renderStudies();
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number); if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
refreshStudySummaries();
} }
function renderStudies() { function renderStudies() {
@@ -304,10 +324,9 @@ function renderStudies() {
button.className = "study-card"; button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number); button.classList.toggle("active", app.study?.ct_number === study.ct_number);
button.classList.toggle("complete", status === "complete"); button.classList.toggle("complete", status === "complete");
button.classList.toggle("loading", status === "loading");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = ` 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> <strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span> <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> <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) { async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty(); 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 }; app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
@@ -983,12 +983,43 @@ function roleCards(roles) {
.join(""); .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(); 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 [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
const refresh = settings.summary_refresh || {};
$("settingsContent").innerHTML = ` $("settingsContent").innerHTML = `
<section class="settings-section"> <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"> <form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" autocomplete="off" /> <input name="username" placeholder="新账号" autocomplete="off" />
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" /> <input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
@@ -1007,13 +1038,27 @@ async function openSettings() {
</section> </section>
<section class="settings-section split"> <section class="settings-section split">
<div> <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> <dl>
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd> <dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd> <dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd> <dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
<dt>状态</dt><dd>${settings.ai?.configured ? "API 已配置" : "API 未配置"}</dd>
</dl> </dl>
<button id="toggleAi" class="settings-action">${settings.ai?.enabled ? "关闭 AI" : "启用 AI"}</button>
</div> </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>
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div> <div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
<dl> <dl>
@@ -1025,7 +1070,8 @@ async function openSettings() {
</section> </section>
`; `;
$("createUserForm").addEventListener("submit", createUser); $("createUserForm").addEventListener("submit", createUser);
$("settingsModal").classList.remove("hidden"); $("toggleAi").addEventListener("click", () => toggleAI(!settings.ai?.enabled));
$("refreshSummariesNow").addEventListener("click", refreshSummariesNow);
} }
async function createUser(event) { async function createUser(event) {
@@ -1042,7 +1088,7 @@ async function createUser(event) {
button.textContent = "创建中"; button.textContent = "创建中";
try { try {
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) }); await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
await openSettings(); await renderSettingsPage();
} catch (err) { } catch (err) {
button.textContent = err.message; button.textContent = err.message;
setTimeout(() => { 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) { function changeZoom(multiplier) {
app.zoom = clamp(app.zoom * multiplier, 0.25, 6); app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
applyTransform(); applyTransform();
@@ -1118,6 +1178,7 @@ function wire() {
$("loginForm").addEventListener("submit", login); $("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout); $("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings); $("settingsBtn").addEventListener("click", openSettings);
$("backToViewer").addEventListener("click", () => showViewerPage(true));
$("infoBtn").addEventListener("click", openInfo); $("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI); $("aiClassify").addEventListener("click", runAI);
$("zoomIn").addEventListener("click", () => changeZoom(1.18)); $("zoomIn").addEventListener("click", () => changeZoom(1.18));
@@ -1187,16 +1248,24 @@ function wire() {
document.querySelectorAll("[data-close]").forEach((button) => { document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden")); button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
}); });
window.addEventListener("popstate", () => handleRoute(false));
wireImageGestures(); wireImageGestures();
} }
async function boot() { async function boot() {
await loadCurrentUser();
await refreshStatus(); 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); if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
} }
wire(); wire();
applyPermissions();
refreshStatus(); refreshStatus();
if (app.token) { if (app.token) {
showLogin(false); showLogin(false);

View File

@@ -40,7 +40,7 @@
</div> </div>
</header> </header>
<main class="workspace"> <main id="viewerPage" class="workspace">
<aside class="study-pane"> <aside class="study-pane">
<div class="pane-head"> <div class="pane-head">
<h2>检查列表</h2> <h2>检查列表</h2>
@@ -157,6 +157,17 @@
</div> </div>
</section> </section>
</main> </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>
<div id="infoModal" class="modal hidden"> <div id="infoModal" class="modal hidden">
@@ -172,19 +183,6 @@
</div> </div>
</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> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -20,6 +20,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
.hidden {
display: none !important;
}
body { body {
margin: 0; margin: 0;
min-width: 1280px; min-width: 1280px;
@@ -370,7 +374,7 @@ button:disabled {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; right: 10px;
min-width: 24px; min-width: 48px;
height: 22px; height: 22px;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
@@ -395,13 +399,6 @@ button:disabled {
background: rgba(240, 181, 78, 0.09); 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 { .study-card.active {
border-color: rgba(52, 116, 246, 0.82); border-color: rgba(52, 116, 246, 0.82);
background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16); background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16);
@@ -947,10 +944,6 @@ button:disabled {
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.settings-card {
width: min(980px, 90vw);
}
.modal-head { .modal-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1012,11 +1005,41 @@ button:disabled {
} }
.settings-content { .settings-content {
max-width: 1180px;
display: grid; display: grid;
gap: 14px; gap: 14px;
margin: 0 auto;
padding: 24px; 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 { .settings-section.split {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1044,6 +1067,22 @@ button:disabled {
font-weight: 700; 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 { .settings-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;