Add PACS image mount cache refresh settings

This commit is contained in:
Codex
2026-05-29 03:18:50 +08:00
parent dbf52d147a
commit 54bae18a21
9 changed files with 694 additions and 11 deletions

4
.gitignore vendored
View File

@@ -28,3 +28,7 @@ PACS_DICOM处理/待处理_DICOM数据/
PACS_DICOM处理/已处理_DICOM数据 PACS_DICOM处理/已处理_DICOM数据
PACS_DICOM处理/已处理_DICOM数据/ PACS_DICOM处理/已处理_DICOM数据/
PACS_DICOM处理/数据处理结果区/ PACS_DICOM处理/数据处理结果区/
# DICOM/UPP 配准运行缓存和共享盘缓存软链接不提交
DICOM_and_UPP配准/runtime/
DICOM_and_UPP配准/缓存数据

View File

@@ -13,6 +13,11 @@ REGISTRATION_TABLE=dicom_upp_registrations
REGISTRATION_WEB_USER=admin REGISTRATION_WEB_USER=admin
REGISTRATION_WEB_PASSWORD=123456 REGISTRATION_WEB_PASSWORD=123456
SMB_USERNAME=change_me
SMB_PASSWORD=change_me
PACS_VIEWER_URL=http://127.0.0.1:8107 PACS_VIEWER_URL=http://127.0.0.1:8107
RELATION_VIEWER_URL=http://127.0.0.1:8108 RELATION_VIEWER_URL=http://127.0.0.1:8108
PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据 PACS_IMAGE_DB_ROOT=/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3
PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3/PACS数据/DICOM数据/已处理_DICOM数据
REGISTRATION_CACHE_ROOT=/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3/PACS数据/DICOM与UPP配准数据
REGISTRATION_RUNTIME_DIR=/home/wkmgc/Desktop/PACS数据处理/DICOM_and_UPP配准/runtime

View File

@@ -2,12 +2,15 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import datetime as dt
import io import io
import json import json
import os import os
import re import re
import secrets import secrets
import shutil
import subprocess import subprocess
import threading
import time import time
from collections import Counter, defaultdict from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
@@ -54,7 +57,12 @@ WEB_USER = os.getenv("REGISTRATION_WEB_USER", "admin")
WEB_PASSWORD = os.getenv("REGISTRATION_WEB_PASSWORD", "123456") WEB_PASSWORD = os.getenv("REGISTRATION_WEB_PASSWORD", "123456")
PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107") PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107")
RELATION_VIEWER_URL = os.getenv("RELATION_VIEWER_URL", "http://127.0.0.1:8108") RELATION_VIEWER_URL = os.getenv("RELATION_VIEWER_URL", "http://127.0.0.1:8108")
PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", "/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据")) PACS_IMAGE_DB_ROOT = Path(os.getenv("PACS_IMAGE_DB_ROOT", "/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3"))
PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", str(PACS_IMAGE_DB_ROOT / "PACS数据" / "DICOM数据" / "已处理_DICOM数据")))
REGISTRATION_CACHE_ROOT = Path(os.getenv("REGISTRATION_CACHE_ROOT", str(PACS_IMAGE_DB_ROOT / "PACS数据" / "DICOM与UPP配准数据")))
RUNTIME_DIR = Path(os.getenv("REGISTRATION_RUNTIME_DIR", str(APP_DIR / "runtime")))
SETTINGS_FILE = RUNTIME_DIR / "settings.json"
CACHE_STATUS_FILE = RUNTIME_DIR / "cache_refresh_status.json"
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
WINDOWS = { WINDOWS = {
@@ -116,6 +124,30 @@ 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]]] = {}
CACHE_REFRESH_LOCK = threading.Lock()
CACHE_REFRESH_THREAD: threading.Thread | None = None
SCHEDULER_THREAD: threading.Thread | None = None
DEFAULT_REFRESH_SETTINGS = {
"auto_refresh_enabled": True,
"refresh_time": "03:00",
"cache_root": str(REGISTRATION_CACHE_ROOT),
}
DEFAULT_CACHE_STATUS: dict[str, Any] = {
"running": False,
"stage": "idle",
"message": "尚未刷新",
"progress": 0,
"total_cases": 0,
"processed_cases": 0,
"copied_files": 0,
"skipped_files": 0,
"missing_files": 0,
"errors": [],
"last_started_at": "",
"last_finished_at": "",
"last_scheduled_date": "",
}
CACHE_REFRESH_STATUS: dict[str, Any] = {**DEFAULT_CACHE_STATUS}
class LoginPayload(BaseModel): class LoginPayload(BaseModel):
@@ -137,6 +169,11 @@ class RegistrationPayload(BaseModel):
notes: str = "" notes: str = ""
class RefreshSettingsPayload(BaseModel):
auto_refresh_enabled: bool = True
refresh_time: str = "03:00"
def pg_env() -> dict[str, str]: def pg_env() -> dict[str, str]:
env = os.environ.copy() env = os.environ.copy()
env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE}) env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE})
@@ -181,6 +218,134 @@ def normalize_ct(value: Any) -> str:
return re.sub(r"\s+", "", str(value or "")).upper() return re.sub(r"\s+", "", str(value or "")).upper()
def now_text() -> str:
return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def ensure_runtime_dir() -> None:
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
def valid_refresh_time(value: str) -> str:
value = str(value or "").strip()
if re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", value):
return value
return "03:00"
def read_refresh_settings() -> dict[str, Any]:
settings = {**DEFAULT_REFRESH_SETTINGS}
try:
if SETTINGS_FILE.exists():
loaded = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
settings.update(loaded)
except Exception:
pass
settings["auto_refresh_enabled"] = bool(settings.get("auto_refresh_enabled", True))
settings["refresh_time"] = valid_refresh_time(str(settings.get("refresh_time") or "03:00"))
settings["cache_root"] = str(REGISTRATION_CACHE_ROOT)
return settings
def write_refresh_settings(settings: dict[str, Any]) -> dict[str, Any]:
payload = {
"auto_refresh_enabled": bool(settings.get("auto_refresh_enabled", True)),
"refresh_time": valid_refresh_time(str(settings.get("refresh_time") or "03:00")),
"cache_root": str(REGISTRATION_CACHE_ROOT),
}
ensure_runtime_dir()
SETTINGS_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return payload
def read_cache_status_file() -> dict[str, Any]:
status = {**DEFAULT_CACHE_STATUS}
try:
if CACHE_STATUS_FILE.exists():
loaded = json.loads(CACHE_STATUS_FILE.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
status.update(loaded)
except Exception:
pass
return status
def write_cache_status(status: dict[str, Any]) -> None:
ensure_runtime_dir()
CACHE_STATUS_FILE.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
def update_cache_status(**updates: Any) -> dict[str, Any]:
global CACHE_REFRESH_STATUS
with CACHE_REFRESH_LOCK:
CACHE_REFRESH_STATUS.update(updates)
status = {**CACHE_REFRESH_STATUS}
try:
write_cache_status(status)
except Exception:
pass
return status
def get_cache_status_snapshot() -> dict[str, Any]:
with CACHE_REFRESH_LOCK:
status = {**CACHE_REFRESH_STATUS}
settings = read_refresh_settings()
try:
refresh_hour, refresh_minute = [int(part) for part in settings["refresh_time"].split(":", 1)]
candidate = dt.datetime.now().replace(hour=refresh_hour, minute=refresh_minute, second=0, microsecond=0)
if candidate <= dt.datetime.now():
candidate += dt.timedelta(days=1)
status["next_scheduled_at"] = candidate.strftime("%Y-%m-%d %H:%M:%S") if settings["auto_refresh_enabled"] else ""
except Exception:
status["next_scheduled_at"] = ""
status["cache_root"] = str(REGISTRATION_CACHE_ROOT)
return status
def safe_path_token(value: Any) -> str:
text_value = re.sub(r"\s+", "_", str(value or "unknown").strip())
text_value = re.sub(r'[\\\\/:*?"<>|]+', "_", text_value)
return text_value[:120] or "unknown"
def mapped_path(value: Any) -> Path:
raw = str(value or "").strip()
if not raw:
return Path("")
path = Path(raw)
if path.exists():
return path
mappings = [
(
"/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3",
PACS_IMAGE_DB_ROOT,
),
(
"/home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/已处理_DICOM数据",
PACS_IMAGE_DB_ROOT / "PACS数据" / "DICOM数据" / "已处理_DICOM数据",
),
(
"/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据",
PACS_IMAGE_DB_ROOT / "PACS数据" / "DICOM数据" / "已处理_DICOM数据",
),
(
"/home/wkmgc/Desktop/PACS数据处理/UPP_STL处理/已处理STL数据",
PACS_IMAGE_DB_ROOT / "PACS数据" / "重建STL数据" / "已处理STL数据",
),
(
"/home/wkmgc/Desktop/Data_Disk_1/PACS数据/重建STL数据/已处理STL数据",
PACS_IMAGE_DB_ROOT / "PACS数据" / "重建STL数据" / "已处理STL数据",
),
]
for source, target in mappings:
if raw == source or raw.startswith(source + "/"):
relative = raw.removeprefix(source).lstrip("/")
return target / relative
return path
def parse_json_list(value: Any) -> list[Any]: def parse_json_list(value: Any) -> list[Any]:
if isinstance(value, list): if isinstance(value, list):
return value return value
@@ -328,10 +493,17 @@ def require_auth(authorization: str | None = Header(default=None), access_token:
@app.on_event("startup") @app.on_event("startup")
def startup() -> None: def startup() -> None:
global CACHE_REFRESH_STATUS
CACHE_REFRESH_STATUS = read_cache_status_file()
if CACHE_REFRESH_STATUS.get("running"):
CACHE_REFRESH_STATUS.update({"running": False, "stage": "interrupted", "message": "上次刷新未正常结束,可手动重新刷新"})
write_refresh_settings(read_refresh_settings())
write_cache_status(CACHE_REFRESH_STATUS)
try: try:
ensure_registration_table() ensure_registration_table()
except Exception: except Exception:
pass pass
start_cache_scheduler()
@app.get("/") @app.get("/")
@@ -394,6 +566,28 @@ def status(_: str = Depends(require_auth)) -> dict[str, Any]:
} }
@app.get("/api/settings")
def settings(_: str = Depends(require_auth)) -> dict[str, Any]:
return {"refresh": read_refresh_settings(), "cache": get_cache_status_snapshot()}
@app.post("/api/settings")
def update_settings(payload: RefreshSettingsPayload, _: str = Depends(require_auth)) -> dict[str, Any]:
settings = write_refresh_settings(payload.dict())
return {"ok": True, "refresh": settings, "cache": get_cache_status_snapshot()}
@app.get("/api/cache/status")
def cache_status(_: str = Depends(require_auth)) -> dict[str, Any]:
return get_cache_status_snapshot()
@app.post("/api/cache/refresh")
def manual_cache_refresh(user: str = Depends(require_auth)) -> dict[str, Any]:
started = start_background_cache_refresh(reason="manual", user=user)
return {"started": started, "cache": get_cache_status_snapshot()}
def annotation_labels_sql() -> str: def annotation_labels_sql() -> str:
return f""" return f"""
SELECT SELECT
@@ -573,7 +767,7 @@ def get_study_record(ct_number: str) -> dict[str, Any]:
def resolve_study_root(study: dict[str, Any]) -> Path: def resolve_study_root(study: dict[str, Any]) -> Path:
root = Path(study.get("processed_path") or "") root = mapped_path(study.get("processed_path") or "")
if root.exists(): if root.exists():
return root return root
target_folder = str(study.get("target_folder_name") or "") target_folder = str(study.get("target_folder_name") or "")
@@ -879,7 +1073,7 @@ def stl_rows_for_ct(ct_number: str, algorithm_model: str = "") -> list[dict[str,
for asset_index, row in enumerate(rows): for asset_index, row in enumerate(rows):
files = parse_json_list(row.get("files")) files = parse_json_list(row.get("files"))
if not files and row.get("processed_stl_dir"): if not files and row.get("processed_stl_dir"):
base = Path(str(row["processed_stl_dir"])) base = mapped_path(row["processed_stl_dir"])
if base.exists(): if base.exists():
files = [ files = [
{ {
@@ -896,17 +1090,18 @@ def stl_rows_for_ct(ct_number: str, algorithm_model: str = "") -> list[dict[str,
path = file_info.get("processed_file_path") or file_info.get("source_file_path") path = file_info.get("processed_file_path") or file_info.get("source_file_path")
if not path: if not path:
continue continue
resolved_path = mapped_path(path)
output.append( output.append(
{ {
"id": index, "id": index,
"asset_id": asset_index, "asset_id": asset_index,
"algorithm_model": row.get("algorithm_model") or "未指定模型", "algorithm_model": row.get("algorithm_model") or "未指定模型",
"file_name": file_info.get("file_name") or Path(path).name, "file_name": file_info.get("file_name") or resolved_path.name,
"segment_name": file_info.get("segment_name") or Path(path).stem, "segment_name": file_info.get("segment_name") or resolved_path.stem,
"family": file_info.get("family") or file_info.get("segment_name") or Path(path).stem, "family": file_info.get("family") or file_info.get("segment_name") or resolved_path.stem,
"category": file_info.get("category") or "未分类", "category": file_info.get("category") or "未分类",
"size_bytes": int(file_info.get("size_bytes") or 0), "size_bytes": int(file_info.get("size_bytes") or 0),
"path": path, "path": str(resolved_path),
} }
) )
index += 1 index += 1
@@ -936,6 +1131,182 @@ def stl_file(ct_number: str, file_id: int, algorithm_model: str = "", _: str = D
return FileResponse(path, media_type="model/stl", filename=path.name) return FileResponse(path, media_type="model/stl", filename=path.name)
def copy_if_changed(source: Path, target: Path) -> bool:
if not source.exists() or not source.is_file():
raise FileNotFoundError(str(source))
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() and target.stat().st_size == source.stat().st_size:
return False
tmp = target.with_suffix(target.suffix + ".tmp")
shutil.copy2(source, tmp)
tmp.replace(target)
return True
def cache_one_case(row: dict[str, Any], cache_root: Path) -> dict[str, int]:
ct_number = normalize_ct(row.get("ct_number") or row.get("ct_key"))
algorithm_model = row.get("algorithm_model") or "未指定模型"
case_dir = cache_root / safe_path_token(ct_number) / safe_path_token(algorithm_model)
stl_dir = case_dir / "stl"
case_dir.mkdir(parents=True, exist_ok=True)
stl_dir.mkdir(parents=True, exist_ok=True)
(case_dir / "case.json").write_text(json.dumps(row, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
counters = {"copied_files": 0, "skipped_files": 0, "missing_files": 0}
# DICOM pixels and per-slice headers stay on-demand; refresh caches only fast index data and STL materials.
series_payload: dict[str, Any] = {
"ct_number": ct_number,
"series_count": row.get("series_count"),
"dicom_file_count": row.get("dicom_file_count"),
"processed_path": str(mapped_path(row.get("processed_path") or "")),
"cached_at": now_text(),
}
(case_dir / "series.json").write_text(json.dumps(series_payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
manifest = []
for file_info in stl_rows_for_ct(ct_number, algorithm_model):
source = Path(str(file_info.get("path") or ""))
target_name = f"{int(file_info.get('id') or 0):03d}_{safe_path_token(source.name or file_info.get('file_name'))}"
target = stl_dir / target_name
cached = {**file_info, "source_path": str(source), "cached_path": str(target), "cached_at": now_text()}
try:
if copy_if_changed(source, target):
counters["copied_files"] += 1
cached["cache_status"] = "copied"
else:
counters["skipped_files"] += 1
cached["cache_status"] = "unchanged"
except FileNotFoundError:
counters["missing_files"] += 1
cached["cache_status"] = "missing"
except Exception as exc:
counters["missing_files"] += 1
cached["cache_status"] = f"error: {exc}"
manifest.append(cached)
(case_dir / "stl_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
return counters
def relation_rows_for_cache() -> list[dict[str, Any]]:
return pg_json_rows(
f"""
SELECT *
FROM ({relation_select()}) relation
ORDER BY
registration_status = 'registered',
COALESCE(study_date, '') DESC,
COALESCE(study_time, '') DESC,
ct_key,
algorithm_model
""",
timeout=60,
)
def run_cache_refresh(reason: str = "manual", user: str = "system") -> None:
cache_root = REGISTRATION_CACHE_ROOT
update_cache_status(
running=True,
stage="starting",
message=f"{reason} 刷新启动",
progress=1,
total_cases=0,
processed_cases=0,
copied_files=0,
skipped_files=0,
missing_files=0,
errors=[],
last_started_at=now_text(),
)
try:
cache_root.mkdir(parents=True, exist_ok=True)
STUDY_CACHE.clear()
STACK_CACHE.clear()
rows = relation_rows_for_cache()
total = len(rows)
update_cache_status(stage="caching", message="正在缓存 DICOM 序列元数据与 STL 材料", total_cases=total, progress=4)
copied = skipped = missing = 0
errors: list[str] = []
for index, row in enumerate(rows, start=1):
label = f"{row.get('ct_number') or row.get('ct_key')} · {row.get('algorithm_model') or '未指定模型'}"
try:
counters = cache_one_case(row, cache_root)
copied += counters["copied_files"]
skipped += counters["skipped_files"]
missing += counters["missing_files"]
except Exception as exc:
errors.append(f"{label}: {exc}")
update_cache_status(
stage="caching",
message=f"正在缓存 {label}",
processed_cases=index,
copied_files=copied,
skipped_files=skipped,
missing_files=missing,
errors=errors[-20:],
progress=4 + int(index / max(total, 1) * 94),
)
update_cache_status(
running=False,
stage="finished",
message=f"刷新完成:{total} 个 CT/模型,复制 {copied} 个 STL复用 {skipped} 个 STL",
progress=100,
total_cases=total,
processed_cases=total,
copied_files=copied,
skipped_files=skipped,
missing_files=missing,
errors=errors[-20:],
last_finished_at=now_text(),
)
except Exception as exc:
update_cache_status(
running=False,
stage="error",
message=f"刷新失败:{exc}",
progress=0,
errors=[str(exc)],
last_finished_at=now_text(),
)
def start_background_cache_refresh(reason: str = "manual", user: str = "system") -> bool:
global CACHE_REFRESH_THREAD
with CACHE_REFRESH_LOCK:
if CACHE_REFRESH_THREAD and CACHE_REFRESH_THREAD.is_alive():
return False
if CACHE_REFRESH_STATUS.get("running"):
return False
CACHE_REFRESH_THREAD = threading.Thread(target=run_cache_refresh, kwargs={"reason": reason, "user": user}, daemon=True)
CACHE_REFRESH_THREAD.start()
return True
def cache_scheduler_loop() -> None:
while True:
try:
settings = read_refresh_settings()
if settings.get("auto_refresh_enabled"):
refresh_time = settings.get("refresh_time") or "03:00"
today = dt.date.today().isoformat()
current = dt.datetime.now().strftime("%H:%M")
status = get_cache_status_snapshot()
if current == refresh_time and status.get("last_scheduled_date") != today:
update_cache_status(last_scheduled_date=today)
start_background_cache_refresh(reason="scheduled", user="system")
except Exception:
pass
time.sleep(30)
def start_cache_scheduler() -> None:
global SCHEDULER_THREAD
if SCHEDULER_THREAD and SCHEDULER_THREAD.is_alive():
return
SCHEDULER_THREAD = threading.Thread(target=cache_scheduler_loop, daemon=True)
SCHEDULER_THREAD.start()
@app.get("/api/registrations/{ct_number}") @app.get("/api/registrations/{ct_number}")
def get_registration(ct_number: str, algorithm_model: str = "未指定模型", _: str = Depends(require_auth)) -> dict[str, Any]: def get_registration(ct_number: str, algorithm_model: str = "未指定模型", _: str = Depends(require_auth)) -> dict[str, Any]:
ensure_registration_table() ensure_registration_table()

View File

@@ -8,7 +8,28 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment:
PACS_IMAGE_DB_ROOT: /pacs-image-db
PACS_PROCESSED_ROOT: /pacs-image-db/PACS数据/DICOM数据/已处理_DICOM数据
REGISTRATION_CACHE_ROOT: /pacs-image-db/PACS数据/DICOM与UPP配准数据
REGISTRATION_RUNTIME_DIR: /app/runtime
ports: ports:
- "8109:8109" - "8109:8109"
volumes: volumes:
- /home/wkmgc/Desktop:/home/wkmgc/Desktop:ro - type: volume
source: pacs_image_db
target: /pacs-image-db
volume:
nocopy: true
- type: bind
source: /home/wkmgc/Desktop/PACS数据处理/DICOM_and_UPP配准/runtime
target: /app/runtime
read_only: false
volumes:
pacs_image_db:
driver: local
driver_opts:
type: cifs
device: //192.168.3.3/pacs影像数据库
o: username=${SMB_USERNAME},password=${SMB_PASSWORD},uid=1001,gid=1001,file_mode=0755,dir_mode=0755,iocharset=utf8,vers=3.0,soft,nounix,mapposix,noperm

View File

@@ -44,6 +44,9 @@ const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "", token: localStorage.getItem("dicom_upp_registration_token") || "",
user: null, user: null,
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" }, links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
settings: null,
cacheStatus: null,
cachePollTimer: 0,
statusFilter: "", statusFilter: "",
partFilter: "", partFilter: "",
modelFilter: "", modelFilter: "",
@@ -224,6 +227,7 @@ async function bootstrap() {
} }
buildPoseControls(); buildPoseControls();
await loadStatus(); await loadStatus();
await loadSettings();
await loadCases(); await loadCases();
} catch (error) { } catch (error) {
console.warn(error); console.warn(error);
@@ -251,6 +255,69 @@ async function loadStatus() {
} }
} }
async function loadSettings() {
try {
const payload = await api("/api/settings");
state.settings = payload.refresh || {};
state.cacheStatus = payload.cache || {};
renderSettings();
pollCacheWhileRunning();
} catch (error) {
console.warn(error);
}
}
function renderSettings() {
const settings = state.settings || {};
const cache = state.cacheStatus || {};
$("autoRefreshEnabled").checked = settings.auto_refresh_enabled !== false;
$("refreshTime").value = settings.refresh_time || "03:00";
$("cacheRootText").textContent = cache.cache_root || settings.cache_root || "";
$("cacheStage").textContent = cache.running ? "刷新中" : cache.stage === "finished" ? "已完成" : cache.stage === "error" ? "异常" : "待刷新";
$("cacheStage").className = `status-pill ${cache.stage === "error" ? "offline" : cache.running || cache.stage === "finished" ? "online" : ""}`;
const progress = Math.max(0, Math.min(100, Number(cache.progress || 0)));
$("cacheProgressFill").style.width = `${progress}%`;
$("cacheMessage").textContent = cache.message || "等待刷新";
$("cacheCases").textContent = `${Number(cache.processed_cases || 0)} / ${Number(cache.total_cases || 0)} CT`;
$("cacheFiles").textContent = `STL 复制 ${Number(cache.copied_files || 0)} · 复用 ${Number(cache.skipped_files || 0)} · 缺失 ${Number(cache.missing_files || 0)}`;
$("cacheNextRun").textContent = cache.next_scheduled_at ? `下次 ${cache.next_scheduled_at}` : "下次 -";
$("manualCacheRefreshBtn").disabled = Boolean(cache.running);
}
async function saveSettings() {
const payload = {
auto_refresh_enabled: $("autoRefreshEnabled").checked,
refresh_time: $("refreshTime").value || "03:00",
};
const result = await api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
state.settings = result.refresh || payload;
state.cacheStatus = result.cache || state.cacheStatus;
renderSettings();
}
async function refreshCacheNow() {
$("manualCacheRefreshBtn").disabled = true;
const result = await api("/api/cache/refresh", { method: "POST", body: JSON.stringify({}) });
state.cacheStatus = result.cache || state.cacheStatus;
renderSettings();
pollCacheWhileRunning(true);
}
function pollCacheWhileRunning(force = false) {
window.clearTimeout(state.cachePollTimer);
const running = Boolean(state.cacheStatus?.running);
if (!running && !force) return;
state.cachePollTimer = window.setTimeout(async () => {
try {
state.cacheStatus = await api("/api/cache/status");
renderSettings();
} catch (error) {
console.warn(error);
}
pollCacheWhileRunning();
}, running ? 1600 : 800);
}
async function loadCases(selectFirst = true) { async function loadCases(selectFirst = true) {
setTopLoading(true); setTopLoading(true);
try { try {
@@ -1924,8 +1991,16 @@ function wireEvents() {
}); });
$("refreshBtn").addEventListener("click", async () => { $("refreshBtn").addEventListener("click", async () => {
await loadStatus(); await loadStatus();
await loadSettings();
await loadCases(false); await loadCases(false);
}); });
$("settingsBtn").addEventListener("click", async () => {
$("settingsPanel").classList.remove("hidden");
await loadSettings();
});
$("settingsCloseBtn").addEventListener("click", () => $("settingsPanel").classList.add("hidden"));
$("saveSettingsBtn").addEventListener("click", saveSettings);
$("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow);
$("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", fitCamera); $("fitBtn").addEventListener("click", fitCamera);

View File

@@ -40,12 +40,60 @@
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button> <button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
<span id="dbStatus" class="status-pill">数据库</span> <span id="dbStatus" class="status-pill">数据库</span>
<button id="refreshBtn" class="ghost-btn" type="button">刷新</button> <button id="refreshBtn" class="ghost-btn" type="button">刷新</button>
<button id="settingsBtn" class="ghost-btn" type="button">设置</button>
<span id="userBadge" class="user-badge">未登录</span> <span id="userBadge" class="user-badge">未登录</span>
<button id="logoutBtn" class="ghost-btn" type="button">退出</button> <button id="logoutBtn" class="ghost-btn" type="button">退出</button>
</div> </div>
</header> </header>
<div id="topLoading" class="top-loading hidden"><span></span></div> <div id="topLoading" class="top-loading hidden"><span></span></div>
<section id="settingsPanel" class="settings-panel hidden" aria-label="系统设置">
<div class="settings-shell">
<div class="settings-head">
<div>
<h2>系统设置</h2>
<p>DICOM / UPP 数据刷新与配准缓存</p>
</div>
<button id="settingsCloseBtn" class="ghost-btn" type="button">关闭</button>
</div>
<div class="settings-grid">
<section class="settings-card">
<div class="settings-card-head">
<h3>自动刷新</h3>
<label class="switch-line">
<input id="autoRefreshEnabled" type="checkbox" />
<span>启用</span>
</label>
</div>
<div class="settings-row">
<label for="refreshTime">每日刷新时间</label>
<input id="refreshTime" type="time" value="03:00" />
</div>
<div class="settings-actions">
<button id="saveSettingsBtn" class="primary-btn" type="button">保存设置</button>
<button id="manualCacheRefreshBtn" class="ghost-btn" type="button">立刻刷新缓存</button>
</div>
</section>
<section class="settings-card">
<div class="settings-card-head">
<h3>缓存状态</h3>
<span id="cacheStage" class="status-pill">未刷新</span>
</div>
<div class="cache-progress">
<span id="cacheProgressFill"></span>
</div>
<div id="cacheMessage" class="cache-message">等待刷新</div>
<div class="settings-metrics">
<span id="cacheCases">0 / 0 CT</span>
<span id="cacheFiles">STL 0</span>
<span id="cacheNextRun">下次 -</span>
</div>
<code id="cacheRootText" class="cache-root"></code>
</section>
</div>
</div>
</section>
<main class="workspace"> <main class="workspace">
<aside class="case-panel panel"> <aside class="case-panel panel">
<div class="panel-head"> <div class="panel-head">

View File

@@ -98,6 +98,146 @@ button {
flex: 0 0 auto; flex: 0 0 auto;
} }
.settings-panel {
position: fixed;
inset: 66px 0 0 0;
z-index: 18;
display: flex;
justify-content: flex-end;
background: rgba(7, 10, 15, 0.62);
backdrop-filter: blur(10px);
}
.settings-shell {
width: min(760px, calc(100vw - 24px));
height: 100%;
padding: 18px;
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(15, 22, 32, 0.98), rgba(8, 13, 20, 0.98));
box-shadow: -24px 0 54px rgba(0, 0, 0, 0.38);
overflow: auto;
}
.settings-head,
.settings-card-head,
.settings-actions,
.settings-metrics {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-head {
margin-bottom: 16px;
}
.settings-head h2,
.settings-card h3 {
margin: 0;
}
.settings-head h2 {
font-size: 22px;
}
.settings-head p {
margin: 5px 0 0;
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.settings-card {
min-width: 0;
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
background: rgba(10, 16, 24, 0.9);
}
.settings-card h3 {
font-size: 17px;
}
.switch-line {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
}
.settings-row {
display: grid;
gap: 8px;
margin: 18px 0;
}
.settings-row label {
color: var(--muted);
font-size: 13px;
}
.settings-row input {
width: 100%;
height: 42px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0 12px;
color: var(--text);
background: rgba(6, 10, 15, 0.92);
}
.settings-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.cache-progress {
height: 10px;
margin: 18px 0 12px;
overflow: hidden;
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
}
.cache-progress span {
display: block;
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--cyan), var(--blue));
transition: width 0.25s ease;
}
.cache-message {
min-height: 42px;
color: var(--text);
}
.settings-metrics {
flex-wrap: wrap;
justify-content: flex-start;
color: var(--muted);
font-size: 12px;
}
.cache-root {
display: block;
margin-top: 14px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 10px;
color: #bce7ff;
background: rgba(6, 10, 15, 0.88);
white-space: normal;
word-break: break-all;
}
.top-actions .ghost-btn, .top-actions .ghost-btn,
.viewer-tools .ghost-btn, .viewer-tools .ghost-btn,
.viewer-tools .primary-btn, .viewer-tools .primary-btn,
@@ -1817,4 +1957,8 @@ input[type="range"] {
overflow-x: auto; overflow-x: auto;
grid-template-columns: 320px 560px 420px; grid-template-columns: 320px 560px 420px;
} }
.settings-grid {
grid-template-columns: 1fr;
}
} }

View File

@@ -9,6 +9,8 @@ PACS_PROCESSED_ROOT=/dicom/已处理_DICOM数据
PACS_BATCH_NAME=2026_5_27_PACS下载数据 PACS_BATCH_NAME=2026_5_27_PACS下载数据
PACS_WEB_USER=admin PACS_WEB_USER=admin
PACS_WEB_PASSWORD=123456 PACS_WEB_PASSWORD=123456
SMB_USERNAME=change_me
SMB_PASSWORD=change_me
KIMI_API_NAME=HIS_Check KIMI_API_NAME=HIS_Check
KIMI_API_KEY=change_me KIMI_API_KEY=change_me
KIMI_API_URL=https://api.moonshot.cn/v1/chat/completions KIMI_API_URL=https://api.moonshot.cn/v1/chat/completions

View File

@@ -9,7 +9,7 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
PACS_PROCESSED_ROOT: /dicom/已处理_DICOM数据 PACS_PROCESSED_ROOT: /pacs-image-db/PACS数据/DICOM数据/已处理_DICOM数据
PACS_BATCH_NAME: 2026_5_27_PACS下载数据 PACS_BATCH_NAME: 2026_5_27_PACS下载数据
PACS_WEB_USER: admin PACS_WEB_USER: admin
PACS_WEB_PASSWORD: "123456" PACS_WEB_PASSWORD: "123456"
@@ -17,4 +17,17 @@ services:
ports: ports:
- "8107:8107" - "8107:8107"
volumes: volumes:
- /home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据:/dicom/已处理_DICOM数据:ro - type: volume
source: pacs_image_db
target: /pacs-image-db
read_only: true
volume:
nocopy: true
volumes:
pacs_image_db:
driver: local
driver_opts:
type: cifs
device: //192.168.3.3/pacs影像数据库
o: username=${SMB_USERNAME},password=${SMB_PASSWORD},uid=1001,gid=1001,file_mode=0755,dir_mode=0755,iocharset=utf8,vers=3.0,soft,nounix,mapposix,noperm