Files
zyt/app/src/doctor_workstation/app.py
T

1236 lines
48 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Application composition root for the doctor workstation."""
from __future__ import annotations
import logging
import os
import sys
import time
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
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 app_icon_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.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
first_value,
friendly_error,
gender_text,
get_value,
has_permission,
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__)
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
if value in (None, "", [], {}):
return ""
if isinstance(value, Mapping):
parts = [
f"{key}:{_video_case_text(item, limit=limit)}"
for key, item in value.items()
if item not in (None, "", [], {})
]
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "、".join(
part
for item in value
if (part := _video_case_text(item, limit=limit))
)[:limit]
return str(value).strip()[:limit]
def _video_identity(value: Any) -> str:
if value in (None, "") or isinstance(value, bool):
return ""
text = str(value).strip()
if not text:
return ""
with suppress(ValueError, TypeError):
return str(int(text))
return text
def _video_identity_matches(expected: Any, actual: Any) -> bool:
normalized_actual = _video_identity(actual)
return not normalized_actual or normalized_actual == _video_identity(expected)
def _build_video_patient_case(
detail: Any,
fallback_record: Any,
*,
diagnosis_id: Any,
patient_id: Any,
patient_name: str,
) -> dict[str, str]:
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
nested = get_value(detail, "data", None)
if isinstance(nested, Mapping):
detail = nested
diagnosis = get_value(detail, "diagnosis", None)
if not diagnosis and isinstance(detail, Mapping):
diagnosis = detail
diagnosis = diagnosis or {}
patient = get_value(detail, "patient", None) or {}
appointment = get_value(detail, "appointment", None) or {}
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
detail_patient_id = first_value(
diagnosis,
"source_patient_id",
default=first_value(
patient,
"source_patient_id",
default=None,
),
)
if not _video_identity_matches(
diagnosis_id,
detail_diagnosis_id,
) or not _video_identity_matches(patient_id, detail_patient_id):
detail = diagnosis = patient = appointment = {}
fallback_diagnosis_id = first_value(
fallback_record,
"diagnosis_id",
"id",
default=None,
)
fallback_patient_id = first_value(
fallback_record,
"source_patient_id",
"patient_id",
default=None,
)
if not _video_identity_matches(
diagnosis_id,
fallback_diagnosis_id,
) or not _video_identity_matches(patient_id, fallback_patient_id):
fallback_record = {}
sources = (diagnosis, patient, appointment, fallback_record)
def pick(*keys: str, limit: int = 2000) -> str:
for source in sources:
value = first_value(source, *keys, default=None)
text = _video_case_text(value, limit=limit)
if text:
return text
return ""
raw_gender = next(
(
first_value(source, "gender_desc", "gender", default=None)
for source in sources
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
),
None,
)
return {
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
"name": pick("patient_name", "name", limit=120)
or _video_case_text(patient_name, limit=120)
or "患者",
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
"age": pick("age", limit=20),
"height": pick("height", limit=20),
"weight": pick("weight", limit=20),
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
"appointmentDate": pick(
"appointment_date",
"latest_appointment_date",
limit=80,
),
"clinicalDiagnosis": pick(
"clinical_diagnosis",
"diagnosis_name",
"disease_name",
),
"chiefComplaint": pick("chief_complaint", "complaint"),
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
"allergyHistory": pick(
"allergy_history_text",
"allergy_history_desc",
"allergy_history",
),
"personalHistory": pick(
"personal_history_text",
"personal_history_desc",
"personal_history",
),
"familyHistory": pick(
"family_history_text",
"family_history_desc",
"family_history",
),
"currentMedication": pick(
"current_medications",
"current_medicine",
"current_medication",
),
"tongue": pick("tongue", "tongue_coating"),
"pulse": pick("pulse", "pulse_condition"),
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
"remark": pick("remark"),
}
class _ChineseQtTranslator(QTranslator):
"""Guarantee Chinese labels for common Qt standard buttons.
Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for
framework text. This small fallback also keeps release builds localized
when a packager omits the optional ``.qm`` files.
"""
_BUTTON_TEXT = {
"OK": "确定",
"Open": "打开",
"Save": "保存",
"Save All": "全部保存",
"Cancel": "取消",
"Close": "关闭",
"Yes": "是",
"Yes to All": "全部确认",
"No": "否",
"No to All": "全部否定",
"Abort": "中止",
"Retry": "重试",
"Ignore": "忽略",
"Discard": "放弃",
"Help": "帮助",
"Apply": "应用",
"Reset": "重置",
"Restore Defaults": "恢复默认设置",
"Don't Save": "不保存",
}
def translate(
self,
context: str,
source_text: str,
disambiguation: str | None = None,
n: int = -1,
) -> str | None:
del context, disambiguation, n
# Returning an empty string tells Qt that an unknown source string has
# a valid, deliberately empty translation. That also erased internal
# values such as QPageSize's "A4", producing zero-sized PDF pages.
# ``None`` delegates unknown text to Qt's installed catalog/source.
return self._BUTTON_TEXT.get(source_text.replace("&", ""))
def _install_chinese_translations(application: QApplication) -> None:
"""Install Simplified Chinese Qt catalogs once for the whole process."""
if getattr(application, "_doctor_workstation_chinese_translators", None):
return
QLocale.setDefault(QLocale("zh_CN"))
translators: list[QTranslator] = []
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
for catalog in ("qt_zh_CN", "qtbase_zh_CN"):
translator = QTranslator(application)
if translator.load(catalog, translations_path):
application.installTranslator(translator)
translators.append(translator)
fallback = _ChineseQtTranslator(application)
application.installTranslator(fallback)
translators.append(fallback)
application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined]
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:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#C23D4E;border-color:#C23D4E;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
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:#707584;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:#F0F2FF;color:#5761F4;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:#A4ADC3;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:#D9E0F2;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.debug_mode = bool(getattr(config, "debug_mode", False))
self.token_store = TokenStore(config.config_dir / "credentials.json")
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
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 = self.debug_mode and config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self._pending_im_request: tuple[str, object] | None = None
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self._video_preview_state: dict[str, Any] | None = None
self._video_preview_generation = 0
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()
self.app_updater = AppUpdateSession(self)
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()
self.app_updater.schedule()
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,
credential_store=self.token_store,
)
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.restore_remembered_credentials()
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()
self.app_updater.schedule()
if self.login_window is not None:
self.login_window.config = self.config
def _on_demo_mode_changed(self, enabled: bool) -> None:
allowed = self.debug_mode and enabled
self.current_demo_mode = allowed
if allowed:
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 = self.debug_mode and bool(payload.get("demo_mode"))
payload["demo_mode"] = 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.shell_window.update_check_requested.connect(
lambda: self.app_updater.check(interactive=True)
)
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()
self.app_updater.schedule(delay_ms=400)
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."""
self._restore_video_preview(activate=False)
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()
self._pending_im_request = None
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")
appointment_id = int(payload.get("appointment_id") or 0)
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
fallback_record = payload.get("record")
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
call_key = f"{diagnosis_id}:{appointment_id}"
if open_im and self._pending_im_request is not None:
pending_key, pending_marker = self._pending_im_request
if pending_key != call_key:
# Retire a previous selection before any early return, including
# when the selected IM window is already open.
if self.video_pending.get(pending_key) is pending_marker:
self.video_pending.pop(pending_key, None)
self._pending_im_request = None
existing_call = self.video_calls.get(call_key)
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
qt_window = getattr(existing_call, "qt_window", None)
if qt_window is not None:
qt_window.show()
qt_window.raise_()
qt_window.activateWindow()
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
return
if (
call_key in self.video_pending
or existing_call is not None
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
closed_previous_im = False
if open_im:
for key, call in tuple(self.video_calls.items()):
if key == call_key or not getattr(call, "open_im", False):
continue
closed_previous_im = True
self.video_calls.pop(key, None)
with suppress(Exception):
call.close()
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,
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
"info",
)
repository = self.current_repository
marker = object()
self.video_pending[call_key] = marker
if open_im:
self._pending_im_request = (call_key, marker)
def get_video_context() -> tuple[Any, dict[str, str]]:
ticket = repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
appointment_id=appointment_id,
)
if appointment_id and int(ticket.raw.get("appointment_id") or 0) != appointment_id:
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
detail: Any = {}
detail_loader = getattr(repository, "patient_detail", None)
if callable(detail_loader):
try:
detail = detail_loader(int(diagnosis_id))
except Exception as error:
LOGGER.warning(
"patient detail could not be loaded for video call",
extra={
"diagnosis_id": diagnosis_id,
"error_type": type(error).__name__,
},
)
patient_case = _build_video_patient_case(
detail,
fallback_record,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
patient_name=patient_name,
)
return ticket, patient_case
def request_ticket() -> None:
if self.video_pending.get(call_key) is not marker:
return
run_async(
get_video_context,
on_success=lambda context: self._launch_video(
context[0],
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
patient_case=context[1],
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
# Tencent IM may take a brief moment to release the previous browser
# connection. The admin version also has only one ChatDialog instance.
if closed_previous_im:
QTimer.singleShot(400, request_ticket)
else:
request_ticket()
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._pending_im_request == (call_key, marker):
self._pending_im_request = 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,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
if self._pending_im_request == (call_key, marker):
self._pending_im_request = 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"),
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
can_ai_assistant=has_permission(
getattr(self.shell_window, "permissions", None),
"tcm.diagnosis/aiAssistant",
default=False,
),
on_open_diagnosis=lambda current_id=diagnosis_id, current_key=call_key: (
self._open_video_diagnosis(current_id, call_key=current_key)
),
)
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 _open_video_diagnosis(self, diagnosis_id: Any, *, call_key: str) -> None:
"""Open the diagnosis while keeping its live video visible as a preview."""
shell = self.shell_window
if shell is None or self.current_repository is None:
return
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
if dialog is None:
return
call = self.video_calls.get(call_key)
video_window = getattr(call, "qt_window", None)
if video_window is not None:
self._show_video_preview(video_window, dialog)
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
"""Pin a compact call window above the modeless diagnosis drawer."""
current = self._video_preview_state
if current is not None and current.get("window") is not video_window:
self._restore_video_preview(activate=False)
self._video_preview_generation += 1
generation = self._video_preview_generation
if current is None or current.get("window") is not video_window:
try:
state = {
"window": video_window,
"geometry": video_window.geometry(),
"minimum_size": video_window.minimumSize(),
"maximized": video_window.isMaximized(),
"full_screen": video_window.isFullScreen(),
"stays_on_top": bool(
video_window.windowFlags()
& Qt.WindowType.WindowStaysOnTopHint
),
}
screen = video_window.screen() or QGuiApplication.primaryScreen()
available = screen.availableGeometry()
preview_width = min(540, max(460, round(available.width() * 0.29)))
preview_height = min(380, max(320, round(preview_width * 0.66)))
margin = 18
if callable(getattr(video_window, "setProperty", None)):
video_window.setProperty("doctorSkipOpenGeometry", True)
video_window.showNormal()
video_window.setMinimumSize(440, 300)
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
video_window.resize(preview_width, preview_height)
video_window.move(
available.x() + available.width() - preview_width - margin,
available.y() + margin,
)
video_window.show()
self._video_preview_state = state
except RuntimeError:
self._video_preview_state = None
return
dialog.finished.connect(
lambda _result, expected=generation: self._restore_video_preview(expected)
)
try:
set_close_handler = getattr(video_window, "set_compact_close_handler", None)
if callable(set_close_handler):
set_close_handler(lambda expected=generation: self._restore_video_preview(expected))
video_window.raise_()
dialog.raise_()
dialog.activateWindow()
except RuntimeError:
self._restore_video_preview(generation, activate=False)
def _restore_video_preview(
self,
generation: int | None = None,
*,
activate: bool = True,
) -> None:
if generation is not None and generation != self._video_preview_generation:
return
state = self._video_preview_state
if state is None:
return
self._video_preview_state = None
self._video_preview_generation += 1
window = state.get("window")
try:
set_close_handler = getattr(window, "set_compact_close_handler", None)
if callable(set_close_handler):
set_close_handler(None)
restore_screenshot = getattr(window, "restore_screenshot_preview", None)
if callable(restore_screenshot):
restore_screenshot()
window.setWindowFlag(
Qt.WindowType.WindowStaysOnTopHint,
bool(state.get("stays_on_top")),
)
window.setMinimumSize(state["minimum_size"])
window.setGeometry(state["geometry"])
if state.get("full_screen"):
window.showFullScreen()
elif state.get("maximized"):
window.showMaximized()
else:
window.showNormal()
window.raise_()
if activate:
window.activateWindow()
if callable(getattr(window, "setProperty", None)):
window.setProperty("doctorSkipOpenGeometry", False)
except (AttributeError, RuntimeError):
return
def _release_video_call(self, call_key: str, call: Any) -> None:
preview = self._video_preview_state
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
self._video_preview_state = None
self._video_preview_generation += 1
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 = app_icon_path()
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def request_quit(self) -> None:
"""Queue a normal application exit so owned resources are released."""
if self._shutting_down:
return
QTimer.singleShot(0, self.application.quit)
def shutdown(self) -> None:
"""Invalidate asynchronous restoration and release owned resources."""
if self._shutting_down:
return
self._shutting_down = True
self._cancel_session_restore()
self.app_updater.shutdown()
set_authentication_expired_handler(None)
self._restore_video_preview(activate=False)
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 _prefer_native_tls_backend() -> None:
"""Keep Qt's HTTPS stack off OpenSSL.
PySide6 on Windows ships no OpenSSL DLLs of its own, so Qt's OpenSSL
TLS backend resolves to CPython's ``libcrypto-3-x64.dll`` and ends up
sharing one OpenSSL instance with the httpx stack. Concurrent use from
both stacks has crashed the process inside libcrypto (access violation
at a stable offset) while pages downloaded images through
``QNetworkAccessManager``. Route Qt network requests through the
native Schannel backend instead, which uses the Windows certificate
store and never touches OpenSSL.
"""
with suppress(Exception):
from PySide6.QtNetwork import QSslSocket
backends = QSslSocket.availableBackends()
if "schannel" in backends and "openssl" in backends:
QSslSocket.setActiveBackend("schannel")
_CRASH_LOG_HANDLE: Any = None
def _install_crash_handler(config: AppConfig) -> None:
"""Write per-thread Python tracebacks of native crashes into the log dir.
Debug launchers already pass ``-X faulthandler`` and dump to stderr;
keep that behavior and only redirect into ``crash.log`` for packaged
or plain runs where stderr is lost.
"""
global _CRASH_LOG_HANDLE
try:
import faulthandler
if faulthandler.is_enabled():
return
path = config.log_dir / "crash.log"
handle = path.open("a", encoding="utf-8", buffering=1)
handle.write(f"\n=== process started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
faulthandler.enable(file=handle, all_threads=True)
_CRASH_LOG_HANDLE = handle
except Exception:
LOGGER.exception("crash handler could not be installed")
def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
with suppress(AttributeError):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
application = QApplication(argv)
_prefer_native_tls_backend()
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
application.setOrganizationName("ZhenYangTang")
application.setOrganizationDomain("zhenyangtang.com")
application.setQuitOnLastWindowClosed(True)
icon_file = app_icon_path()
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)
_install_crash_handler(config)
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"]