更新
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Zhenyang doctor workstation."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user