更新
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user