This commit is contained in:
Your Name
2026-08-12 11:03:28 +08:00
parent 09d3fcbf82
commit bc9ad1d3cd
32 changed files with 4586 additions and 2341 deletions
+151 -44
View File
@@ -9,7 +9,7 @@ import time
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QApplication,
@@ -43,7 +43,71 @@ from doctor_workstation.ui.widgets import (
from doctor_workstation.video import BackendMode, launch_video_call
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
LOGGER = logging.getLogger(__name__)
LOGGER = logging.getLogger(__name__)
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:
del context, disambiguation, n
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:
@@ -200,11 +264,12 @@ class ApplicationController(QObject):
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 = 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)
@@ -212,8 +277,9 @@ class ApplicationController(QObject):
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()
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()
@@ -498,20 +564,40 @@ class ApplicationController(QObject):
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 "患者")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
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
call_key = str(diagnosis_id)
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)
@@ -525,7 +611,11 @@ class ApplicationController(QObject):
dialog.show()
return
show_toast(parent, "正在获取安全通话凭证…", "info")
show_toast(
parent,
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
"info",
)
repository = self.current_repository
marker = object()
self.video_pending[call_key] = marker
@@ -536,23 +626,35 @@ class ApplicationController(QObject):
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 request_ticket() -> None:
if self.video_pending.get(call_key) is not marker:
return
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,
open_im=open_im,
patient_name=patient_name,
),
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,
@@ -579,9 +681,11 @@ class ApplicationController(QObject):
diagnosis_id: Any,
patient_id: Any,
repository: Any,
call_key: str,
marker: object,
) -> None:
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
@@ -604,9 +708,11 @@ class ApplicationController(QObject):
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"),
)
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
@@ -682,7 +788,8 @@ def _create_application(argv: list[str]) -> QApplication:
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
application = QApplication(argv)
application = QApplication(argv)
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
application.setOrganizationName("ZhenYangTang")
@@ -593,6 +593,28 @@ class DemoDoctorRepository:
safe_name = source.name.replace("\\", "_").replace("/", "_")
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: Literal["image", "video", "file"],
cid: int = 0,
) -> str:
"""Return a synthetic server URI for an in-memory demo capture."""
if cid < 0:
raise ValueError("cid must be non-negative")
if not content:
raise ValueError("material content must not be empty")
safe_name = Path(filename).name.strip().replace("\\", "_").replace("/", "_")
if not safe_name:
raise ValueError("filename is required")
kind = _material_kind(material_type)
with self._lock:
material_id = self._next_material_id
self._next_material_id += 1
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
def add_doctor_note(
self,
diagnosis_id: int,
@@ -8,6 +8,7 @@ import time
from collections.abc import Mapping
from contextlib import suppress
from datetime import date
from io import BytesIO
from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
@@ -109,6 +110,15 @@ class DoctorRepository(Protocol):
) -> str:
"""Upload one local note material and return its server URI."""
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: Literal["image", "video", "file"],
cid: int = 0,
) -> str:
"""Upload an in-memory capture and return its server URI."""
def get_prescription_template(self, template_id: int) -> PrescriptionTemplate:
"""Return one prescription-library record."""
@@ -960,6 +970,34 @@ class RemoteDoctorRepository:
)
return _normalise_material_reference(payload, endpoint)
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: Literal["image", "video", "file"],
cid: int = 0,
) -> str:
"""Upload a trusted in-memory capture without a local plaintext file."""
if cid < 0:
raise ValueError("cid must be non-negative")
if not content:
raise ValueError("material content must not be empty")
if len(content) > 10 * 1024 * 1024:
raise ValueError("material content exceeds 10 MB")
safe_name = Path(filename).name.strip()
if not safe_name or safe_name in {".", ".."}:
raise ValueError("filename is required")
kind = _material_kind(material_type)
mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
endpoint = f"upload/{kind}"
payload = self.client.post_multipart(
endpoint,
files={"file": (safe_name, BytesIO(content), mime_type)},
data={"cid": str(cid)},
)
return _normalise_material_reference(payload, endpoint)
def notify_assistant(self, appointment_id: int) -> Any:
"""Ask the server to notify the assigned medical assistant."""
@@ -2,6 +2,9 @@
from __future__ import annotations
import base64
import ctypes
import hashlib
import json
import os
import stat
@@ -28,12 +31,13 @@ _AUTO_KEYRING = object()
class TokenStore:
"""Store access tokens, but never user passwords.
"""Store tokens and optional login passwords with OS-protected storage.
If a working ``keyring`` backend is importable, the token is stored there
and the JSON file contains at most the remembered account name and the
non-secret API scope. Otherwise the JSON file is atomically written with
owner-only ``0600`` permissions.
non-secret API scope. Login passwords use the keyring first; on Windows,
an unavailable keyring falls back to a DPAPI-encrypted blob that only the
current Windows user can decrypt. Plaintext passwords are never written.
"""
def __init__(
@@ -103,7 +107,7 @@ class TokenStore:
"""Persist a token plus optional account and API-scope metadata.
Passing an empty ``account`` explicitly forgets a previously remembered
account. Passwords are never accepted or persisted.
account and its keyring password.
"""
cleaned = token.strip()
@@ -115,6 +119,17 @@ class TokenStore:
data = self._read_file()
if account is not None:
account_value = account.strip()
previous_account = str(data.get("account") or "").strip()
previous_scope = self._normalise_scope(data.get("scope"))
next_scope = (
self._normalise_scope(scope) if scope is not None else previous_scope
)
if previous_account and (
previous_account != account_value
or (account_value and previous_scope != next_scope)
):
self.clear_password(account=previous_account, scope=previous_scope)
data.pop("credential", None)
if account_value:
data["account"] = account_value
else:
@@ -137,7 +152,7 @@ class TokenStore:
self._write_file(data)
def clear_token(self) -> None:
"""Delete the token while retaining an explicitly remembered account."""
"""Delete the token while retaining remembered login credentials."""
if self._uses_keyring and self._keyring is not None:
try:
@@ -146,11 +161,12 @@ class TokenStore:
self._uses_keyring = False
data = self._read_file()
data.pop("token", None)
data.pop("scope", None)
if not data.get("account"):
data.pop("scope", None)
self._write_file(data)
def load_account(self) -> str | None:
"""Load the remembered login account; no password is ever stored."""
"""Load the non-secret remembered login account."""
value = self._read_file().get("account")
return str(value) if isinstance(value, str) and value else None
@@ -167,13 +183,104 @@ class TokenStore:
self._write_file(data)
def clear_account(self) -> None:
"""Forget the remembered account without changing the stored token."""
"""Forget the remembered account and its keyring password."""
data = self._read_file()
self.clear_password(
account=str(data.get("account") or ""),
scope=str(data.get("scope") or ""),
)
self.save_account("")
def load_password(self, *, account: str, scope: str) -> str | None:
"""Load one scoped password from keyring or a Windows DPAPI blob."""
target = self._password_target(account, scope)
if not target:
return None
if self._uses_keyring and self._keyring is not None:
try:
value = self._keyring.get_password(self.service_name, target)
if value:
return str(value)
except Exception:
self._uses_keyring = False
data = self._read_file()
if self._password_target(
str(data.get("account") or ""),
str(data.get("scope") or ""),
) != target:
return None
credential = data.get("credential")
if not isinstance(credential, str) or not credential:
return None
return self._unprotect_password(credential, target)
def save_password(self, password: str, *, account: str, scope: str) -> bool:
"""Save one password using keyring or Windows user-scoped DPAPI."""
target = self._password_target(account, scope)
if not target or not password:
return False
data = self._read_file()
previous_target = self._password_target(
str(data.get("account") or ""),
str(data.get("scope") or ""),
)
if (
previous_target
and previous_target != target
and self._uses_keyring
and self._keyring is not None
):
with suppress(Exception):
self._keyring.delete_password(self.service_name, previous_target)
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.set_password(self.service_name, target, password)
except Exception:
self._uses_keyring = False
else:
data["account"] = account.strip()
data["scope"] = self._normalise_scope(scope)
data.pop("credential", None)
self._write_file(data)
return True
protected = self._protect_password(password, target)
if not protected:
return False
data["account"] = account.strip()
data["scope"] = self._normalise_scope(scope)
data["credential"] = protected
self._write_file(data)
return True
def clear_password(self, *, account: str, scope: str) -> None:
"""Delete one scoped login password from the OS keyring."""
target = self._password_target(account, scope)
if not target:
return
if self._uses_keyring and self._keyring is not None:
with suppress(Exception):
self._keyring.delete_password(self.service_name, target)
data = self._read_file()
stored_target = self._password_target(
str(data.get("account") or ""),
str(data.get("scope") or ""),
)
if stored_target == target and "credential" in data:
data.pop("credential", None)
self._write_file(data)
def clear(self) -> None:
"""Delete both token and remembered account information."""
data = self._read_file()
self.clear_password(
account=str(data.get("account") or ""),
scope=str(data.get("scope") or ""),
)
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.delete_password(self.service_name, self.token_name)
@@ -225,6 +332,120 @@ class TokenStore:
return str(value or "").strip().rstrip("/")
def _password_target(self, account: str, scope: str) -> str:
"""Return an opaque keyring name isolated by account and API server."""
clean_account = account.strip().casefold()
clean_scope = self._normalise_scope(scope).casefold()
if not clean_account or not clean_scope:
return ""
digest = hashlib.sha256(f"{clean_scope}\n{clean_account}".encode()).hexdigest()
return f"login-password:{digest}"
def _protect_password(self, password: str, target: str) -> str | None:
"""Return a Windows DPAPI blob, or ``None`` on unsupported systems."""
if os.name != "nt":
return None
try:
encrypted = self._crypt_protect(
password.encode("utf-8"),
self._credential_entropy(target),
)
except (OSError, ValueError):
return None
return base64.b64encode(encrypted).decode("ascii")
def _unprotect_password(self, protected: str, target: str) -> str | None:
"""Decrypt a Windows DPAPI blob for this account/server target."""
if os.name != "nt":
return None
try:
encrypted = base64.b64decode(protected, validate=True)
plaintext = self._crypt_unprotect(
encrypted,
self._credential_entropy(target),
)
return plaintext.decode("utf-8")
except (OSError, UnicodeError, ValueError):
return None
def _credential_entropy(self, target: str) -> bytes:
return hashlib.sha256(f"{self.service_name}\n{target}".encode()).digest()
@staticmethod
def _crypt_protect(data: bytes, entropy: bytes) -> bytes:
from ctypes import wintypes
class DataBlob(ctypes.Structure):
_fields_ = [
("size", wintypes.DWORD),
("data", ctypes.POINTER(ctypes.c_ubyte)),
]
def make_blob(value: bytes) -> tuple[DataBlob, ctypes.Array[Any]]:
buffer = ctypes.create_string_buffer(value)
blob = DataBlob(
len(value),
ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
)
return blob, buffer
source, _source_buffer = make_blob(data)
optional_entropy, _entropy_buffer = make_blob(entropy)
destination = DataBlob()
if not ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(source),
None,
ctypes.byref(optional_entropy),
None,
None,
0x01,
ctypes.byref(destination),
):
raise ctypes.WinError()
try:
return ctypes.string_at(destination.data, destination.size)
finally:
ctypes.windll.kernel32.LocalFree(destination.data)
@staticmethod
def _crypt_unprotect(data: bytes, entropy: bytes) -> bytes:
from ctypes import wintypes
class DataBlob(ctypes.Structure):
_fields_ = [
("size", wintypes.DWORD),
("data", ctypes.POINTER(ctypes.c_ubyte)),
]
def make_blob(value: bytes) -> tuple[DataBlob, ctypes.Array[Any]]:
buffer = ctypes.create_string_buffer(value)
blob = DataBlob(
len(value),
ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
)
return blob, buffer
source, _source_buffer = make_blob(data)
optional_entropy, _entropy_buffer = make_blob(entropy)
destination = DataBlob()
if not ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(source),
None,
ctypes.byref(optional_entropy),
None,
None,
0x01,
ctypes.byref(destination),
):
raise ctypes.WinError()
try:
return ctypes.string_at(destination.data, destination.size)
finally:
ctypes.windll.kernel32.LocalFree(destination.data)
def _read_file(self) -> dict[str, Any]:
try:
content = self.path.read_text(encoding="utf-8")
@@ -234,10 +455,18 @@ class TokenStore:
if not isinstance(value, dict):
return {}
# Explicit allow-list guarantees accidental password-like fields are ignored.
return {key: value[key] for key in ("token", "account", "scope") if key in value}
return {
key: value[key]
for key in ("token", "account", "scope", "credential")
if key in value
}
def _write_file(self, data: dict[str, Any]) -> None:
safe = {key: data[key] for key in ("token", "account", "scope") if data.get(key)}
safe = {
key: data[key]
for key in ("token", "account", "scope", "credential")
if data.get(key)
}
if not safe:
with suppress(OSError):
self.path.unlink(missing_ok=True)
+74 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QPoint, QSettings, Qt, QTimer, Signal
@@ -70,8 +71,9 @@ class LoginWindow(QMainWindow):
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
``repository`` and ``demo_mode``. Keeping the selected repository in the
payload lets the composition root construct the shell without guessing.
``remember_account`` is captured before the worker starts and is forwarded
to the repository so every account metadata store follows the same choice.
The remember-password choice is captured before the worker starts. The
repository receives the corresponding account-metadata flag, while this
window stores the password only after authentication succeeds.
"""
login_succeeded = Signal(object)
@@ -87,6 +89,7 @@ class LoginWindow(QMainWindow):
config: Any | None = None,
demo_repository: Any | None = None,
settings: QSettings | None = None,
credential_store: Any | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -96,9 +99,12 @@ class LoginWindow(QMainWindow):
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
self.credential_store = credential_store
self.active_repository = repository
self.authenticated_user: Any = None
self._loading = False
self._restored_account = ""
self._restored_scope = ""
self.setWindowTitle("甄养堂 · 医生工作站")
self.setMinimumSize(860, 590)
@@ -214,7 +220,7 @@ class LoginWindow(QMainWindow):
layout.addLayout(brand_row)
layout.addStretch(2)
eyebrow = QLabel("DOCTOR WORKSTATION")
eyebrow = QLabel("医生工作站")
eyebrow.setStyleSheet(
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
)
@@ -300,8 +306,8 @@ class LoginWindow(QMainWindow):
card_layout.addLayout(password_row)
choices = QHBoxLayout()
self.remember_check = _VisibleCheckBox("记住账号")
self.remember_check.setToolTip("仅保存账号,不保存密码")
self.remember_check = _VisibleCheckBox("记住密码")
self.remember_check.setToolTip("密码使用系统安全凭据或 Windows DPAPI 加密,不保存明文")
choices.addWidget(self.remember_check)
choices.addStretch(1)
self.demo_check = _VisibleCheckBox("演示模式")
@@ -394,8 +400,6 @@ class LoginWindow(QMainWindow):
def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "")
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
self.account_edit.setText(remembered)
self.remember_check.setChecked(bool(remembered))
configured_url = getattr(self.config, "base_url", "") or getattr(
self.config, "api_base_url", ""
)
@@ -418,11 +422,49 @@ class LoginWindow(QMainWindow):
self.allow_self_signed_check.setChecked(not verify_ssl)
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
self.demo_check.setChecked(True)
self.account_edit.setText(remembered)
self.restore_remembered_credentials()
if remembered:
self.password_edit.setFocus()
else:
self.account_edit.setFocus()
def _credential_scope(self) -> str:
if hasattr(self, "server_url_edit"):
scope = self.server_url_edit.text().strip()
if scope:
return scope.rstrip("/")
return str(
getattr(self.config, "base_url", "")
or getattr(self.config, "api_base_url", "")
or ""
).strip().rstrip("/")
def restore_remembered_credentials(self) -> None:
"""Restore a password from the OS keyring without touching config files."""
configured_account = getattr(self.config, "remembered_account", "")
account = str(
self.settings.value("auth/remembered_account", configured_account) or ""
).strip()
scope = self._credential_scope()
password = ""
should_restore = _setting_bool(
self.settings.value("auth/remember_password", False),
False,
)
loader = getattr(self.credential_store, "load_password", None)
if should_restore and account and scope and callable(loader):
try:
password = str(loader(account=account, scope=scope) or "")
except Exception:
password = ""
self.account_edit.setText(account)
self.password_edit.setText(password)
self.remember_check.setChecked(bool(password))
self._restored_account = account if password else ""
self._restored_scope = scope if password else ""
def _toggle_password(self, visible: bool) -> None:
self.password_edit.setEchoMode(
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
@@ -557,6 +599,7 @@ class LoginWindow(QMainWindow):
payload,
account,
remember_account,
password,
),
on_error=self._on_login_error,
on_finished=lambda: self._set_loading(False),
@@ -604,15 +647,39 @@ class LoginWindow(QMainWindow):
payload: dict[str, Any],
account: str,
remember_account: bool | None = None,
password: str | None = None,
) -> None:
if remember_account is None:
remember_account = self.remember_check.isChecked()
scope = self._credential_scope()
is_demo = bool(payload.get("demo_mode"))
password_saved = False
clearer = getattr(self.credential_store, "clear_password", None)
if callable(clearer) and self._restored_account and (
not remember_account
or self._restored_account != account
or self._restored_scope != scope
):
with suppress(Exception):
clearer(account=self._restored_account, scope=self._restored_scope)
if remember_account:
self.settings.setValue("auth/remembered_account", account)
saver = getattr(self.credential_store, "save_password", None)
if not is_demo and password and scope and callable(saver):
try:
password_saved = bool(saver(password, account=account, scope=scope))
except Exception:
password_saved = False
else:
self.settings.remove("auth/remembered_account")
if callable(clearer):
with suppress(Exception):
clearer(account=account, scope=scope)
self.settings.setValue("auth/remember_password", password_saved)
self.settings.sync()
self._emit_config_update(remembered_account=account if remember_account else "")
self._restored_account = account if password_saved else ""
self._restored_scope = scope if password_saved else ""
self.password_edit.clear()
self.authenticated_user = payload.get("user")
self.login_succeeded.emit(payload)
@@ -843,6 +843,7 @@ class AppointmentsPage(QWidget):
"patient_id": patient_id,
"diagnosis_id": diagnosis_id,
"patient_name": first_value(row, "patient_name", default="患者"),
"mode": "im",
"record": row,
}
)
@@ -8,9 +8,10 @@ from datetime import date, timedelta
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QTextCursor
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap, QTextCursor
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QFrame,
QGridLayout,
@@ -22,6 +23,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QStackedWidget,
QTabBar,
@@ -118,6 +120,13 @@ def _attachment_name(value: object) -> str:
return name or text
def _is_image_attachment(value: object) -> bool:
"""Return whether a server attachment can be previewed as an image."""
path = str(value or "").split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"))
def _is_local_material_reference(value: str) -> bool:
"""Reject local filesystem references before a note JSON is constructed."""
@@ -139,12 +148,14 @@ class QueueRow(QWidget):
layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(5)
top = QHBoxLayout()
top.setSpacing(8)
name = QLabel(
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
)
name.setStyleSheet("font-size:14px; font-weight:700; color:#172033;")
top.addWidget(name)
top.addStretch(1)
name.setMinimumWidth(0)
name.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
top.addWidget(name, 1)
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
badge = StatusBadge(
display_text(
@@ -157,7 +168,12 @@ class QueueRow(QWidget):
),
STATUS_KIND.get(status_number, "neutral"),
)
top.addWidget(badge)
badge.setMinimumWidth(max(54, badge.sizeHint().width()))
top.addWidget(
badge,
0,
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop,
)
layout.addLayout(top)
time = first_value(
record, "appointment_time_text", "appointment_time", "time", default="时间待确认"
@@ -168,6 +184,10 @@ class QueueRow(QWidget):
f"{display_text(first_value(record, 'age'))}"
)
meta.setProperty("role", "muted")
# Long timestamps must not dictate the minimum width of the whole
# queue row and push the status badge underneath the viewport edge.
meta.setMinimumWidth(0)
meta.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout.addWidget(meta)
assistant = first_value(record, "assistant_name", default=None)
if assistant:
@@ -211,6 +231,7 @@ class ReceptionPage(QWidget):
self._pending_tongue_images: list[str] = []
self._pending_report_files: list[str] = []
self._note_busy = False
self._attachment_preview_generation = 0
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
@@ -335,7 +356,8 @@ class ReceptionPage(QWidget):
self.notify_button = QPushButton("通知医助")
self.notify_button.clicked.connect(self._notify_assistant)
action_row.addWidget(self.notify_button)
self.video_button = QPushButton("发起视频")
self.video_button = QPushButton("IM 问诊")
self.video_button.setToolTip("打开患者 IM,可发送消息并从会话中发起视频")
self.video_button.setProperty("variant", "secondary")
self.video_button.clicked.connect(self._request_video)
action_row.addWidget(self.video_button)
@@ -1245,13 +1267,25 @@ class ReceptionPage(QWidget):
("report_files", "检查报告"),
):
for path in _sequence(first_value(note, image_type, default=[])):
path_text = str(path).strip()
attachment = QWidget()
attachment_layout = QHBoxLayout(attachment)
attachment_layout.setContentsMargins(0, 0, 0, 0)
label = QLabel(f"{caption}{_attachment_name(path)}")
label.setToolTip(str(path))
label = QLabel(f"{caption}{_attachment_name(path_text)}")
label.setToolTip(path_text)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
attachment_layout.addWidget(label, 1)
preview_button = QPushButton(
"预览" if _is_image_attachment(path_text) else "打开"
)
preview_button.setProperty("variant", "secondary")
preview_button.setEnabled(bool(path_text))
preview_button.clicked.connect(
lambda _checked=False, current_path=path_text, button=preview_button: (
self._preview_note_attachment(current_path, button)
)
)
attachment_layout.addWidget(preview_button)
if self._can_note and note_id is not None:
delete_button = QPushButton("删除")
delete_button.setProperty("variant", "secondary")
@@ -1266,6 +1300,97 @@ class ReceptionPage(QWidget):
layout.addWidget(attachment)
self.notes_layout.addWidget(card)
def _preview_note_attachment(self, path: str, button: QPushButton) -> None:
"""Preview note images in-app and open non-image reports safely."""
target = str(path or "").strip()
if not target:
return
if not _is_image_attachment(target):
url = QUrl(target)
if (
not url.isValid()
or url.scheme().lower() not in {"https", "http"}
or not url.host()
):
show_toast(self, "附件地址无效,无法打开。", "warning", 4200)
return
if not QDesktopServices.openUrl(url):
show_toast(self, "系统未能打开该附件。", "danger", 4200)
return
download = getattr(self.repository, "download_public_image", None)
if not callable(download):
show_toast(self, "当前数据源不支持图片预览。", "warning", 4200)
return
self._attachment_preview_generation += 1
generation = self._attachment_preview_generation
button.setEnabled(False)
button.setText("加载中…")
run_async(
lambda: invoke(self.repository, "download_public_image", url=target),
on_success=lambda payload: self._show_note_image_preview(
target, payload, button, generation
),
on_error=lambda error: self._note_image_preview_failed(
error, button, generation
),
)
def _show_note_image_preview(
self,
path: str,
payload: Any,
button: QPushButton,
generation: int,
) -> None:
if generation != self._attachment_preview_generation:
return
button.setEnabled(True)
button.setText("预览")
content = bytes(payload or b"")
pixmap = QPixmap()
if not content or len(content) > 10 * 1024 * 1024 or not pixmap.loadFromData(content):
show_toast(self, "服务器返回的图片无法预览。", "danger", 4600)
return
dialog = QDialog(self)
dialog.setWindowTitle(f"预览 · {_attachment_name(path)}")
dialog.setModal(True)
dialog.resize(920, 680)
layout = QVBoxLayout(dialog)
layout.setContentsMargins(14, 14, 14, 14)
scroll = QScrollArea(dialog)
scroll.setWidgetResizable(True)
image = QLabel()
image.setAlignment(Qt.AlignmentFlag.AlignCenter)
image.setPixmap(
pixmap.scaled(
880,
620,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
scroll.setWidget(image)
layout.addWidget(scroll, 1)
close_button = QPushButton("关闭")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(dialog.accept)
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
dialog.exec()
def _note_image_preview_failed(
self,
error: Exception,
button: QPushButton,
generation: int,
) -> None:
if generation != self._attachment_preview_generation:
return
button.setEnabled(True)
button.setText("预览")
show_toast(self, f"图片预览失败:{friendly_error(error)}", "danger", 5200)
def _reset_detail_content(self, seed: Any = None) -> None:
self.patient_name_label.setText(
display_text(first_value(seed, "patient_name", "name", default=""))
@@ -1753,6 +1878,7 @@ class ReceptionPage(QWidget):
"patient_name",
default=first_value(diagnosis, "patient_name", default="患者"),
),
"mode": "im",
"record": self._selected_record,
}
self.video_requested.emit(payload)
+50 -2
View File
@@ -25,7 +25,13 @@ from PySide6.QtWidgets import (
QWidget,
)
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.core.errors import (
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
@@ -331,7 +337,45 @@ def friendly_error(error: Any) -> str:
"服务器证书不受系统信任。若这是可信内网的自签名服务器,请展开“服务器设置”,"
"勾选“信任自签名证书(仅内网调试)”后重新登录,设置会自动应用。"
)
return text or "操作未完成,请稍后重试。"
if isinstance(error, AuthenticationExpiredError):
return "登录状态已失效,请重新登录。"
if isinstance(error, ApiTimeoutError) or "timed out" in lowered or "timeout" in lowered:
return "连接服务器超时,请检查网络后重试。"
if isinstance(error, ApiProtocolError) or any(
marker in lowered
for marker in (
"api response envelope",
"api response is not valid json",
"invalid json",
)
):
return "服务器返回的数据格式不正确,请联系管理员检查接口。"
if isinstance(error, ApiHttpError):
status_code = getattr(error, "status_code", None)
suffix = f"(状态码 {status_code}" if status_code else ""
return f"服务器请求失败{suffix},请稍后重试。"
if isinstance(error, ApiTransportError) or any(
marker in lowered
for marker in (
"connection refused",
"connecterror",
"connection error",
"failed to connect",
"getaddrinfo failed",
"name or service not known",
"network is unreachable",
)
):
return "无法连接服务器,请检查服务器地址与网络。"
if any(marker in lowered for marker in ("unauthorized", "forbidden", "permission denied")):
return "当前账号无权执行此操作。"
if "not found" in lowered:
return "未找到所需数据。"
if any("\u4e00" <= character <= "\u9fff" for character in text):
return text
if isinstance(error, TypeError):
return "程序执行失败,请重试;若问题持续出现,请联系管理员。"
return "操作未完成,请稍后重试。"
class PageHeader(QWidget):
@@ -379,6 +423,10 @@ class StatusBadge(QLabel):
self.setObjectName("StatusBadge")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
# QSS padding alone is not a reliable minimum on every Windows DPI
# scale. Keep enough physical row height so badge text is never
# squeezed into the thin coloured strip seen in list item widgets.
self.setMinimumHeight(24)
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
@@ -359,6 +359,8 @@ class VideoCallLauncher:
*,
diagnosis_id: Any = None,
patient_id: Any = None,
open_im: bool = False,
patient_name: str = "患者",
) -> Any:
request = self.prepare(
ticket,
@@ -374,6 +376,8 @@ class VideoCallLauncher:
remote_url=self.remote_url,
logger=self.logger,
browser_opener=self.browser_opener,
open_im=open_im,
patient_name=patient_name,
)
@@ -388,6 +392,8 @@ def launch_video_call(
remote_url: str | None = None,
logger: Any = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -402,4 +408,6 @@ def launch_video_call(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
open_im=open_im,
patient_name=patient_name,
)
@@ -262,6 +262,53 @@ class OrderedCallLifecycle:
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
if not content:
raise ValueError("screenshot content must not be empty")
if len(content) > 10 * 1024 * 1024:
raise ValueError("screenshot content exceeds 10 MB")
clean_name = str(filename or "callshot.jpg").strip() or "callshot.jpg"
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
upload = getattr(self.repository, "upload_material_bytes", None)
add_note = getattr(self.repository, "add_doctor_note", None)
if not callable(upload) or not callable(add_note):
raise ValueError("video repository does not implement screenshot storage")
def operation() -> str:
reference = str(
_call_repository_method(
upload,
{
"content": content,
"filename": clean_name,
"material_type": "image",
"cid": 0,
},
)
or ""
).strip()
if not reference:
raise ValueError("screenshot upload returned no server reference")
_call_repository_method(
add_note,
{
"diagnosis_id": self.request.diagnosis_id,
"content": "",
"tongue_images": [reference],
},
)
self.logger.info(
"video screenshot stored in doctor notes",
extra={"video_call": self.request.safe_log_context()},
)
return reference
return self._worker.submit("screenshot", operation)
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
+169 -35
View File
@@ -7,6 +7,8 @@ actual call requires an isolated QtWebEngine profile and an active QApplication.
from __future__ import annotations
import base64
import binascii
import json
import logging
import sys
@@ -181,11 +183,33 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
@Slot(str) # type: ignore[misc]
def saveScreenshot(self, data_url: str) -> None: # noqa: N802 - Qt bridge API
"""Forward one bounded JPEG data URL to the trusted desktop host."""
if not isinstance(data_url, str) or len(data_url) > 14 * 1024 * 1024:
self._callback(
{
"source": "doctor-call",
"event": "screenshot-invalid",
"message": "截屏图片过大,无法保存。",
}
)
return
self._callback(
{
"source": "doctor-call",
"event": "screenshot",
"dataUrl": data_url,
}
)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
def __init__(
self,
@@ -194,12 +218,19 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
lifecycle_factory: Callable[[], OrderedCallLifecycle],
open_im: bool = False,
patient_name: str = "患者",
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self._lifecycle_factory = lifecycle_factory
self._lifecycles = [lifecycle]
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -213,10 +244,14 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self.setWindowTitle("视频面诊")
self.setWindowTitle(
f"{self.patient_name} IM 问诊" if self.open_im else "视频面诊"
)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
@@ -248,6 +283,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._screenshot_completed.connect(self._on_screenshot_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -326,17 +362,24 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
return
try:
start_future = self.lifecycle.start()
except Exception:
self.logger.error(
"video call record could not be queued",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "record-start-queue-failed"
self.close()
return
start_future.add_done_callback(self._notify_start_completed)
self._media_active = True
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"mode": "chat" if self.open_im else "video",
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
script = f"""
(() => {{
if (!window.doctorConsultation
|| typeof window.doctorConsultation.open !== 'function') {{
return false;
}}
void window.doctorConsultation.open({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
@@ -350,25 +393,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
if not succeeded:
self._close_reason = "record-start-failed"
self.close()
self._start_requested = False
self._call_cycle_closed = True
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(false, "
'"通话记录创建失败,请稍后重试。");'
)
return
self._media_active = True
config_json = json.dumps(
self.request.to_web_config(),
ensure_ascii=True,
separators=(",", ":"),
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(true, '');"
)
script = f"""
(() => {{
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
return false;
}}
void window.doctorCall.start({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
@@ -380,6 +414,18 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
event = str(message.get("event", ""))
if event == "call-start-request":
self._start_call_cycle()
return
if event == "screenshot":
self._save_screenshot(str(message.get("dataUrl") or ""))
return
if event == "screenshot-invalid":
self._on_screenshot_completed(
False,
str(message.get("message") or "截屏图片无效。")[:200],
)
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
@@ -388,16 +434,80 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle":
if status == "idle" and not self.open_im:
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self._close_from_companion("companion-hangup")
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
self._close_from_companion("companion-error")
if self._start_requested:
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
self._close_from_companion("companion-error")
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
if self._call_cycle_closed:
self.lifecycle = self._lifecycle_factory()
self._lifecycles.append(self.lifecycle)
self._call_cycle_closed = False
self._start_requested = True
try:
future = self.lifecycle.start()
except Exception:
self._start_requested = False
self._on_lifecycle_started(False)
return
future.add_done_callback(self._notify_start_completed)
def _save_screenshot(self, data_url: str) -> None:
prefix = "data:image/jpeg;base64,"
if not data_url.startswith(prefix):
self._on_screenshot_completed(False, "截屏图片格式不正确。")
return
try:
content = base64.b64decode(data_url[len(prefix) :], validate=True)
except (ValueError, binascii.Error):
self._on_screenshot_completed(False, "截屏图片解析失败。")
return
try:
future = self.lifecycle.save_screenshot(
content,
f"callshot-{self.request.diagnosis_id}.jpg",
)
except Exception as error:
self._on_screenshot_completed(False, str(error)[:200])
return
future.add_done_callback(self._notify_screenshot_completed)
def _notify_screenshot_completed(self, future: Future[str]) -> None:
try:
future.result()
except Exception as error:
succeeded = False
message = str(error)[:200] or "截屏保存失败。"
else:
succeeded = True
message = "截屏已保存到患者信息。"
with suppress(RuntimeError):
self._screenshot_completed.emit(succeeded, message)
def _on_screenshot_completed(self, succeeded: bool, message: str) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
)
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
@@ -415,11 +525,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorCall?.hangup?.().catch(() => undefined)"
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self.lifecycle.end(self._close_reason)
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
def _release_webengine(self) -> None:
if self._released:
return
@@ -486,6 +600,8 @@ class VideoCallWindow:
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -500,14 +616,23 @@ class VideoCallWindow:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.repository = repository
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._lifecycles = [self.lifecycle]
self._session: Any = None
def _new_lifecycle(self) -> OrderedCallLifecycle:
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
self._lifecycles.append(lifecycle)
return lifecycle
@property
def qt_window(self) -> Any:
return self._session
@@ -519,6 +644,9 @@ class VideoCallWindow:
self.location,
self.lifecycle,
logger=self.logger,
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -545,7 +673,9 @@ class VideoCallWindow:
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
return self.lifecycle.wait(timeout)
if self._session is not None:
return self._session.wait_for_lifecycles(timeout)
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
wait = wait_for_lifecycle
@@ -558,6 +688,8 @@ def open_video_call(
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -570,6 +702,8 @@ def open_video_call(
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
).open()