1236 lines
48 KiB
Python
Executable File
1236 lines
48 KiB
Python
Executable File
"""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"]
|