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

View File

@@ -13,6 +13,11 @@ REGISTRATION_TABLE=dicom_upp_registrations
REGISTRATION_WEB_USER=admin
REGISTRATION_WEB_PASSWORD=123456
SMB_USERNAME=change_me
SMB_PASSWORD=change_me
PACS_VIEWER_URL=http://127.0.0.1:8107
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
import base64
import datetime as dt
import io
import json
import os
import re
import secrets
import shutil
import subprocess
import threading
import time
from collections import Counter, defaultdict
from pathlib import Path
@@ -54,7 +57,12 @@ WEB_USER = os.getenv("REGISTRATION_WEB_USER", "admin")
WEB_PASSWORD = os.getenv("REGISTRATION_WEB_PASSWORD", "123456")
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")
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_]*$")
WINDOWS = {
@@ -116,6 +124,30 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, str] = {}
STUDY_CACHE: dict[str, dict[str, Any]] = {}
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
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):
@@ -137,6 +169,11 @@ class RegistrationPayload(BaseModel):
notes: str = ""
class RefreshSettingsPayload(BaseModel):
auto_refresh_enabled: bool = True
refresh_time: str = "03:00"
def pg_env() -> dict[str, str]:
env = os.environ.copy()
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()
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]:
if isinstance(value, list):
return value
@@ -328,10 +493,17 @@ def require_auth(authorization: str | None = Header(default=None), access_token:
@app.on_event("startup")
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:
ensure_registration_table()
except Exception:
pass
start_cache_scheduler()
@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:
return f"""
SELECT
@@ -573,7 +767,7 @@ def get_study_record(ct_number: str) -> dict[str, Any]:
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():
return root
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):
files = parse_json_list(row.get("files"))
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():
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")
if not path:
continue
resolved_path = mapped_path(path)
output.append(
{
"id": index,
"asset_id": asset_index,
"algorithm_model": row.get("algorithm_model") or "未指定模型",
"file_name": file_info.get("file_name") or Path(path).name,
"segment_name": file_info.get("segment_name") or Path(path).stem,
"family": file_info.get("family") or file_info.get("segment_name") or Path(path).stem,
"file_name": file_info.get("file_name") or resolved_path.name,
"segment_name": file_info.get("segment_name") or resolved_path.stem,
"family": file_info.get("family") or file_info.get("segment_name") or resolved_path.stem,
"category": file_info.get("category") or "未分类",
"size_bytes": int(file_info.get("size_bytes") or 0),
"path": path,
"path": str(resolved_path),
}
)
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)
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}")
def get_registration(ct_number: str, algorithm_model: str = "未指定模型", _: str = Depends(require_auth)) -> dict[str, Any]:
ensure_registration_table()

View File

@@ -8,7 +8,28 @@ services:
restart: unless-stopped
env_file:
- .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:
- "8109:8109"
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") || "",
user: null,
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: "",
partFilter: "",
modelFilter: "",
@@ -224,6 +227,7 @@ async function bootstrap() {
}
buildPoseControls();
await loadStatus();
await loadSettings();
await loadCases();
} catch (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) {
setTopLoading(true);
try {
@@ -1924,8 +1991,16 @@ function wireEvents() {
});
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadSettings();
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"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", fitCamera);

View File

@@ -40,12 +40,60 @@
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
<span id="dbStatus" class="status-pill">数据库</span>
<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>
<button id="logoutBtn" class="ghost-btn" type="button">退出</button>
</div>
</header>
<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">
<aside class="case-panel panel">
<div class="panel-head">

View File

@@ -98,6 +98,146 @@ button {
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,
.viewer-tools .ghost-btn,
.viewer-tools .primary-btn,
@@ -1817,4 +1957,8 @@ input[type="range"] {
overflow-x: auto;
grid-template-columns: 320px 560px 420px;
}
.settings-grid {
grid-template-columns: 1fr;
}
}