Add PACS image mount cache refresh settings
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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配准/缓存数据
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user