Move settings to page and cache study summaries
This commit is contained in:
@@ -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 标注分别记录。
|
||||||
|
|
||||||
## 数据安全
|
## 数据安全
|
||||||
|
|||||||
@@ -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="账号格式不正确")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
if (location.pathname === "/settings" && isAdmin()) {
|
||||||
|
await showSettingsPage(false);
|
||||||
|
} else {
|
||||||
await loadStudies();
|
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user