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