This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Zhenyang doctor workstation."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+19
View File
@@ -0,0 +1,19 @@
import os
import sys
import traceback
from doctor_workstation.app import main
def _run() -> int:
try:
return main()
except Exception:
if "--smoke-test" not in sys.argv and os.getenv("DOCTOR_SMOKE_TEST") != "1":
raise
traceback.print_exc()
return 1
if __name__ == "__main__":
raise SystemExit(_run())
+716
View File
@@ -0,0 +1,716 @@
"""Application composition root for the doctor workstation."""
from __future__ import annotations
import logging
import os
import sys
import time
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QApplication,
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from doctor_workstation.config import AppConfig
from doctor_workstation.core import Session
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.logging_setup import configure_logging
from doctor_workstation.resources import resource_path, video_dist_path
from doctor_workstation.services import (
DemoDoctorRepository,
RemoteDoctorRepository,
TokenStore,
build_repository,
)
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
from doctor_workstation.ui.widgets import (
friendly_error,
run_async,
set_authentication_expired_handler,
show_toast,
)
from doctor_workstation.video import BackendMode, launch_video_call
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
LOGGER = logging.getLogger(__name__)
class _UnconfiguredRepository:
"""Login boundary used until an administrator supplies a backend URL."""
def login(
self,
account: str,
password: str,
*,
remember_account: bool = False,
) -> Session:
del account, password, remember_account
raise ValueError("请先展开“服务器设置”,填写管理员提供的 HTTPS 接口地址。")
def get_current_user(self) -> None:
return None
class DemoVideoDialog(QDialog):
"""Non-network video-room preview used only by the explicit demo mode."""
def __init__(self, patient_name: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._seconds = 0
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#0B1210;}"
"QLabel{color:#EAF2EE;}"
"QFrame#RemoteStage{background:#14211E;border:1px solid #2C403A;border-radius:18px;}"
"QFrame#LocalStage{background:#20312C;border:1px solid #3C554D;border-radius:14px;}"
"QPushButton{min-width:96px;min-height:42px;border-radius:21px;background:#253A34;"
"color:#F4F8F6;border:1px solid #3C554D;}"
"QPushButton:hover{background:#304A42;}"
"QPushButton#Hangup{background:#B94B44;border-color:#CF625B;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
root.setSpacing(14)
header = QHBoxLayout()
title = QLabel(f"{patient_name or '患者'} 的视频面诊")
title.setStyleSheet("font-size:18px;font-weight:700;")
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#91B9AC;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
header.addWidget(self.duration_label)
root.addLayout(header)
stage = QFrame()
stage.setObjectName("RemoteStage")
stage_layout = QVBoxLayout(stage)
stage_layout.setContentsMargins(22, 22, 22, 22)
stage_layout.addStretch(1)
avatar = QLabel((patient_name or "")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
waiting.setAlignment(Qt.AlignmentFlag.AlignCenter)
waiting.setStyleSheet("font-size:17px;font-weight:600;")
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#80948D;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
local = QFrame(stage)
local.setObjectName("LocalStage")
local.setGeometry(24, 24, 178, 112)
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#A9BDB6;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
controls = QHBoxLayout()
controls.addStretch(1)
self.mic_button = QPushButton("麦克风 开")
self.mic_button.setCheckable(True)
self.mic_button.toggled.connect(
lambda muted: self.mic_button.setText("麦克风 关" if muted else "麦克风 开")
)
controls.addWidget(self.mic_button)
self.camera_button = QPushButton("摄像头 开")
self.camera_button.setCheckable(True)
self.camera_button.toggled.connect(
lambda off: self.camera_button.setText("摄像头 关" if off else "摄像头 开")
)
controls.addWidget(self.camera_button)
hangup = QPushButton("结束面诊")
hangup.setObjectName("Hangup")
hangup.clicked.connect(self.close)
controls.addWidget(hangup)
controls.addStretch(1)
root.addLayout(controls)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._timer.start(1000)
def _tick(self) -> None:
self._seconds += 1
minutes, seconds = divmod(self._seconds, 60)
self.duration_label.setText(f"{minutes:02d}:{seconds:02d}")
class ApplicationController(QObject):
"""Own windows, repositories and the authenticated application lifecycle."""
def __init__(self, application: QApplication, config: AppConfig) -> None:
super().__init__()
self.application = application
self.config = config
self.token_store = TokenStore(config.config_dir / "credentials.json")
self.demo_repository = DemoDoctorRepository()
self.remote_repository: RemoteDoctorRepository | None = None
self.login_window: LoginWindow | None = None
self.shell_window: ShellWindow | None = None
self.current_repository: Any = None
self.current_demo_mode = config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self._restore_generation = 0
self._restore_in_progress = False
self._restore_worker: Any = None
self._shutting_down = False
self._authentication_expiry_in_progress = False
self._rebuild_remote_repository()
set_authentication_expired_handler(self._on_authentication_expired)
application.aboutToQuit.connect(self.shutdown)
def start(self) -> None:
"""Show login immediately, then validate a production token off-thread."""
self._show_login()
self._begin_session_restore()
def _base_repository(self) -> Any:
return self.remote_repository or _UnconfiguredRepository()
def _show_login(self) -> None:
if self.login_window is None:
self.login_window = LoginWindow(
self._base_repository(),
self.config,
self.demo_repository,
)
self.login_window.login_succeeded.connect(self._on_login_succeeded)
self.login_window.config_changed.connect(self._on_config_changed)
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
self._apply_window_icon(self.login_window)
else:
self.login_window.repository = self._base_repository()
self.login_window.config = self.config
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
self.login_window.show()
self.login_window.raise_()
self.login_window.activateWindow()
def _on_config_changed(self, payload: Any) -> None:
previous_connection = (
self.config.api_base_url,
self.config.request_timeout,
self.config.verify_ssl,
)
if isinstance(payload, AppConfig):
updated = payload
elif isinstance(payload, dict):
aliases = {
"base_url": "api_base_url",
"read_timeout": "request_timeout",
}
changes = {aliases.get(key, key): value for key, value in payload.items()}
allowed = {
"api_base_url",
"demo_mode",
"video_mode",
"video_web_url",
"verify_ssl",
"request_timeout",
"log_level",
"remembered_account",
}
updated = self.config.with_updates(
**{key: value for key, value in changes.items() if key in allowed}
)
else:
return
self.config = updated
with suppress(OSError):
self.config.save_preferences()
next_connection = (
self.config.api_base_url,
self.config.request_timeout,
self.config.verify_ssl,
)
if next_connection != previous_connection:
self._cancel_session_restore()
self._rebuild_remote_repository()
if self.login_window is not None:
self.login_window.repository = self._base_repository()
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
if self.login_window is not None:
self.login_window.config = self.config
def _on_demo_mode_changed(self, enabled: bool) -> None:
self.current_demo_mode = enabled
if enabled:
self._cancel_session_restore()
def _rebuild_remote_repository(self) -> None:
old = self.remote_repository
self.remote_repository = None
if self.config.api_base_url:
repository = build_repository(
demo=False,
base_url=self.config.api_base_url,
token_store=self.token_store,
timeout=self.config.request_timeout,
verify=self.config.verify_ssl,
)
if isinstance(repository, RemoteDoctorRepository):
self.remote_repository = repository
if old is not None and old is not self.current_repository:
with suppress(Exception):
old.client.close()
def _begin_session_restore(self) -> None:
"""Validate a scoped persisted token without racing manual login."""
repository = self.remote_repository
if (
self._shutting_down
or self.config.demo_mode
or repository is None
or self.current_repository is not None
):
return
self._restore_generation += 1
generation = self._restore_generation
self._restore_in_progress = True
if self.login_window is not None:
self.login_window.set_session_restore_pending(True)
self._restore_worker = run_async(
repository.restore_session,
on_success=lambda session: self._on_restore_success(
session,
repository,
generation,
),
on_error=lambda error: self._on_restore_error(
error,
repository,
generation,
),
on_finished=lambda: self._on_restore_finished(repository, generation),
)
def _restore_is_current(
self,
repository: RemoteDoctorRepository,
generation: int,
) -> bool:
"""Return whether a restore callback still owns the login surface."""
return (
not self._shutting_down
and self._restore_in_progress
and generation == self._restore_generation
and repository is self.remote_repository
)
def _finish_session_restore(
self,
repository: RemoteDoctorRepository,
generation: int,
) -> bool:
"""Release login controls only for the currently active restore."""
if not self._restore_is_current(repository, generation):
return False
self._restore_in_progress = False
self._restore_worker = None
if self.login_window is not None:
self.login_window.set_session_restore_pending(False)
return True
def _on_restore_success(
self,
session: object,
repository: RemoteDoctorRepository,
generation: int,
) -> None:
"""Enter the shell only for a current, fully validated session."""
if not self._finish_session_restore(repository, generation):
return
if session is None:
return
if not isinstance(session, Session) or not session.authenticated:
with suppress(Exception):
repository.logout()
self._login_guard_error("已保存的登录状态无效,请重新登录。")
return
self._on_login_succeeded(
{
"session": session,
"user": session.user,
"repository": repository,
"demo_mode": False,
"restored_session": True,
}
)
def _on_restore_error(
self,
error: Exception,
repository: RemoteDoctorRepository,
generation: int,
) -> None:
"""Return control to login after a current restore attempt fails."""
if not self._finish_session_restore(repository, generation):
return
if isinstance(error, AuthenticationExpiredError):
message = "登录状态已失效,请重新登录。"
else:
message = f"自动恢复登录失败:{friendly_error(error)}"
self._login_guard_error(message)
def _on_restore_finished(
self,
repository: RemoteDoctorRepository,
generation: int,
) -> None:
"""Release controls when a worker finishes without a result callback."""
self._finish_session_restore(repository, generation)
def _cancel_session_restore(self) -> None:
"""Invalidate late callbacks and re-enable manual login controls."""
self._restore_generation += 1
if not self._restore_in_progress:
return
self._restore_in_progress = False
self._restore_worker = None
if self.login_window is not None:
self.login_window.set_session_restore_pending(False)
def _on_login_succeeded(self, payload: dict[str, Any]) -> None:
self._cancel_session_restore()
session = payload.get("session")
repository = payload.get("repository")
if not isinstance(session, Session) or repository is None:
self._login_guard_error("登录响应不完整,请重试。")
return
if session.password_change_required:
with suppress(Exception):
repository.logout()
self._login_guard_error("该账号需要先修改初始密码,请在管理后台完成后重新登录。")
return
if session.work_wechat_binding_required:
with suppress(Exception):
repository.logout()
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
return
demo_mode = bool(payload.get("demo_mode"))
if not demo_mode and not session.menu:
with suppress(Exception):
repository.logout()
self._login_guard_error("当前账号没有可用医生端菜单,请联系管理员授权。")
return
self.current_repository = repository
self._authentication_expiry_in_progress = False
self.current_demo_mode = demo_mode
self.shell_window = ShellWindow(repository, payload, session.permissions)
self.shell_window.logout_requested.connect(self._logout)
self.shell_window.video_requested.connect(self._request_video)
self._apply_window_icon(self.shell_window)
if self.login_window is not None:
self.login_window.hide()
self.shell_window.show()
self.shell_window.raise_()
self.shell_window.activateWindow()
def _login_guard_error(self, message: str) -> None:
if self.login_window is not None:
self.login_window.error_banner.show_message(message, "warning")
self.login_window.show()
def _on_authentication_expired(self, error: AuthenticationExpiredError) -> bool:
"""Consume an active-shell expiry and atomically return to login."""
if self.shell_window is None or self.current_repository is None:
return False
if self._authentication_expiry_in_progress:
return True
self._authentication_expiry_in_progress = True
LOGGER.info(
"authenticated session expired",
extra={"api_code": error.code, "request_id": error.request_id},
)
self._logout(message="登录状态已失效,请重新登录。")
return True
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
self.video_calls.clear()
self.video_pending.clear()
for dialog in self.demo_video_dialogs.values():
dialog.close()
self.demo_video_dialogs.clear()
if self.current_repository is not None:
with suppress(Exception):
self.current_repository.logout()
self.current_repository = None
if self.shell_window is not None:
self.shell_window.close()
self.shell_window.deleteLater()
self.shell_window = None
self._show_login()
if message and self.login_window is not None:
self.login_window.error_banner.show_message(message, "warning")
def _request_video(self, payload: dict[str, Any]) -> None:
parent = self.shell_window
if parent is None or self.current_repository is None:
return
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
call_key = str(diagnosis_id)
if (
call_key in self.video_pending
or call_key in self.video_calls
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
if self.current_demo_mode:
dialog = DemoVideoDialog(patient_name, parent)
dialog.finished.connect(
lambda _result, key=call_key, item=dialog: self._forget_demo_dialog(
key,
item,
)
)
self.demo_video_dialogs[call_key] = dialog
dialog.show()
return
show_toast(parent, "正在获取安全通话凭证…", "info")
repository = self.current_repository
marker = object()
self.video_pending[call_key] = marker
def get_ticket() -> Any:
return repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
def _video_ticket_error(
self,
call_key: str,
marker: object,
parent: QWidget,
error: Exception,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
if self.shell_window is parent:
show_toast(
parent,
f"视频准备失败:{friendly_error(error)}",
"danger",
5200,
)
def _launch_video(
self,
ticket: Any,
*,
diagnosis_id: Any,
patient_id: Any,
repository: Any,
call_key: str,
marker: object,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
if (
self.shell_window is None
or self.current_repository is not repository
or call_key in self.video_calls
):
return
try:
mode = BackendMode.parse(self.config.video_mode)
if mode is BackendMode.BROWSER:
raise ValueError("当前后端未提供一次性通话交接票据,浏览器视频模式已安全停用。")
if not WEBENGINE_AVAILABLE:
raise ValueError("当前安装缺少 QtWebEngine,无法打开受信任的视频窗口。")
call = launch_video_call(
ticket,
repository=repository,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=mode,
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
self.shell_window,
f"视频启动失败:{friendly_error(error)}",
"danger",
5600,
)
return
self.video_calls[call_key] = call
qt_window = getattr(call, "qt_window", None)
if qt_window is not None:
qt_window.destroyed.connect(
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
key,
expected,
)
)
def _release_video_call(self, call_key: str, call: Any) -> None:
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
if self.demo_video_dialogs.get(call_key) is dialog:
self.demo_video_dialogs.pop(call_key, None)
dialog.deleteLater()
@staticmethod
def _wait_for_video_lifecycle(calls: tuple[Any, ...], *, timeout: float) -> bool:
"""Give queued endCall writes one short shared deadline after media closes."""
deadline = time.monotonic() + max(0.0, timeout)
complete = True
for call in calls:
remaining = deadline - time.monotonic()
if remaining <= 0:
complete = False
break
wait = getattr(call, "wait_for_lifecycle", None)
if callable(wait) and not wait(remaining):
complete = False
if not complete:
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
return complete
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = resource_path("icon.svg")
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def shutdown(self) -> None:
"""Invalidate asynchronous restoration and release owned resources."""
if self._shutting_down:
return
self._shutting_down = True
self._cancel_session_restore()
set_authentication_expired_handler(None)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
if self.remote_repository is not None:
with suppress(Exception):
self.remote_repository.client.close()
def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
application = QApplication(argv)
application.setApplicationName("臻阳堂医生工作站")
application.setApplicationDisplayName("臻阳堂医生工作站")
application.setOrganizationName("ZhenYangTang")
application.setOrganizationDomain("zhenyangtang.com")
application.setQuitOnLastWindowClosed(True)
icon_file = resource_path("icon.svg")
if icon_file.exists():
application.setWindowIcon(QIcon(str(icon_file)))
apply_theme(application)
return application
def main(argv: list[str] | None = None) -> int:
"""Start the GUI application and return its process exit code."""
config = AppConfig.load()
configure_logging(config.log_dir, config.log_level)
LOGGER.info("doctor workstation starting", extra={"demo_mode": config.demo_mode})
raw_argv = list(sys.argv if argv is None else argv)
smoke_test = "--smoke-test" in raw_argv
application = _create_application(
[argument for argument in raw_argv if argument != "--smoke-test"]
)
controller = ApplicationController(application, config)
controller.start()
if smoke_test or os.getenv("DOCTOR_SMOKE_TEST") == "1":
QTimer.singleShot(1200, application.quit)
return application.exec()
__all__ = ["ApplicationController", "DemoVideoDialog", "main"]
+172
View File
@@ -0,0 +1,172 @@
"""Application configuration and per-user preferences.
Secrets are intentionally excluded: the desktop client only receives a short-lived
TRTC UserSig from the authenticated backend and never stores an SDKSecretKey.
"""
from __future__ import annotations
import json
import os
from contextlib import suppress
from dataclasses import asdict, dataclass, fields, replace
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional during pure unit tests
load_dotenv = None
try:
from platformdirs import user_config_dir, user_log_dir
except ImportError: # pragma: no cover - deterministic fallback
user_config_dir = None
user_log_dir = None
APP_NAME = "ZhenyangDoctor"
APP_AUTHOR = "Zhenyangtang"
def _as_bool(value: str | bool | None, default: bool) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
def _safe_timeout(value: str | int | float | None, default: float = 30.0) -> float:
try:
parsed = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return max(3.0, min(parsed, 120.0))
def _config_home() -> Path:
override = os.getenv("DOCTOR_CONFIG_DIR", "").strip()
if override:
return Path(override).expanduser()
if user_config_dir is not None:
return Path(user_config_dir(APP_NAME, APP_AUTHOR))
return Path.home() / f".{APP_NAME.lower()}"
def _log_home() -> Path:
override = os.getenv("DOCTOR_LOG_DIR", "").strip()
if override:
return Path(override).expanduser()
if user_log_dir is not None:
return Path(user_log_dir(APP_NAME, APP_AUTHOR))
return _config_home() / "logs"
def normalize_api_base_url(value: str) -> str:
"""Return a normalized HTTP(S) base URL ending in ``/adminapi``.
Empty values are accepted for demo mode. Credentials, fragments and query
strings are rejected to prevent accidentally persisting tokens in settings.
"""
raw = (value or "").strip().rstrip("/")
if not raw:
return ""
parts = urlsplit(raw)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise ValueError("服务器地址必须是完整的 http:// 或 https:// 地址")
if parts.username or parts.password or parts.query or parts.fragment:
raise ValueError("服务器地址不能包含账号、密码、查询参数或片段")
path = parts.path.rstrip("/")
if not path.endswith("/adminapi"):
path = f"{path}/adminapi" if path else "/adminapi"
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
@dataclass(frozen=True, slots=True)
class AppConfig:
"""Runtime configuration loaded from environment and user preferences."""
api_base_url: str = ""
demo_mode: bool = True
video_mode: str = "embedded"
video_web_url: str = ""
verify_ssl: bool = True
request_timeout: float = 30.0
log_level: str = "INFO"
remembered_account: str = ""
@property
def config_dir(self) -> Path:
return _config_home()
@property
def log_dir(self) -> Path:
return _log_home()
@property
def preferences_file(self) -> Path:
return self.config_dir / "preferences.json"
@classmethod
def load(cls, env_file: Path | None = None) -> AppConfig:
if load_dotenv is not None:
load_dotenv(dotenv_path=env_file, override=False)
raw_url = os.getenv("DOCTOR_API_BASE_URL", "")
try:
api_url = normalize_api_base_url(raw_url)
except ValueError:
api_url = ""
config = cls(
api_base_url=api_url,
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), True),
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
verify_ssl=_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True),
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
)
return config._merge_preferences()
def _merge_preferences(self) -> AppConfig:
try:
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return self
allowed = {item.name for item in fields(self)}
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
if "api_base_url" in clean:
try:
clean["api_base_url"] = normalize_api_base_url(str(clean["api_base_url"]))
except ValueError:
clean.pop("api_base_url", None)
if "video_mode" in clean and clean["video_mode"] not in {"embedded", "browser"}:
clean.pop("video_mode", None)
if "request_timeout" in clean:
clean["request_timeout"] = _safe_timeout(clean["request_timeout"])
return replace(self, **clean)
def save_preferences(self) -> None:
"""Persist non-secret preferences atomically with user-only intent."""
self.config_dir.mkdir(parents=True, exist_ok=True)
target = self.preferences_file
temporary = target.with_suffix(".tmp")
payload = asdict(self)
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with suppress(OSError):
os.chmod(temporary, 0o600)
temporary.replace(target)
def with_updates(self, **changes: Any) -> AppConfig:
if "api_base_url" in changes:
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
raise ValueError("视频模式只能是 embedded 或 browser")
if "request_timeout" in changes:
changes["request_timeout"] = _safe_timeout(changes["request_timeout"])
return replace(self, **changes)
@@ -0,0 +1,57 @@
"""UI-independent domain primitives for the doctor workstation."""
from .errors import (
ApiBusinessError,
ApiError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationError,
AuthenticationExpiredError,
InstallationRequiredError,
NeedBindWorkWechatError,
OpenNewPageError,
OpenPageRequiredError,
RepositoryNotFoundError,
WorkWechatBindingRequiredError,
)
from .models import (
Appointment,
CallTicket,
Consultation,
PageResult,
Patient,
Prescription,
PrescriptionTemplate,
UserProfile,
)
from .permissions import PermissionSet
from .session import Session
__all__ = [
"ApiBusinessError",
"ApiError",
"ApiHttpError",
"ApiProtocolError",
"ApiTimeoutError",
"ApiTransportError",
"Appointment",
"AuthenticationError",
"AuthenticationExpiredError",
"CallTicket",
"Consultation",
"InstallationRequiredError",
"NeedBindWorkWechatError",
"OpenNewPageError",
"OpenPageRequiredError",
"PageResult",
"Patient",
"PermissionSet",
"Prescription",
"PrescriptionTemplate",
"RepositoryNotFoundError",
"Session",
"UserProfile",
"WorkWechatBindingRequiredError",
]
+93
View File
@@ -0,0 +1,93 @@
"""Structured errors shared by HTTP and repository implementations."""
from __future__ import annotations
from typing import Any
class ApiError(RuntimeError):
"""Base API error carrying machine-readable response context."""
def __init__(
self,
message: str,
*,
code: int | None = None,
data: Any = None,
status_code: int | None = None,
request_id: str | None = None,
) -> None:
"""Initialise a structured API error."""
super().__init__(message)
self.message = message
self.code = code
self.data = data
self.status_code = status_code
self.request_id = request_id
class ApiTransportError(ApiError):
"""A network failure occurred before a valid API response was received."""
class ApiTimeoutError(ApiTransportError):
"""A request exceeded its configured timeout and exhausted safe retries."""
class ApiHttpError(ApiTransportError):
"""The server returned a non-successful HTTP status code."""
class ApiProtocolError(ApiError):
"""The response was not valid JSON or did not contain a valid envelope."""
class ApiBusinessError(ApiError):
"""The API returned envelope code ``0`` for a rejected business action."""
class AuthenticationExpiredError(ApiError):
"""The API returned envelope code ``-1`` and the session must be cleared."""
class WorkWechatBindingRequiredError(ApiError):
"""The API returned code ``10`` and Enterprise WeChat binding is required."""
class OpenPageRequiredError(ApiError):
"""The API returned code ``2`` with an external page that needs user action."""
def __init__(
self,
message: str,
*,
url: str = "",
data: Any = None,
status_code: int | None = None,
request_id: str | None = None,
) -> None:
"""Initialise the redirect signal without opening a browser implicitly."""
super().__init__(
message,
code=2,
data=data,
status_code=status_code,
request_id=request_id,
)
self.url = url
class InstallationRequiredError(ApiError):
"""The legacy API returned code ``-2`` for a missing companion component."""
class RepositoryNotFoundError(KeyError):
"""A requested in-memory or remote domain object could not be found."""
# Readable compatibility aliases for callers that use shorter exception names.
AuthenticationError = AuthenticationExpiredError
NeedBindWorkWechatError = WorkWechatBindingRequiredError
OpenNewPageError = OpenPageRequiredError
+913
View File
@@ -0,0 +1,913 @@
"""Typed domain models used by the doctor workstation service layer.
The production API is not backed by a published schema and has accumulated a
few field aliases over time. The ``from_dict`` factories in this module are
therefore deliberately conservative: known fields are normalised while the
complete source mapping is retained in ``raw`` for forward compatibility.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from math import ceil
from typing import Any, Generic, TypeVar
JSONDict = dict[str, Any]
T = TypeVar("T")
U = TypeVar("U")
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _text(value: object, default: str = "") -> str:
if value is None:
return default
return str(value)
def _integer(value: object, default: int | None = 0) -> int | None:
if value is None or value == "":
return default
if isinstance(value, bool):
return int(value)
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
try:
return int(float(str(value)))
except (TypeError, ValueError):
return default
def _number(value: object) -> float | None:
if value is None or value == "":
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _boolean(value: object, default: bool = False) -> bool:
if value is None or value == "":
return default
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "no", "off", "null"}
return bool(value)
def _int_or_text(value: object, default: int | str = 0) -> int | str:
if value is None or value == "":
return default
converted = _integer(value, None)
return converted if converted is not None else str(value)
def _dict_list(value: object) -> list[JSONDict]:
if not isinstance(value, (list, tuple)):
return []
return [dict(item) for item in value if isinstance(item, Mapping)]
def _string_list(value: object) -> list[str]:
"""Return a clean list for APIs that alternate between CSV and arrays."""
values: object = value.split(",") if isinstance(value, str) else value
if not isinstance(values, (list, tuple, set, frozenset)):
return []
return [text for item in values if (text := _text(item).strip())]
def _string_tuple(value: object) -> tuple[str, ...]:
if isinstance(value, str):
values: object = value.split(",")
else:
values = value
if not isinstance(values, (list, tuple, set, frozenset)):
return ()
return tuple(item for item in (_text(entry).strip() for entry in values) if item)
def _role_tuple(value: object) -> tuple[int, ...]:
if isinstance(value, str):
values: object = value.split(",")
elif isinstance(value, (int, float)):
values = [value]
else:
values = value
if not isinstance(values, (list, tuple, set, frozenset)):
return ()
result: list[int] = []
for entry in values:
candidate = entry.get("id") if isinstance(entry, Mapping) else entry
role_id = _integer(candidate, None)
if role_id is not None and role_id not in result:
result.append(role_id)
return tuple(result)
def _formula_type(value: object) -> int | str:
"""Normalise known main/aux formula labels while preserving unknown values."""
text = _text(value).strip()
lowered = text.lower()
if lowered in {"", "1", "main", "primary", "主方"}:
return "main"
if lowered in {"2", "aux", "auxiliary", "secondary", "辅方"}:
return "aux"
converted = _integer(value, None)
return converted if converted is not None else text
@dataclass(slots=True)
class UserProfile:
"""Authenticated doctor profile returned by ``auth.admin/mySelf``."""
id: int = 0
account: str = ""
name: str = ""
avatar: str = ""
phone: str = ""
role_ids: tuple[int, ...] = ()
root: bool = False
department_id: int | None = None
department_name: str = ""
permissions: tuple[str, ...] = ()
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> UserProfile:
"""Build a profile from either a user object or a ``mySelf`` result."""
outer = _mapping(data)
nested_user = outer.get("user")
source = _mapping(nested_user) if isinstance(nested_user, Mapping) else outer
roles = source.get("role_ids", source.get("role_id", source.get("roles")))
permissions = outer.get("permissions", source.get("permissions", ()))
department = _mapping(source.get("department", source.get("dept")))
return cls(
id=_integer(source.get("id", source.get("user_id")), 0) or 0,
account=_text(source.get("account", source.get("username"))),
name=_text(
source.get(
"name",
source.get("real_name", source.get("nickname", source.get("account"))),
)
),
avatar=_text(source.get("avatar")),
phone=_text(source.get("phone", source.get("mobile"))),
role_ids=_role_tuple(roles),
root=_boolean(source.get("root", source.get("is_root"))),
department_id=_integer(
source.get("department_id", source.get("dept_id", department.get("id"))),
None,
),
department_name=_text(
source.get(
"department_name",
source.get("dept_name", department.get("name")),
)
),
permissions=_string_tuple(permissions),
raw=dict(source),
)
@dataclass(slots=True)
class Appointment:
"""A doctor appointment and the patient summary shown in queue views."""
id: int = 0
patient_id: int = 0
diagnosis_id: int | None = None
patient_name: str = ""
patient_phone: str = ""
gender: int | str | None = None
gender_desc: str = ""
age: int | None = None
height: float | None = None
weight: float | None = None
doctor_id: int | None = None
doctor_name: str = ""
assistant_id: int | None = None
assistant_name: str = ""
appointment_date: str = ""
appointment_time: str = ""
period: str = ""
appointment_type: int | str | None = None
appointment_type_text: str = ""
channel: int | str | None = None
channel_text: str = ""
status: int | str = 0
status_desc: str = ""
diagnosis_confirmed: bool = False
has_prescription: bool = False
prescription_audit_status: int | None = None
prescription_void_status: int | None = None
remark: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Appointment:
"""Build an appointment while accepting historical API aliases."""
source = _mapping(data)
return cls(
id=_integer(source.get("id", source.get("appointment_id")), 0) or 0,
patient_id=_integer(source.get("patient_id", source.get("source_patient_id")), 0) or 0,
diagnosis_id=_integer(source.get("diagnosis_id"), None),
patient_name=_text(source.get("patient_name", source.get("name"))),
patient_phone=_text(
source.get("patient_phone", source.get("phone", source.get("phone_masked")))
),
gender=source.get("gender", source.get("gender_desc")),
gender_desc=_text(source.get("gender_desc")),
age=_integer(source.get("age"), None),
height=_number(source.get("height")),
weight=_number(source.get("weight")),
doctor_id=_integer(source.get("doctor_id"), None),
doctor_name=_text(source.get("doctor_name")),
assistant_id=_integer(source.get("assistant_id"), None),
assistant_name=_text(source.get("assistant_name")),
appointment_date=_text(source.get("appointment_date", source.get("date"))),
appointment_time=_text(
source.get(
"appointment_time",
source.get("appointment_time_text", source.get("time")),
)
),
period=_text(source.get("period")),
appointment_type=source.get("appointment_type", source.get("type")),
appointment_type_text=_text(
source.get("appointment_type_text", source.get("type_text"))
),
channel=source.get("channel", source.get("appointment_channel")),
channel_text=_text(source.get("channel_text", source.get("channel_name"))),
status=_int_or_text(source.get("status"), 0),
status_desc=_text(source.get("status_desc", source.get("appointment_status_text"))),
diagnosis_confirmed=_boolean(source.get("diagnosis_confirmed")),
has_prescription=_boolean(source.get("has_prescription")),
prescription_audit_status=_integer(source.get("prescription_audit_status"), None),
prescription_void_status=_integer(source.get("prescription_void_status"), None),
remark=_text(source.get("remark")),
raw=dict(source),
)
@dataclass(slots=True)
class Patient:
"""A patient row from the doctor's scoped first-visit patient list."""
id: int = 0
diagnosis_id: int | None = None
source_patient_id: int | None = None
name: str = ""
gender: int | str | None = None
gender_desc: str = ""
age: int | None = None
phone: str = ""
phone_masked: str = ""
has_id_card: bool = False
assistant_id: int | None = None
assistant_name: str = ""
appointment_id: int | None = None
appointment_doctor_id: int | None = None
appointment_doctor_name: str = ""
appointment_status: int | str | None = None
appointment_status_text: str = ""
appointment_time_text: str = ""
appointment_date: str = ""
appointment_time: str = ""
appointments: list[JSONDict] = field(default_factory=list)
revisit_count: int = 0
confirmed: bool = False
confirmation_text: str = ""
diagnosis_date_text: str = ""
status_filter: str = ""
id_card: str = ""
region: str = ""
is_self_patient: bool = False
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@property
def patient_name(self) -> str:
"""Return the backend-style alias for ``name``."""
return self.name
@property
def patient_phone(self) -> str:
"""Return the best available phone value under the queue-style alias."""
return self.phone_masked or self.phone
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Patient:
"""Build a patient from a tolerant first-visit list row."""
source = _mapping(data)
diagnosis_id = _integer(source.get("diagnosis_id", source.get("id")), None)
item_id = _integer(source.get("id", diagnosis_id), 0) or 0
return cls(
id=item_id,
diagnosis_id=diagnosis_id,
source_patient_id=_integer(source.get("source_patient_id"), None),
name=_text(source.get("patient_name", source.get("name"))),
gender=source.get("gender", source.get("gender_desc")),
gender_desc=_text(source.get("gender_desc")),
age=_integer(source.get("age"), None),
phone=_text(source.get("phone", source.get("patient_phone"))),
phone_masked=_text(source.get("phone_masked", source.get("phone"))),
has_id_card=_boolean(source.get("has_id_card")),
assistant_id=_integer(source.get("assistant_id"), None),
assistant_name=_text(source.get("assistant_name")),
appointment_id=_integer(source.get("appointment_id"), None),
appointment_doctor_id=_integer(source.get("appointment_doctor_id"), None),
appointment_doctor_name=_text(source.get("appointment_doctor_name")),
appointment_status=(
_int_or_text(source.get("appointment_status"))
if source.get("appointment_status") not in (None, "")
else None
),
appointment_status_text=_text(source.get("appointment_status_text")),
appointment_time_text=_text(source.get("appointment_time_text")),
appointment_date=_text(source.get("appointment_date")),
appointment_time=_text(source.get("appointment_time")),
appointments=_dict_list(source.get("appointments")),
revisit_count=_integer(source.get("revisit_count"), 0) or 0,
confirmed=_boolean(source.get("confirmed", source.get("diagnosis_confirmed"))),
confirmation_text=_text(source.get("confirmation_text")),
diagnosis_date_text=_text(source.get("diagnosis_date_text")),
status_filter=_text(source.get("status_filter", source.get("visit_status"))),
id_card=_text(source.get("id_card")),
region=_text(source.get("region", source.get("region_text"))),
is_self_patient=_boolean(source.get("is_self_patient")),
raw=dict(source),
)
@dataclass(slots=True)
class Consultation:
"""A diagnosis/consultation row used by the consultation workspace."""
id: int = 0
patient_id: int | None = None
appointment_id: int | None = None
patient_name: str = ""
patient_phone: str = ""
phone_masked: str = ""
id_card: str = ""
gender: int | str | None = None
gender_desc: str = ""
age: int | None = None
doctor_id: int | None = None
doctor_name: str = ""
assistant_id: int | None = None
assistant_name: str = ""
diagnosis_date: str = ""
appointment_date: str = ""
appointment_time: str = ""
period: str = ""
has_appointment: bool = False
appointment_status: int | str | None = None
appointment_status_text: str = ""
appointments: list[JSONDict] = field(default_factory=list)
consultation_type: int | str | None = None
clinical_diagnosis: str = ""
chief_complaint: str = ""
present_illness: str = ""
past_history: str = ""
allergy_history: str = ""
personal_history: str = ""
family_history: str = ""
current_medicine: str = ""
local_diagnosis: str = ""
prescription_opinion: str = ""
tongue: str = ""
pulse: str = ""
status: int | str = 0
status_desc: str = ""
confirmed: bool = False
diagnosis_view_records: list[JSONDict] = field(default_factory=list)
has_prescription: bool = False
unserved_days: int | None = None
video_hint: str = ""
source: int | str | None = None
source_text: str = ""
remark: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Consultation:
"""Build a consultation from a diagnosis row or reception detail."""
source = _mapping(data)
nested = _mapping(source.get("diagnosis"))
if nested:
merged: JSONDict = dict(source)
merged.update(nested)
source = merged
appointments = _dict_list(source.get("appointments"))
appointment = _mapping(source.get("appointment", source.get("latest_appointment")))
if not appointment:
appointment = appointments[0] if appointments else {}
view_records = _dict_list(
source.get("DiagnosisViewRecord", source.get("diagnosis_view_records"))
)
confirmed_value: object = source.get("confirmed", source.get("diagnosis_confirmed"))
if confirmed_value in (None, "") and view_records:
confirmed_value = any(_boolean(item.get("is_confirmed")) for item in view_records)
explicit_has_appointment = source.get("has_appointment")
has_appointment = (
_boolean(explicit_has_appointment)
if explicit_has_appointment not in (None, "")
else bool(appointment or appointments or source.get("appointment_id"))
)
appointment_status_value = source.get("appointment_status")
if appointment_status_value in (None, ""):
appointment_status_value = appointment.get("status")
return cls(
id=_integer(source.get("id", source.get("diagnosis_id")), 0) or 0,
patient_id=_integer(source.get("patient_id", source.get("source_patient_id")), None),
appointment_id=_integer(source.get("appointment_id", appointment.get("id")), None),
patient_name=_text(source.get("patient_name", source.get("name"))),
patient_phone=_text(source.get("patient_phone", source.get("phone"))),
phone_masked=_text(source.get("phone_masked")),
id_card=_text(source.get("id_card")),
gender=source.get("gender", source.get("gender_desc")),
gender_desc=_text(source.get("gender_desc")),
age=_integer(source.get("age"), None),
doctor_id=_integer(source.get("doctor_id", appointment.get("doctor_id")), None),
doctor_name=_text(source.get("doctor_name", appointment.get("doctor_name"))),
assistant_id=_integer(source.get("assistant_id"), None),
assistant_name=_text(source.get("assistant_name")),
diagnosis_date=_text(source.get("diagnosis_date", source.get("diagnosis_date_text"))),
appointment_date=_text(
source.get(
"appointment_date",
source.get(
"latest_appointment_date",
appointment.get("appointment_date", appointment.get("date")),
),
)
),
appointment_time=_text(
source.get(
"appointment_time",
source.get(
"appointment_time_text",
source.get(
"latest_appointment_time",
appointment.get("appointment_time", appointment.get("time_text")),
),
),
)
),
period=_text(source.get("period", appointment.get("period"))),
has_appointment=has_appointment,
appointment_status=(
_int_or_text(appointment_status_value)
if appointment_status_value not in (None, "")
else None
),
appointment_status_text=_text(
source.get(
"appointment_status_text",
appointment.get("status_text", appointment.get("status_desc")),
)
),
appointments=appointments,
consultation_type=source.get("consultation_type", source.get("visit_type")),
clinical_diagnosis=_text(source.get("clinical_diagnosis")),
chief_complaint=_text(source.get("chief_complaint", source.get("complaint"))),
present_illness=_text(
source.get("present_illness", source.get("present_illness_history"))
),
past_history=_text(source.get("past_history")),
allergy_history=_text(source.get("allergy_history")),
personal_history=_text(source.get("personal_history")),
family_history=_text(source.get("family_history")),
current_medicine=_text(source.get("current_medicine")),
local_diagnosis=_text(source.get("local_diagnosis")),
prescription_opinion=_text(source.get("prescription_opinion")),
tongue=_text(source.get("tongue")),
pulse=_text(source.get("pulse", source.get("pulse_condition"))),
# Diagnosis enablement and appointment workflow are separate domains.
status=_int_or_text(source.get("status"), 0),
status_desc=_text(source.get("status_desc")),
confirmed=_boolean(confirmed_value),
diagnosis_view_records=view_records,
has_prescription=_boolean(source.get("has_prescription")),
unserved_days=_integer(
source.get("unserved_days", source.get("unserved_day_count")), None
),
video_hint=_text(source.get("video_hint", source.get("call_hint"))),
source=source.get("source", source.get("diagnosis_source")),
source_text=_text(source.get("source_text", source.get("source_name"))),
remark=_text(source.get("remark")),
raw=dict(source),
)
@property
def diagnosis_confirmed(self) -> bool:
"""Return the appointment-list alias for the confirmation flag."""
return self.confirmed
@dataclass(slots=True)
class PrescriptionTemplate:
"""A reusable prescription-library formula owned by a doctor."""
id: int = 0
name: str = ""
formula_type: int | str = 1
herbs: list[JSONDict] = field(default_factory=list)
is_public: bool = False
disable_edit: bool = False
creator_id: int | None = None
creator_name: str = ""
create_time: str = ""
update_time: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@property
def prescription_name(self) -> str:
"""Return the API field alias for the template name."""
return self.name
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> PrescriptionTemplate:
"""Build a prescription template from a list or detail response."""
source = _mapping(data)
return cls(
id=_integer(source.get("id"), 0) or 0,
name=_text(source.get("prescription_name", source.get("name"))),
formula_type=_formula_type(source.get("formula_type")),
herbs=_dict_list(source.get("herbs")),
is_public=_boolean(source.get("is_public")),
disable_edit=_boolean(source.get("disable_edit")),
creator_id=_integer(source.get("creator_id"), None),
creator_name=_text(source.get("creator_name")),
create_time=_text(source.get("create_time")),
update_time=_text(source.get("update_time")),
raw=dict(source),
)
def to_api_dict(self, *, include_id: bool = True) -> JSONDict:
"""Serialise this template using the server's field names."""
payload: JSONDict = {
"prescription_name": self.name,
"formula_type": "辅方" if _formula_type(self.formula_type) == "aux" else "主方",
"herbs": [dict(item) for item in self.herbs],
"is_public": int(self.is_public),
"disable_edit": int(self.disable_edit),
}
if include_id and self.id:
payload["id"] = self.id
return payload
@dataclass(slots=True)
class Prescription:
"""An issued prescription, including audit state and dosage information."""
id: int = 0
diagnosis_id: int | None = None
appointment_id: int | None = None
case_record: JSONDict = field(default_factory=dict)
sn: str = ""
patient_name: str = ""
phone: str = ""
phone_masked: str = ""
gender: int | str | None = None
age: int | None = None
visit_no: str = ""
prescription_date: str = ""
prescription_type: int | str | None = None
herbs: list[JSONDict] = field(default_factory=list)
clinical_diagnosis: str = ""
tongue: str = ""
tongue_image: str = ""
pulse: str = ""
pulse_condition: str = ""
dosage_amount: float | None = None
dosage_unit: str = ""
dosage_bag_count: int | None = None
need_decoction: bool = False
bags_per_dose: int | None = None
dose_count: int | None = None
dose_unit: str = ""
usage_days: int | None = None
times_per_day: int | None = None
usage_instruction: str = ""
usage_time: str = ""
usage_way: str = ""
dietary_taboo: list[str] = field(default_factory=list)
usage_notes: str = ""
aux_usage: JSONDict = field(default_factory=dict)
doctor_name: str = ""
doctor_signature: str = ""
creator_id: int | None = None
assistant_name: str = ""
is_system_auto: bool = False
is_shared: bool = False
visible_role_ids: tuple[int, ...] = ()
audit_status: int | None = None
audit_time: str = ""
audit_by_name: str = ""
audit_remark: str = ""
business_prescription_audit_status: int | None = None
business_prescription_audit_rejected: bool = False
business_prescription_audit_remark: str = ""
void_status: int | None = None
void_by_name: str = ""
void_time: str = ""
has_prescription_order: bool = False
prescription_order_id: int | None = None
order_no: str = ""
recipient_name: str = ""
recipient_phone: str = ""
shipping_province: str = ""
shipping_city: str = ""
shipping_district: str = ""
shipping_address: str = ""
pharmacy_remark: str = ""
remark_assistant: str = ""
medication_days: int | None = None
pill_requirement: str = ""
create_time: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Prescription:
"""Build a prescription from a compact list row or a full detail object."""
source = _mapping(data)
return cls(
id=_integer(source.get("id"), 0) or 0,
diagnosis_id=_integer(source.get("diagnosis_id"), None),
appointment_id=_integer(source.get("appointment_id"), None),
case_record=dict(_mapping(source.get("case_record"))),
sn=_text(source.get("sn")),
patient_name=_text(source.get("patient_name")),
phone=_text(source.get("phone", source.get("patient_phone"))),
phone_masked=_text(source.get("phone_masked")),
gender=source.get("gender", source.get("gender_desc")),
age=_integer(source.get("age"), None),
visit_no=_text(source.get("visit_no")),
prescription_date=_text(source.get("prescription_date")),
prescription_type=source.get("prescription_type"),
herbs=_dict_list(source.get("herbs")),
clinical_diagnosis=_text(source.get("clinical_diagnosis")),
tongue=_text(source.get("tongue")),
tongue_image=_text(source.get("tongue_image")),
pulse=_text(source.get("pulse")),
pulse_condition=_text(source.get("pulse_condition")),
dosage_amount=_number(source.get("dosage_amount")),
dosage_unit=_text(source.get("dosage_unit")),
dosage_bag_count=_integer(source.get("dosage_bag_count"), None),
need_decoction=_boolean(source.get("need_decoction")),
bags_per_dose=_integer(source.get("bags_per_dose"), None),
dose_count=_integer(source.get("dose_count"), None),
dose_unit=_text(source.get("dose_unit")),
usage_days=_integer(source.get("usage_days"), None),
times_per_day=_integer(source.get("times_per_day"), None),
usage_instruction=_text(source.get("usage_instruction")),
usage_time=_text(source.get("usage_time")),
usage_way=_text(source.get("usage_way")),
dietary_taboo=_string_list(source.get("dietary_taboo")),
usage_notes=_text(source.get("usage_notes")),
aux_usage=dict(_mapping(source.get("aux_usage"))),
doctor_name=_text(source.get("doctor_name")),
doctor_signature=_text(source.get("doctor_signature")),
creator_id=_integer(source.get("creator_id"), None),
assistant_name=_text(source.get("assistant_name")),
is_system_auto=_boolean(source.get("is_system_auto")),
is_shared=_boolean(source.get("is_shared")),
visible_role_ids=_role_tuple(source.get("visible_role_ids")),
audit_status=_integer(source.get("audit_status"), None),
audit_time=_text(source.get("audit_time")),
audit_by_name=_text(source.get("audit_by_name")),
audit_remark=_text(source.get("audit_remark")),
business_prescription_audit_status=_integer(
source.get("business_prescription_audit_status"), None
),
business_prescription_audit_rejected=_boolean(
source.get("business_prescription_audit_rejected")
),
business_prescription_audit_remark=_text(
source.get("business_prescription_audit_remark")
),
void_status=_integer(source.get("void_status"), None),
void_by_name=_text(source.get("void_by_name")),
void_time=_text(source.get("void_time")),
has_prescription_order=_boolean(source.get("has_prescription_order")),
prescription_order_id=_integer(source.get("prescription_order_id"), None),
order_no=_text(source.get("order_no")),
recipient_name=_text(source.get("recipient_name")),
recipient_phone=_text(source.get("recipient_phone")),
shipping_province=_text(source.get("shipping_province")),
shipping_city=_text(source.get("shipping_city")),
shipping_district=_text(source.get("shipping_district")),
shipping_address=_text(source.get("shipping_address")),
pharmacy_remark=_text(source.get("pharmacy_remark", source.get("pharmacy_note"))),
remark_assistant=_text(source.get("remark_assistant")),
medication_days=_integer(source.get("medication_days"), None),
pill_requirement=_text(source.get("pill_requirement", source.get("make_pill_remark"))),
create_time=_text(source.get("create_time")),
raw=dict(source),
)
def to_api_dict(self, *, include_id: bool = True) -> JSONDict:
"""Serialise the complete editable prescription DTO for add/edit."""
payload: JSONDict = {
"diagnosis_id": self.diagnosis_id,
"appointment_id": self.appointment_id,
"case_record": dict(self.case_record),
"creator_id": self.creator_id,
"is_system_auto": int(self.is_system_auto),
"patient_name": self.patient_name,
"phone": self.phone,
"gender": self.gender,
"age": self.age,
"visit_no": self.visit_no,
"prescription_date": self.prescription_date,
"prescription_type": self.prescription_type,
"tongue": self.tongue,
"tongue_image": self.tongue_image,
"pulse": self.pulse,
"pulse_condition": self.pulse_condition,
"clinical_diagnosis": self.clinical_diagnosis,
"herbs": [dict(item) for item in self.herbs],
"dosage_amount": self.dosage_amount,
"dosage_unit": self.dosage_unit,
"dosage_bag_count": self.dosage_bag_count,
"need_decoction": int(self.need_decoction),
"bags_per_dose": self.bags_per_dose,
"dose_count": self.dose_count,
"dose_unit": self.dose_unit,
"usage_days": self.usage_days,
"times_per_day": self.times_per_day,
"usage_instruction": self.usage_instruction,
"usage_time": self.usage_time,
"usage_way": self.usage_way,
"dietary_taboo": list(self.dietary_taboo),
"usage_notes": self.usage_notes,
"aux_usage": dict(self.aux_usage),
"doctor_name": self.doctor_name,
"doctor_signature": self.doctor_signature,
"is_shared": int(self.is_shared),
"visible_role_ids": list(self.visible_role_ids),
"audit_status": self.audit_status,
}
if include_id and self.id:
payload["id"] = self.id
return {key: value for key, value in payload.items() if value is not None}
@dataclass(slots=True)
class PageResult(Generic[T]):
"""A normalised paginated result with the API's optional extension data."""
items: list[T] = field(default_factory=list)
total: int = 0
page_no: int = 1
page_size: int = 20
extend: JSONDict = field(default_factory=dict)
@property
def lists(self) -> list[T]:
"""Return ``items`` under the legacy API name used by the web client."""
return self.items
@property
def count(self) -> int:
"""Return ``total`` under the legacy API name used by the web client."""
return self.total
@property
def pages(self) -> int:
"""Return the number of pages, or zero when the result is empty."""
return ceil(self.total / self.page_size) if self.total and self.page_size else 0
@classmethod
def from_payload(
cls,
payload: object,
parser: Callable[[Mapping[str, Any]], T],
*,
page_no: int = 1,
page_size: int = 20,
) -> PageResult[T]:
"""Normalise common list/count aliases and parse only mapping rows."""
top_source = _mapping(payload)
if isinstance(payload, (list, tuple)):
rows: object = payload
source: Mapping[str, Any] = {}
else:
source = _mapping(payload)
rows = []
nested = source.get("data")
if not any(key in source for key in ("lists", "items", "rows", "records")):
if isinstance(nested, Mapping):
source = nested
elif isinstance(nested, (list, tuple)):
rows = nested
source = {}
else:
rows = []
if not rows:
rows = next(
(
source[key]
for key in ("lists", "items", "rows", "records", "data")
if isinstance(source.get(key), (list, tuple))
),
[],
)
parsed = [parser(row) for row in rows if isinstance(row, Mapping)]
total = _integer(
source.get("count", source.get("total", source.get("total_count"))),
len(parsed),
)
result_page = _integer(
source.get("page_no", source.get("page", source.get("current_page"))),
page_no,
)
result_size = _integer(
source.get("page_size", source.get("per_page", source.get("limit"))),
page_size,
)
extend: JSONDict = {}
for candidate in (
top_source.get("extend", top_source.get("meta")),
source.get("extend", source.get("meta")),
):
if isinstance(candidate, Mapping):
extend.update(candidate)
return cls(
items=parsed,
total=max(total or 0, 0),
page_no=max(result_page or page_no, 1),
page_size=max(result_size or page_size, 1),
extend=extend,
)
def map(self, transform: Callable[[T], U]) -> PageResult[U]:
"""Return a page with transformed items and unchanged pagination data."""
return PageResult(
items=[transform(item) for item in self.items],
total=self.total,
page_no=self.page_no,
page_size=self.page_size,
extend=dict(self.extend),
)
@dataclass(slots=True)
class CallTicket:
"""Short-lived Tencent IM/TRTC credentials for one consultation flow."""
sdk_app_id: int = 0
user_id: str = ""
user_sig: str = ""
patient_user_id: str = ""
assistant_id: str = ""
diagnosis_id: int | None = None
room_id: str = ""
call_record_id: int | None = None
is_lochost_vod: bool = False
expires_at: int | None = None
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> CallTicket:
"""Build a call ticket from the backend's camelCase or snake_case form."""
source = _mapping(data)
return cls(
sdk_app_id=_integer(source.get("sdkAppId", source.get("sdk_app_id")), 0) or 0,
user_id=_text(source.get("userId", source.get("user_id"))),
user_sig=_text(source.get("userSig", source.get("user_sig"))),
patient_user_id=_text(source.get("patientUserId", source.get("patient_user_id"))),
assistant_id=_text(source.get("assistant_id", source.get("assistantId"))),
diagnosis_id=_integer(source.get("diagnosis_id"), None),
room_id=_text(source.get("room_id", source.get("roomId"))),
call_record_id=_integer(source.get("call_record_id", source.get("callRecordId")), None),
is_lochost_vod=_boolean(source.get("isLochostVod", source.get("is_lochost_vod"))),
expires_at=_integer(source.get("expires_at", source.get("expireTime")), None),
raw=dict(source),
)
@@ -0,0 +1,152 @@
"""Immutable permission helpers matching the admin client's semantics."""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
def _normalise(values: Iterable[str] | str | None) -> frozenset[str]:
if values is None:
return frozenset()
candidates = (values,) if isinstance(values, str) else values
return frozenset(value.strip() for value in candidates if value and value.strip())
def _requirements(values: tuple[object, ...]) -> tuple[str, ...]:
if len(values) == 1 and not isinstance(values[0], str):
candidate = values[0]
if isinstance(candidate, Iterable):
values = tuple(candidate)
return tuple(str(value).strip() for value in values if value is not None and str(value).strip())
@dataclass(frozen=True, slots=True, init=False)
class PermissionSet:
"""A permission collection supporting wildcard, AND and OR checks.
``permissions`` accepts the flat permission list returned by ``mySelf``.
``pages`` and ``actions`` are optional categories for callers that keep UI
navigation and action capabilities separate; checks operate on their union.
"""
permissions: frozenset[str]
page_permissions: frozenset[str]
action_permissions: frozenset[str]
def __init__(
self,
permissions: Iterable[str] | str | None = None,
*,
pages: Iterable[str] | str | None = None,
actions: Iterable[str] | str | None = None,
page_permissions: Iterable[str] | str | None = None,
action_permissions: Iterable[str] | str | None = None,
) -> None:
"""Create an immutable, whitespace-normalised permission set."""
object.__setattr__(self, "permissions", _normalise(permissions))
object.__setattr__(
self,
"page_permissions",
_normalise(pages) | _normalise(page_permissions),
)
object.__setattr__(
self,
"action_permissions",
_normalise(actions) | _normalise(action_permissions),
)
@property
def is_superuser(self) -> bool:
"""Return whether the global ``*`` wildcard is present."""
return "*" in self._all_permissions
@property
def _all_permissions(self) -> frozenset[str]:
return self.permissions | self.page_permissions | self.action_permissions
def has(self, permission: str) -> bool:
"""Return whether an exact or wildcard grant covers ``permission``."""
required = permission.strip()
if not required:
return False
grants = self._all_permissions
if "*" in grants or required in grants:
return True
return any(grant.endswith("/*") and required.startswith(grant[:-1]) for grant in grants)
def allows(self, permission: str) -> bool:
"""Alias for :meth:`has`, convenient in UI guard code."""
return self.has(permission)
def has_all(self, *permissions: object) -> bool:
"""Return true when every requested permission is granted (AND)."""
return all(self.has(permission) for permission in _requirements(permissions))
def all(self, *permissions: object) -> bool:
"""Alias for :meth:`has_all`, mirroring the web client's AND helper."""
return self.has_all(*permissions)
def has_any(self, *permissions: object) -> bool:
"""Return true when at least one requested permission is granted (OR)."""
return any(self.has(permission) for permission in _requirements(permissions))
def any(self, *permissions: object) -> bool:
"""Alias for :meth:`has_any`, mirroring ``v-perms`` OR semantics."""
return self.has_any(*permissions)
def can_access_page(self, permission: str) -> bool:
"""Check the permission protecting a page or navigation entry."""
return self.has(permission)
def has_page(self, permission: str) -> bool:
"""Alias for :meth:`can_access_page`."""
return self.can_access_page(permission)
def can_perform_action(self, permission: str) -> bool:
"""Check the permission protecting a button or write action."""
return self.has(permission)
def has_action(self, permission: str) -> bool:
"""Alias for :meth:`can_perform_action`."""
return self.can_perform_action(permission)
def can(self, page_or_permission: str, action: str | None = None) -> bool:
"""Check a direct permission or a ``page/action`` combination."""
if action is None:
return self.has(page_or_permission)
permission = f"{page_or_permission.rstrip('/')}/{action.lstrip('/')}"
return self.has(permission)
def __contains__(self, permission: object) -> bool:
"""Support ``permission in permission_set`` checks."""
return isinstance(permission, str) and self.has(permission)
def __iter__(self) -> Iterator[str]:
"""Iterate over all explicit grants in stable sorted order."""
return iter(sorted(self._all_permissions))
def __len__(self) -> int:
"""Return the number of distinct explicit grants."""
return len(self._all_permissions)
def __bool__(self) -> bool:
"""Return whether at least one grant exists."""
return bool(self._all_permissions)
@@ -0,0 +1,40 @@
"""Authenticated session state independent of the UI framework."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .models import UserProfile
from .permissions import PermissionSet
@dataclass(slots=True)
class Session:
"""The token, user, permissions and login guards for one signed-in doctor."""
token: str = ""
user: UserProfile = field(default_factory=UserProfile)
permissions: PermissionSet = field(default_factory=PermissionSet)
menu: list[dict[str, Any]] = field(default_factory=list)
is_paw: int = 1
need_bind_work_wechat: bool = False
metadata: dict[str, Any] = field(default_factory=dict, repr=False)
@property
def authenticated(self) -> bool:
"""Return whether this session contains a non-empty access token."""
return bool(self.token.strip())
@property
def password_change_required(self) -> bool:
"""Return whether first-login password replacement is required."""
return self.is_paw == 0
@property
def work_wechat_binding_required(self) -> bool:
"""Return whether the user must bind Enterprise WeChat before use."""
return self.need_bind_work_wechat
@@ -0,0 +1,58 @@
"""Structured application logging with credential redaction."""
from __future__ import annotations
import logging
import re
from logging.handlers import RotatingFileHandler
from pathlib import Path
_SECRET_PATTERNS = (
re.compile(r'(?i)(token|usersig|authorization)(["\'\s:=]+)([^,\s"\']+)'),
re.compile(r'(?i)(password)(["\'\s:=]+)([^,\s"\']+)'),
)
class SecretRedactionFilter(logging.Filter):
"""Remove known credential-shaped values from every rendered log record."""
def filter(self, record: logging.LogRecord) -> bool:
rendered = record.getMessage()
for pattern in _SECRET_PATTERNS:
rendered = pattern.sub(r"\1\2<redacted>", rendered)
record.msg = rendered
record.args = ()
return True
def configure_logging(log_dir: Path, level: str = "INFO") -> Path:
"""Configure console and rotating file logging and return the log path."""
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "doctor-workstation.log"
numeric_level = getattr(logging, level.upper(), logging.INFO)
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s%(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
redaction = SecretRedactionFilter()
root = logging.getLogger()
root.setLevel(numeric_level)
root.handlers.clear()
file_handler = RotatingFileHandler(
log_file,
maxBytes=2 * 1024 * 1024,
backupCount=4,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
file_handler.addFilter(redaction)
root.addHandler(file_handler)
console = logging.StreamHandler()
console.setFormatter(formatter)
console.addFilter(redaction)
root.addHandler(console)
return log_file
+28
View File
@@ -0,0 +1,28 @@
"""Locate bundled resources in source and PyInstaller builds."""
from __future__ import annotations
import sys
from pathlib import Path
def project_root() -> Path:
frozen_root = getattr(sys, "_MEIPASS", None)
if frozen_root:
return Path(frozen_root)
return Path(__file__).resolve().parents[2]
def resource_path(*parts: str) -> Path:
return project_root().joinpath("resources", *parts)
def video_dist_path() -> Path:
candidates = (
project_root() / "video_companion" / "dist" / "index.html",
project_root() / "video_companion_dist" / "index.html",
)
for candidate in candidates:
if candidate.exists():
return candidate
return resource_path("video", "index.html")
@@ -0,0 +1,27 @@
"""Transport, credential and repository adapters for the doctor workstation."""
from .api_client import ApiClient
from .factory import build_repository
from .mock_repository import DEMO_PERMISSIONS, DemoDoctorRepository
from .repository import (
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
DoctorRepository,
RemoteDoctorRepository,
)
from .token_store import KeyringLike, TokenStore
__all__ = [
"ApiClient",
"AuditAction",
"DEMO_PERMISSIONS",
"DemoDoctorRepository",
"DoctorRepository",
"KeyringLike",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
"PRESCRIPTION_PERMISSIONS",
"RemoteDoctorRepository",
"TokenStore",
"build_repository",
]
@@ -0,0 +1,348 @@
"""Synchronous HTTP client for the legacy admin API envelope."""
from __future__ import annotations
import time
from collections.abc import Callable, Mapping
from threading import RLock
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
InstallationRequiredError,
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
class ApiClient:
"""A small, testable client implementing the admin API contract.
The supplied base URL may be either the site origin or an URL already
ending in ``/adminapi``. Timeout retries are deliberately limited to GET
requests so that medical write operations are never submitted twice.
"""
API_VERSION = "1.9.4"
def __init__(
self,
base_url: str,
*,
token: str = "",
timeout: float | httpx.Timeout = 30.0,
max_retries: int = 2,
retry_backoff: float = 0.0,
verify: bool = True,
transport: httpx.BaseTransport | None = None,
client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Create a client without performing any network requests."""
if max_retries < 0:
raise ValueError("max_retries must be non-negative")
if retry_backoff < 0:
raise ValueError("retry_backoff must be non-negative")
if client is not None and transport is not None:
raise ValueError("pass either client or transport, not both")
self.base_url = self.normalise_base_url(base_url)
self.timeout = timeout
self.max_retries = max_retries
self.retry_backoff = retry_backoff
self._sleep = sleep
self._token = token.strip()
self._lock = RLock()
self._owns_client = client is None
self._client = client or httpx.Client(transport=transport, verify=verify)
@staticmethod
def normalise_base_url(base_url: str) -> str:
"""Return an absolute URL ending in exactly one ``/adminapi/``."""
candidate = base_url.strip()
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("base_url must be an absolute http(s) URL")
if parsed.query or parsed.fragment:
raise ValueError("base_url must not include query parameters or fragments")
path = parsed.path.rstrip("/")
if path.lower().endswith("/adminapi"):
normalised_path = f"{path}/"
else:
normalised_path = f"{path}/adminapi/" if path else "/adminapi/"
return urlunsplit((parsed.scheme, parsed.netloc, normalised_path, "", ""))
@property
def token(self) -> str:
"""Return the current in-memory access token."""
with self._lock:
return self._token
@token.setter
def token(self, value: str) -> None:
"""Replace the in-memory access token used by later requests."""
with self._lock:
self._token = value.strip()
def set_token(self, token: str) -> None:
"""Set the authentication token; provided for explicit session code."""
self.token = token
def clear_token(self) -> None:
"""Remove the authentication token from memory."""
self.token = ""
def get(
self,
endpoint: str,
params: Mapping[str, Any] | None = None,
*,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue a GET request and return the unwrapped envelope data."""
return self.request("GET", endpoint, params=params, headers=headers)
def post(
self,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
json: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue a non-retried JSON POST and return the unwrapped data."""
if payload is not None and json is not None:
raise ValueError("pass either payload or json, not both")
body = json if json is not None else payload
return self.request("POST", endpoint, json=body or {}, headers=headers)
def post_multipart(
self,
endpoint: str,
*,
files: Mapping[str, Any],
data: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue a non-retried multipart POST and return unwrapped data.
``httpx`` owns the multipart boundary. In particular this method does
not inherit the JSON ``Content-Type`` used by ordinary API writes.
File objects remain owned by the caller and are consumed synchronously.
"""
if not files:
raise ValueError("multipart files must not be empty")
return self.request(
"POST",
endpoint,
data=data or {},
files=files,
headers=headers,
)
def request(
self,
method: str,
endpoint: str,
*,
params: Mapping[str, Any] | None = None,
json: Mapping[str, Any] | None = None,
data: Mapping[str, Any] | None = None,
files: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue one API request with structured transport/envelope errors."""
verb = method.upper().strip()
if verb not in {"GET", "POST"}:
raise ValueError("ApiClient only supports GET and POST")
if verb == "GET" and (json is not None or data is not None or files is not None):
raise ValueError("GET requests cannot include a body")
if json is not None and (data is not None or files is not None):
raise ValueError("JSON and multipart/form data are mutually exclusive")
if files is not None and verb != "POST":
raise ValueError("multipart uploads require POST")
url = self._endpoint_url(endpoint)
request_headers = self._headers(
headers,
json_content_type=files is None and data is None,
)
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
for attempt in range(attempts):
try:
response = self._client.request(
verb,
url,
params=dict(params) if params is not None else None,
json=dict(json) if verb == "POST" and json is not None else None,
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=self.timeout,
)
break
except httpx.TimeoutException as exc:
if attempt + 1 < attempts:
delay = self.retry_backoff * (2**attempt)
if delay:
self._sleep(delay)
continue
raise ApiTimeoutError(
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"{verb} {endpoint} failed: {exc}",
data={"method": verb, "endpoint": endpoint},
) from exc
if response is None: # Defensive; the loop always returns or raises.
raise ApiTransportError(f"{verb} {endpoint} produced no response")
return self._unwrap(response)
def close(self) -> None:
"""Close the internally-created HTTP transport."""
if self._owns_client:
self._client.close()
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
"""Close owned resources when leaving a context manager."""
self.close()
def _endpoint_url(self, endpoint: str) -> str:
value = endpoint.strip()
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
raise ValueError("endpoint must be a relative API path")
path = parsed.path.lstrip("/")
if path.lower().startswith("adminapi/"):
path = path[len("adminapi/") :]
if not path:
raise ValueError("endpoint must not be empty")
suffix = f"?{parsed.query}" if parsed.query else ""
return f"{self.base_url}{path}{suffix}"
def _headers(
self,
extra: Mapping[str, str] | None,
*,
json_content_type: bool = True,
) -> dict[str, str]:
result = {
"Accept": "application/json",
"version": self.API_VERSION,
}
if json_content_type:
result["Content-Type"] = "application/json;charset=UTF-8"
token = self.token
if token:
result["token"] = token
if extra:
result.update(extra)
if not json_content_type:
for key in tuple(result):
if key.lower() == "content-type":
result.pop(key)
return result
@staticmethod
def _request_id(response: httpx.Response) -> str | None:
return response.headers.get("x-request-id") or response.headers.get("request-id")
def _unwrap(self, response: httpx.Response) -> Any:
request_id = self._request_id(response)
if not 200 <= response.status_code < 300:
raise ApiHttpError(
f"API returned HTTP {response.status_code}",
status_code=response.status_code,
request_id=request_id,
)
try:
envelope = response.json()
except (ValueError, UnicodeDecodeError) as exc:
raise ApiProtocolError(
"API response is not valid JSON",
status_code=response.status_code,
request_id=request_id,
) from exc
if not isinstance(envelope, Mapping):
raise ApiProtocolError(
"API response envelope must be an object",
data=envelope,
status_code=response.status_code,
request_id=request_id,
)
raw_code = envelope.get("code")
try:
if isinstance(raw_code, bool):
raise ValueError
code = int(raw_code)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"API response envelope has no valid code",
data=dict(envelope),
status_code=response.status_code,
request_id=request_id,
) from exc
data = envelope.get("data")
message = str(envelope.get("msg") or envelope.get("message") or "").strip()
if code == 1:
return data
context = {
"code": code,
"data": data,
"status_code": response.status_code,
"request_id": request_id,
}
if code == 0:
if not message and isinstance(data, str):
message = data
raise ApiBusinessError(message or "API rejected the operation", **context)
if code == -1:
raise AuthenticationExpiredError(message or "Login has expired", **context)
if code == 10:
raise WorkWechatBindingRequiredError(
message or "Enterprise WeChat binding is required", **context
)
if code == 2:
target = str(_mapping(data).get("url") or "")
raise OpenPageRequiredError(
message or "The operation must continue in another page",
url=target,
data=data,
status_code=response.status_code,
request_id=request_id,
)
if code == -2:
raise InstallationRequiredError(
message or "A required companion component is not installed", **context
)
raise ApiProtocolError(message or f"Unsupported API envelope code: {code}", **context)
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
@@ -0,0 +1,5 @@
"""Compatibility module exporting the in-memory demo repository."""
from .mock_repository import DEMO_PERMISSIONS, DemoDoctorRepository
__all__ = ["DEMO_PERMISSIONS", "DemoDoctorRepository"]
@@ -0,0 +1,46 @@
"""Small construction helper used by the application bootstrap layer."""
from __future__ import annotations
import httpx
from .api_client import ApiClient
from .mock_repository import DemoDoctorRepository
from .repository import DoctorRepository, RemoteDoctorRepository
from .token_store import TokenStore
def build_repository(
*,
demo: bool,
base_url: str | None = None,
token_store: TokenStore | None = None,
client: ApiClient | None = None,
token: str = "",
timeout: float | httpx.Timeout = 30.0,
max_retries: int = 2,
verify: bool = True,
) -> DoctorRepository:
"""Build a demo or remote repository without depending on ``AppConfig``.
``client`` is primarily useful for tests or advanced bootstrap code. For
normal remote use, provide ``base_url`` and this factory creates the
correctly configured :class:`ApiClient`.
"""
if demo:
if client is not None:
raise ValueError("client cannot be supplied in demo mode")
return DemoDoctorRepository()
api_client = client
if api_client is None:
if base_url is None or not base_url.strip():
raise ValueError("base_url is required in remote mode")
api_client = ApiClient(
base_url,
token=token,
timeout=timeout,
max_retries=max_retries,
verify=verify,
)
return RemoteDoctorRepository(api_client, token_store)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
"""Compatibility module exporting the production doctor repository."""
from .repository import (
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
DoctorRepository,
RemoteDoctorRepository,
)
__all__ = [
"AuditAction",
"DoctorRepository",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
"PRESCRIPTION_PERMISSIONS",
"RemoteDoctorRepository",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
"""Token persistence using OS keyring with a restricted-file fallback."""
from __future__ import annotations
import json
import os
import stat
import tempfile
from contextlib import suppress
from pathlib import Path
from typing import Any, Protocol
class KeyringLike(Protocol):
"""The small subset shared by the optional keyring module and its backends."""
def get_password(self, service: str, username: str) -> str | None:
"""Return a stored secret or ``None``."""
def set_password(self, service: str, username: str, password: str) -> None:
"""Persist one secret."""
def delete_password(self, service: str, username: str) -> None:
"""Delete one secret."""
_AUTO_KEYRING = object()
class TokenStore:
"""Store access tokens, but never user passwords.
If a working ``keyring`` backend is importable, the token is stored there
and the JSON file contains at most the remembered account name and the
non-secret API scope. Otherwise the JSON file is atomically written with
owner-only ``0600`` permissions.
"""
def __init__(
self,
path: str | os.PathLike[str] | None = None,
*,
service_name: str = "zyt-doctor-workstation",
token_name: str = "access-token",
keyring_backend: KeyringLike | None | object = _AUTO_KEYRING,
) -> None:
"""Create a token store without writing any files."""
self.path = Path(path) if path is not None else self.default_path()
self.service_name = service_name
self.token_name = token_name
self._keyring = self._load_keyring(keyring_backend)
self._uses_keyring = self._probe_keyring()
@staticmethod
def default_path() -> Path:
"""Return a per-user fallback credential path for the current platform."""
if os.name == "nt":
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
else:
root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
return root / "ZYTDoctorWorkstation" / "credentials.json"
@property
def uses_keyring(self) -> bool:
"""Return whether token operations currently use a working keyring."""
return self._uses_keyring
def load_token(self, *, scope: str | None = None) -> str | None:
"""Load a token, optionally requiring an exact persisted API scope.
Supplying ``scope`` is the safe choice for automatic login restoration:
an older unscoped token, or a token issued by another API base URL, is
never returned to the caller.
"""
data = self._read_file()
if scope is not None:
expected_scope = self._normalise_scope(scope)
stored_scope = self._normalise_scope(data.get("scope"))
if not expected_scope or stored_scope != expected_scope:
return None
if self._uses_keyring and self._keyring is not None:
try:
token = self._keyring.get_password(self.service_name, self.token_name)
if token:
return token
except Exception:
self._uses_keyring = False
value = data.get("token")
return str(value) if isinstance(value, str) and value else None
def save_token(
self,
token: str,
*,
account: str | None = None,
scope: str | None = None,
) -> None:
"""Persist a token plus optional account and API-scope metadata.
Passing an empty ``account`` explicitly forgets a previously remembered
account. Passwords are never accepted or persisted.
"""
cleaned = token.strip()
if not cleaned:
self.clear_token()
if account is not None:
self.save_account(account)
return
data = self._read_file()
if account is not None:
account_value = account.strip()
if account_value:
data["account"] = account_value
else:
data.pop("account", None)
if scope is not None:
scope_value = self._normalise_scope(scope)
if scope_value:
data["scope"] = scope_value
else:
data.pop("scope", None)
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.set_password(self.service_name, self.token_name, cleaned)
data.pop("token", None)
self._write_file(data)
return
except Exception:
self._uses_keyring = False
data["token"] = cleaned
self._write_file(data)
def clear_token(self) -> None:
"""Delete the token while retaining an explicitly remembered account."""
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.delete_password(self.service_name, self.token_name)
except Exception:
self._uses_keyring = False
data = self._read_file()
data.pop("token", None)
data.pop("scope", None)
self._write_file(data)
def load_account(self) -> str | None:
"""Load the remembered login account; no password is ever stored."""
value = self._read_file().get("account")
return str(value) if isinstance(value, str) and value else None
def save_account(self, account: str) -> None:
"""Remember only the account name used to pre-fill the login form."""
data = self._read_file()
cleaned = account.strip()
if cleaned:
data["account"] = cleaned
else:
data.pop("account", None)
self._write_file(data)
def clear_account(self) -> None:
"""Forget the remembered account without changing the stored token."""
self.save_account("")
def clear(self) -> None:
"""Delete both token and remembered account information."""
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.delete_password(self.service_name, self.token_name)
except Exception:
self._uses_keyring = False
try:
self.path.unlink(missing_ok=True)
except OSError:
self._write_file({})
def get_token(self) -> str | None:
"""Compatibility alias for :meth:`load_token`."""
return self.load_token()
def set_token(self, token: str) -> None:
"""Compatibility alias for :meth:`save_token`."""
self.save_token(token)
def delete_token(self) -> None:
"""Compatibility alias for :meth:`clear_token`."""
self.clear_token()
@staticmethod
def _load_keyring(candidate: KeyringLike | None | object) -> KeyringLike | None:
if candidate is not _AUTO_KEYRING:
return candidate if candidate is not None else None # type: ignore[return-value]
try:
import keyring # type: ignore[import-not-found]
return keyring
except (ImportError, RuntimeError):
return None
def _probe_keyring(self) -> bool:
if self._keyring is None:
return False
try:
self._keyring.get_password(self.service_name, self.token_name)
return True
except Exception:
return False
@staticmethod
def _normalise_scope(value: object) -> str:
"""Return a stable comparison form for a non-secret API base URL."""
return str(value or "").strip().rstrip("/")
def _read_file(self) -> dict[str, Any]:
try:
content = self.path.read_text(encoding="utf-8")
value = json.loads(content)
except (FileNotFoundError, OSError, UnicodeError, json.JSONDecodeError):
return {}
if not isinstance(value, dict):
return {}
# Explicit allow-list guarantees accidental password-like fields are ignored.
return {key: value[key] for key in ("token", "account", "scope") if key in value}
def _write_file(self, data: dict[str, Any]) -> None:
safe = {key: data[key] for key in ("token", "account", "scope") if data.get(key)}
if not safe:
with suppress(OSError):
self.path.unlink(missing_ok=True)
return
self.path.parent.mkdir(parents=True, exist_ok=True)
temp_fd, temp_name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent)
temp_path = Path(temp_name)
try:
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(temp_fd, "w", encoding="utf-8", newline="\n") as handle:
temp_fd = -1
json.dump(safe, handle, ensure_ascii=False, separators=(",", ":"))
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, self.path)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
finally:
if temp_fd >= 0:
os.close(temp_fd)
with suppress(OSError):
temp_path.unlink(missing_ok=True)
@@ -0,0 +1,7 @@
"""Qt Widgets user interface for the doctor workstation."""
from .login import LoginWindow
from .shell import ShellWindow
from .theme import apply_theme
__all__ = ["LoginWindow", "ShellWindow", "apply_theme"]
@@ -0,0 +1,5 @@
"""Reusable doctor-workstation dialogs."""
from .diagnosis import DiagnosisDialog
__all__ = ["DiagnosisDialog"]
@@ -0,0 +1,920 @@
"""Patient diagnosis detail/editor used by the scoped patient workspace."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from ..widgets import (
MessageBanner,
display_text,
first_value,
friendly_error,
gender_text,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
section_title,
)
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
_PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders"
_PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$")
_ID_CARD_PATTERN = re.compile(
r"^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$"
)
_DIAGNOSIS_FIELDS = (
("患者姓名", "patient_name", "患者姓名"),
("手机号", "phone", "11 位手机号"),
("身份证号", "id_card", "18 位身份证号(可选)"),
("性别", "gender", "1=男,0=女"),
("年龄", "age", "患者年龄"),
("诊断日期", "diagnosis_date", "YYYY-MM-DD"),
("诊断类型", "diagnosis_type", "初诊/复诊类型"),
("证型", "syndrome_type", "中医证型"),
("糖尿病类型", "diabetes_type", "糖尿病分型"),
("糖尿病发现年", "diabetes_discovery_year", "发现年份或病程描述"),
("当地医院诊断", "local_hospital_diagnosis", "多项以顿号分隔"),
("当地医院", "local_hospital_name", "当地就诊医院"),
("婚姻状态", "marital_status", "婚姻状态"),
("身高", "height", "cm"),
("体重", "weight", "kg"),
("地区", "region", "所在地区"),
("收缩压", "systolic_pressure", "mmHg"),
("舒张压", "diastolic_pressure", "mmHg"),
("空腹血糖", "fasting_blood_sugar", "mmol/L"),
("主诉", "chief_complaint", "患者此次就诊的主要诉求"),
("现病史", "present_illness", "主要症状、持续时间与变化"),
("症状", "symptoms", "当前主要症状"),
("既往史", "past_history", "既往史;多项可用顿号分隔"),
("外伤史", "trauma_history", "0/1 或具体说明"),
("手术史", "surgery_history", "0/1 或具体说明"),
("过敏史", "allergy_history", "0/1 或具体说明"),
("家族史", "family_history", "0/1 或具体说明"),
("妊娠史", "pregnancy_history", "0/1 或具体说明"),
("食欲", "appetite", "多项以顿号分隔"),
("饮水", "water_intake", "饮水情况"),
("饮食", "diet_condition", "多项以顿号分隔"),
("体重变化", "weight_change", "体重变化"),
("身体感觉", "body_feeling", "多项以顿号分隔"),
("睡眠", "sleep_condition", "多项以顿号分隔"),
("眼部", "eye_condition", "多项以顿号分隔"),
("头部感觉", "head_feeling", "多项以顿号分隔"),
("出汗", "sweat_condition", "多项以顿号分隔"),
("皮肤", "skin_condition", "多项以顿号分隔"),
("小便", "urine_condition", "多项以顿号分隔"),
("大便", "stool_condition", "多项以顿号分隔"),
("肾脏情况", "kidney_condition", "多项以顿号分隔"),
("脂肪肝程度", "fatty_liver_degree", "脂肪肝程度"),
("舌象", "tongue", "舌质、舌苔等观察"),
("舌苔", "tongue_coating", "舌苔记录"),
("脉象", "pulse", "脉象记录"),
("临床诊断", "clinical_diagnosis", "填写临床诊断"),
("治则", "treatment_principle", "治疗原则"),
("处方", "prescription", "处方摘要"),
("处方意见", "prescription_opinion", "辨证与处方意见"),
("医嘱", "doctor_advice", "医生嘱托"),
("当前用药", "current_medications", "患者当前用药"),
("补充备注", "remark", "其他需要记录的信息"),
)
_LIST_FIELDS = {
"local_hospital_diagnosis",
"appetite",
"diet_condition",
"body_feeling",
"sleep_condition",
"eye_condition",
"head_feeling",
"sweat_condition",
"skin_condition",
"urine_condition",
"stool_condition",
"kidney_condition",
}
_INTEGER_FIELDS = {
"gender",
"age",
"marital_status",
"systolic_pressure",
"diastolic_pressure",
"trauma_history",
"surgery_history",
"allergy_history",
"family_history",
"pregnancy_history",
}
_FLOAT_FIELDS = {"height", "weight", "fasting_blood_sugar"}
_PATIENT_BASIC_FIELDS = {"patient_name", "phone", "id_card", "gender", "age"}
def _mask_phone(value: Any) -> str:
text = str(value or "").strip()
return f"{text[:3]}****{text[-4:]}" if len(text) == 11 else ("***" if text else "")
def _mask_id_card(value: Any) -> str:
text = str(value or "").strip()
if len(text) == 18:
return f"{text[:6]}********{text[-4:]}"
if len(text) == 15:
return f"{text[:6]}*****{text[-4:]}"
return "***" if text else ""
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
def _editor_text(value: Any) -> str:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "".join(str(item) for item in value)
return "" if value is None else str(value)
def _duplicate_found(value: Any) -> bool:
for key in ("exists", "duplicate", "data.exists", "data.duplicate"):
candidate = get_value(value, key, None)
if candidate is not None:
if isinstance(candidate, str):
return candidate.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(candidate)
return False
def _field_value(key: str, text: str, original: Any) -> Any:
value = text.strip()
if isinstance(original, Sequence) and not isinstance(original, (str, bytes, bytearray)):
return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()]
if isinstance(original, bool):
return value.lower() not in {"", "0", "false", "no", "off"}
if isinstance(original, int) or (original in (None, "") and key in _INTEGER_FIELDS):
return _int(value, 0) if value else ""
if isinstance(original, float) or (original in (None, "") and key in _FLOAT_FIELDS):
try:
return float(value) if value else ""
except ValueError:
return value
if key in _LIST_FIELDS:
return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()]
return value
def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any:
"""Call the first repository method present while keeping keyword tolerance."""
for name in names:
if callable(getattr(repository, name, None)):
return invoke(repository, name, **kwargs)
return invoke(repository, names[0], **kwargs)
def _mapping(value: Any) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class DiagnosisDialog(QDialog):
"""Load a diagnosis and its server-backed histories without blocking Qt."""
saved = Signal()
def __init__(
self,
repository: Any,
parent: QWidget | None = None,
*,
permissions: Any = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = (
permissions
if permissions is not None
else getattr(parent, "permissions", None)
if parent is not None
else None
)
self._can_phone_plain = has_permission(self.permissions, _PHONE_PERMISSION, default=False)
self._can_patient_orders = has_permission(
self.permissions, _PATIENT_ORDERS_PERMISSION, default=False
)
self._diagnosis_id = 0
self._patient_id = 0
self._editable = False
self._generation = 0
self._save_generation = 0
self._orders_generation = 0
self._orders_page = 1
self._orders_page_size = 10
self._orders_total = 0
self._detail: Any = None
self._field_originals: dict[str, Any] = {}
self.setModal(True)
self.setWindowTitle("患者信息详情")
self.setMinimumSize(680, 520)
self.resize(880, 680)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(12)
heading = QHBoxLayout()
identity = QVBoxLayout()
identity.setSpacing(2)
self.title_label = QLabel("患者信息详情")
self.title_label.setProperty("role", "pageTitle")
identity.addWidget(self.title_label)
self.meta_label = QLabel("正在准备诊单…")
self.meta_label.setProperty("role", "muted")
identity.addWidget(self.meta_label)
heading.addLayout(identity, 1)
self.mode_label = QLabel("只读")
self.mode_label.setObjectName("StatusBadge")
self.mode_label.setProperty("kind", "neutral")
heading.addWidget(self.mode_label, 0, Qt.AlignmentFlag.AlignTop)
root.addLayout(heading)
self.banner = MessageBanner()
root.addWidget(self.banner)
self.tabs = QTabWidget()
self.tabs.setObjectName("DiagnosisTabs")
self.tabs.addTab(self._build_overview_tab(), "患者与病历")
self.tabs.addTab(self._build_notes_tab(), "医生备注")
self.tabs.addTab(self._build_history_tab("appointment"), "挂号记录")
self.tabs.addTab(self._build_history_tab("assign"), "指派记录")
if self._can_patient_orders:
self.tabs.addTab(self._build_orders_tab(), "患者订单")
root.addWidget(self.tabs, 1)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
self.save_button = self.buttons.addButton(
"保存病历", QDialogButtonBox.ButtonRole.AcceptRole
)
self.save_button.setProperty("variant", "primary")
self.save_button.clicked.connect(self._save)
self.buttons.rejected.connect(self.reject)
root.addWidget(self.buttons)
def _build_overview_tab(self) -> QWidget:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
layout = QVBoxLayout(content)
layout.setContentsMargins(4, 12, 4, 16)
layout.setSpacing(12)
summary = QFrame()
summary.setObjectName("SubtleCard")
summary_layout = QGridLayout(summary)
summary_layout.setContentsMargins(14, 12, 14, 12)
summary_layout.setHorizontalSpacing(18)
summary_layout.setVerticalSpacing(10)
self.summary_fields: dict[str, QLabel] = {}
fields = (
("患者", "patient"),
("手机", "phone"),
("身份证", "id_card"),
("性别 / 年龄", "gender_age"),
("身高 / 体重", "body"),
("预约", "appointment"),
("医生 / 医助", "staff"),
("血压", "pressure"),
("空腹血糖", "blood_sugar"),
)
for index, (caption, key) in enumerate(fields):
row, column = divmod(index, 2)
box = QVBoxLayout()
label = QLabel(caption)
label.setProperty("role", "muted")
value = QLabel("")
value.setWordWrap(True)
value.setStyleSheet("font-weight:600;")
box.addWidget(label)
box.addWidget(value)
summary_layout.addLayout(box, row, column)
self.summary_fields[key] = value
layout.addWidget(summary)
layout.addWidget(section_title("病历内容"))
form_host = QFrame()
form_host.setObjectName("SubtleCard")
form = QFormLayout(form_host)
form.setContentsMargins(14, 12, 14, 12)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(10)
self.edit_fields: dict[str, QPlainTextEdit] = {}
for caption, key, placeholder in _DIAGNOSIS_FIELDS:
edit = QPlainTextEdit()
edit.setPlaceholderText(placeholder)
edit.setMinimumHeight(46)
edit.setMaximumHeight(82)
form.addRow(caption, edit)
self.edit_fields[key] = edit
layout.addWidget(form_host)
layout.addStretch(1)
scroll.setWidget(content)
return scroll
def _build_notes_tab(self) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(4, 12, 4, 12)
self.notes_table = QTableWidget(0, 3)
self.notes_table.setHorizontalHeaderLabels(["时间", "医生", "内容"])
self.notes_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.notes_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.notes_table.verticalHeader().setVisible(False)
self.notes_table.horizontalHeader().setStretchLastSection(True)
self.notes_table.setColumnWidth(0, 150)
self.notes_table.setColumnWidth(1, 110)
layout.addWidget(self.notes_table)
return widget
def _build_history_tab(self, kind: str) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(4, 12, 4, 12)
if kind == "appointment":
table = QTableWidget(0, 7)
table.setHorizontalHeaderLabels(
["状态", "患者", "医生", "医助", "预约日期", "时段", "备注"]
)
widths = (88, 110, 100, 100, 105, 80)
self.appointment_table = table
else:
table = QTableWidget(0, 6)
table.setHorizontalHeaderLabels(
["操作时间", "原医助", "新医助", "继承", "操作人", "账号"]
)
widths = (145, 100, 100, 70, 100)
self.assign_table = table
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
table.verticalHeader().setVisible(False)
table.horizontalHeader().setStretchLastSection(True)
for index, width in enumerate(widths):
table.setColumnWidth(index, width)
layout.addWidget(table)
return widget
def _build_orders_tab(self) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(4, 12, 4, 12)
self.orders_table = QTableWidget(0, 8)
self.orders_table.setHorizontalHeaderLabels(
["订单号", "处方", "患者/收货人", "手机", "金额", "发货", "状态", "创建时间"]
)
self.orders_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.orders_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.orders_table.verticalHeader().setVisible(False)
self.orders_table.horizontalHeader().setStretchLastSection(True)
for index, width in enumerate((130, 70, 120, 110, 90, 90, 100)):
self.orders_table.setColumnWidth(index, width)
layout.addWidget(self.orders_table, 1)
footer = QHBoxLayout()
self.orders_summary = QLabel("共 0 条")
self.orders_summary.setProperty("role", "muted")
footer.addWidget(self.orders_summary)
footer.addStretch(1)
self.orders_previous = QLabel('<a href="prev">上一页</a>')
self.orders_previous.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.orders_previous.linkActivated.connect(lambda _link: self._change_orders_page(-1))
footer.addWidget(self.orders_previous)
self.orders_page_label = QLabel("1 / 1")
footer.addWidget(self.orders_page_label)
self.orders_next = QLabel('<a href="next">下一页</a>')
self.orders_next.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.orders_next.linkActivated.connect(lambda _link: self._change_orders_page(1))
footer.addWidget(self.orders_next)
layout.addLayout(footer)
return widget
def open_for(self, diagnosis_id: int, *, editable: bool = False, seed: Any = None) -> None:
"""Open immediately, then replace the seed with authoritative server data."""
self._diagnosis_id = int(diagnosis_id)
self._patient_id = 0
self._editable = bool(
editable and has_permission(self.permissions, "tcm.diagnosis/edit", default=True)
)
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
self._orders_page = 1
self._orders_total = 0
generation = self._generation
self._detail = seed
self.setWindowTitle("编辑患者病历" if self._editable else "患者信息详情")
self.title_label.setText("编辑患者病历" if self._editable else "患者信息详情")
self.mode_label.setText("可编辑" if self._editable else "只读")
self.mode_label.setProperty("kind", "success" if self._editable else "neutral")
self.mode_label.style().unpolish(self.mode_label)
self.mode_label.style().polish(self.mode_label)
self.save_button.setVisible(self._editable)
for key, field in self.edit_fields.items():
field.setReadOnly(
not self._editable or (key in {"phone", "id_card"} and not self._can_phone_plain)
)
self._clear_tables()
if seed is not None:
self._render(seed, [], [])
self.banner.show_message("正在加载完整诊单与历史记录…", "info")
self.open()
diagnosis_id_snapshot = self._diagnosis_id
editable_snapshot = self._editable
run_async(
lambda: self._load_bundle(diagnosis_id_snapshot, editable_snapshot),
on_success=lambda result: self._apply_bundle(result, generation),
on_error=lambda error: self._load_error(error, generation),
on_finished=lambda: None,
)
def _load_bundle(self, diagnosis_id: int, editable: bool) -> dict[str, Any]:
detail_names = (
("get_diagnosis_detail", "patient_detail", "diagnosis_readonly_detail")
if editable
else ("patient_detail", "diagnosis_readonly_detail", "get_diagnosis_detail")
)
detail = _invoke_first(
self.repository,
detail_names,
diagnosis_id=diagnosis_id,
id=diagnosis_id,
readonly=not editable,
)
appointments: list[Any] = []
assignments: list[Any] = []
history_errors: list[str] = []
try:
appointments = page_items(
_invoke_first(
self.repository,
("appointment_history",),
diagnosis_id=diagnosis_id,
page_no=1,
page_size=500,
)
)
except Exception as error: # history failure must not hide the diagnosis
history_errors.append(f"挂号记录:{friendly_error(error)}")
try:
assignments = page_items(
_invoke_first(
self.repository,
("assign_history",),
diagnosis_id=diagnosis_id,
page_no=1,
page_size=50,
)
)
except Exception as error: # history failure must not hide the diagnosis
history_errors.append(f"指派记录:{friendly_error(error)}")
orders: list[Any] = []
orders_total = 0
diagnosis = get_value(detail, "diagnosis", None) or detail or {}
patient = get_value(detail, "patient", None) or {}
patient_id = _int(
first_value(
diagnosis,
"patient_id",
"source_patient_id",
default=first_value(patient, "patient_id", "id", default=0),
),
0,
)
if self._can_patient_orders:
try:
order_result = self._query_orders(diagnosis_id, patient_id, 1)
orders = page_items(order_result)
orders_total = page_total(order_result, len(orders))
except Exception as error: # order failure must not hide the diagnosis
history_errors.append(f"患者订单:{friendly_error(error)}")
return {
"detail": detail,
"appointments": appointments,
"assignments": assignments,
"patient_id": patient_id,
"orders": orders,
"orders_total": orders_total,
"history_errors": history_errors,
}
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
filters: dict[str, Any] = {
"context_diagnosis_id": diagnosis_id,
"scene": "diagnosis_edit",
}
if patient_id > 0:
filters["patient_id"] = patient_id
return _invoke_first(
self.repository,
("list_prescription_orders",),
page_no=page,
page_size=self._orders_page_size,
**filters,
)
def _apply_bundle(self, result: Any, generation: int) -> None:
if generation != self._generation:
return
detail = get_value(result, "detail", None)
appointments = get_value(result, "appointments", []) or []
assignments = get_value(result, "assignments", []) or []
self._detail = detail
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
self._render(detail, appointments, assignments)
if self._can_patient_orders:
self._orders_total = int(get_value(result, "orders_total", 0) or 0)
self._fill_orders(get_value(result, "orders", []) or [])
self._update_orders_pager()
errors = get_value(result, "history_errors", []) or []
if errors:
self.banner.show_message("".join(str(item) for item in errors), "warning")
else:
self.banner.clear()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _render(self, detail: Any, appointments: Sequence[Any], assignments: Sequence[Any]) -> None:
diagnosis = get_value(detail, "diagnosis", None) or detail or {}
patient = get_value(detail, "patient", None) or {}
appointment = get_value(detail, "appointment", None) or {}
patient_name = first_value(
diagnosis,
"patient_name",
"name",
default=first_value(patient, "patient_name", "name", default="未命名患者"),
)
gender = gender_text(
first_value(
diagnosis,
"gender_desc",
"gender",
default=first_value(patient, "gender_desc", "gender"),
)
)
age = display_text(first_value(diagnosis, "age", default=first_value(patient, "age")))
self.meta_label.setText(f"诊单 #{self._diagnosis_id} · {patient_name} · {gender} · {age}")
self.summary_fields["patient"].setText(display_text(patient_name))
self.summary_fields["phone"].setText(
display_text(
first_value(
diagnosis,
"phone",
"patient_phone",
default=first_value(
patient,
"phone",
"patient_phone",
default=first_value(appointment, "patient_phone"),
),
)
if self._can_phone_plain
else _mask_phone(
first_value(
diagnosis,
"phone",
"patient_phone",
default=first_value(
patient,
"phone",
"patient_phone",
default=first_value(appointment, "patient_phone"),
),
)
)
)
)
id_card = first_value(
diagnosis,
"id_card",
default=first_value(patient, "id_card", default=""),
)
self.summary_fields["id_card"].setText(
display_text(id_card if self._can_phone_plain else _mask_id_card(id_card))
)
self.summary_fields["gender_age"].setText(f"{gender} / {age}")
self.summary_fields["body"].setText(
f"{display_text(first_value(diagnosis, 'height', default=first_value(patient, 'height')))} cm / "
f"{display_text(first_value(diagnosis, 'weight', default=first_value(patient, 'weight')))} kg"
)
appointment_text = " ".join(
part
for part in (
display_text(first_value(appointment, "appointment_date"), ""),
display_text(
first_value(appointment, "appointment_time", "appointment_time_text"), ""
),
)
if part
)
self.summary_fields["appointment"].setText(appointment_text or "暂无预约")
self.summary_fields["staff"].setText(
f"{display_text(first_value(appointment, 'doctor_name'))} / "
f"{display_text(first_value(appointment, 'assistant_name'))}"
)
self.summary_fields["pressure"].setText(
f"{display_text(first_value(diagnosis, 'systolic_pressure'))} / "
f"{display_text(first_value(diagnosis, 'diastolic_pressure'))} mmHg"
)
self.summary_fields["blood_sugar"].setText(
f"{display_text(first_value(diagnosis, 'fasting_blood_sugar'))} mmol/L"
)
basic_locked = _truthy(
first_value(diagnosis, "patient_basic_locked", default=False)
) or not _truthy(first_value(diagnosis, "can_edit_patient_basic", default=True))
self._field_originals.clear()
for key, field in self.edit_fields.items():
raw = first_value(
diagnosis,
key,
default=first_value(
patient,
key,
default=first_value(
diagnosis,
"patient_phone" if key == "phone" else key,
default="",
),
),
)
self._field_originals[key] = raw
if key == "phone" and not self._can_phone_plain:
rendered = _mask_phone(raw)
elif key == "id_card" and not self._can_phone_plain:
rendered = _mask_id_card(raw)
else:
rendered = _editor_text(raw)
field.setPlainText(rendered)
field.setReadOnly(
not self._editable
or (key in _PATIENT_BASIC_FIELDS and basic_locked)
or (key in {"phone", "id_card"} and not self._can_phone_plain)
)
notes = (
get_value(detail, "doctor_notes", None)
or get_value(diagnosis, "doctor_notes", None)
or []
)
if not isinstance(notes, (list, tuple)):
notes = [notes]
self._fill_notes(notes)
self._fill_appointments(appointments)
self._fill_assignments(assignments)
def _clear_tables(self) -> None:
self.notes_table.setRowCount(0)
self.appointment_table.setRowCount(0)
self.assign_table.setRowCount(0)
if self._can_patient_orders:
self.orders_table.setRowCount(0)
self.orders_summary.setText("共 0 条")
self.orders_page_label.setText("1 / 1")
@staticmethod
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
for column, value in enumerate(values):
item = QTableWidgetItem(display_text(value))
item.setToolTip(item.text() if len(item.text()) > 18 else "")
table.setItem(row, column, item)
def _fill_notes(self, rows: Sequence[Any]) -> None:
self.notes_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.notes_table,
index,
(
first_value(row, "create_time", "created_at", "time"),
first_value(row, "doctor_name", "creator_name", default="医生"),
first_value(row, "content", "note", "remark"),
),
)
def _fill_appointments(self, rows: Sequence[Any]) -> None:
self.appointment_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.appointment_table,
index,
(
first_value(row, "status_desc", "status_text", "status"),
first_value(row, "patient_name"),
first_value(row, "doctor_name"),
first_value(row, "assistant_name"),
first_value(row, "appointment_date"),
first_value(row, "appointment_time", "period"),
first_value(row, "remark"),
),
)
def _fill_assignments(self, rows: Sequence[Any]) -> None:
self.assign_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.assign_table,
index,
(
first_value(row, "create_time_text", "create_time"),
first_value(row, "from_assistant_name", default=""),
first_value(row, "to_assistant_name", "assistant_name", default=""),
"" if bool(first_value(row, "is_inherit", default=False)) else "",
first_value(row, "operator_name"),
first_value(row, "operator_account"),
),
)
def _fill_orders(self, rows: Sequence[Any]) -> None:
self.orders_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.orders_table,
index,
(
first_value(row, "order_no", "sn", "id"),
first_value(row, "prescription_id"),
first_value(row, "recipient_name", "patient_name"),
first_value(row, "recipient_phone", "phone"),
first_value(row, "amount"),
first_value(row, "ship_mode", "express_company"),
first_value(row, "status_text", "fulfillment_status_text", "status"),
first_value(row, "create_time", "created_at"),
),
)
def _update_orders_pager(self) -> None:
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
self.orders_summary.setText(f"{self._orders_total}")
self.orders_page_label.setText(f"{self._orders_page} / {pages}")
self.orders_previous.setEnabled(self._orders_page > 1)
self.orders_next.setEnabled(self._orders_page < pages)
def _change_orders_page(self, offset: int) -> None:
if not self._can_patient_orders or self._diagnosis_id <= 0:
return
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
target_page = self._orders_page + offset
if target_page < 1 or target_page > pages:
return
self._orders_generation += 1
generation = self._orders_generation
diagnosis_id = self._diagnosis_id
patient_id = self._patient_id
self.banner.show_message("正在加载患者订单…", "info")
run_async(
lambda: self._query_orders(diagnosis_id, patient_id, target_page),
on_success=lambda result: self._apply_orders_page(
result, diagnosis_id, target_page, generation
),
on_error=lambda error: self._orders_error(error, diagnosis_id, generation),
)
def _apply_orders_page(
self,
result: Any,
diagnosis_id: int,
page: int,
generation: int,
) -> None:
if generation != self._orders_generation or diagnosis_id != self._diagnosis_id:
return
rows = page_items(result)
self._orders_page = page
self._orders_total = page_total(result, len(rows))
self._fill_orders(rows)
self._update_orders_pager()
self.banner.clear()
def _orders_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
if generation == self._orders_generation and diagnosis_id == self._diagnosis_id:
self.banner.show_message(friendly_error(error), "danger")
def _save(self) -> None:
if not self._editable or self._diagnosis_id <= 0:
return
diagnosis_id = self._diagnosis_id
changes = {
key: _field_value(key, field.toPlainText(), self._field_originals.get(key))
for key, field in self.edit_fields.items()
if key not in {"phone", "id_card"} or self._can_phone_plain
}
patient_name = str(changes.get("patient_name") or "").strip()
if not patient_name:
self.banner.show_message("请输入患者姓名。", "warning")
self.edit_fields["patient_name"].setFocus()
return
phone = str(changes.get("phone") or "").strip()
if self._can_phone_plain and not _PHONE_PATTERN.fullmatch(phone):
self.banner.show_message("手机号格式不正确。", "warning")
self.edit_fields["phone"].setFocus()
return
id_card = str(changes.get("id_card") or "").strip()
if self._can_phone_plain and id_card and not _ID_CARD_PATTERN.fullmatch(id_card):
self.banner.show_message("身份证号格式不正确。", "warning")
self.edit_fields["id_card"].setFocus()
return
self._save_generation += 1
generation = self._save_generation
self.save_button.setEnabled(False)
self.banner.show_message("正在保存病历…", "info")
run_async(
lambda: self._validate_and_save(diagnosis_id, changes),
on_success=lambda _result: self._save_success(generation),
on_error=lambda error: self._save_error(error, generation),
on_finished=lambda: self._save_finished(generation),
)
def _validate_and_save(self, diagnosis_id: int, changes: Mapping[str, Any]) -> Any:
if self._can_phone_plain:
phone = str(changes.get("phone") or "").strip()
phone_result = _invoke_first(
self.repository,
("check_diagnosis_phone",),
payload={"phone": phone, "id": diagnosis_id},
)
if _duplicate_found(phone_result):
raise ValueError(
str(
first_value(
phone_result, "message", "data.message", default="手机号已存在。"
)
)
)
id_card = str(changes.get("id_card") or "").strip()
if id_card:
id_card_result = _invoke_first(
self.repository,
("check_diagnosis_id_card",),
payload={"id_card": id_card, "id": diagnosis_id},
)
if _duplicate_found(id_card_result):
raise ValueError(
str(
first_value(
id_card_result,
"message",
"data.message",
default="身份证号已存在。",
)
)
)
return _invoke_first(
self.repository,
("update_diagnosis",),
diagnosis=diagnosis_id,
changes=dict(changes),
)
def _save_success(self, generation: int) -> None:
if generation != self._save_generation:
return
self.banner.show_message("病历已保存。", "success")
self.saved.emit()
def _save_error(self, error: Exception, generation: int) -> None:
if generation == self._save_generation:
self.banner.show_message(friendly_error(error), "danger")
def _save_finished(self, generation: int) -> None:
if generation == self._save_generation:
self.save_button.setEnabled(True)
__all__ = ["DiagnosisDialog"]
File diff suppressed because it is too large Load Diff
+440
View File
@@ -0,0 +1,440 @@
"""Account login window for the doctor workstation."""
from __future__ import annotations
from typing import Any
from PySide6.QtCore import QSettings, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
class LoginWindow(QMainWindow):
"""A responsive login surface with optional demo-repository switching.
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
``repository`` and ``demo_mode``. Keeping the selected repository in the
payload lets the composition root construct the shell without guessing.
``remember_account`` is captured before the worker starts and is forwarded
to the repository so every account metadata store follows the same choice.
"""
login_succeeded = Signal(object)
authenticated = Signal(object)
login_failed = Signal(str)
server_settings_changed = Signal(dict)
config_changed = Signal(object)
demo_mode_changed = Signal(bool)
def __init__(
self,
repository: Any,
config: Any | None = None,
demo_repository: Any | None = None,
settings: QSettings | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.config = config
if demo_repository is None:
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
self.active_repository = repository
self.authenticated_user: Any = None
self._loading = False
self.setWindowTitle("臻阳堂 · 医生工作站")
self.setMinimumSize(860, 590)
self.resize(1120, 720)
canvas = QWidget()
canvas.setObjectName("LoginCanvas")
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(26, 26, 26, 26)
root.setSpacing(26)
root.addWidget(self._build_brand_panel(), 5)
root.addWidget(self._build_login_area(), 6)
self._restore_settings()
def _build_brand_panel(self) -> QWidget:
panel = QWidget()
panel.setObjectName("LoginBrandPanel")
panel.setMinimumWidth(310)
panel.setMaximumWidth(470)
layout = QVBoxLayout(panel)
layout.setContentsMargins(38, 38, 38, 38)
layout.setSpacing(18)
brand_row = QHBoxLayout()
mark = QLabel("")
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(42, 42)
mark.setStyleSheet(
"color:#0F6D64; background:#DDF1EC; border-radius:12px; font-size:20px; font-weight:700;"
)
brand_row.addWidget(mark)
brand_name = QLabel("臻阳堂医疗")
brand_name.setStyleSheet("color:#FCFBF8; font-size:16px; font-weight:700;")
brand_row.addWidget(brand_name)
brand_row.addStretch(1)
layout.addLayout(brand_row)
layout.addStretch(2)
eyebrow = QLabel("DOCTOR WORKSTATION")
eyebrow.setStyleSheet("color:#82B7A9; font-size:11px; font-weight:700; letter-spacing:1px;")
layout.addWidget(eyebrow)
headline = QLabel("把诊间工作,\n留在一个安静的界面里。")
headline.setProperty("role", "display")
headline.setWordWrap(True)
layout.addWidget(headline)
description = QLabel("接诊、问诊、患者与处方信息统一呈现,帮助医生专注于每一次沟通。")
description.setWordWrap(True)
description.setStyleSheet("color:#B8CCC5; font-size:14px; line-height:1.6;")
layout.addWidget(description)
layout.addStretch(3)
privacy = QLabel("本工作站仅供获授权的医疗人员使用\n请勿在公共设备保存账号")
privacy.setWordWrap(True)
privacy.setStyleSheet("color:#82A198; font-size:11px;")
layout.addWidget(privacy)
return panel
def _build_login_area(self) -> QWidget:
area = QWidget()
outer = QVBoxLayout(area)
outer.setContentsMargins(20, 10, 20, 10)
outer.addStretch(1)
self.card = QFrame()
self.card.setObjectName("LoginCard")
self.card.setMaximumWidth(470)
card_layout = QVBoxLayout(self.card)
card_layout.setContentsMargins(40, 36, 40, 36)
card_layout.setSpacing(14)
title = QLabel("欢迎回来")
title.setProperty("role", "pageTitle")
card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted")
card_layout.addWidget(subtitle)
card_layout.addSpacing(6)
self.error_banner = MessageBanner()
card_layout.addWidget(self.error_banner)
account_label = QLabel("账号")
account_label.setStyleSheet("font-weight:600;")
card_layout.addWidget(account_label)
self.account_edit = QLineEdit()
self.account_edit.setPlaceholderText("手机号或工作账号")
self.account_edit.setClearButtonEnabled(True)
self.account_edit.setAccessibleName("登录账号")
card_layout.addWidget(self.account_edit)
password_label = QLabel("密码")
password_label.setStyleSheet("font-weight:600;")
card_layout.addWidget(password_label)
password_row = QHBoxLayout()
password_row.setSpacing(6)
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.password_edit.setPlaceholderText("请输入密码")
self.password_edit.setAccessibleName("登录密码")
self.password_edit.returnPressed.connect(self.submit)
password_row.addWidget(self.password_edit, 1)
self.reveal_button = QToolButton()
self.reveal_button.setText("显示")
self.reveal_button.setCheckable(True)
self.reveal_button.setToolTip("显示或隐藏密码")
self.reveal_button.toggled.connect(self._toggle_password)
password_row.addWidget(self.reveal_button)
card_layout.addLayout(password_row)
choices = QHBoxLayout()
self.remember_check = QCheckBox("记住账号")
self.remember_check.setToolTip("仅保存账号,不保存密码")
choices.addWidget(self.remember_check)
choices.addStretch(1)
self.demo_check = QCheckBox("演示模式")
self.demo_check.setEnabled(self.demo_repository is not None)
if self.demo_repository is None:
self.demo_check.setToolTip("当前未配置演示数据")
self.demo_check.toggled.connect(self._on_demo_toggled)
choices.addWidget(self.demo_check)
card_layout.addLayout(choices)
self.login_button = QPushButton("登录工作站")
self.login_button.setProperty("variant", "primary")
self.login_button.setMinimumHeight(42)
self.login_button.clicked.connect(self.submit)
card_layout.addWidget(self.login_button)
self.server_toggle = QPushButton("服务器设置 +")
self.server_toggle.setProperty("variant", "ghost")
self.server_toggle.setCheckable(True)
self.server_toggle.clicked.connect(self._toggle_server_panel)
card_layout.addWidget(self.server_toggle)
self.server_panel = QFrame()
self.server_panel.setObjectName("SubtleCard")
server_layout = QVBoxLayout(self.server_panel)
server_layout.setContentsMargins(14, 12, 14, 12)
server_layout.setSpacing(8)
server_layout.addWidget(QLabel("服务地址"))
self.server_url_edit = QLineEdit()
self.server_url_edit.setPlaceholderText("由管理员提供,例如 https://api.example.com")
server_layout.addWidget(self.server_url_edit)
timeout_row = QHBoxLayout()
timeout_row.addWidget(QLabel("读取超时"))
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(10, 180)
self.timeout_spin.setSuffix("")
self.timeout_spin.setValue(60)
timeout_row.addWidget(self.timeout_spin)
timeout_row.addStretch(1)
self.save_server_button = QPushButton("保存设置")
self.save_server_button.setProperty("variant", "secondary")
self.save_server_button.clicked.connect(self._save_server_settings)
timeout_row.addWidget(self.save_server_button)
server_layout.addLayout(timeout_row)
server_hint = QLabel("生产环境应使用管理员下发的 HTTPS 地址。")
server_hint.setProperty("role", "muted")
server_hint.setWordWrap(True)
server_layout.addWidget(server_hint)
self.server_panel.setVisible(False)
card_layout.addWidget(self.server_panel)
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
footnote.setProperty("role", "muted")
footnote.setWordWrap(True)
card_layout.addWidget(footnote)
outer.addWidget(self.card, 0, Qt.AlignmentFlag.AlignHCenter)
outer.addStretch(1)
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
return area
def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "")
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
self.account_edit.setText(remembered)
self.remember_check.setChecked(bool(remembered))
configured_url = getattr(self.config, "base_url", "") or getattr(
self.config, "api_base_url", ""
)
self.server_url_edit.setText(
str(self.settings.value("server/base_url", configured_url) or "")
)
try:
configured_timeout = getattr(self.config, "request_timeout", 60)
timeout = int(
self.settings.value("server/read_timeout", configured_timeout) or configured_timeout
)
except (TypeError, ValueError):
timeout = 60
self.timeout_spin.setValue(max(10, min(180, timeout)))
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
self.demo_check.setChecked(True)
if remembered:
self.password_edit.setFocus()
else:
self.account_edit.setFocus()
def _toggle_password(self, visible: bool) -> None:
self.password_edit.setEchoMode(
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
)
self.reveal_button.setText("隐藏" if visible else "显示")
def _on_demo_toggled(self, enabled: bool) -> None:
self.active_repository = self.demo_repository if enabled else self.repository
self.server_toggle.setEnabled(not enabled and not self._loading)
self.demo_mode_changed.emit(enabled)
self._emit_config_update(demo_mode=enabled)
self.error_banner.clear()
if enabled:
self.account_edit.setPlaceholderText("可留空,使用演示医生")
self.password_edit.setPlaceholderText("可留空")
else:
self.account_edit.setPlaceholderText("手机号或工作账号")
self.password_edit.setPlaceholderText("请输入密码")
def _toggle_server_panel(self, expanded: bool) -> None:
self.server_panel.setVisible(expanded)
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
def _save_server_settings(self) -> None:
base_url = self.server_url_edit.text().strip().rstrip("/")
if base_url and not base_url.startswith(
("https://", "http://localhost", "http://127.0.0.1")
):
self.error_banner.show_message(
"服务地址需使用 HTTPS;本机调试可使用 localhost。", "warning"
)
return
values = {
"base_url": base_url,
"read_timeout": self.timeout_spin.value(),
"api_base_url": base_url,
"request_timeout": self.timeout_spin.value(),
}
self.settings.setValue("server/base_url", base_url)
self.settings.setValue("server/read_timeout", self.timeout_spin.value())
self.settings.sync()
self.server_settings_changed.emit(values)
self._emit_config_update(
api_base_url=base_url,
request_timeout=self.timeout_spin.value(),
)
self.error_banner.show_message("服务器设置已保存,将在连接时生效。", "success")
def _emit_config_update(self, **changes: Any) -> None:
updater = getattr(self.config, "with_updates", None)
if callable(updater):
try:
self.config = updater(**changes)
except (TypeError, ValueError):
self.config_changed.emit(changes)
else:
self.config_changed.emit(self.config)
return
self.config_changed.emit(changes)
def submit(self) -> None:
if self._loading:
return
demo_mode = self.demo_check.isChecked()
remember_account = self.remember_check.isChecked()
account = self.account_edit.text().strip()
password = self.password_edit.text()
if demo_mode:
account = account or str(getattr(self.active_repository, "DEMO_ACCOUNT", "doctor"))
password = password or str(
getattr(self.active_repository, "DEMO_PASSWORD", "doctor123")
)
if not account:
self.error_banner.show_message("请输入登录账号。", "warning")
self.account_edit.setFocus()
return
if not password:
self.error_banner.show_message("请输入密码。", "warning")
self.password_edit.setFocus()
return
repository = self.active_repository
if repository is None:
self.error_banner.show_message("演示服务尚未配置。", "warning")
return
self.error_banner.clear()
self._set_loading(True)
def authenticate() -> dict[str, Any]:
session = invoke(
repository,
"login",
account=account,
password=password,
remember_account=remember_account,
)
user = invoke(repository, "get_current_user")
return {
"session": session,
"user": user,
"repository": repository,
"demo_mode": demo_mode,
"remember_account": remember_account,
}
run_async(
authenticate,
on_success=lambda payload: self._on_login_success(
payload,
account,
remember_account,
),
on_error=self._on_login_error,
on_finished=lambda: self._set_loading(False),
)
def _set_loading(
self,
loading: bool,
*,
button_text: str = "正在登录…",
overlay_text: str = "正在验证账号…",
) -> None:
self._loading = loading
self.login_button.setEnabled(not loading)
self.account_edit.setEnabled(not loading)
self.password_edit.setEnabled(not loading)
self.remember_check.setEnabled(not loading)
self.reveal_button.setEnabled(not loading)
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
self.server_panel.setEnabled(not loading)
self.server_url_edit.setEnabled(not loading)
self.timeout_spin.setEnabled(not loading)
self.save_server_button.setEnabled(not loading)
self.login_button.setText(button_text if loading else "登录工作站")
self.busy_overlay.set_message(overlay_text)
self.busy_overlay.setVisible(loading)
if loading:
self.busy_overlay.raise_()
def set_session_restore_pending(self, pending: bool) -> None:
"""Block manual login controls while persisted-token validation runs."""
if pending:
self.error_banner.clear()
self._set_loading(
pending,
button_text="正在恢复登录…",
overlay_text="正在验证已保存的登录状态…",
)
def _on_login_success(
self,
payload: dict[str, Any],
account: str,
remember_account: bool | None = None,
) -> None:
if remember_account is None:
remember_account = self.remember_check.isChecked()
if remember_account:
self.settings.setValue("auth/remembered_account", account)
else:
self.settings.remove("auth/remembered_account")
self.settings.sync()
self._emit_config_update(remembered_account=account if remember_account else "")
self.password_edit.clear()
self.authenticated_user = payload.get("user")
self.login_succeeded.emit(payload)
self.authenticated.emit(payload.get("session"))
def _on_login_error(self, error: Exception) -> None:
message = friendly_error(error)
self.error_banner.show_message(message, "danger")
self.login_failed.emit(message)
self.password_edit.selectAll()
self.password_edit.setFocus()
__all__ = ["LoginWindow"]
@@ -0,0 +1,15 @@
"""Business pages shown inside :class:`doctor_workstation.ui.shell.ShellWindow`."""
from .consultations import ConsultationsPage
from .patients import PatientsPage
from .prescription_library import PrescriptionLibraryPage
from .prescriptions import PrescriptionsPage
from .reception import ReceptionPage
__all__ = [
"ConsultationsPage",
"PatientsPage",
"PrescriptionLibraryPage",
"PrescriptionsPage",
"ReceptionPage",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
"""Reusable prescription-template library matching the admin workflow."""
from __future__ import annotations
from typing import Any
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..widgets import (
EmptyState,
MessageBanner,
PageHeader,
Pager,
SortableTable,
TableColumn,
display_text,
first_value,
friendly_error,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
show_toast,
)
def _formula_text(value: Any, _row: Any = None) -> str:
text = str(value or "").strip().lower()
return "辅方" if text in {"2", "aux", "auxiliary", "secondary", "辅方"} else "主方"
def _visibility_text(value: Any, _row: Any = None) -> str:
return "所有人可见" if value in (True, 1, "1", "public", "all") else "仅自己可见"
def _disable_edit_text(value: Any, _row: Any = None) -> str:
return "已禁用" if value in (True, 1, "1") else "可修改"
def _herb_count(_value: Any, row: Any) -> str:
herbs = get_value(row, "herbs", None) or []
return f"{len(herbs)}" if isinstance(herbs, (list, tuple)) else "0味"
def _herbs_detail(_value: Any, row: Any) -> str:
herbs = get_value(row, "herbs", None) or []
if not isinstance(herbs, (list, tuple)):
return display_text(herbs)
pieces = []
for herb in herbs:
name = first_value(herb, "name", "medicine_name", default="药材")
dosage = first_value(herb, "dosage", "amount", default="")
pieces.append(f"{name} {dosage}g".strip())
return "".join(pieces) if pieces else "暂无药材"
class PrescriptionLibraryPage(QWidget):
"""Filter, inspect, and manage reusable prescriptions."""
def __init__(
self,
repository: Any,
permissions: Any = None,
current_user: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
self._generation = 0
self._loading = False
self._refresh_pending = False
self._page = 1
self._page_size = 15
root = QVBoxLayout(self)
root.setContentsMargins(24, 20, 24, 24)
root.setSpacing(15)
header = PageHeader(
"我的处方库",
"管理可复用药材组合;公开模板可被其他医生导入,禁用修改仅作用于导入后的处方。",
)
self.new_button = QPushButton(" 新增处方")
self.new_button.setProperty("variant", "primary")
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
self.new_button.clicked.connect(self._new_template)
header.add_action(self.new_button)
root.addWidget(header)
filters = QFrame()
filters.setObjectName("FilterBar")
grid = QGridLayout(filters)
grid.setContentsMargins(16, 13, 16, 13)
grid.setHorizontalSpacing(10)
self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("处方名称")
self.name_filter.setClearButtonEnabled(True)
self.name_filter.returnPressed.connect(self._search)
grid.addWidget(self.name_filter, 0, 0, 1, 2)
self.formula_filter = QComboBox()
self.formula_filter.addItem("全部类型", "")
self.formula_filter.addItem("主方", "主方")
self.formula_filter.addItem("辅方", "辅方")
grid.addWidget(self.formula_filter, 0, 2)
self.visibility_filter = QComboBox()
self.visibility_filter.addItem("全部公开范围", "")
self.visibility_filter.addItem("仅自己可见", 0)
self.visibility_filter.addItem("所有人可见", 1)
grid.addWidget(self.visibility_filter, 0, 3)
query = QPushButton("查询")
query.setProperty("variant", "secondary")
query.clicked.connect(self._search)
grid.addWidget(query, 0, 4)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters)
grid.addWidget(reset, 0, 5)
grid.setColumnStretch(0, 1)
root.addWidget(filters)
self.banner = MessageBanner()
root.addWidget(self.banner)
card = QFrame()
card.setObjectName("Card")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(16, 14, 16, 14)
card_layout.setSpacing(10)
toolbar = QHBoxLayout()
title = QLabel("处方模板")
title.setProperty("role", "sectionTitle")
toolbar.addWidget(title)
toolbar.addStretch(1)
self.view_button = QPushButton("查看")
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
self.view_button.setEnabled(False)
self.view_button.clicked.connect(self._view_selected)
toolbar.addWidget(self.view_button)
self.edit_button = QPushButton("编辑")
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
self.edit_button.setEnabled(False)
self.edit_button.clicked.connect(self._edit_selected)
toolbar.addWidget(self.edit_button)
self.delete_button = QPushButton("删除")
self.delete_button.setProperty("variant", "danger")
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
self.delete_button.setEnabled(False)
self.delete_button.clicked.connect(self._delete_selected)
toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
card_layout.addLayout(toolbar)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable(
[
TableColumn("id", "ID", 60),
TableColumn("prescription_name", "处方名称", 180),
TableColumn("formula_type", "处方类型", 90, _formula_text),
TableColumn("herbs", "药材数量", 90, _herb_count),
TableColumn("herbs", "药材明细", 300, _herbs_detail),
TableColumn("is_public", "是否公开", 110, _visibility_text),
TableColumn("disable_edit", "禁用修改", 95, _disable_edit_text),
TableColumn("creator_name", "创建人", 100),
TableColumn("create_time", "创建时间", 150),
]
)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager.page_changed.connect(self._change_page)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host)
empty = EmptyState(
"还没有处方模板",
"可以新增常用药材组合,之后开方时快速导入。",
"新增处方",
)
empty.action_button.setVisible(self.new_button.isVisible())
empty.action_requested.connect(self._new_template)
self.stack.addWidget(empty)
card_layout.addWidget(self.stack, 1)
root.addWidget(card, 1)
def _search(self) -> None:
self._page = 1
self.refresh()
def _reset_filters(self) -> None:
self.name_filter.clear()
self.formula_filter.setCurrentIndex(0)
self.visibility_filter.setCurrentIndex(0)
self._search()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh()
def refresh(self) -> None:
if self._loading:
self._refresh_pending = True
return
self._loading = True
self._refresh_pending = False
self._generation += 1
generation = self._generation
query = {
"prescription_name": self.name_filter.text().strip(),
"formula_type": self.formula_filter.currentData(),
"is_public": self.visibility_filter.currentData(),
"page": self._page,
"page_size": self._page_size,
}
requested_page = self._page
self.banner.show_message("正在加载处方库…", "info")
run_async(
lambda: invoke(
self.repository,
"prescription_library",
**query,
),
on_success=lambda result: self._apply_result(result, generation, requested_page),
on_error=lambda error: self._load_error(error, generation),
on_finished=lambda: self._load_finished(generation),
)
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
self.table.set_rows(rows)
self.pager.update_state(requested_page, page_total(result, len(rows)))
self.stack.setCurrentIndex(0 if rows else 1)
self.banner.clear()
self._selection_changed()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _load_finished(self, generation: int) -> None:
if generation == self._generation:
self._loading = False
if self._refresh_pending:
self._refresh_pending = False
self.refresh()
def _selection_changed(self) -> None:
row = self.table.current_data()
self.view_button.setEnabled(row is not None)
manageable = self._can_manage_row(row)
self.edit_button.setEnabled(row is not None and manageable)
self.delete_button.setEnabled(row is not None and manageable)
def _can_manage_row(self, row: Any) -> bool:
if row is None:
return False
user_id = first_value(self.current_user, "id", "user_id", default=None)
creator_id = first_value(row, "creator_id", "doctor_id", default=None)
if user_id is not None and creator_id is not None and str(user_id) == str(creator_id):
return True
if _truthy(first_value(self.current_user, "root", "is_root", default=False)):
return True
role_ids = first_value(self.current_user, "role_ids", "role_id", default=[]) or []
if not isinstance(role_ids, (list, tuple, set)):
role_ids = [role_ids]
return any(str(role) in {"0", "3"} for role in role_ids)
def _new_template(self) -> None:
if not has_permission(self.permissions, "wcf.prescription/add"):
return
dialog = PrescriptionTemplateDialog(
self.repository,
mode="add",
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_template(dialog.payload(), None)
def _view_selected(self) -> None:
row = self.table.current_data()
if row is None or not has_permission(self.permissions, "wcf.prescription/read"):
return
PrescriptionTemplateDialog(
self.repository,
row,
mode="view",
parent=self,
).exec()
def _edit_selected(self) -> None:
row = self.table.current_data()
if (
row is None
or not has_permission(self.permissions, "wcf.prescription/edit")
or not self._can_manage_row(row)
):
return
dialog = PrescriptionTemplateDialog(
self.repository,
row,
mode="edit",
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_template(
dialog.payload(),
first_value(row, "id", "template_id", default=None),
)
def _save_template(self, payload: dict[str, Any], template_id: Any) -> None:
permission = "wcf.prescription/add" if template_id is None else "wcf.prescription/edit"
if not has_permission(self.permissions, permission):
return
self._set_actions_enabled(False)
if template_id is None:
def operation() -> Any:
return invoke(
self.repository,
"create_prescription_template",
template=payload,
)
else:
def operation() -> Any:
return invoke(
self.repository,
"update_prescription_template",
template=template_id,
changes=payload,
)
run_async(
operation,
on_success=lambda _result: self._mutation_success("处方模板已保存。"),
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4300),
on_finished=lambda: self._set_actions_enabled(True),
)
def _delete_selected(self) -> None:
row = self.table.current_data()
template_id = first_value(row, "id", "template_id", default=None)
if (
row is None
or template_id is None
or not has_permission(self.permissions, "wcf.prescription/delete")
or not self._can_manage_row(row)
):
return
name = display_text(first_value(row, "prescription_name", "name", default="该模板"))
answer = QMessageBox.warning(
self,
"删除处方模板",
f"确定删除“{name}”吗?此操作无法撤销。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
if answer != QMessageBox.StandardButton.Yes:
return
self._set_actions_enabled(False)
run_async(
lambda: invoke(
self.repository,
"delete_prescription_template",
template_id=template_id,
),
on_success=lambda _result: self._mutation_success("处方模板已删除。"),
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4300),
on_finished=lambda: self._set_actions_enabled(True),
)
def _mutation_success(self, message: str) -> None:
show_toast(self, message, "success")
self.refresh()
def _set_actions_enabled(self, enabled: bool) -> None:
self.new_button.setEnabled(enabled)
if not enabled:
self.view_button.setEnabled(False)
self.edit_button.setEnabled(False)
self.delete_button.setEnabled(False)
else:
self._selection_changed()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
__all__ = ["PrescriptionLibraryPage", "PrescriptionTemplateDialog"]
@@ -0,0 +1,933 @@
"""Issued-prescription management matching the admin route contract."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import QDateTime, Qt
from PySide6.QtWidgets import (
QComboBox,
QDateTimeEdit,
QDialog,
QDialogButtonBox,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from ..dialogs.prescription import (
AuditPrescriptionDialog,
DiagnosisDetailDialog,
PatchPatientDialog,
PrescriptionDetailDialog,
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionOrderListDialog,
)
from ..widgets import (
EmptyState,
MessageBanner,
PageHeader,
Pager,
SortableTable,
TableColumn,
display_text,
first_value,
friendly_error,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
show_toast,
)
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
def prescription_status(row: Any) -> tuple[str, str]:
"""Return the admin-equivalent combined status label and visual kind."""
if _int(first_value(row, "void_status", "is_void"), 0) == 1:
return "已作废", "danger"
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
return "已驳回", "danger"
status = _int(first_value(row, "audit_status", "status", default=0), 0)
if status == 2:
return "已驳回", "danger"
if status == 1:
return "已通过", "success"
return "待审核", "warning"
def is_approved_active(row: Any) -> bool:
return (
_int(first_value(row, "audit_status", "status"), 0) == 1
and _int(first_value(row, "void_status", "is_void"), 0) != 1
)
def can_patch_patient(row: Any) -> bool:
return row is not None and _int(first_value(row, "void_status", "is_void"), 0) != 1
def can_create_order(row: Any) -> bool:
return can_patch_patient(row) and not _truthy(
first_value(row, "has_prescription_order", default=False)
)
def can_audit(row: Any) -> bool:
return (
row is not None
and _int(first_value(row, "audit_status", "status"), 0) == 0
and _int(first_value(row, "void_status", "is_void"), 0) != 1
)
def can_edit_or_delete(row: Any) -> bool:
return row is not None and not is_approved_active(row)
def _formula(value: Any) -> str:
text = str(value or "").strip().lower()
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
def _order_warnings(row: Any) -> list[str]:
if not _truthy(first_value(row, "has_prescription_order", default=False)):
return []
herbs = get_value(row, "herbs", None) or []
if not isinstance(herbs, (list, tuple)) or not herbs:
return ["请开方,当前处方药材为空白"]
seen: set[str] = set()
duplicate: list[str] = []
for herb in herbs:
name = str(first_value(herb, "name", "medicine_name", default="")).strip()
key = "".join(name.split()).lower()
if key and key in seen and name not in duplicate:
duplicate.append(name)
seen.add(key)
return [f"已有关联业务订单,存在重复药材:{''.join(duplicate)}"] if duplicate else []
def _sn_cell(_value: Any, row: Any) -> str:
sn = first_value(row, "sn", "prescription_no", "id", default="")
prescription_id = first_value(row, "id", "prescription_id", default="")
warnings = _order_warnings(row)
suffix = "\n" + "\n".join(warnings) if warnings else ""
return f"{sn}\nID: {prescription_id}{suffix}"
def _patient_cell(_value: Any, row: Any) -> str:
gender = first_value(row, "gender", default=None)
gender_text = "" if gender in (1, "1") else "" if gender in (0, "0") else "未知"
age = first_value(row, "age", default="")
return f"{first_value(row, 'patient_name', default='')}\n{gender_text} · {age}"
def _source_cell(_value: Any, row: Any) -> str:
return (
"空白处方"
if _truthy(first_value(row, "is_system_auto", "source", default=False))
else "手工"
)
def _audit_cell(_value: Any, row: Any) -> str:
label = prescription_status(row)[0]
reasons = []
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
reasons.append(
"业务订单审核:"
+ display_text(first_value(row, "business_prescription_audit_remark", default=""))
)
if _int(first_value(row, "audit_status"), 0) == 2:
reasons.append(
"消费者处方审核:" + display_text(first_value(row, "audit_remark", default=""))
)
return label + ("\n" + "\n".join(reasons) if reasons else "")
def _void_cell(_value: Any, row: Any) -> str:
return "作废" if _int(first_value(row, "void_status", "is_void"), 0) == 1 else ""
def _doctor_cell(_value: Any, row: Any) -> str:
return (
f"{first_value(row, 'doctor_name', 'creator_name', default='')}\n"
f"{first_value(row, 'prescription_date', default='')}"
)
class DoctorMultiSelect(QWidget):
"""Compact checkable doctor selector fed by list rows/extend data."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._options: dict[int, str] = {}
self._selected: set[int] = set()
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.button = QPushButton("全部医生")
self.button.clicked.connect(self._choose)
layout.addWidget(self.button)
def values(self) -> list[int]:
return sorted(self._selected)
def clear(self) -> None:
self._selected.clear()
self._update_text()
def update_options(self, rows: list[Any]) -> None:
for row in rows:
doctor_id = _int(first_value(row, "id", "creator_id", "doctor_id"), 0)
name = str(first_value(row, "name", "doctor_name", "creator_name", default="")).strip()
if doctor_id and name:
self._options[doctor_id] = name
self._update_text()
def _choose(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("选择医生")
dialog.resize(360, 430)
layout = QVBoxLayout(dialog)
listing = QListWidget()
for doctor_id, name in sorted(self._options.items(), key=lambda item: item[1]):
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if doctor_id in self._selected else Qt.CheckState.Unchecked
)
listing.addItem(item)
layout.addWidget(listing)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self._selected = {
_int(listing.item(index).data(Qt.ItemDataRole.UserRole))
for index in range(listing.count())
if listing.item(index).checkState() == Qt.CheckState.Checked
}
self._selected.discard(0)
self._update_text()
def _update_text(self) -> None:
if not self._selected:
self.button.setText("全部医生")
return
names = [self._options.get(value, str(value)) for value in sorted(self._selected)]
self.button.setText("".join(names[:2]) + (f"{len(names)}" if len(names) > 2 else ""))
class PrescriptionsPage(QWidget):
"""Complete issued-prescription route: list, workflow actions, print, and orders."""
def __init__(
self,
repository: Any,
permissions: Any = None,
current_user: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
self._page = 1
self._page_size = 15
self._generation = 0
self._loading = False
self._refresh_pending = False
self._detail_generation = 0
self._detail_target = 0
self._diagnosis_detail_generation = 0
self._diagnosis_detail_target = 0
self._mutation_pending = False
root = QVBoxLayout(self)
root.setContentsMargins(24, 20, 24, 24)
root.setSpacing(14)
header = PageHeader(
"已开处方",
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
)
self.orders_button = QPushButton("业务订单")
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
self.orders_button.clicked.connect(lambda: self._open_orders())
header.add_action(self.orders_button)
self.add_button = QPushButton(" 新增处方")
self.add_button.setProperty("variant", "primary")
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
self.add_button.clicked.connect(self._add_prescription)
header.add_action(self.add_button)
root.addWidget(header)
root.addWidget(self._build_filters())
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_table_card(), 1)
self._load_doctor_options()
def _build_filters(self) -> QWidget:
frame = QFrame()
frame.setObjectName("FilterBar")
grid = QGridLayout(frame)
grid.setContentsMargins(14, 12, 14, 12)
grid.setHorizontalSpacing(9)
grid.setVerticalSpacing(8)
self.quick_date = QComboBox()
self.quick_date.addItem("全部时间", "all")
self.quick_date.addItem("今日", "today")
self.quick_date.addItem("昨日", "yesterday")
self.quick_date.addItem("前天", "before_yesterday")
self.quick_date.addItem("自定义", "custom")
self.quick_date.currentIndexChanged.connect(self._quick_date_changed)
grid.addWidget(self.quick_date, 0, 0)
self.start_time = QDateTimeEdit(QDateTime.currentDateTime().addDays(-7))
self.start_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.start_time.setCalendarPopup(True)
self.start_time.setEnabled(False)
grid.addWidget(self.start_time, 0, 1)
self.end_time = QDateTimeEdit(QDateTime.currentDateTime())
self.end_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.end_time.setCalendarPopup(True)
self.end_time.setEnabled(False)
grid.addWidget(self.end_time, 0, 2)
self.audit_filter = QComboBox()
self.audit_filter.addItem("待审核", "pending")
self.audit_filter.addItem("全部审核状态", "all")
self.audit_filter.addItem("已通过", "passed")
self.audit_filter.addItem("未通过", "not_passed")
self.audit_filter.addItem("已驳回", "rejected")
self.audit_filter.setCurrentIndex(1)
grid.addWidget(self.audit_filter, 0, 3)
self.source_filter = QComboBox()
self.source_filter.addItem("全部来源", "all")
self.source_filter.addItem("手工", "manual")
self.source_filter.addItem("空白处方", "system")
grid.addWidget(self.source_filter, 0, 4)
self.sn_filter = QLineEdit()
self.sn_filter.setPlaceholderText("处方编号")
self.sn_filter.setClearButtonEnabled(True)
self.sn_filter.returnPressed.connect(self._search)
grid.addWidget(self.sn_filter, 1, 0)
self.patient_filter = QLineEdit()
self.patient_filter.setPlaceholderText("患者姓名")
self.patient_filter.setClearButtonEnabled(True)
self.patient_filter.returnPressed.connect(self._search)
grid.addWidget(self.patient_filter, 1, 1)
self.doctor_filter = DoctorMultiSelect()
current_id = _int(first_value(self.current_user, "id", "user_id"), 0)
current_name = str(first_value(self.current_user, "name", "real_name", default="")).strip()
if current_id and current_name:
self.doctor_filter.update_options([{"id": current_id, "name": current_name}])
grid.addWidget(self.doctor_filter, 1, 2)
query = QPushButton("查询")
query.setProperty("variant", "secondary")
query.clicked.connect(self._search)
grid.addWidget(query, 1, 3)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters)
grid.addWidget(reset, 1, 4)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 1)
return frame
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("Card")
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 13, 14, 13)
layout.setSpacing(9)
toolbar = QHBoxLayout()
title = QLabel("处方列表")
title.setProperty("role", "sectionTitle")
toolbar.addWidget(title)
toolbar.addStretch(1)
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
toolbar.addWidget(self.view_button)
self.patch_button = self._action_button(
"修正患者", "tcm.prescription/patchPatient", self._patch_selected
)
toolbar.addWidget(self.patch_button)
self.create_order_button = self._action_button(
"创建订单", "tcm.prescriptionOrder/create", self._create_order
)
toolbar.addWidget(self.create_order_button)
self.edit_button = self._action_button("编辑", "cf.prescription/edit", self._edit_selected)
toolbar.addWidget(self.edit_button)
self.audit_button = self._action_button(
"审核", "cf.prescription/audit", self._audit_selected
)
toolbar.addWidget(self.audit_button)
self.delete_button = self._action_button(
"删除", "cf.prescription/del", self._delete_selected, danger=True
)
toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
layout.addLayout(toolbar)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable(
[
TableColumn("sn", "处方编号", 190, _sn_cell),
TableColumn("prescription_type", "处方类型", 95),
TableColumn("is_system_auto", "来源", 90, _source_cell),
TableColumn("patient_name", "患者信息", 120, _patient_cell),
TableColumn("audit_status", "审核状态", 220, _audit_cell),
TableColumn("void_status", "作废", 70, _void_cell),
TableColumn("doctor_name", "医生信息", 130, _doctor_cell),
TableColumn("assistant_name", "医助", 90),
TableColumn("create_time", "创建时间", 145),
]
)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager.page_changed.connect(self._change_page)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host)
self.stack.addWidget(
EmptyState("没有找到处方", "请调整创建时间、审核状态或患者筛选后重试。")
)
layout.addWidget(self.stack, 1)
return card
def _action_button(
self,
text: str,
permission: str,
handler: Callable[[], None],
*,
danger: bool = False,
) -> QPushButton:
button = QPushButton(text)
if danger:
button.setProperty("variant", "danger")
button.setVisible(has_permission(self.permissions, permission))
button.setEnabled(False)
button.clicked.connect(handler)
return button
def _load_doctor_options(self) -> None:
"""Populate the creator multi-select from the repository's doctor catalog."""
method = getattr(self.repository, "list_diagnosis_doctors", None)
if not callable(method):
return
run_async(
method,
on_success=lambda result: self.doctor_filter.update_options(page_items(result)),
)
def _quick_date_changed(self) -> None:
custom = self.quick_date.currentData() == "custom"
self.start_time.setEnabled(custom)
self.end_time.setEnabled(custom)
self._search()
def _date_filters(self) -> tuple[str, str]:
value = self.quick_date.currentData()
if value == "all":
return "", ""
if value == "custom":
return (
self.start_time.dateTime().toString("yyyy-MM-dd HH:mm:ss"),
self.end_time.dateTime().toString("yyyy-MM-dd HH:mm:ss"),
)
offset = {"today": 0, "yesterday": 1, "before_yesterday": 2}.get(value, 0)
target = datetime.now().date() - timedelta(days=offset)
return (
f"{target.isoformat()} 00:00:00",
f"{target.isoformat()} 23:59:59",
)
def _search(self) -> None:
self._page = 1
self.refresh()
def _reset_filters(self) -> None:
self.quick_date.blockSignals(True)
self.quick_date.setCurrentIndex(0)
self.quick_date.blockSignals(False)
self.start_time.setEnabled(False)
self.end_time.setEnabled(False)
self.audit_filter.setCurrentIndex(1)
self.source_filter.setCurrentIndex(0)
self.sn_filter.clear()
self.patient_filter.clear()
self.doctor_filter.clear()
self._search()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh()
def refresh(self) -> None:
if self._loading:
self._refresh_pending = True
return
self._loading = True
self._refresh_pending = False
self._generation += 1
generation = self._generation
requested_page = self._page
page_size = self._page_size
start_time, end_time = self._date_filters()
filters: dict[str, Any] = {
"sn": self.sn_filter.text().strip(),
"patient_name": self.patient_filter.text().strip(),
"audit_filter": ""
if self.audit_filter.currentData() == "all"
else self.audit_filter.currentData(),
"source_filter": ""
if self.source_filter.currentData() == "all"
else self.source_filter.currentData(),
"start_time": start_time,
"end_time": end_time,
}
creator_ids = self.doctor_filter.values()
if creator_ids:
filters["creator_ids"] = creator_ids
self.banner.show_message("正在加载处方列表…", "info")
run_async(
lambda: invoke(
self.repository,
"prescriptions",
page=requested_page,
page_size=page_size,
**filters,
),
on_success=lambda result: self._apply_result(result, generation, requested_page),
on_error=lambda error: self._load_error(error, generation),
on_finished=lambda: self._load_finished(generation),
)
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
self.table.set_rows(rows)
self.pager.update_state(requested_page, page_total(result, len(rows)))
self.stack.setCurrentIndex(0 if rows else 1)
doctor_rows = []
for row in rows:
doctor_rows.append(
{
"id": first_value(row, "creator_id", "doctor_id"),
"name": first_value(row, "doctor_name", "creator_name"),
}
)
extend_doctors = get_value(result, "extend.doctors", None) or get_value(
result, "doctors", None
)
if isinstance(extend_doctors, (list, tuple)):
doctor_rows.extend(extend_doctors)
self.doctor_filter.update_options(doctor_rows)
self.banner.clear()
if rows and self.table.currentRow() < 0:
self.table.selectRow(0)
self._selection_changed()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _load_finished(self, generation: int) -> None:
if generation == self._generation:
self._loading = False
if self._refresh_pending:
self._refresh_pending = False
self.refresh()
def _selection_changed(self) -> None:
row = self.table.current_data()
active = not self._mutation_pending
self.view_button.setEnabled(active and row is not None)
self.patch_button.setEnabled(active and can_patch_patient(row))
self.create_order_button.setEnabled(active and can_create_order(row))
self.edit_button.setEnabled(active and can_edit_or_delete(row))
self.audit_button.setEnabled(active and can_audit(row))
self.delete_button.setEnabled(active and can_edit_or_delete(row))
def _set_mutation_pending(self, pending: bool) -> None:
self._mutation_pending = pending
self.add_button.setEnabled(not pending)
self.orders_button.setEnabled(not pending)
self._selection_changed()
def _selected(self) -> Any:
return self.table.current_data()
def _get_prescription(self, prescription_id: int) -> Any:
method = self.repository.get_prescription
return method(prescription_id)
def _load_detail(
self,
row: Any,
callback: Callable[[Any], None],
*,
message: str = "正在加载处方详情…",
) -> None:
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
if not prescription_id:
self.banner.show_message("处方 ID 无效。", "warning")
return
self._detail_generation += 1
generation = self._detail_generation
self._detail_target = prescription_id
self.banner.show_message(message, "info")
run_async(
lambda: self._get_prescription(prescription_id),
on_success=lambda detail: self._detail_success(
detail, prescription_id, generation, callback
),
on_error=lambda error: self._detail_error(error, prescription_id, generation),
)
def _detail_success(
self,
detail: Any,
prescription_id: int,
generation: int,
callback: Callable[[Any], None],
) -> None:
if generation != self._detail_generation or prescription_id != self._detail_target:
return
self.banner.clear()
callback(detail)
def _detail_error(self, error: Exception, prescription_id: int, generation: int) -> None:
if generation == self._detail_generation and prescription_id == self._detail_target:
self.banner.show_message(friendly_error(error), "danger")
def _view_selected(self) -> None:
row = self._selected()
if row is None or not has_permission(self.permissions, "cf.prescription/read"):
return
self._load_detail(row, self._show_detail)
def _show_detail(self, detail: Any) -> None:
if not has_permission(self.permissions, "cf.prescription/read"):
return
dialog = PrescriptionDetailDialog(
detail,
can_open_diagnosis=has_permission(self.permissions, "tcm.diagnosis/readonlyDetail"),
can_open_orders=has_permission(self.permissions, "tcm.prescriptionOrder/lists"),
parent=self,
)
dialog.diagnosis_requested.connect(self._open_diagnosis)
dialog.orders_requested.connect(lambda prescription_id: self._open_orders(prescription_id))
dialog.exec()
def _open_diagnosis(self, diagnosis_id: int) -> None:
if not has_permission(self.permissions, "tcm.diagnosis/readonlyDetail"):
self.banner.show_message("无权查看诊单详情。", "danger")
return
diagnosis_id = _int(diagnosis_id, 0)
if diagnosis_id <= 0:
self.banner.show_message("诊单 ID 无效。", "warning")
return
method = getattr(self.repository, "get_diagnosis_detail", None)
readonly_keyword = True
if not callable(method):
method = getattr(self.repository, "diagnosis_readonly_detail", None)
readonly_keyword = False
if not callable(method):
self.banner.show_message("当前 repository 不支持诊单详情。", "danger")
return
self._diagnosis_detail_generation += 1
generation = self._diagnosis_detail_generation
self._diagnosis_detail_target = diagnosis_id
self.banner.show_message("正在加载诊单详情…", "info")
run_async(
lambda: (
method(diagnosis_id, readonly=True) if readonly_keyword else method(diagnosis_id)
),
on_success=lambda detail: self._diagnosis_detail_success(
detail, diagnosis_id, generation
),
on_error=lambda error: self._diagnosis_detail_error(error, diagnosis_id, generation),
)
def _diagnosis_detail_success(self, detail: Any, diagnosis_id: int, generation: int) -> None:
if (
generation != self._diagnosis_detail_generation
or diagnosis_id != self._diagnosis_detail_target
):
return
self.banner.clear()
DiagnosisDetailDialog(detail, self).exec()
def _diagnosis_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
if (
generation == self._diagnosis_detail_generation
and diagnosis_id == self._diagnosis_detail_target
):
self.banner.show_message(friendly_error(error), "danger")
def _add_prescription(self) -> None:
if not has_permission(self.permissions, "cf.prescription/add"):
return
dialog = PrescriptionEditorDialog(
self.repository,
mode="add",
current_user=self.current_user,
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_prescription(dialog.payload(), None)
def _edit_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "cf.prescription/edit")
or not can_edit_or_delete(row)
):
return
self._load_detail(row, self._open_editor, message="正在准备编辑处方…")
def _open_editor(self, detail: Any) -> None:
dialog = PrescriptionEditorDialog(
self.repository,
detail,
mode="edit",
current_user=self.current_user,
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_prescription(
dialog.payload(),
_int(first_value(detail, "id", "prescription_id"), 0),
)
def _save_prescription(
self,
payload: dict[str, Any],
prescription_id: int | None,
) -> None:
permission = "cf.prescription/edit" if prescription_id else "cf.prescription/add"
if not has_permission(self.permissions, permission):
return
self._set_mutation_pending(True)
if prescription_id:
def operation() -> Any:
return self.repository.update_prescription(
prescription_id,
changes=payload,
)
message = "处方已保存并重新进入待审核。"
else:
def operation() -> Any:
return self.repository.create_prescription(payload)
message = "处方已新增并提交审核。"
run_async(
operation,
on_success=lambda _result: self._mutation_success(message),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _delete_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "cf.prescription/del")
or not can_edit_or_delete(row)
):
return
answer = QMessageBox.warning(
self,
"删除处方",
"确定删除该处方吗?此操作无法撤销。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
if answer != QMessageBox.StandardButton.Yes:
return
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
self._set_mutation_pending(True)
run_async(
lambda: self.repository.delete_prescription(prescription_id),
on_success=lambda _result: self._mutation_success("处方已删除。"),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _patch_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "tcm.prescription/patchPatient")
or not can_patch_patient(row)
):
return
dialog = PatchPatientDialog(row, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
self._set_mutation_pending(True)
run_async(
lambda: self.repository.patch_prescription_patient(
payload["id"],
patient_name=payload["patient_name"],
phone=payload["phone"],
gender=payload["gender"],
),
on_success=lambda _result: self._mutation_success("患者信息已修正。"),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _audit_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "cf.prescription/audit")
or not can_audit(row)
):
return
dialog = AuditPrescriptionDialog(row, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
self._set_mutation_pending(True)
run_async(
lambda: self.repository.audit_prescription(
payload["id"],
action=payload["action"],
remark=payload["remark"],
),
on_success=lambda result: self._audit_success(result, payload["action"]),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _audit_success(self, result: Any, action: str) -> None:
message = "处方已驳回并作废。" if action == "reject" else "处方审核已通过。"
self._mutation_success(message)
if (
isinstance(result, Mapping)
and result.get("wecom_notify_ok") is False
and result.get("wecom_notify_hint")
):
show_toast(self, str(result["wecom_notify_hint"]), "warning", 5000)
def _create_order(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "tcm.prescriptionOrder/create")
or not can_create_order(row)
):
return
self._load_detail(row, self._open_order_editor, message="正在准备业务订单…")
def _open_order_editor(self, detail: Any) -> None:
if not has_permission(self.permissions, "tcm.prescriptionOrder/create"):
return
dialog = PrescriptionOrderDialog(
self.repository,
detail,
can_select_ship_mode=has_permission(
self.permissions, "tcm.prescriptionOrder/setShipMode"
),
can_view_internal_cost=has_permission(self.permissions, "finance.account_log/lists"),
can_edit_pharmacy_remark=has_permission(
self.permissions, "tcm.prescriptionOrder/editRemarkExtra"
),
parent=self,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
self._set_mutation_pending(True)
run_async(
lambda: self.repository.create_prescription_order(payload),
on_success=lambda result: self._order_created(result),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _order_created(self, result: Any) -> None:
order_no = first_value(result, "order_no", "data.order_no", default="")
message = f"业务订单已创建:{order_no}" if order_no else "业务订单已创建。"
self._mutation_success(message)
def _open_orders(self, prescription_id: int | None = None) -> None:
if not has_permission(self.permissions, "tcm.prescriptionOrder/lists"):
return
PrescriptionOrderListDialog(
self.repository,
prescription_id=prescription_id,
parent=self,
).exec()
def _mutation_success(self, message: str) -> None:
self.banner.clear()
show_toast(self, message, "success", 3600)
self.refresh()
def _mutation_error(self, error: Exception) -> None:
message = friendly_error(error)
self.banner.show_message(message, "danger")
show_toast(self, message, "danger", 5000)
def showEvent(self, event: Any) -> None:
super().showEvent(event)
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
__all__ = [
"PrescriptionsPage",
"can_audit",
"can_create_order",
"can_edit_or_delete",
"can_patch_patient",
"is_approved_active",
"prescription_status",
]
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
"""Authenticated application shell with permission-aware navigation."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from .pages import (
ConsultationsPage,
PatientsPage,
PrescriptionLibraryPage,
PrescriptionsPage,
ReceptionPage,
)
from .widgets import EmptyState, StatusBadge, display_text, first_value, get_value
@dataclass(frozen=True)
class NavigationItem:
key: str
title: str
glyph: str
page_type: type[QWidget]
permissions: tuple[str, ...]
NAVIGATION = (
NavigationItem(
"reception",
"接诊台",
"",
ReceptionPage,
("doctor.appointment/lists",),
),
NavigationItem(
"prescription_library",
"我的处方库",
"",
PrescriptionLibraryPage,
("tcm.prescriptionLibrary/lists",),
),
NavigationItem(
"prescriptions",
"已开处方",
"",
PrescriptionsPage,
("tcm.prescription/lists",),
),
NavigationItem(
"patients",
"我的患者",
"",
PatientsPage,
("firstvisit.myPatient/lists",),
),
NavigationItem(
"consultations",
"问诊列表",
"",
ConsultationsPage,
("tcm.diagnosis/lists",),
),
)
_NAVIGATION_BY_PERMISSION = {item.permissions[0]: item for item in NAVIGATION}
_MENU_ROUTE_IDENTIFIERS = {
"reception": {
"reception",
"patient/reception",
"patient/reception/index",
},
"prescription_library": {
"prescription-library",
"prescription_library",
"consumer/prescription/list",
},
"prescriptions": {
"prescriptions",
"consumer/prescription/index",
},
"patients": {
"patients",
"first_visit/my_patients",
"first_visit/my_patients/index",
},
"consultations": {
"consultations",
"tcm/diagnosis",
"tcm/diagnosis/index",
},
}
def _canonical_allowed(permissions: Any, code: str) -> bool:
"""Apply the core exact/wildcard semantics without slash-dot aliases."""
if permissions is None:
return True
for method_name in ("allows", "has", "can_access_page", "has_page", "can"):
method = getattr(permissions, method_name, None)
if callable(method):
try:
return bool(method(code))
except (TypeError, ValueError):
continue
raw = permissions
for attr in ("codes", "permissions", "values"):
candidate = getattr(permissions, attr, None)
if candidate is not None and not callable(candidate):
raw = candidate
break
if isinstance(raw, Mapping):
nested = first_value(raw, "codes", "permissions", "values", default=None)
if nested is not None:
raw = nested
if isinstance(raw, Mapping):
available = {str(key) for key, enabled in raw.items() if enabled}
elif isinstance(raw, str):
available = {raw}
else:
try:
available = {str(value) for value in raw}
except TypeError:
return False
if "*" in available or code in available:
return True
return any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available)
def _menu_rows(value: Any) -> list[Mapping[str, Any]]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
return []
return [row for row in value if isinstance(row, Mapping)]
def _menu_visible(row: Mapping[str, Any]) -> bool:
value = get_value(row, "is_show", 1)
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "hidden", "no"}
return value != 0
def _menu_enabled(row: Mapping[str, Any]) -> bool:
value = get_value(row, "is_disable", 0)
if isinstance(value, str):
return value.strip().lower() not in {"1", "true", "disabled", "yes"}
return value != 1
def _menu_sort(row: Mapping[str, Any]) -> float:
try:
return float(first_value(row, "sort", "sort_order", "order", default=0))
except (TypeError, ValueError):
return 0.0
def _visible_menu_nodes(value: Any) -> list[Mapping[str, Any]]:
"""Flatten visible, enabled nodes; larger admin sort values come first."""
nodes = _menu_rows(value)
ordered = sorted(enumerate(nodes), key=lambda pair: (-_menu_sort(pair[1]), pair[0]))
result: list[Mapping[str, Any]] = []
for _index, node in ordered:
if not _menu_visible(node) or not _menu_enabled(node):
continue
result.append(node)
children = first_value(node, "children", "child", "childs", default=[])
result.extend(_visible_menu_nodes(children))
return result
def _normalise_route(value: Any) -> str:
route = str(value or "").strip().replace("\\", "/").lower()
route = route.split("?", 1)[0].split("#", 1)[0].strip("/")
if route.endswith(".vue"):
route = route[:-4]
return route
def _menu_permissions(row: Mapping[str, Any]) -> tuple[str, ...]:
value = first_value(row, "perms", "permission", "meta.perms", default="")
if isinstance(value, str):
return (value.strip(),) if value.strip() else ()
if isinstance(value, Sequence):
return tuple(str(item).strip() for item in value if str(item).strip())
return ()
def _match_navigation(row: Mapping[str, Any]) -> NavigationItem | None:
for permission in _menu_permissions(row):
item = _NAVIGATION_BY_PERMISSION.get(permission)
if item is not None:
return item
identifiers = {
_normalise_route(first_value(row, "paths", "path")),
_normalise_route(get_value(row, "component", "")),
}
identifiers.discard("")
for item in NAVIGATION:
if identifiers & _MENU_ROUTE_IDENTIFIERS[item.key]:
return item
return None
def _resolve_navigation(
menu: Any,
permissions: Any,
*,
demo_mode: bool,
) -> list[tuple[NavigationItem, str]]:
"""Resolve only locally supported pages from the authoritative menu tree."""
rows = _menu_rows(menu)
if rows:
resolved: list[tuple[NavigationItem, str]] = []
seen: set[str] = set()
for row in _visible_menu_nodes(rows):
item = _match_navigation(row)
if item is None or item.key in seen:
continue
if not _canonical_allowed(permissions, item.permissions[0]):
continue
title_value = first_value(row, "name", "title", "meta.title", default=item.title)
title = str(title_value).strip() or item.title
resolved.append((item, title))
seen.add(item.key)
return resolved
if demo_mode:
return [
(item, item.title)
for item in NAVIGATION
if _canonical_allowed(permissions, item.permissions[0])
]
return []
class ShellWindow(QMainWindow):
"""Main workstation window.
The expected construction signature is ``ShellWindow(repository, session,
permissions=None)``. ``session`` may be a Session dataclass, the payload
emitted by :class:`LoginWindow`, or a plain mapping.
"""
logout_requested = Signal()
video_requested = Signal(dict)
page_changed = Signal(str)
def __init__(
self,
repository: Any,
session: Any,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.login_payload = session
self.session = get_value(session, "session", None) or session
self.current_user = (
get_value(session, "user", None)
or get_value(self.session, "user", None)
or get_value(session, "current_user", None)
or session
)
if permissions is not None:
self.permissions = permissions
else:
session_permissions = get_value(self.session, "permissions", None)
self.permissions = (
session_permissions
if session_permissions is not None
else get_value(self.current_user, "permissions", None)
)
session_menu = get_value(self.session, "menu", None)
self.menu = session_menu if session_menu is not None else get_value(session, "menu", [])
self.demo_mode = bool(
get_value(session, "demo_mode", False)
or get_value(self.session, "metadata.demo", False)
or get_value(self.session, "metadata.demo_mode", False)
)
self.navigation = _resolve_navigation(
self.menu,
self.permissions,
demo_mode=self.demo_mode,
)
self.pages: dict[str, QWidget] = {}
self.nav_buttons: dict[str, QPushButton] = {}
self.page_titles: dict[int, str] = {}
self.setWindowTitle("臻阳堂 · 医生工作站")
self.setMinimumSize(1024, 640)
self.resize(1280, 800)
canvas = QWidget()
canvas.setObjectName("AppCanvas")
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.addWidget(self._build_sidebar())
workspace = QWidget()
workspace_layout = QVBoxLayout(workspace)
workspace_layout.setContentsMargins(0, 0, 0, 0)
workspace_layout.setSpacing(0)
workspace_layout.addWidget(self._build_topbar())
self.stack = QStackedWidget()
workspace_layout.addWidget(self.stack, 1)
root.addWidget(workspace, 1)
self._register_pages()
def _build_sidebar(self) -> QWidget:
sidebar = QWidget()
sidebar.setObjectName("Sidebar")
sidebar.setFixedWidth(216)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(18, 22, 18, 18)
layout.setSpacing(8)
brand = QHBoxLayout()
mark = QLabel("")
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(38, 38)
mark.setStyleSheet(
"color:#0F6D64; background:#DDF1EC; border-radius:11px; font-size:18px; font-weight:700;"
)
brand.addWidget(mark)
brand_text = QVBoxLayout()
brand_text.setSpacing(0)
name = QLabel("医生工作站")
name.setStyleSheet("color:#FFFFFF; font-size:15px; font-weight:700;")
brand_text.addWidget(name)
institution = QLabel("臻阳堂医疗")
institution.setStyleSheet("color:#82A198; font-size:10px;")
brand_text.addWidget(institution)
brand.addLayout(brand_text)
brand.addStretch(1)
layout.addLayout(brand)
layout.addSpacing(28)
navigation_label = QLabel("工作区")
navigation_label.setStyleSheet("color:#78998F; font-size:10px; font-weight:700;")
layout.addWidget(navigation_label)
self.nav_layout = QVBoxLayout()
self.nav_layout.setSpacing(6)
layout.addLayout(self.nav_layout)
layout.addStretch(1)
safety = QFrame()
safety.setStyleSheet("background:#20483D; border-radius:12px;")
safety_layout = QVBoxLayout(safety)
safety_layout.setContentsMargins(12, 11, 12, 11)
safety_layout.setSpacing(4)
safety_title = QLabel("● 安全连接")
safety_title.setStyleSheet("color:#B9DDD3; font-size:11px; font-weight:700;")
safety_layout.addWidget(safety_title)
safety_text = QLabel("医疗数据按账号权限展示")
safety_text.setWordWrap(True)
safety_text.setStyleSheet("color:#91ACA3; font-size:10px;")
safety_layout.addWidget(safety_text)
layout.addWidget(safety)
version = QLabel("Doctor Workstation")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
version.setStyleSheet("color:#617F76; font-size:9px;")
layout.addWidget(version)
return sidebar
def _build_topbar(self) -> QWidget:
topbar = QFrame()
topbar.setObjectName("TopBar")
topbar.setFixedHeight(68)
layout = QHBoxLayout(topbar)
layout.setContentsMargins(24, 0, 22, 0)
layout.setSpacing(11)
self.context_label = QLabel("工作台")
self.context_label.setStyleSheet("color:#315147; font-size:14px; font-weight:600;")
layout.addWidget(self.context_label)
layout.addStretch(1)
self.connection_badge = StatusBadge("服务正常", "success")
layout.addWidget(self.connection_badge)
display_name = display_text(
first_value(
self.current_user, "name", "display_name", "nickname", "account", default="医生"
)
)
avatar = QLabel(display_name[:1] if display_name else "")
avatar.setObjectName("UserAvatar")
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(avatar)
identity = QVBoxLayout()
identity.setSpacing(0)
user_name = QLabel(display_name)
user_name.setStyleSheet("font-weight:700; color:#17382F;")
identity.addWidget(user_name)
role = QLabel(self._role_text())
role.setProperty("role", "muted")
role.setStyleSheet("font-size:10px;")
identity.addWidget(role)
layout.addLayout(identity)
logout = QPushButton("退出")
logout.setProperty("variant", "ghost")
logout.setToolTip("退出当前账号")
logout.clicked.connect(lambda: self.logout_requested.emit())
layout.addWidget(logout)
return topbar
def _role_text(self) -> str:
department = first_value(
self.current_user, "department_name", "department.name", default=""
)
role_ids = first_value(self.current_user, "role_ids", "role_id", default=[]) or []
if not isinstance(role_ids, (list, tuple, set, frozenset)):
role_ids = [role_ids]
role_values = {str(value) for value in role_ids}
role = "医生" if "1" in role_values else "医助" if "2" in role_values else "医疗人员"
return f"{department} · {role}" if department else role
def _register_pages(self) -> None:
self.nav_group = QButtonGroup(self)
self.nav_group.setExclusive(True)
first_button: QPushButton | None = None
for item, title in self.navigation:
page = item.page_type(
self.repository,
permissions=self.permissions,
current_user=self.current_user,
)
if hasattr(page, "video_requested"):
page.video_requested.connect(lambda payload: self.video_requested.emit(payload))
index = self.stack.addWidget(page)
self.pages[item.key] = page
self.page_titles[index] = title
button = QPushButton(f"{item.glyph} {title}")
button.setProperty("variant", "nav")
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(
lambda _checked=False, page_index=index, key=item.key: self._navigate(
page_index, key
)
)
self.nav_group.addButton(button, index)
self.nav_layout.addWidget(button)
self.nav_buttons[item.key] = button
if first_button is None:
first_button = button
if first_button is None:
denied = EmptyState(
"暂无可用工作区",
"当前账号没有医生工作站页面权限,请联系管理员调整授权。",
)
index = self.stack.addWidget(denied)
self.page_titles[index] = "权限受限"
self.stack.setCurrentIndex(index)
self.context_label.setText("权限受限")
return
first_button.setChecked(True)
first_index = self.nav_group.id(first_button)
self._navigate(
first_index,
next(key for key, button in self.nav_buttons.items() if button is first_button),
)
def _navigate(self, index: int, key: str) -> None:
if index < 0 or index >= self.stack.count():
return
self.stack.setCurrentIndex(index)
self.context_label.setText(self.page_titles.get(index, "工作台"))
button = self.nav_buttons.get(key)
if button is not None:
button.setChecked(True)
page = self.stack.widget(index)
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
self.page_changed.emit(key)
def navigate(self, key: str) -> bool:
"""Navigate to a visible page by stable key; return whether it exists."""
button = self.nav_buttons.get(key)
page = self.pages.get(key)
if button is None or page is None:
return False
self._navigate(self.stack.indexOf(page), key)
return True
def refresh_current_page(self) -> None:
page = self.stack.currentWidget()
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
def set_connection_state(self, online: bool, message: str = "") -> None:
self.connection_badge.set_status(
message or ("服务正常" if online else "连接中断"),
"success" if online else "danger",
)
__all__ = ["NAVIGATION", "NavigationItem", "ShellWindow"]
+298
View File
@@ -0,0 +1,298 @@
"""Application-wide visual theme.
The UI deliberately uses a restrained, clinical palette: warm whites for long
working sessions, ink green navigation, and teal for actionable state. The
theme is pure QSS so it remains dependable in frozen Windows and macOS builds.
"""
from __future__ import annotations
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
COLORS = {
"canvas": "#F4F3EF",
"surface": "#FCFBF8",
"surface_alt": "#F0F2EF",
"ink": "#17382F",
"ink_soft": "#315147",
"teal": "#168579",
"teal_dark": "#0F6D64",
"teal_pale": "#DDF1EC",
"text": "#18211E",
"muted": "#66736D",
"line": "#D9DEDA",
"danger": "#B5473F",
"danger_pale": "#F8E8E5",
"warning": "#9A6A19",
"warning_pale": "#F8F0DA",
"success": "#287659",
"success_pale": "#E1F1E9",
"info": "#35698B",
"info_pale": "#E5EFF5",
}
GLOBAL_QSS = r"""
QWidget {
color: #18211E;
background-color: transparent;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QMainWindow, QDialog, QWidget#AppCanvas, QWidget#LoginCanvas {
background-color: #F4F3EF;
}
QLabel[role="muted"] { color: #66736D; }
QLabel[role="eyebrow"] {
color: #168579;
font-size: 11px;
font-weight: 700;
}
QLabel[role="pageTitle"] {
color: #17382F;
font-size: 25px;
font-weight: 700;
}
QLabel[role="sectionTitle"] {
color: #17382F;
font-size: 16px;
font-weight: 700;
}
QLabel[role="display"] {
color: #FCFBF8;
font-size: 30px;
font-weight: 700;
}
QLabel[role="metric"] {
color: #17382F;
font-size: 22px;
font-weight: 700;
}
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel {
background-color: #FCFBF8;
border: 1px solid #D9DEDA;
border-radius: 16px;
}
QFrame#SubtleCard {
background-color: #F0F2EF;
border: 1px solid #E1E5E1;
border-radius: 12px;
}
QFrame#Divider { background-color: #D9DEDA; min-height: 1px; max-height: 1px; }
QPushButton {
min-height: 36px;
padding: 0 15px;
border: 1px solid #CBD3CE;
border-radius: 9px;
background-color: #FCFBF8;
color: #24443B;
font-weight: 600;
}
QPushButton:hover { background-color: #F0F2EF; border-color: #AEBBB4; }
QPushButton:pressed { background-color: #E6EAE6; }
QPushButton:disabled { color: #9AA39E; background-color: #EFF1EF; border-color: #E1E5E1; }
QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #168579;
border-color: #168579;
}
QPushButton[variant="primary"]:hover { background-color: #0F6D64; border-color: #0F6D64; }
QPushButton[variant="secondary"] {
color: #0F6D64;
background-color: #DDF1EC;
border-color: #B7DDD4;
}
QPushButton[variant="secondary"]:hover { background-color: #CCE8E1; }
QPushButton[variant="danger"] {
color: #A43C35;
background-color: #F8E8E5;
border-color: #EBC6C1;
}
QPushButton[variant="danger"]:hover { background-color: #F1D7D3; }
QPushButton[variant="ghost"] { border-color: transparent; background-color: transparent; }
QPushButton[variant="ghost"]:hover { background-color: #E8ECE9; }
QPushButton[variant="nav"] {
min-height: 44px;
padding: 0 15px;
border: 0;
border-radius: 10px;
background-color: transparent;
color: #C8D7D1;
text-align: left;
font-weight: 600;
}
QPushButton[variant="nav"]:hover { background-color: #244B40; color: #FFFFFF; }
QPushButton[variant="nav"]:checked { background-color: #DDF1EC; color: #0D5E56; }
QToolButton {
min-width: 32px;
min-height: 32px;
border: 0;
border-radius: 8px;
color: #315147;
}
QToolButton:hover { background-color: #E8ECE9; }
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QDateEdit, QSpinBox, QDoubleSpinBox {
min-height: 36px;
padding: 0 11px;
border: 1px solid #CBD3CE;
border-radius: 9px;
background-color: #FFFFFF;
selection-background-color: #B7DDD4;
selection-color: #17382F;
}
QTextEdit, QPlainTextEdit { padding: 9px 11px; }
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover, QDateEdit:hover,
QSpinBox:hover, QDoubleSpinBox:hover { border-color: #9DAEA5; }
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QDateEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus { border: 2px solid #168579; }
QLineEdit:disabled, QTextEdit:disabled, QComboBox:disabled { background-color: #EFF1EF; color: #8A958F; }
QComboBox::drop-down, QDateEdit::drop-down { border: 0; width: 25px; }
QComboBox QAbstractItemView {
background-color: #FFFFFF;
border: 1px solid #CBD3CE;
border-radius: 8px;
padding: 4px;
selection-background-color: #DDF1EC;
selection-color: #17382F;
}
QCheckBox, QRadioButton { spacing: 8px; }
QCheckBox::indicator, QRadioButton::indicator { width: 17px; height: 17px; }
QCheckBox::indicator:unchecked {
background-color: #FFFFFF;
border: 1px solid #AEBBB4;
border-radius: 4px;
}
QCheckBox::indicator:checked {
background-color: #168579;
border: 1px solid #168579;
border-radius: 4px;
}
QTableWidget, QTableView {
background-color: #FCFBF8;
alternate-background-color: #F6F7F4;
border: 0;
border-radius: 12px;
gridline-color: #E5E8E5;
selection-background-color: #DDF1EC;
selection-color: #17382F;
outline: 0;
}
QTableWidget::item, QTableView::item { padding: 9px 8px; border-bottom: 1px solid #E6E9E6; }
QHeaderView::section {
background-color: #EEF1EE;
color: #53635C;
border: 0;
border-bottom: 1px solid #D9DEDA;
padding: 10px 8px;
font-size: 12px;
font-weight: 700;
}
QTableCornerButton::section { background-color: #EEF1EE; border: 0; }
QListWidget {
background-color: transparent;
border: 0;
outline: 0;
}
QListWidget::item { border: 0; margin: 2px 0; }
QListWidget::item:selected { background-color: #DDF1EC; color: #17382F; border-radius: 11px; }
QListWidget::item:hover { background-color: #F0F2EF; border-radius: 11px; }
QTabBar::tab {
min-height: 36px;
padding: 0 16px;
margin-right: 4px;
color: #66736D;
background-color: transparent;
border: 0;
border-radius: 9px;
font-weight: 600;
}
QTabBar::tab:hover { background-color: #F0F2EF; }
QTabBar::tab:selected { background-color: #DDF1EC; color: #0F6D64; }
QScrollArea { border: 0; background-color: transparent; }
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
QScrollBar::handle:vertical { background: #C5CEC8; min-height: 30px; border-radius: 4px; }
QScrollBar::handle:vertical:hover { background: #9FAEA6; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
QScrollBar::handle:horizontal { background: #C5CEC8; min-width: 30px; border-radius: 4px; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QProgressBar { min-height: 6px; max-height: 6px; border: 0; border-radius: 3px; background: #E1E5E1; }
QProgressBar::chunk { border-radius: 3px; background-color: #168579; }
QLabel#StatusBadge {
padding: 4px 9px;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
}
QLabel#StatusBadge[kind="neutral"] { color: #53635C; background-color: #E8ECE9; }
QLabel#StatusBadge[kind="success"] { color: #216348; background-color: #E1F1E9; }
QLabel#StatusBadge[kind="warning"] { color: #805714; background-color: #F8F0DA; }
QLabel#StatusBadge[kind="danger"] { color: #A43C35; background-color: #F8E8E5; }
QLabel#StatusBadge[kind="info"] { color: #2F5F7E; background-color: #E5EFF5; }
QLabel#StatusBadge[kind="accent"] { color: #0F6D64; background-color: #DDF1EC; }
QFrame#MessageBanner { border-radius: 10px; }
QFrame#MessageBanner[kind="info"] { background-color: #E5EFF5; border: 1px solid #C6DCE8; }
QFrame#MessageBanner[kind="success"] { background-color: #E1F1E9; border: 1px solid #C2E1D1; }
QFrame#MessageBanner[kind="warning"] { background-color: #F8F0DA; border: 1px solid #EADAAE; }
QFrame#MessageBanner[kind="danger"] { background-color: #F8E8E5; border: 1px solid #EBC6C1; }
QLabel#Toast {
color: #FFFFFF;
background-color: #17382F;
border: 1px solid #315147;
border-radius: 11px;
padding: 11px 16px;
font-weight: 600;
}
QLabel#Toast[kind="danger"] { background-color: #8F3832; border-color: #A94740; }
QLabel#Toast[kind="success"] { background-color: #216348; border-color: #2B7759; }
QWidget#Sidebar { background-color: #17382F; }
QFrame#TopBar { background-color: #FCFBF8; border-bottom: 1px solid #D9DEDA; }
QLabel#UserAvatar {
min-width: 36px; max-width: 36px; min-height: 36px; max-height: 36px;
color: #0F6D64; background-color: #DDF1EC; border-radius: 18px;
font-size: 15px; font-weight: 700;
}
QWidget#LoginBrandPanel { background-color: #17382F; border-radius: 22px; }
QFrame#LoginCard { background-color: #FCFBF8; border: 1px solid #D9DEDA; border-radius: 20px; }
QFrame#BusyOverlay { background-color: rgba(244, 243, 239, 220); border-radius: 16px; }
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
QSplitter::handle:hover { background-color: #DDE3DF; }
QToolTip { color: #FFFFFF; background-color: #17382F; border: 0; padding: 6px; }
"""
def apply_theme(app: QApplication) -> None:
"""Apply the global palette and stylesheet to ``app``."""
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["canvas"]))
palette.setColor(QPalette.ColorRole.WindowText, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Base, QColor("#FFFFFF"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(COLORS["surface_alt"]))
palette.setColor(QPalette.ColorRole.Text, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Button, QColor(COLORS["surface"]))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Highlight, QColor(COLORS["teal_pale"]))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(COLORS["ink"]))
palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#8A958F"))
app.setPalette(palette)
app.setStyleSheet(GLOBAL_QSS)
__all__ = ["COLORS", "GLOBAL_QSS", "apply_theme"]
+687
View File
@@ -0,0 +1,687 @@
"""Shared widgets and safe asynchronous helpers for the UI layer."""
from __future__ import annotations
import inspect
import traceback
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import QResizeEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core.errors import AuthenticationExpiredError
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
def set_authentication_expired_handler(
handler: AuthenticationExpiredHandler | None,
) -> None:
"""Install the application-level expired-session callback.
The callback returns ``True`` when it consumed the error. Returning
``False`` preserves the originating operation's local error handling, as is
required when authentication itself fails on the login screen.
"""
global _AUTHENTICATION_EXPIRED_HANDLER
_AUTHENTICATION_EXPIRED_HANDLER = handler
def get_value(value: Any, key: str, default: Any = None) -> Any:
"""Read a dotted key from mappings, dataclasses, or ordinary objects."""
current = value
for part in key.split("."):
if current is None:
return default
if isinstance(current, Mapping):
current = current.get(part, default)
else:
marker = object()
candidate = getattr(current, part, marker)
if candidate is marker:
raw = getattr(current, "raw", None)
candidate = raw.get(part, marker) if isinstance(raw, Mapping) else marker
current = default if candidate is marker else candidate
if current is default:
return default
return current
def first_value(value: Any, *keys: str, default: Any = None) -> Any:
"""Return the first present, non-empty value from ``keys``."""
for key in keys:
candidate = get_value(value, key, None)
if candidate is not None and candidate != "":
return candidate
return default
def display_text(value: Any, default: str = "") -> str:
if value is None or value == "":
return default
if isinstance(value, bool):
return "" if value else ""
if isinstance(value, (datetime, date)):
return value.strftime("%Y-%m-%d %H:%M" if isinstance(value, datetime) else "%Y-%m-%d")
return str(value)
def gender_text(value: Any, default: str = "") -> str:
"""Format the legacy gender codes without exposing numeric API values."""
normalized = str(value).strip().lower() if value not in (None, "") else ""
labels = {
"0": "未知",
"1": "",
"2": "",
"m": "",
"male": "",
"f": "",
"female": "",
"unknown": "未知",
}
return labels.get(normalized, display_text(value, default))
def page_items(result: Any) -> list[Any]:
"""Extract rows from common PageResult/dict response shapes."""
if result is None:
return []
if isinstance(result, (list, tuple)):
return list(result)
for key in ("items", "lists", "results", "rows", "data"):
items = get_value(result, key, None)
if isinstance(items, (list, tuple)):
return list(items)
if key == "data" and items is not None and items is not result:
nested = page_items(items)
if nested:
return nested
return []
def page_total(result: Any, fallback: int = 0) -> int:
for key in ("total", "count", "total_count", "data.total", "data.count"):
value = get_value(result, key, None)
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
pass
return fallback
def has_permission(permissions: Any, codes: str | Sequence[str], default: bool = True) -> bool:
"""Check canonical permissions with exact and resource-wildcard semantics.
A sequence uses OR semantics, matching the admin client's route guards.
Permission names are opaque: ``resource/action`` never aliases
``resource.action``.
"""
if permissions is None:
return default
requested = tuple(
code.strip()
for code in ((codes,) if isinstance(codes, str) else tuple(codes))
if code and code.strip()
)
if not requested:
return True
for method_name in ("allows", "has", "can", "contains", "has_permission"):
method = getattr(permissions, method_name, None)
if callable(method):
for code in requested:
try:
if bool(method(code)):
return True
except (TypeError, ValueError):
continue
raw = permissions
for attr in ("codes", "permissions", "values"):
candidate = getattr(permissions, attr, None)
if candidate is not None and not callable(candidate):
raw = candidate
break
if isinstance(raw, Mapping):
available = {str(key).strip() for key, enabled in raw.items() if enabled}
elif isinstance(raw, str):
available = {raw}
else:
try:
available = {str(item).strip() for item in raw}
except TypeError:
return default
return any(
"*" in available
or code in available
or any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available)
for code in requested
)
def invoke(repository: Any, method_name: str, /, **kwargs: Any) -> Any:
"""Invoke a repository method with keyword filtering for contract tolerance."""
method = getattr(repository, method_name, None)
if method is None and method_name == "save_prescription_template":
template = kwargs.get("template") or {}
template_id = kwargs.get("template_id", kwargs.get("id"))
if template_id is None:
creator = (
getattr(repository, "create_prescription_template", None)
or repository.add_prescription_template
)
return creator(template=template)
updater = (
getattr(repository, "update_prescription_template", None)
or repository.edit_prescription_template
)
return updater(template_id, changes=template)
aliases = {
"reception_queue": "list_appointments",
"reception_detail": "get_reception",
"prescription_library": "list_prescription_templates",
"prescriptions": "list_prescriptions",
"prescription_detail": "get_prescription",
"patients": "list_patients",
"consultations": "list_consultations",
}
resolved_name = method_name
if method is None:
resolved_name = aliases.get(method_name, method_name)
method = getattr(repository, resolved_name, None)
if method is None and method_name == "reception_detail":
resolved_name = "reception"
method = getattr(repository, resolved_name)
if method is None:
raise AttributeError(f"repository has no method {method_name!r}")
call_kwargs = dict(kwargs)
if resolved_name.startswith("list_") and "page" in call_kwargs and "page_no" not in call_kwargs:
call_kwargs["page_no"] = call_kwargs.pop("page")
if (
resolved_name == "get_prescription"
and "id" in call_kwargs
and "prescription_id" not in call_kwargs
):
call_kwargs["prescription_id"] = call_kwargs.pop("id")
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
return method(**call_kwargs)
parameters = signature.parameters
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
return method(**call_kwargs)
accepted = {
name: value
for name, value in call_kwargs.items()
if name in parameters
and parameters[name].kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
if len(parameters) == 1 and not accepted:
only = next(iter(parameters.values()))
if only.name in {"payload", "data", "query", "filters", "params"}:
return method(call_kwargs)
return method(**accepted)
class WorkerSignals(QObject):
result = Signal(object)
error = Signal(object, str)
finished = Signal()
class Worker(QRunnable):
"""A small QRunnable that marshals results back through Qt signals."""
def __init__(self, function: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
super().__init__()
self.function = function
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
@Slot()
def run(self) -> None:
try:
result = self.function(*self.args, **self.kwargs)
except Exception as exc: # UI boundary: report domain and transport errors alike.
self.signals.error.emit(exc, traceback.format_exc())
else:
self.signals.result.emit(result)
finally:
self.signals.finished.emit()
_RUNNING_WORKERS: set[Worker] = set()
def _dispatch_async_error(
error: Exception,
local_handler: Callable[[Exception], None] | None,
) -> None:
"""Route session expiry globally before falling back to a page handler."""
handled = False
if isinstance(error, AuthenticationExpiredError):
handler = _AUTHENTICATION_EXPIRED_HANDLER
if handler is not None:
try:
handled = bool(handler(error))
except Exception:
traceback.print_exc()
if not handled and local_handler is not None:
local_handler(error)
def run_async(
function: Callable[..., Any],
*args: Any,
on_success: Callable[[Any], None] | None = None,
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
if on_success is not None:
worker.signals.result.connect(on_success)
worker.signals.error.connect(lambda exc, _tb: _dispatch_async_error(exc, on_error))
if on_finished is not None:
worker.signals.finished.connect(on_finished)
worker.signals.finished.connect(lambda: _RUNNING_WORKERS.discard(worker))
(pool or QThreadPool.globalInstance()).start(worker)
return worker
def friendly_error(error: Any) -> str:
text = str(error).strip()
return text or "操作未完成,请稍后重试。"
class PageHeader(QWidget):
"""Consistent title, subtitle, and action area for business pages."""
def __init__(
self,
title: str,
subtitle: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
text_layout = QVBoxLayout()
text_layout.setSpacing(3)
self.title_label = QLabel(title)
self.title_label.setProperty("role", "pageTitle")
text_layout.addWidget(self.title_label)
self.subtitle_label = QLabel(subtitle)
self.subtitle_label.setProperty("role", "muted")
self.subtitle_label.setWordWrap(True)
self.subtitle_label.setVisible(bool(subtitle))
text_layout.addWidget(self.subtitle_label)
layout.addLayout(text_layout, 1)
self.actions = QHBoxLayout()
self.actions.setSpacing(8)
layout.addLayout(self.actions)
def add_action(self, widget: QWidget) -> QWidget:
self.actions.addWidget(widget)
return widget
def set_subtitle(self, text: str) -> None:
self.subtitle_label.setText(text)
self.subtitle_label.setVisible(bool(text))
class StatusBadge(QLabel):
def __init__(
self, text: str = "", kind: str = "neutral", parent: QWidget | None = None
) -> None:
super().__init__(text, parent)
self.setObjectName("StatusBadge")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
def set_status(self, text: str, kind: str = "neutral") -> None:
self.setText(text)
self.set_kind(kind)
class EmptyState(QWidget):
action_requested = Signal()
def __init__(
self,
title: str = "暂无数据",
description: str = "调整筛选条件后再试试。",
action_text: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 44, 24, 44)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(8)
glyph = QLabel("")
glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
glyph.setStyleSheet("font-size: 30px; color: #9DAEA5;")
layout.addWidget(glyph)
title_label = QLabel(title)
title_label.setProperty("role", "sectionTitle")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
description_label = QLabel(description)
description_label.setProperty("role", "muted")
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
description_label.setWordWrap(True)
layout.addWidget(description_label)
self.action_button = QPushButton(action_text)
self.action_button.setProperty("variant", "secondary")
self.action_button.setVisible(bool(action_text))
self.action_button.clicked.connect(self.action_requested)
layout.addWidget(self.action_button, 0, Qt.AlignmentFlag.AlignCenter)
class MessageBanner(QFrame):
def __init__(self, text: str = "", kind: str = "info", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("MessageBanner")
self.setProperty("kind", kind)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(9)
self.icon = QLabel("i")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20)
self.icon.setStyleSheet("font-weight: 700;")
self.label = QLabel(text)
self.label.setWordWrap(True)
layout.addWidget(self.icon)
layout.addWidget(self.label, 1)
self.setVisible(bool(text))
def show_message(self, text: str, kind: str = "info") -> None:
glyphs = {"info": "i", "success": "", "warning": "!", "danger": "!"}
self.label.setText(text)
self.icon.setText(glyphs.get(kind, "i"))
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
self.setVisible(bool(text))
def clear(self) -> None:
self.setVisible(False)
self.label.clear()
class Toast(QLabel):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("Toast")
self.setWordWrap(True)
self.setMaximumWidth(420)
self.hide()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
self.setText(text)
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
self.adjustSize()
parent = self.parentWidget()
if parent is not None:
self.move(max(16, parent.width() - self.width() - 24), 20)
self.raise_()
self.show()
self._timer.start(duration)
def show_toast(parent: QWidget, text: str, kind: str = "info", duration: int = 2800) -> None:
window = parent.window()
toast = getattr(window, "_doctor_workstation_toast", None)
if not isinstance(toast, Toast):
toast = Toast(window)
window._doctor_workstation_toast = toast
toast.show_message(text, kind, duration)
class BusyOverlay(QFrame):
"""Non-blocking visual guard for a card or page while a worker is active."""
def __init__(self, parent: QWidget, text: str = "正在加载…") -> None:
super().__init__(parent)
self.setObjectName("BusyOverlay")
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(10)
self.label = QLabel(text)
self.label.setProperty("role", "muted")
progress = QProgressBar()
progress.setRange(0, 0)
progress.setFixedWidth(140)
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignCenter)
self.hide()
def set_message(self, text: str) -> None:
self.label.setText(text)
def showEvent(self, event: Any) -> None:
self.setGeometry(self.parentWidget().rect())
self.raise_()
super().showEvent(event)
class OverlayHost(QWidget):
"""Widget base that automatically sizes a BusyOverlay child."""
def resizeEvent(self, event: QResizeEvent) -> None:
overlay = getattr(self, "busy_overlay", None)
if isinstance(overlay, BusyOverlay):
overlay.setGeometry(self.rect())
super().resizeEvent(event)
@dataclass(frozen=True)
class TableColumn:
key: str
title: str
width: int = 0
formatter: Callable[[Any, Any], str] | None = None
alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
class SortableTable(QTableWidget):
"""A QTableWidget that safely retains the source object after sorting."""
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.columns = list(columns)
self.setColumnCount(len(self.columns))
self.setHorizontalHeaderLabels([column.title for column in self.columns])
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setSortingEnabled(True)
self.verticalHeader().setVisible(False)
self.horizontalHeader().setStretchLastSection(True)
for index, column in enumerate(self.columns):
if column.width:
self.setColumnWidth(index, column.width)
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "appointment_id", default=None)
self.setSortingEnabled(False)
self.clearContents()
materialized = list(rows)
self.setRowCount(len(materialized))
row_to_select = -1
for row_index, row in enumerate(materialized):
row_id = first_value(row, "id", "appointment_id", default=None)
if selected_id is not None and row_id == selected_id:
row_to_select = row_index
for column_index, column in enumerate(self.columns):
raw = get_value(row, column.key, None)
text = column.formatter(raw, row) if column.formatter else display_text(raw)
item = QTableWidgetItem(text)
item.setTextAlignment(column.alignment)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setToolTip(text if len(text) > 18 else "")
self.setItem(row_index, column_index, item)
self.setSortingEnabled(True)
if row_to_select >= 0:
self.selectRow(row_to_select)
def current_data(self) -> Any:
row = self.currentRow()
if row < 0:
return None
item = self.item(row, 0)
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
class Pager(QWidget):
page_changed = Signal(int)
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 4, 0, 0)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
self.previous = QPushButton("上一页")
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.page_label = QLabel("1 / 1")
self.page_label.setMinimumWidth(58)
self.page_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_label)
self.next = QPushButton("下一页")
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"{self.total}")
self.page_label.setText(f"{self.page} / {self.page_count}")
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < self.page_count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def card_layout(card: QFrame, margins: int = 18, spacing: int = 12) -> QVBoxLayout:
layout = QVBoxLayout(card)
layout.setContentsMargins(margins, margins, margins, margins)
layout.setSpacing(spacing)
return layout
def section_title(text: str, trailing: QWidget | None = None) -> QWidget:
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel(text)
title.setProperty("role", "sectionTitle")
layout.addWidget(title)
layout.addStretch(1)
if trailing is not None:
layout.addWidget(trailing)
return container
def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
child_layout = item.layout()
if widget is not None:
widget.deleteLater()
elif child_layout is not None:
clear_layout(child_layout) # type: ignore[arg-type]
__all__ = [
"BusyOverlay",
"EmptyState",
"MessageBanner",
"OverlayHost",
"PageHeader",
"Pager",
"SortableTable",
"StatusBadge",
"TableColumn",
"Toast",
"Worker",
"card_layout",
"clear_layout",
"display_text",
"first_value",
"friendly_error",
"get_value",
"has_permission",
"invoke",
"page_items",
"page_total",
"run_async",
"section_title",
"set_authentication_expired_handler",
"show_toast",
]
@@ -0,0 +1,23 @@
"""Optional video-call integration for the doctor workstation."""
from .launcher import (
BackendMode,
VideoCallLauncher,
VideoCallRequest,
VideoTicketError,
launch_video_call,
normalize_backend_ticket,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
__all__ = [
"BackendMode",
"OrderedCallLifecycle",
"VideoCallLauncher",
"VideoCallRequest",
"VideoTicketError",
"launch_video_call",
"normalize_backend_ticket",
"require_supported_backend",
]
@@ -0,0 +1,405 @@
"""Pure-Python contract and launcher for the optional video companion.
The backend is the only authority that may issue ``userSig``. This module
normalizes that short-lived ticket and deliberately keeps Qt imports out of the
contract layer so it remains importable in core-only installations and tests.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
class VideoTicketError(ValueError):
"""Raised when a backend video ticket is incomplete or unsafe."""
class BackendMode(StrEnum):
"""Supported rendering backends for a video call."""
EMBEDDED = "embedded"
BROWSER = "browser"
@classmethod
def parse(cls, value: BackendMode | str) -> BackendMode:
if isinstance(value, cls):
return value
try:
return cls(str(value).strip().lower())
except ValueError as exc:
raise VideoTicketError("backend mode must be 'embedded' or 'browser'") from exc
def require_supported_backend(value: BackendMode | str) -> BackendMode:
"""Reject browser launch until a server-issued one-time handoff exists."""
mode = BackendMode.parse(value)
if mode is BackendMode.BROWSER:
raise VideoTicketError(
"browser video mode is disabled until a server-issued one-time handoff is available"
)
return mode
Identifier = int | str
def _identifier(value: Any, field_name: str) -> Identifier:
if isinstance(value, bool) or value is None:
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
if isinstance(value, int):
if value <= 0:
raise VideoTicketError(f"{field_name} must be a positive identifier")
return value
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
return cleaned
raise VideoTicketError(f"{field_name} must be a string or integer")
def _non_empty_string(value: Any, field_name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise VideoTicketError(f"{field_name} must be a non-empty string")
return value.strip()
def _sdk_app_id(value: Any, field_name: str = "SDKAppID") -> int:
if isinstance(value, bool):
raise VideoTicketError(f"{field_name} must be a positive integer")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise VideoTicketError(f"{field_name} must be a positive integer") from exc
if parsed <= 0 or str(value).strip() != str(parsed):
raise VideoTicketError(f"{field_name} must be a positive integer")
return parsed
def _normalized_key(key: Any) -> str:
return "".join(character for character in str(key).lower() if character.isalnum())
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
def _reject_server_secrets(payload: Mapping[str, Any]) -> None:
for key in payload:
if _normalized_key(key) in _FORBIDDEN_SECRET_KEYS:
raise VideoTicketError("backend ticket contains forbidden server-side secret material")
def _contains_ticket_fields(payload: Mapping[str, Any]) -> bool:
keys = {_normalized_key(key) for key in payload}
return bool(keys & {"sdkappid", "userid", "usersig", "targetuserid", "patientuserid"})
def _ticket_payload(ticket: Mapping[str, Any]) -> Mapping[str, Any]:
_reject_server_secrets(ticket)
if _contains_ticket_fields(ticket):
return ticket
for envelope_key in ("data", "result", "ticket"):
nested = ticket.get(envelope_key)
if isinstance(nested, Mapping):
_reject_server_secrets(nested)
if _contains_ticket_fields(nested):
return nested
return ticket
def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
"""Adapt the repository's CallTicket model without importing core models."""
if isinstance(ticket, Mapping):
return ticket
raw = getattr(ticket, "raw", None)
if isinstance(raw, Mapping):
_reject_server_secrets(raw)
attribute_aliases = {
"sdkAppId": "sdk_app_id",
"userId": "user_id",
"userSig": "user_sig",
"patientUserId": "patient_user_id",
"diagnosisId": "diagnosis_id",
"patientId": "patient_id",
}
adapted = {
json_name: getattr(ticket, attribute_name)
for json_name, attribute_name in attribute_aliases.items()
if hasattr(ticket, attribute_name)
}
if adapted:
return adapted
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
def _read_aliases(
payload: Mapping[str, Any],
aliases: tuple[str, ...],
field_name: str,
converter: Callable[[Any, str], Any],
*,
required: bool = True,
) -> Any:
converted: list[Any] = []
for alias in aliases:
if alias in payload and payload[alias] is not None:
converted.append(converter(payload[alias], field_name))
if not converted:
if required:
raise VideoTicketError(f"backend ticket is missing {field_name}")
return None
if any(value != converted[0] and str(value) != str(converted[0]) for value in converted[1:]):
raise VideoTicketError(f"backend ticket has conflicting {field_name} aliases")
return converted[0]
def _merge_identifier(
payload_value: Identifier | None,
explicit_value: Any,
field_name: str,
) -> Identifier:
if explicit_value is None:
if payload_value is None:
raise VideoTicketError(f"backend ticket is missing {field_name}")
return payload_value
normalized = _identifier(explicit_value, field_name)
if (
payload_value is not None
and payload_value != normalized
and str(payload_value) != str(normalized)
):
raise VideoTicketError(f"backend ticket conflicts with requested {field_name}")
return normalized
@dataclass(frozen=True, slots=True)
class VideoCallRequest:
"""Validated data required to start one doctor-to-patient video call.
``user_sig`` is excluded from ``repr``. Use :meth:`safe_log_context` for
structured logs; never serialize the dataclass itself into diagnostics.
"""
sdk_app_id: int
user_id: str
user_sig: str = field(repr=False)
target_user_id: str
diagnosis_id: Identifier
patient_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED
def __post_init__(self) -> None:
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
object.__setattr__(
self,
"target_user_id",
_non_empty_string(self.target_user_id, "targetUserId"),
)
object.__setattr__(
self,
"diagnosis_id",
_identifier(self.diagnosis_id, "diagnosisId"),
)
if self.patient_id is not None:
object.__setattr__(
self,
"patient_id",
_identifier(self.patient_id, "patientId"),
)
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
@classmethod
def from_backend_ticket(
cls,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
) -> VideoCallRequest:
return normalize_backend_ticket(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=backend_mode,
)
def to_web_config(self) -> dict[str, Any]:
"""Return the canonical JavaScript bridge payload.
The returned mapping contains the short-lived credential and therefore
must only be passed in memory to the trusted companion page.
"""
return {
"SDKAppID": self.sdk_app_id,
"userID": self.user_id,
"userSig": self.user_sig,
"targetUserId": self.target_user_id,
"diagnosisId": self.diagnosis_id,
}
def safe_log_context(self) -> dict[str, Any]:
"""Return non-secret call metadata suitable for structured logging."""
return {
"diagnosis_id": self.diagnosis_id,
"patient_id": self.patient_id,
"backend_mode": self.backend_mode.value,
}
def normalize_backend_ticket(
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
) -> VideoCallRequest:
"""Normalize backend camel-case aliases into a validated call request."""
payload = _ticket_payload(_ticket_mapping(ticket))
payload_diagnosis = _read_aliases(
payload,
("diagnosisId", "diagnosis_id"),
"diagnosisId",
_identifier,
required=False,
)
payload_patient = _read_aliases(
payload,
("patientId", "patient_id"),
"patientId",
_identifier,
required=False,
)
normalized_diagnosis = _merge_identifier(
payload_diagnosis,
diagnosis_id,
"diagnosisId",
)
if patient_id is not None:
normalized_patient = _merge_identifier(payload_patient, patient_id, "patientId")
else:
normalized_patient = payload_patient
return VideoCallRequest(
sdk_app_id=_read_aliases(
payload,
("SDKAppID", "sdkAppId", "sdkAppID"),
"SDKAppID",
_sdk_app_id,
),
user_id=_read_aliases(
payload,
("userID", "userId"),
"userID",
_non_empty_string,
),
user_sig=_read_aliases(
payload,
("userSig", "user_sig"),
"userSig",
_non_empty_string,
),
target_user_id=_read_aliases(
payload,
("targetUserId", "patientUserId"),
"targetUserId",
_non_empty_string,
),
diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient,
backend_mode=BackendMode.parse(backend_mode),
)
@dataclass(slots=True)
class VideoCallLauncher:
"""Small composition root that defers the optional Qt import until launch."""
repository: Any
backend_mode: BackendMode | str = BackendMode.EMBEDDED
local_dist: str | Path | None = None
remote_url: str | None = None
logger: Any = None
browser_opener: Callable[[str], bool] | None = None
def prepare(
self,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
) -> VideoCallRequest:
require_supported_backend(self.backend_mode)
return normalize_backend_ticket(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=self.backend_mode,
)
def launch(
self,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
) -> Any:
request = self.prepare(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
)
from .window import open_video_call
return open_video_call(
request,
repository=self.repository,
local_dist=self.local_dist,
remote_url=self.remote_url,
logger=self.logger,
browser_opener=self.browser_opener,
)
def launch_video_call(
ticket: Any,
*,
repository: Any,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: Any = None,
browser_opener: Callable[[str], bool] | None = None,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
return VideoCallLauncher(
repository=repository,
backend_mode=backend_mode,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
).launch(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
)
@@ -0,0 +1,307 @@
"""Ordered, non-blocking backend lifecycle writes for one video call.
The repository uses synchronous HTTP. A dedicated daemon worker keeps
``start_call -> bind_call_room -> end_call`` ordered without ever blocking the
Qt GUI thread. Only non-secret call metadata is logged.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import queue
import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from typing import Any, TypeVar
from .launcher import VideoCallRequest
ResultT = TypeVar("ResultT")
def _settled_future(value: ResultT) -> Future[ResultT]:
future: Future[ResultT] = Future()
future.set_result(value)
return future
def _resolve_result(result: Any) -> Any:
if inspect.isawaitable(result):
return asyncio.run(result)
return result
def _call_repository_method(method: Callable[..., Any], payload: Mapping[str, Any]) -> Any:
"""Invoke common repository signatures with a non-secret payload only."""
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
return _resolve_result(method(**payload))
parameters = list(signature.parameters.values())
if any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters):
return _resolve_result(method(**payload))
keyword_names = {
parameter.name
for parameter in parameters
if parameter.kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
accepted = {key: value for key, value in payload.items() if key in keyword_names}
required = [
parameter
for parameter in parameters
if parameter.default is inspect.Parameter.empty
and parameter.kind
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
if len(parameters) == 1 and not accepted:
result = method(dict(payload))
elif any(parameter.kind is inspect.Parameter.POSITIONAL_ONLY for parameter in required):
missing = [parameter.name for parameter in required if parameter.name not in payload]
if missing:
raise TypeError("repository method requires unsupported positional parameters")
ordered = [payload[parameter.name] for parameter in required]
result = method(*ordered, **accepted)
else:
result = method(**accepted)
return _resolve_result(result)
@dataclass(slots=True)
class _WorkItem:
operation: str
callback: Callable[[], Any]
future: Future[Any]
class _OrderedDaemonWorker:
"""A minimal FIFO executor with timeout-aware idle observation."""
def __init__(self, logger: logging.Logger, log_context: Mapping[str, Any]) -> None:
self._logger = logger
self._log_context = dict(log_context)
self._queue: queue.Queue[_WorkItem | None] = queue.Queue()
self._lock = threading.Lock()
self._idle = threading.Event()
self._idle.set()
self._pending = 0
self._stopping = False
self._thread = threading.Thread(
target=self._run,
name="video-call-lifecycle",
daemon=True,
)
self._thread.start()
@property
def is_daemon(self) -> bool:
return self._thread.daemon
def submit(self, operation: str, callback: Callable[[], ResultT]) -> Future[ResultT]:
with self._lock:
if self._stopping:
raise RuntimeError("video lifecycle worker is stopping")
future: Future[ResultT] = Future()
self._pending += 1
self._idle.clear()
self._queue.put(_WorkItem(operation, callback, future))
return future
def stop_when_idle(self) -> None:
with self._lock:
if self._stopping:
return
self._stopping = True
self._queue.put(None)
def wait(self, timeout: float = 0.25) -> bool:
bounded = max(0.0, min(float(timeout), 5.0))
return self._idle.wait(bounded)
def _run(self) -> None:
while True:
item = self._queue.get()
if item is None:
self._queue.task_done()
return
try:
if item.future.set_running_or_notify_cancel():
try:
item.future.set_result(item.callback())
except BaseException as error:
item.future.set_exception(error)
self._logger.error(
"video lifecycle operation failed",
extra={
"video_call": self._log_context,
"operation": item.operation,
"error_type": type(error).__name__,
},
)
finally:
with self._lock:
self._pending -= 1
if self._pending == 0:
self._idle.set()
self._queue.task_done()
class OrderedCallLifecycle:
"""Idempotent lifecycle state machine backed by one FIFO daemon worker."""
def __init__(
self,
request: VideoCallRequest,
repository: Any,
logger: logging.Logger,
) -> None:
if repository is None:
raise ValueError("a video call repository is required")
self.request = request
self.repository = repository
self.logger = logger
self.started = False
self.ended = False
self.bound_room_id: str | None = None
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._lock = threading.RLock()
self._worker = _OrderedDaemonWorker(logger, request.safe_log_context())
@property
def worker_is_daemon(self) -> bool:
return self._worker.is_daemon
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
return self._start_future
method = getattr(self.repository, "start_call", None)
if not callable(method):
raise ValueError("video repository does not implement start_call")
payload: dict[str, Any] = {
"diagnosis_id": self.request.diagnosis_id,
"call_type": 2,
}
if self.request.patient_id is not None:
payload["patient_id"] = self.request.patient_id
def operation() -> bool:
_call_repository_method(method, payload)
with self._lock:
self.started = True
self.logger.info(
"video call record started",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._start_future = self._worker.submit("start", operation)
return self._start_future
def bind_room(self, room_id: Any) -> Future[bool]:
cleaned = str(room_id or "").strip()
if not cleaned or cleaned == "0":
return _settled_future(False)
with self._lock:
if self._end_future is not None:
return _settled_future(False)
if self.bound_room_id:
if self.bound_room_id != cleaned:
self.logger.warning(
"ignoring a changed TRTC room identifier",
extra={"video_call": self.request.safe_log_context()},
)
return _settled_future(self.bound_room_id == cleaned)
if self._claimed_room_id:
if self._claimed_room_id != cleaned:
self.logger.warning(
"ignoring a changed TRTC room identifier",
extra={"video_call": self.request.safe_log_context()},
)
return _settled_future(False)
return self._bind_future or _settled_future(False)
if self._start_future is None:
self.start()
self._claimed_room_id = cleaned
method = getattr(self.repository, "bind_call_room", None)
def operation() -> bool:
with self._lock:
started = self.started
if not started:
return False
if not callable(method):
self.logger.warning(
"video repository does not implement bind_call_room",
extra={"video_call": self.request.safe_log_context()},
)
return False
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
"TRTC room bound to video call record",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
return self._end_future
method = getattr(self.repository, "end_call", None)
def operation() -> bool:
with self._lock:
started = self.started
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
)
with self._lock:
self.ended = True
self.logger.info(
"video call record ended",
extra={
"video_call": {
**self.request.safe_log_context(),
"reason": str(reason)[:80],
}
},
)
return True
self._end_future = self._worker.submit("end", operation)
self._end_future.add_done_callback(lambda _future: self._worker.stop_when_idle())
return self._end_future
def wait(self, timeout: float = 0.25) -> bool:
"""Wait for queued writes for at most five seconds; never waits indefinitely."""
return self._worker.wait(timeout)
__all__ = ["OrderedCallLifecycle"]
@@ -0,0 +1,109 @@
"""Pure-Python trust policy for the embedded video companion document."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlsplit
from urllib.request import url2pathname
class TrustedDocumentError(ValueError):
"""Raised when a companion location cannot form a safe allowlist."""
def _normalized_host(host: str | None) -> str:
if not host:
return ""
try:
return host.encode("idna").decode("ascii").lower()
except UnicodeError:
return host.lower()
def _normalized_file_path(value: str) -> str | None:
parsed = urlsplit(value)
if parsed.scheme.lower() != "file" or parsed.netloc not in {"", "localhost"}:
return None
path = Path(url2pathname(unquote(parsed.path))).resolve()
rendered = str(path)
return rendered.casefold() if os.name == "nt" else rendered
@dataclass(frozen=True, slots=True)
class TrustedDocumentPolicy:
"""Exact main-document allowlist plus origin matching for media grants."""
scheme: str
host: str
port: int | None
path: str
query: str
local_path: str | None = None
@classmethod
def from_url(cls, url: str, *, is_local: bool) -> TrustedDocumentPolicy:
parsed = urlsplit(url)
scheme = parsed.scheme.lower()
if is_local:
local_path = _normalized_file_path(url)
if local_path is None:
raise TrustedDocumentError("local video companion must be a file URL")
return cls("file", "", None, parsed.path, parsed.query, local_path)
if scheme != "https" or not parsed.hostname:
raise TrustedDocumentError("remote embedded video companion must use HTTPS")
if parsed.username or parsed.password:
raise TrustedDocumentError("remote embedded video companion must not use credentials")
try:
port = parsed.port or 443
except ValueError as error:
raise TrustedDocumentError(
"remote embedded video companion has an invalid port"
) from error
return cls(
"https",
_normalized_host(parsed.hostname),
port,
parsed.path or "/",
parsed.query,
)
def allows_main_document(self, candidate: str) -> bool:
"""Allow only the configured file or HTTPS document, including its query."""
parsed = urlsplit(candidate)
if self.scheme == "file":
return (
parsed.query == self.query and _normalized_file_path(candidate) == self.local_path
)
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else None)
except ValueError:
return False
return (
parsed.scheme.lower() == self.scheme
and _normalized_host(parsed.hostname) == self.host
and port == self.port
and (parsed.path or "/") == self.path
and parsed.query == self.query
)
def allows_origin(self, candidate: str) -> bool:
"""Match only the origin that supplied the trusted main document."""
parsed = urlsplit(candidate)
if self.scheme == "file":
return parsed.scheme.lower() == "file" and parsed.netloc in {"", "localhost"}
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else None)
except ValueError:
return False
return (
parsed.scheme.lower() == self.scheme
and _normalized_host(parsed.hostname) == self.host
and port == self.port
)
__all__ = ["TrustedDocumentError", "TrustedDocumentPolicy"]
+585
View File
@@ -0,0 +1,585 @@
"""Hardened QtWebEngine host for the video companion.
Browser launch is intentionally disabled until the backend provides a
single-use handoff ticket. PySide6 remains optional at import time, while an
actual call requires an isolated QtWebEngine profile and an active QApplication.
"""
from __future__ import annotations
import json
import logging
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from .launcher import (
VideoCallRequest,
VideoTicketError,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
_WEBENGINE_IMPORT_ERROR: Exception | None = _qt_import_error
else: # pragma: no cover - requires a GUI runtime.
_WEBENGINE_IMPORT_ERROR = None
WEBENGINE_AVAILABLE = _WEBENGINE_IMPORT_ERROR is None
_LOGGER = logging.getLogger(__name__)
_SENSITIVE_QUERY_KEYS = {"usersig", "sdksecret", "sdksecretkey", "secretkey"}
class VideoWindowError(RuntimeError):
"""Raised when the trusted embedded companion cannot be opened."""
@dataclass(frozen=True, slots=True)
class CompanionLocation:
url: str
is_local: bool
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise VideoWindowError("remote video companion URL must use HTTPS")
if parsed.username or parsed.password:
raise VideoWindowError("remote video companion URL must not contain credentials")
url_parameter_keys = {
"".join(character for character in key.lower() if character.isalnum())
for key, _ in (*parse_qsl(parsed.query), *parse_qsl(parsed.fragment))
}
if url_parameter_keys & _SENSITIVE_QUERY_KEYS:
raise VideoWindowError("remote video companion URL must not contain RTC credentials")
return value
def _candidate_index(local_dist: str | Path) -> Path:
candidate = Path(local_dist).expanduser().resolve()
return candidate if candidate.name.lower() == "index.html" else candidate / "index.html"
def _default_local_indexes() -> tuple[Path, ...]:
candidates: list[Path] = []
bundle_root = getattr(sys, "_MEIPASS", None)
if bundle_root:
candidates.append(Path(bundle_root) / "video_companion_dist" / "index.html")
project_root = Path(__file__).resolve().parents[3]
candidates.append(project_root / "video_companion" / "dist" / "index.html")
return tuple(candidates)
def resolve_companion_location(
*,
local_dist: str | Path | None = None,
remote_url: str | None = None,
) -> CompanionLocation:
"""Resolve the trusted document used inside QtWebEngine."""
indexes = (
(_candidate_index(local_dist),) if local_dist is not None else _default_local_indexes()
)
for index in indexes:
if index.is_file():
return CompanionLocation(index.as_uri(), is_local=True)
if remote_url:
return CompanionLocation(_validate_remote_url(remote_url), is_local=False)
raise VideoWindowError(
"video companion is unavailable: build video_companion/dist or configure an HTTPS URL"
)
def webengine_unavailable_reason() -> str | None:
"""Return a non-sensitive diagnostic reason without importing Qt again."""
if _WEBENGINE_IMPORT_ERROR is None:
return None
return f"{type(_WEBENGINE_IMPORT_ERROR).__name__}: QtWebEngine is not installed"
if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration test.
class _RestrictedWebEnginePage(QWebEnginePage): # type: ignore[misc, valid-type]
def __init__(
self,
profile: Any,
policy: TrustedDocumentPolicy,
logger: logging.Logger,
parent: Any,
) -> None:
super().__init__(profile, parent)
self._policy = policy
self._logger = logger
self._shutting_down = False
def begin_shutdown(self) -> None:
self._shutting_down = True
def acceptNavigationRequest(
self,
url: Any,
navigation_type: Any,
is_main_frame: bool,
) -> bool:
del navigation_type
if not is_main_frame:
return True
rendered = url.toString()
if self._shutting_down and rendered == "about:blank":
return True
if self._policy.allows_main_document(rendered):
return True
self._logger.warning(
"blocked video companion main-document navigation",
extra={
"target_scheme": url.scheme(),
"target_host": url.host(),
},
)
return False
def createWindow(self, window_type: Any) -> Any:
del window_type
self._logger.warning("blocked video companion popup window")
return None
class _QtVideoBridge(QObject): # type: ignore[misc, valid-type]
def __init__(self, callback: Callable[[Mapping[str, Any]], None]) -> None:
super().__init__()
self._callback = callback
@Slot(str) # type: ignore[misc]
def notify(self, payload: str) -> None:
if not isinstance(payload, str) or len(payload) > 16_384:
return
try:
message = json.loads(payload)
except (TypeError, ValueError):
return
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
def __init__(
self,
request: VideoCallRequest,
location: CompanionLocation,
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self.logger = logger
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
is_local=location.is_local,
)
except TrustedDocumentError as error:
raise VideoWindowError(str(error)) from error
self._injected = False
self._media_active = False
self._closing = False
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self.setWindowTitle("视频面诊")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
self._profile = QWebEngineProfile(self)
if not self._profile.isOffTheRecord():
raise VideoWindowError("video WebEngine profile must be off-the-record")
profile_policy = QWebEngineProfile.PersistentCookiesPolicy
cache_type = QWebEngineProfile.HttpCacheType
self._profile.setPersistentCookiesPolicy(profile_policy.NoPersistentCookies)
self._profile.setHttpCacheType(cache_type.MemoryHttpCache)
self._profile.downloadRequested.connect(self._deny_download)
self.web_view = QWebEngineView(self)
self._page = _RestrictedWebEnginePage(
self._profile,
self._policy,
self.logger,
self.web_view,
)
self.web_view.setPage(self._page)
self.setCentralWidget(self.web_view)
self._configure_settings(self._page.settings())
self._bridge = _QtVideoBridge(self._handle_bridge_message)
self._channel = QWebChannel(self._page)
self._channel.registerObject("qtVideoBridge", self._bridge)
self._page.setWebChannel(self._channel)
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
def _configure_settings(self, settings: Any) -> None:
attributes = getattr(QWebEngineSettings, "WebAttribute", QWebEngineSettings)
values = (
("LocalContentCanAccessRemoteUrls", self.location.is_local),
("PlaybackRequiresUserGesture", False),
("JavascriptCanOpenWindows", False),
("AllowRunningInsecureContent", False),
)
for name, enabled in values:
attribute = getattr(attributes, name, None)
if attribute is not None:
settings.setAttribute(attribute, enabled)
def _connect_permissions(self) -> None:
if hasattr(self._page, "featurePermissionRequested"):
self._page.featurePermissionRequested.connect(self._grant_legacy_media_permission)
if hasattr(self._page, "permissionRequested"):
self._page.permissionRequested.connect(self._grant_media_permission)
def _permission_context_is_trusted(self, origin: Any) -> bool:
if self._closing or self._released or not self._media_active:
return False
if not self._policy.allows_main_document(self._page.url().toString()):
return False
return self._policy.allows_origin(origin.toString())
def _grant_legacy_media_permission(self, origin: Any, feature: Any) -> None:
features = QWebEnginePage.Feature
allowed = {
features.MediaAudioCapture,
features.MediaVideoCapture,
features.MediaAudioVideoCapture,
}
policies = QWebEnginePage.PermissionPolicy
trusted = self._permission_context_is_trusted(origin) and feature in allowed
policy = (
policies.PermissionGrantedByUser if trusted else policies.PermissionDeniedByUser
)
self._page.setFeaturePermission(origin, feature, policy)
if trusted:
self._legacy_grants.append((origin, feature))
def _grant_media_permission(self, permission: Any) -> None:
permission_type = permission.permissionType()
allowed_names = {
"MediaAudioCapture",
"MediaVideoCapture",
"MediaAudioVideoCapture",
}
trusted = (
permission.isValid()
and permission_type.name in allowed_names
and self._permission_context_is_trusted(permission.origin())
)
if trusted:
permission.grant()
self._permission_grants.append(permission)
else:
permission.deny()
def _deny_download(self, download: Any) -> None:
download.cancel()
def _on_load_finished(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self.logger.error(
"embedded video companion failed to load",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "page-load-failed"
self.close()
return
try:
start_future = self.lifecycle.start()
except Exception:
self.logger.error(
"video call record could not be queued",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "record-start-queue-failed"
self.close()
return
start_future.add_done_callback(self._notify_start_completed)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception:
succeeded = False
with suppress(RuntimeError):
self._start_completed.emit(succeeded)
def _on_lifecycle_started(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self._close_reason = "record-start-failed"
self.close()
return
self._media_active = True
config_json = json.dumps(
self.request.to_web_config(),
ensure_ascii=True,
separators=(",", ":"),
)
script = f"""
(() => {{
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
return false;
}}
void window.doctorCall.start({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
if not self._injected:
self._close_reason = "bridge-api-missing"
self.close()
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
if self._closing:
return
event = str(message.get("event", ""))
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
if event == "room":
return
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle":
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
self._close_from_companion("companion-error")
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
self._close_reason = reason
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self.close()
def _begin_shutdown(self) -> None:
if self._closing:
return
self._closing = True
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorCall?.hangup?.().catch(() => undefined)"
)
self.lifecycle.end(self._close_reason)
self._release_webengine()
def _release_webengine(self) -> None:
if self._released:
return
self._released = True
policies = QWebEnginePage.PermissionPolicy
for origin, feature in self._legacy_grants:
with suppress(RuntimeError):
self._page.setFeaturePermission(
origin,
feature,
policies.PermissionDeniedByUser,
)
self._legacy_grants.clear()
for permission in self._permission_grants:
try:
if permission.isValid():
permission.reset()
except RuntimeError:
pass
self._permission_grants.clear()
try:
self._channel.deregisterObject(self._bridge)
self._page.setWebChannel(None)
except RuntimeError:
pass
try:
self._profile.cookieStore().deleteAllCookies()
self._profile.clearHttpCache()
self._profile.clearAllVisitedLinks()
except RuntimeError:
pass
try:
self._page.begin_shutdown()
self._page.setUrl(QUrl("about:blank"))
except RuntimeError:
pass
view = self.takeCentralWidget()
if view is not None:
view.close()
view.deleteLater()
self._page.deleteLater()
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
self._begin_shutdown()
event.accept()
else:
_EmbeddedVideoWindow = None # type: ignore[assignment, misc]
class VideoCallWindow:
"""Facade for the only currently supported backend: embedded QtWebEngine."""
def __init__(
self,
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
self.backend_mode = require_supported_backend(request.backend_mode)
except VideoTicketError as error:
raise VideoWindowError(str(error)) from error
if not WEBENGINE_AVAILABLE:
raise VideoWindowError(
"embedded video is unavailable and automatic browser fallback is disabled"
)
if QApplication is None or QApplication.instance() is None:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._session: Any = None
@property
def qt_window(self) -> Any:
return self._session
def open(self) -> VideoCallWindow:
try:
self._session = _EmbeddedVideoWindow(
self.request,
self.location,
self.lifecycle,
logger=self.logger,
)
except Exception:
self.lifecycle.end("window-open-failed")
raise
self._session.show()
self._session.raise_()
self._session.activateWindow()
return self
show = open
def hangup(self) -> None:
if self._session is not None:
self._session.hangup()
else:
self.lifecycle.end("unopened-session")
def close(self) -> None:
if self._session is not None:
self._session.close()
else:
self.lifecycle.end("unopened-session")
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
return self.lifecycle.wait(timeout)
wait = wait_for_lifecycle
def open_video_call(
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
if not isinstance(request, VideoCallRequest):
raise VideoTicketError("request must be a VideoCallRequest")
return VideoCallWindow(
request,
repository=repository,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
).open()
__all__ = [
"CompanionLocation",
"TrustedDocumentPolicy",
"VideoCallWindow",
"VideoWindowError",
"WEBENGINE_AVAILABLE",
"open_video_call",
"resolve_companion_location",
"webengine_unavailable_reason",
]