Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de990a921b |
@@ -40,7 +40,7 @@ from doctor_workstation.ui.widgets import (
|
|||||||
set_authentication_expired_handler,
|
set_authentication_expired_handler,
|
||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
from doctor_workstation.video import BackendMode, launch_video_call
|
from doctor_workstation.video import BackendMode, launch_video_call, launch_video_watch
|
||||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
@@ -66,10 +66,17 @@ class _UnconfiguredRepository:
|
|||||||
class DemoVideoDialog(QDialog):
|
class DemoVideoDialog(QDialog):
|
||||||
"""Non-network video-room preview used only by the explicit demo mode."""
|
"""Non-network video-room preview used only by the explicit demo mode."""
|
||||||
|
|
||||||
def __init__(self, patient_name: str, parent: QWidget | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
patient_name: str,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
*,
|
||||||
|
watch_only: bool = False,
|
||||||
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._seconds = 0
|
self._seconds = 0
|
||||||
self.setWindowTitle("视频面诊 · 演示模式")
|
self.watch_only = watch_only
|
||||||
|
self.setWindowTitle("旁观视频通话 · 演示模式" if watch_only else "视频面诊 · 演示模式")
|
||||||
self.setMinimumSize(760, 520)
|
self.setMinimumSize(760, 520)
|
||||||
self.resize(980, 660)
|
self.resize(980, 660)
|
||||||
self.setModal(False)
|
self.setModal(False)
|
||||||
@@ -88,7 +95,11 @@ class DemoVideoDialog(QDialog):
|
|||||||
root.setContentsMargins(22, 18, 22, 22)
|
root.setContentsMargins(22, 18, 22, 22)
|
||||||
root.setSpacing(14)
|
root.setSpacing(14)
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel(f"与 {patient_name or '患者'} 的视频面诊")
|
title = QLabel(
|
||||||
|
f"旁观 {patient_name or '患者'} 的视频通话"
|
||||||
|
if watch_only
|
||||||
|
else f"与 {patient_name or '患者'} 的视频面诊"
|
||||||
|
)
|
||||||
title.setStyleSheet("font-size:18px;font-weight:700;")
|
title.setStyleSheet("font-size:18px;font-weight:700;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
@@ -112,16 +123,21 @@ class DemoVideoDialog(QDialog):
|
|||||||
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
|
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
|
||||||
)
|
)
|
||||||
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
|
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||||
waiting = QLabel("等待患者接听…")
|
waiting = QLabel("旁观演示画面" if watch_only else "等待患者接听…")
|
||||||
waiting.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
waiting.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
waiting.setStyleSheet("font-size:17px;font-weight:600;")
|
waiting.setStyleSheet("font-size:17px;font-weight:600;")
|
||||||
stage_layout.addWidget(waiting)
|
stage_layout.addWidget(waiting)
|
||||||
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
|
hint = QLabel(
|
||||||
|
"仅观看,不会开启摄像头与麦克风"
|
||||||
|
if watch_only
|
||||||
|
else "生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit"
|
||||||
|
)
|
||||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
hint.setStyleSheet("color:#80948D;font-size:12px;")
|
hint.setStyleSheet("color:#80948D;font-size:12px;")
|
||||||
stage_layout.addWidget(hint)
|
stage_layout.addWidget(hint)
|
||||||
stage_layout.addStretch(1)
|
stage_layout.addStretch(1)
|
||||||
|
|
||||||
|
if not watch_only:
|
||||||
local = QFrame(stage)
|
local = QFrame(stage)
|
||||||
local.setObjectName("LocalStage")
|
local.setObjectName("LocalStage")
|
||||||
local.setGeometry(24, 24, 178, 112)
|
local.setGeometry(24, 24, 178, 112)
|
||||||
@@ -134,6 +150,7 @@ class DemoVideoDialog(QDialog):
|
|||||||
|
|
||||||
controls = QHBoxLayout()
|
controls = QHBoxLayout()
|
||||||
controls.addStretch(1)
|
controls.addStretch(1)
|
||||||
|
if not watch_only:
|
||||||
self.mic_button = QPushButton("麦克风 开")
|
self.mic_button = QPushButton("麦克风 开")
|
||||||
self.mic_button.setCheckable(True)
|
self.mic_button.setCheckable(True)
|
||||||
self.mic_button.toggled.connect(
|
self.mic_button.toggled.connect(
|
||||||
@@ -146,7 +163,7 @@ class DemoVideoDialog(QDialog):
|
|||||||
lambda off: self.camera_button.setText("摄像头 关" if off else "摄像头 开")
|
lambda off: self.camera_button.setText("摄像头 关" if off else "摄像头 开")
|
||||||
)
|
)
|
||||||
controls.addWidget(self.camera_button)
|
controls.addWidget(self.camera_button)
|
||||||
hangup = QPushButton("结束面诊")
|
hangup = QPushButton("离开旁观" if watch_only else "结束面诊")
|
||||||
hangup.setObjectName("Hangup")
|
hangup.setObjectName("Hangup")
|
||||||
hangup.clicked.connect(self.close)
|
hangup.clicked.connect(self.close)
|
||||||
controls.addWidget(hangup)
|
controls.addWidget(hangup)
|
||||||
@@ -179,6 +196,8 @@ class ApplicationController(QObject):
|
|||||||
self.current_demo_mode = config.demo_mode
|
self.current_demo_mode = config.demo_mode
|
||||||
self.video_calls: dict[str, Any] = {}
|
self.video_calls: dict[str, Any] = {}
|
||||||
self.video_pending: dict[str, object] = {}
|
self.video_pending: dict[str, object] = {}
|
||||||
|
self.video_watches: dict[str, Any] = {}
|
||||||
|
self.watch_pending: dict[str, object] = {}
|
||||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||||
self._restore_generation = 0
|
self._restore_generation = 0
|
||||||
self._restore_in_progress = False
|
self._restore_in_progress = False
|
||||||
@@ -441,6 +460,7 @@ class ApplicationController(QObject):
|
|||||||
self.shell_window = ShellWindow(repository, payload, session.permissions)
|
self.shell_window = ShellWindow(repository, payload, session.permissions)
|
||||||
self.shell_window.logout_requested.connect(self._logout)
|
self.shell_window.logout_requested.connect(self._logout)
|
||||||
self.shell_window.video_requested.connect(self._request_video)
|
self.shell_window.video_requested.connect(self._request_video)
|
||||||
|
self.shell_window.watch_requested.connect(self._request_watch)
|
||||||
self._apply_window_icon(self.shell_window)
|
self._apply_window_icon(self.shell_window)
|
||||||
if self.login_window is not None:
|
if self.login_window is not None:
|
||||||
self.login_window.hide()
|
self.login_window.hide()
|
||||||
@@ -478,6 +498,11 @@ class ApplicationController(QObject):
|
|||||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||||
self.video_calls.clear()
|
self.video_calls.clear()
|
||||||
self.video_pending.clear()
|
self.video_pending.clear()
|
||||||
|
for watch in self.video_watches.values():
|
||||||
|
with suppress(Exception):
|
||||||
|
watch.close()
|
||||||
|
self.video_watches.clear()
|
||||||
|
self.watch_pending.clear()
|
||||||
for dialog in self.demo_video_dialogs.values():
|
for dialog in self.demo_video_dialogs.values():
|
||||||
dialog.close()
|
dialog.close()
|
||||||
self.demo_video_dialogs.clear()
|
self.demo_video_dialogs.clear()
|
||||||
@@ -508,7 +533,10 @@ class ApplicationController(QObject):
|
|||||||
if (
|
if (
|
||||||
call_key in self.video_pending
|
call_key in self.video_pending
|
||||||
or call_key in self.video_calls
|
or call_key in self.video_calls
|
||||||
|
or call_key in self.watch_pending
|
||||||
|
or call_key in self.video_watches
|
||||||
or call_key in self.demo_video_dialogs
|
or call_key in self.demo_video_dialogs
|
||||||
|
or f"watch:{call_key}" in self.demo_video_dialogs
|
||||||
):
|
):
|
||||||
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
||||||
return
|
return
|
||||||
@@ -630,6 +658,130 @@ class ApplicationController(QObject):
|
|||||||
if self.video_calls.get(call_key) is call:
|
if self.video_calls.get(call_key) is call:
|
||||||
self.video_calls.pop(call_key, None)
|
self.video_calls.pop(call_key, None)
|
||||||
|
|
||||||
|
def _request_watch(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Fetch a server-authorized receive-only room ticket for one diagnosis."""
|
||||||
|
|
||||||
|
parent = self.shell_window
|
||||||
|
repository = self.current_repository
|
||||||
|
if parent is None or repository is None:
|
||||||
|
return
|
||||||
|
diagnosis_id = payload.get("diagnosis_id")
|
||||||
|
patient_name = str(payload.get("patient_name") or "患者")
|
||||||
|
try:
|
||||||
|
diagnosis_value = int(diagnosis_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
diagnosis_value = 0
|
||||||
|
if diagnosis_value <= 0:
|
||||||
|
show_toast(parent, "诊单信息不完整,无法进入旁观。", "danger", 4200)
|
||||||
|
return
|
||||||
|
watch_key = str(diagnosis_value)
|
||||||
|
if (
|
||||||
|
watch_key in self.watch_pending
|
||||||
|
or watch_key in self.video_watches
|
||||||
|
or watch_key in self.video_pending
|
||||||
|
or watch_key in self.video_calls
|
||||||
|
or f"watch:{watch_key}" in self.demo_video_dialogs
|
||||||
|
):
|
||||||
|
show_toast(parent, "该诊单的视频会话正在准备或进行中。", "info", 3600)
|
||||||
|
return
|
||||||
|
if self.current_demo_mode:
|
||||||
|
demo_key = f"watch:{watch_key}"
|
||||||
|
dialog = DemoVideoDialog(patient_name, parent, watch_only=True)
|
||||||
|
dialog.finished.connect(
|
||||||
|
lambda _result, key=demo_key, item=dialog: self._forget_demo_dialog(key, item)
|
||||||
|
)
|
||||||
|
self.demo_video_dialogs[demo_key] = dialog
|
||||||
|
dialog.show()
|
||||||
|
return
|
||||||
|
|
||||||
|
show_toast(parent, "正在获取旁观房间凭证…", "info")
|
||||||
|
marker = object()
|
||||||
|
self.watch_pending[watch_key] = marker
|
||||||
|
run_async(
|
||||||
|
lambda: repository.get_assistant_watch_ticket(diagnosis_value),
|
||||||
|
on_success=lambda ticket: self._launch_watch(
|
||||||
|
ticket,
|
||||||
|
diagnosis_id=diagnosis_value,
|
||||||
|
repository=repository,
|
||||||
|
watch_key=watch_key,
|
||||||
|
marker=marker,
|
||||||
|
),
|
||||||
|
on_error=lambda error: self._watch_ticket_error(
|
||||||
|
watch_key,
|
||||||
|
marker,
|
||||||
|
parent,
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _watch_ticket_error(
|
||||||
|
self,
|
||||||
|
watch_key: str,
|
||||||
|
marker: object,
|
||||||
|
parent: QWidget,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
if self.watch_pending.get(watch_key) is not marker:
|
||||||
|
return
|
||||||
|
self.watch_pending.pop(watch_key, None)
|
||||||
|
if self.shell_window is parent:
|
||||||
|
show_toast(parent, f"旁观准备失败:{friendly_error(error)}", "danger", 5200)
|
||||||
|
|
||||||
|
def _launch_watch(
|
||||||
|
self,
|
||||||
|
ticket: Any,
|
||||||
|
*,
|
||||||
|
diagnosis_id: int,
|
||||||
|
repository: Any,
|
||||||
|
watch_key: str,
|
||||||
|
marker: object,
|
||||||
|
) -> None:
|
||||||
|
if self.watch_pending.get(watch_key) is not marker:
|
||||||
|
return
|
||||||
|
self.watch_pending.pop(watch_key, None)
|
||||||
|
if (
|
||||||
|
self.shell_window is None
|
||||||
|
or self.current_repository is not repository
|
||||||
|
or watch_key in self.video_watches
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mode = BackendMode.parse(self.config.video_mode)
|
||||||
|
if mode is BackendMode.BROWSER:
|
||||||
|
raise ValueError("浏览器旁观模式未启用,请使用嵌入式视频组件。")
|
||||||
|
if not WEBENGINE_AVAILABLE:
|
||||||
|
raise ValueError("当前安装缺少 QtWebEngine,无法打开旁观窗口。")
|
||||||
|
watch = launch_video_watch(
|
||||||
|
ticket,
|
||||||
|
diagnosis_id=diagnosis_id,
|
||||||
|
backend_mode=mode,
|
||||||
|
local_dist=video_dist_path(),
|
||||||
|
remote_url=self.config.video_web_url or None,
|
||||||
|
logger=logging.getLogger("doctor_workstation.video.watch"),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
LOGGER.exception("assistant watch could not be launched")
|
||||||
|
show_toast(
|
||||||
|
self.shell_window,
|
||||||
|
f"旁观启动失败:{friendly_error(error)}",
|
||||||
|
"danger",
|
||||||
|
5600,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.video_watches[watch_key] = watch
|
||||||
|
qt_window = getattr(watch, "qt_window", None)
|
||||||
|
if qt_window is not None:
|
||||||
|
qt_window.destroyed.connect(
|
||||||
|
lambda _obj=None, key=watch_key, expected=watch: self._release_video_watch(
|
||||||
|
key,
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _release_video_watch(self, watch_key: str, watch: Any) -> None:
|
||||||
|
if self.video_watches.get(watch_key) is watch:
|
||||||
|
self.video_watches.pop(watch_key, None)
|
||||||
|
|
||||||
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
||||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||||
self.demo_video_dialogs.pop(call_key, None)
|
self.demo_video_dialogs.pop(call_key, None)
|
||||||
@@ -672,6 +824,11 @@ class ApplicationController(QObject):
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
call.close()
|
call.close()
|
||||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||||
|
for watch in tuple(self.video_watches.values()):
|
||||||
|
with suppress(Exception):
|
||||||
|
watch.close()
|
||||||
|
self.video_watches.clear()
|
||||||
|
self.watch_pending.clear()
|
||||||
if self.remote_repository is not None:
|
if self.remote_repository is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self.remote_repository.client.close()
|
self.remote_repository.client.close()
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ class Consultation:
|
|||||||
has_prescription: bool = False
|
has_prescription: bool = False
|
||||||
unserved_days: int | None = None
|
unserved_days: int | None = None
|
||||||
video_hint: str = ""
|
video_hint: str = ""
|
||||||
|
video_call_hint: JSONDict = field(default_factory=dict)
|
||||||
source: int | str | None = None
|
source: int | str | None = None
|
||||||
source_text: str = ""
|
source_text: str = ""
|
||||||
remark: str = ""
|
remark: str = ""
|
||||||
@@ -429,6 +430,12 @@ class Consultation:
|
|||||||
appointment_status_value = source.get("appointment_status")
|
appointment_status_value = source.get("appointment_status")
|
||||||
if appointment_status_value in (None, ""):
|
if appointment_status_value in (None, ""):
|
||||||
appointment_status_value = appointment.get("status")
|
appointment_status_value = appointment.get("status")
|
||||||
|
gender_value = source.get("gender", source.get("gender_desc"))
|
||||||
|
gender_description = _text(source.get("gender_desc"))
|
||||||
|
if not gender_description:
|
||||||
|
gender_number = _integer(gender_value, None)
|
||||||
|
gender_description = {0: "女", 1: "男"}.get(gender_number, "")
|
||||||
|
video_call_hint = _mapping(source.get("video_call_hint"))
|
||||||
return cls(
|
return cls(
|
||||||
id=_integer(source.get("id", source.get("diagnosis_id")), 0) or 0,
|
id=_integer(source.get("id", source.get("diagnosis_id")), 0) or 0,
|
||||||
patient_id=_integer(source.get("patient_id", source.get("source_patient_id")), None),
|
patient_id=_integer(source.get("patient_id", source.get("source_patient_id")), None),
|
||||||
@@ -437,8 +444,8 @@ class Consultation:
|
|||||||
patient_phone=_text(source.get("patient_phone", source.get("phone"))),
|
patient_phone=_text(source.get("patient_phone", source.get("phone"))),
|
||||||
phone_masked=_text(source.get("phone_masked")),
|
phone_masked=_text(source.get("phone_masked")),
|
||||||
id_card=_text(source.get("id_card")),
|
id_card=_text(source.get("id_card")),
|
||||||
gender=source.get("gender", source.get("gender_desc")),
|
gender=gender_value,
|
||||||
gender_desc=_text(source.get("gender_desc")),
|
gender_desc=gender_description,
|
||||||
age=_integer(source.get("age"), None),
|
age=_integer(source.get("age"), None),
|
||||||
doctor_id=_integer(source.get("doctor_id", appointment.get("doctor_id")), None),
|
doctor_id=_integer(source.get("doctor_id", appointment.get("doctor_id")), None),
|
||||||
doctor_name=_text(source.get("doctor_name", appointment.get("doctor_name"))),
|
doctor_name=_text(source.get("doctor_name", appointment.get("doctor_name"))),
|
||||||
@@ -504,7 +511,13 @@ class Consultation:
|
|||||||
unserved_days=_integer(
|
unserved_days=_integer(
|
||||||
source.get("unserved_days", source.get("unserved_day_count")), None
|
source.get("unserved_days", source.get("unserved_day_count")), None
|
||||||
),
|
),
|
||||||
video_hint=_text(source.get("video_hint", source.get("call_hint"))),
|
video_hint=_text(
|
||||||
|
source.get(
|
||||||
|
"video_hint",
|
||||||
|
source.get("call_hint", video_call_hint.get("label")),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
video_call_hint=dict(video_call_hint),
|
||||||
source=source.get("source", source.get("diagnosis_source")),
|
source=source.get("source", source.get("diagnosis_source")),
|
||||||
source_text=_text(source.get("source_text", source.get("source_name"))),
|
source_text=_text(source.get("source_text", source.get("source_name"))),
|
||||||
remark=_text(source.get("remark")),
|
remark=_text(source.get("remark")),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .repository import (
|
|||||||
_audit_action,
|
_audit_action,
|
||||||
_body,
|
_body,
|
||||||
_daily_record_body,
|
_daily_record_body,
|
||||||
|
_diagnosis_create_body,
|
||||||
_identified_body,
|
_identified_body,
|
||||||
_material_kind,
|
_material_kind,
|
||||||
_prescription_payload,
|
_prescription_payload,
|
||||||
@@ -62,6 +63,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
|||||||
"tcm.diagnosis/setRevisitSlotStartOffset",
|
"tcm.diagnosis/setRevisitSlotStartOffset",
|
||||||
"tcm.diagnosis/guahaoLogList",
|
"tcm.diagnosis/guahaoLogList",
|
||||||
"tcm.diagnosis/guahao",
|
"tcm.diagnosis/guahao",
|
||||||
|
"tcm.diagnosis/watchCall",
|
||||||
"tcm.diagnosis/order",
|
"tcm.diagnosis/order",
|
||||||
"tcm.diagnosis/qrcode",
|
"tcm.diagnosis/qrcode",
|
||||||
"tcm.diagnosis/videoQr",
|
"tcm.diagnosis/videoQr",
|
||||||
@@ -1455,6 +1457,13 @@ class DemoDoctorRepository:
|
|||||||
consultation.appointment_time = appointment.appointment_time
|
consultation.appointment_time = appointment.appointment_time
|
||||||
return deepcopy(_appointment_dict(appointment))
|
return deepcopy(_appointment_dict(appointment))
|
||||||
|
|
||||||
|
def create_diagnosis_appointment(
|
||||||
|
self, payload: Mapping[str, Any] | None = None, **fields: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a demo appointment through the diagnosis workspace contract."""
|
||||||
|
|
||||||
|
return self.book_patient_appointment(payload, **fields)
|
||||||
|
|
||||||
def cancel_patient_appointment(self, appointment_id: int) -> dict[str, Any]:
|
def cancel_patient_appointment(self, appointment_id: int) -> dict[str, Any]:
|
||||||
"""Persist cancellation status 2 across all demo workspaces."""
|
"""Persist cancellation status 2 across all demo workspaces."""
|
||||||
|
|
||||||
@@ -1484,6 +1493,21 @@ class DemoDoctorRepository:
|
|||||||
raise ValueError("appointment_id must be positive")
|
raise ValueError("appointment_id must be positive")
|
||||||
return self.cancel_patient_appointment(appointment_id)
|
return self.cancel_patient_appointment(appointment_id)
|
||||||
|
|
||||||
|
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
|
"""Return a non-production receive-only room ticket for UI demonstration."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
consultation = self._find_consultation(diagnosis_id)
|
||||||
|
hint = consultation.raw.get("video_call_hint")
|
||||||
|
room_id = hint.get("room_id") if isinstance(hint, Mapping) else None
|
||||||
|
return {
|
||||||
|
"sdkAppId": 1400000000,
|
||||||
|
"userId": f"doctor_{consultation.assistant_id or 2001}",
|
||||||
|
"userSig": "DEMO_ONLY_NOT_A_REAL_SIGNATURE",
|
||||||
|
"roomId": int(room_id or 900001),
|
||||||
|
"patientName": consultation.patient_name,
|
||||||
|
}
|
||||||
|
|
||||||
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
|
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
"""Return the complete readonly diagnosis aggregate."""
|
"""Return the complete readonly diagnosis aggregate."""
|
||||||
|
|
||||||
@@ -1645,7 +1669,7 @@ class DemoDoctorRepository:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new mutable demo diagnosis and matching patient row."""
|
"""Create a new mutable demo diagnosis and matching patient row."""
|
||||||
|
|
||||||
body = _body(diagnosis, fields)
|
body = _diagnosis_create_body(diagnosis, fields)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
diagnosis_id = max((row.id for row in self._consultations), default=500) + 1
|
diagnosis_id = max((row.id for row in self._consultations), default=500) + 1
|
||||||
body["id"] = diagnosis_id
|
body["id"] = diagnosis_id
|
||||||
|
|||||||
@@ -223,12 +223,20 @@ class DoctorRepository(Protocol):
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
"""Create an appointment from the patient workspace."""
|
"""Create an appointment from the patient workspace."""
|
||||||
|
|
||||||
|
def create_diagnosis_appointment(
|
||||||
|
self, payload: Mapping[str, Any] | None = None, **fields: Any
|
||||||
|
) -> Any:
|
||||||
|
"""Create an appointment from the diagnosis workspace."""
|
||||||
|
|
||||||
def cancel_patient_appointment(self, appointment_id: int) -> Any:
|
def cancel_patient_appointment(self, appointment_id: int) -> Any:
|
||||||
"""Cancel an appointment from the patient workspace."""
|
"""Cancel an appointment from the patient workspace."""
|
||||||
|
|
||||||
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
|
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
|
||||||
"""Cancel a diagnosis-list appointment through the doctor route."""
|
"""Cancel a diagnosis-list appointment through the doctor route."""
|
||||||
|
|
||||||
|
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
|
"""Return a receive-only room ticket for the assigned assistant."""
|
||||||
|
|
||||||
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
||||||
"""Return an editable or permission-aware readonly diagnosis detail."""
|
"""Return an editable or permission-aware readonly diagnosis detail."""
|
||||||
|
|
||||||
@@ -1536,6 +1544,13 @@ class RemoteDoctorRepository:
|
|||||||
|
|
||||||
return self.client.post("firstvisit.myPatient/createAppointment", _body(payload, fields))
|
return self.client.post("firstvisit.myPatient/createAppointment", _body(payload, fields))
|
||||||
|
|
||||||
|
def create_diagnosis_appointment(
|
||||||
|
self, payload: Mapping[str, Any] | None = None, **fields: Any
|
||||||
|
) -> Any:
|
||||||
|
"""Create an appointment through the canonical diagnosis-list route."""
|
||||||
|
|
||||||
|
return self.client.post("doctor.appointment/create", _body(payload, fields))
|
||||||
|
|
||||||
def cancel_patient_appointment(self, appointment_id: int) -> Any:
|
def cancel_patient_appointment(self, appointment_id: int) -> Any:
|
||||||
"""Cancel an appointment through the patient-scoped endpoint."""
|
"""Cancel an appointment through the patient-scoped endpoint."""
|
||||||
|
|
||||||
@@ -1548,6 +1563,18 @@ class RemoteDoctorRepository:
|
|||||||
raise ValueError("appointment_id must be positive")
|
raise ValueError("appointment_id must be positive")
|
||||||
return self.client.post("doctor.appointment/cancel", {"id": appointment_id})
|
return self.client.post("doctor.appointment/cancel", {"id": appointment_id})
|
||||||
|
|
||||||
|
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
|
"""Load the server-authorized receive-only TRTC room parameters."""
|
||||||
|
|
||||||
|
if diagnosis_id <= 0:
|
||||||
|
raise ValueError("diagnosis_id must be positive")
|
||||||
|
return dict(
|
||||||
|
_require_mapping(
|
||||||
|
self.client.get("tcm.diagnosis/watchCall", {"diagnosis_id": diagnosis_id}),
|
||||||
|
"tcm.diagnosis/watchCall",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
|
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
"""Compatibility name for permission-aware readonly diagnosis details."""
|
"""Compatibility name for permission-aware readonly diagnosis details."""
|
||||||
|
|
||||||
@@ -1603,7 +1630,7 @@ class RemoteDoctorRepository:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a diagnosis using the complete edit-form mapping."""
|
"""Create a diagnosis using the complete edit-form mapping."""
|
||||||
|
|
||||||
body = _body(diagnosis, fields)
|
body = _diagnosis_create_body(diagnosis, fields)
|
||||||
result = self.client.post("tcm.diagnosis/add", body)
|
result = self.client.post("tcm.diagnosis/add", body)
|
||||||
return _merge_result(body, result)
|
return _merge_result(body, result)
|
||||||
|
|
||||||
@@ -2285,6 +2312,44 @@ def _body(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnosis_create_body(
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
fields: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate the production ``tcm.diagnosis/add`` identity contract."""
|
||||||
|
|
||||||
|
body = _body(payload, fields)
|
||||||
|
patient_name = str(body.get("patient_name") or "").strip()
|
||||||
|
phone = str(body.get("phone") or "").strip()
|
||||||
|
diagnosis_type = str(body.get("diagnosis_type") or "").strip()
|
||||||
|
local_hospital_name = str(body.get("local_hospital_name") or "").strip()
|
||||||
|
if not patient_name:
|
||||||
|
raise ValueError("patient_name is required")
|
||||||
|
if not re.fullmatch(r"1[3-9]\d{9}", phone):
|
||||||
|
raise ValueError("phone must be a valid 11-digit mobile number")
|
||||||
|
gender = _to_int(body.get("gender"), -1)
|
||||||
|
if gender not in {0, 1}:
|
||||||
|
raise ValueError("gender must be 0 or 1")
|
||||||
|
age = _to_int(body.get("age"), -1)
|
||||||
|
if age < 0 or age > 150:
|
||||||
|
raise ValueError("age must be between 0 and 150")
|
||||||
|
if not diagnosis_type:
|
||||||
|
raise ValueError("diagnosis_type is required")
|
||||||
|
if not local_hospital_name:
|
||||||
|
raise ValueError("local_hospital_name is required")
|
||||||
|
body.update(
|
||||||
|
{
|
||||||
|
"patient_name": patient_name,
|
||||||
|
"phone": phone,
|
||||||
|
"gender": gender,
|
||||||
|
"age": age,
|
||||||
|
"diagnosis_type": diagnosis_type,
|
||||||
|
"local_hospital_name": local_hospital_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
_RECORD_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
_RECORD_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
_RECORD_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
_RECORD_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||||
|
|
||||||
|
|||||||
@@ -1284,7 +1284,7 @@ class DiagnosisTableHost(QFrame):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
action_policy: Mapping[str, bool] | None = None,
|
action_policy: Mapping[str, Any] | None = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -1419,8 +1419,51 @@ class DiagnosisTableHost(QFrame):
|
|||||||
layout = QHBoxLayout(host)
|
layout = QHBoxLayout(host)
|
||||||
layout.setContentsMargins(3, 2, 3, 2)
|
layout.setContentsMargins(3, 2, 3, 2)
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
video_capable = self.action_policy.get("video_call", False)
|
watch_state = str(first_value(record, "video_call_hint.state", default="none") or "none")
|
||||||
if video_capable and _appointment_active(record):
|
watch_label = display_text(
|
||||||
|
first_value(record, "video_call_hint.label", default=""),
|
||||||
|
"暂无可旁观通话",
|
||||||
|
)
|
||||||
|
watch_user_id = _as_int(self.action_policy.get("watch_user_id"))
|
||||||
|
assigned_assistant_id = _as_int(
|
||||||
|
first_value(record, "assistant_id", "assistant", default=0)
|
||||||
|
)
|
||||||
|
watch_capable = bool(self.action_policy.get("watch_call", False))
|
||||||
|
assigned_watcher = (
|
||||||
|
watch_capable
|
||||||
|
and watch_user_id > 0
|
||||||
|
and assigned_assistant_id == watch_user_id
|
||||||
|
)
|
||||||
|
video_capable = bool(self.action_policy.get("video_call", False))
|
||||||
|
if assigned_watcher and watch_state in {"live", "pending_room"}:
|
||||||
|
button = QToolButton(host)
|
||||||
|
button.setText("进入旁观")
|
||||||
|
button.setProperty("rowLink", "success" if watch_state == "live" else "warning")
|
||||||
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
button.setEnabled(watch_state == "live")
|
||||||
|
button.setToolTip(
|
||||||
|
"仅观看,不会开启摄像头与麦克风"
|
||||||
|
if watch_state == "live"
|
||||||
|
else "医生尚未接通或未同步房间号,请稍后再试"
|
||||||
|
)
|
||||||
|
button.clicked.connect(
|
||||||
|
lambda _checked=False, item=record: self.action_requested.emit(
|
||||||
|
"watch_call", item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
layout.addWidget(button)
|
||||||
|
elif assigned_watcher:
|
||||||
|
label = QLabel(watch_label, host)
|
||||||
|
label.setProperty("fixedMuted", True)
|
||||||
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
label.setWordWrap(True)
|
||||||
|
layout.addWidget(label)
|
||||||
|
elif watch_state in {"live", "pending_room"}:
|
||||||
|
label = QLabel("通话中" if watch_state == "live" else "接通中", host)
|
||||||
|
label.setProperty("fixedMuted", True)
|
||||||
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
layout.addWidget(label)
|
||||||
|
elif video_capable and _appointment_active(record):
|
||||||
button = QToolButton(host)
|
button = QToolButton(host)
|
||||||
button.setText("进入视频问诊")
|
button.setText("进入视频问诊")
|
||||||
button.setProperty("rowLink", "primary")
|
button.setProperty("rowLink", "primary")
|
||||||
|
|||||||
@@ -390,6 +390,12 @@ def _watch_cell(_value: Any, row: Any) -> str:
|
|||||||
}.get(str(state), display_text(label or state))
|
}.get(str(state), display_text(label or state))
|
||||||
|
|
||||||
|
|
||||||
|
def _watch_state(row: Any) -> str:
|
||||||
|
"""Return the server-authored assistant-watch state for one diagnosis."""
|
||||||
|
|
||||||
|
return str(first_value(row, "video_call_hint.state", default="none") or "none")
|
||||||
|
|
||||||
|
|
||||||
def _option_rows(value: Any, dictionary_type: str = "") -> list[Any]:
|
def _option_rows(value: Any, dictionary_type: str = "") -> list[Any]:
|
||||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||||
return list(value)
|
return list(value)
|
||||||
@@ -428,7 +434,7 @@ class _DiagnosisCreateDialog(QDialog):
|
|||||||
form.addRow("手机号 *", self.phone)
|
form.addRow("手机号 *", self.phone)
|
||||||
self.gender = QComboBox()
|
self.gender = QComboBox()
|
||||||
self.gender.addItem("男", 1)
|
self.gender.addItem("男", 1)
|
||||||
self.gender.addItem("女", 2)
|
self.gender.addItem("女", 0)
|
||||||
form.addRow("性别 *", self.gender)
|
form.addRow("性别 *", self.gender)
|
||||||
self.age = QSpinBox()
|
self.age = QSpinBox()
|
||||||
self.age.setRange(0, 150)
|
self.age.setRange(0, 150)
|
||||||
@@ -445,6 +451,9 @@ class _DiagnosisCreateDialog(QDialog):
|
|||||||
form.addRow("诊断类型 *", self.diagnosis_type)
|
form.addRow("诊断类型 *", self.diagnosis_type)
|
||||||
self.syndrome_type = QLineEdit()
|
self.syndrome_type = QLineEdit()
|
||||||
form.addRow("证型", self.syndrome_type)
|
form.addRow("证型", self.syndrome_type)
|
||||||
|
self.local_hospital_name = QLineEdit()
|
||||||
|
self.local_hospital_name.setMaxLength(255)
|
||||||
|
form.addRow("当地就诊医院名称 *", self.local_hospital_name)
|
||||||
self.local_hospital_diagnosis = QLineEdit()
|
self.local_hospital_diagnosis = QLineEdit()
|
||||||
form.addRow("当地医院诊断 *", self.local_hospital_diagnosis)
|
form.addRow("当地医院诊断 *", self.local_hospital_diagnosis)
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
@@ -471,6 +480,7 @@ class _DiagnosisCreateDialog(QDialog):
|
|||||||
"fasting_blood_sugar": self.fasting_blood_sugar.value(),
|
"fasting_blood_sugar": self.fasting_blood_sugar.value(),
|
||||||
"diagnosis_type": self.diagnosis_type.text().strip(),
|
"diagnosis_type": self.diagnosis_type.text().strip(),
|
||||||
"syndrome_type": self.syndrome_type.text().strip(),
|
"syndrome_type": self.syndrome_type.text().strip(),
|
||||||
|
"local_hospital_name": self.local_hospital_name.text().strip(),
|
||||||
"local_hospital_diagnosis": [local_diagnosis] if local_diagnosis else [],
|
"local_hospital_diagnosis": [local_diagnosis] if local_diagnosis else [],
|
||||||
"diagnosis_date": QDate.currentDate().toString("yyyy-MM-dd"),
|
"diagnosis_date": QDate.currentDate().toString("yyyy-MM-dd"),
|
||||||
"status": 1,
|
"status": 1,
|
||||||
@@ -487,6 +497,9 @@ class _DiagnosisCreateDialog(QDialog):
|
|||||||
if not payload["diagnosis_type"]:
|
if not payload["diagnosis_type"]:
|
||||||
self.banner.show_message("请输入诊断类型。", "warning")
|
self.banner.show_message("请输入诊断类型。", "warning")
|
||||||
return
|
return
|
||||||
|
if not payload["local_hospital_name"]:
|
||||||
|
self.banner.show_message("请输入当地就诊医院名称。", "warning")
|
||||||
|
return
|
||||||
if not payload["local_hospital_diagnosis"]:
|
if not payload["local_hospital_diagnosis"]:
|
||||||
self.banner.show_message("请输入当地医院诊断。", "warning")
|
self.banner.show_message("请输入当地医院诊断。", "warning")
|
||||||
return
|
return
|
||||||
@@ -526,13 +539,14 @@ class _DiagnosisOrderDialog(QDialog):
|
|||||||
|
|
||||||
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
|
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._patient_id = _as_int(first_value(record, "patient_id", "source_patient_id"))
|
self._diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id"))
|
||||||
self.setWindowTitle("创建订单")
|
self.setWindowTitle("创建订单")
|
||||||
self.setMinimumWidth(480)
|
self.setMinimumWidth(480)
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
patient = QLabel(
|
patient = QLabel(
|
||||||
f"{display_text(first_value(record, 'patient_name'), '患者')} (#{self._patient_id})"
|
f"{display_text(first_value(record, 'patient_name'), '患者')} "
|
||||||
|
f"(诊单 #{self._diagnosis_id})"
|
||||||
)
|
)
|
||||||
patient.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
patient.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
form.addRow("患者", patient)
|
form.addRow("患者", patient)
|
||||||
@@ -561,8 +575,8 @@ class _DiagnosisOrderDialog(QDialog):
|
|||||||
root.addWidget(buttons)
|
root.addWidget(buttons)
|
||||||
|
|
||||||
def _accept_checked(self) -> None:
|
def _accept_checked(self) -> None:
|
||||||
if self._patient_id <= 0:
|
if self._diagnosis_id <= 0:
|
||||||
QMessageBox.warning(self, "创建订单", "患者标识不完整,无法创建订单。")
|
QMessageBox.warning(self, "创建订单", "诊单标识不完整,无法创建订单。")
|
||||||
return
|
return
|
||||||
if self.order_type.currentData() is None:
|
if self.order_type.currentData() is None:
|
||||||
QMessageBox.warning(self, "创建订单", "请选择订单类型。")
|
QMessageBox.warning(self, "创建订单", "请选择订单类型。")
|
||||||
@@ -571,7 +585,8 @@ class _DiagnosisOrderDialog(QDialog):
|
|||||||
|
|
||||||
def payload(self) -> dict[str, Any]:
|
def payload(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"patient_id": self._patient_id,
|
# The server order domain historically names the diagnosis owner ``patient_id``.
|
||||||
|
"patient_id": self._diagnosis_id,
|
||||||
"order_type": _as_int(self.order_type.currentData()),
|
"order_type": _as_int(self.order_type.currentData()),
|
||||||
"amount": float(self.amount.value()),
|
"amount": float(self.amount.value()),
|
||||||
"remark": self.remark.text().strip(),
|
"remark": self.remark.text().strip(),
|
||||||
@@ -844,6 +859,7 @@ class ConsultationsPage(QWidget):
|
|||||||
"""Diagnosis workspace with canonical filters and guarded row actions."""
|
"""Diagnosis workspace with canonical filters and guarded row actions."""
|
||||||
|
|
||||||
video_requested = Signal(dict)
|
video_requested = Signal(dict)
|
||||||
|
watch_requested = Signal(dict)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -894,6 +910,10 @@ class ConsultationsPage(QWidget):
|
|||||||
"appointment_logs": _repository_method_name(repository, "list_appointment_logs"),
|
"appointment_logs": _repository_method_name(repository, "list_appointment_logs"),
|
||||||
"create_order": _repository_method_name(repository, "create_diagnosis_order"),
|
"create_order": _repository_method_name(repository, "create_diagnosis_order"),
|
||||||
"order_qr": _repository_method_name(repository, "generate_order_qrcode"),
|
"order_qr": _repository_method_name(repository, "generate_order_qrcode"),
|
||||||
|
"create_appointment": _repository_method_name(
|
||||||
|
repository, "create_diagnosis_appointment"
|
||||||
|
),
|
||||||
|
"watch_call": _repository_method_name(repository, "get_assistant_watch_ticket"),
|
||||||
}
|
}
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
@@ -1186,10 +1206,18 @@ class ConsultationsPage(QWidget):
|
|||||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||||
"prescription": _canonical_allowed(permissions, "tcm.diagnosis/kaifang"),
|
"prescription": _canonical_allowed(permissions, "tcm.diagnosis/kaifang"),
|
||||||
"appointment": _canonical_allowed(permissions, "tcm.diagnosis/guahao"),
|
"appointment": bool(
|
||||||
|
_canonical_allowed(permissions, "tcm.diagnosis/guahao")
|
||||||
|
and self._repository_methods["create_appointment"]
|
||||||
|
),
|
||||||
"assign": _canonical_allowed(permissions, "tcm.diagnosis/assign"),
|
"assign": _canonical_allowed(permissions, "tcm.diagnosis/assign"),
|
||||||
"delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"),
|
"delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"),
|
||||||
"video_call": self._native_video_capable,
|
"video_call": self._native_video_capable,
|
||||||
|
"watch_call": bool(
|
||||||
|
_canonical_allowed(permissions, "tcm.diagnosis/watchCall")
|
||||||
|
and self._repository_methods["watch_call"]
|
||||||
|
),
|
||||||
|
"watch_user_id": _as_int(first_value(current_user, "id", "user_id")),
|
||||||
"appointment_cancel": bool(
|
"appointment_cancel": bool(
|
||||||
_canonical_allowed(permissions, "tcm.diagnosis/guahao")
|
_canonical_allowed(permissions, "tcm.diagnosis/guahao")
|
||||||
and self._repository_methods["cancel_appointment"]
|
and self._repository_methods["cancel_appointment"]
|
||||||
@@ -1870,6 +1898,7 @@ class ConsultationsPage(QWidget):
|
|||||||
"confirm_qr": self._request_confirm_qr,
|
"confirm_qr": self._request_confirm_qr,
|
||||||
"appointment_logs": self._request_appointment_logs,
|
"appointment_logs": self._request_appointment_logs,
|
||||||
"create_order": self._create_diagnosis_order,
|
"create_order": self._create_diagnosis_order,
|
||||||
|
"watch_call": self._request_watch_call,
|
||||||
"delete": self._delete_selected,
|
"delete": self._delete_selected,
|
||||||
}
|
}
|
||||||
handler = actions.get(action)
|
handler = actions.get(action)
|
||||||
@@ -2014,22 +2043,42 @@ class ConsultationsPage(QWidget):
|
|||||||
self._run_mutation(cancel, "医助指派已取消。")
|
self._run_mutation(cancel, "医助指派已取消。")
|
||||||
|
|
||||||
def _book_selected_appointment(self) -> None:
|
def _book_selected_appointment(self) -> None:
|
||||||
if not _canonical_allowed(self.permissions, "tcm.diagnosis/guahao"):
|
method_name = self._repository_methods.get("create_appointment")
|
||||||
|
if (
|
||||||
|
not _canonical_allowed(self.permissions, "tcm.diagnosis/guahao")
|
||||||
|
or method_name is None
|
||||||
|
or self._mutation_pending
|
||||||
|
):
|
||||||
return
|
return
|
||||||
record = self.table.current_data()
|
record = self.table.current_data()
|
||||||
if record is None:
|
if record is None:
|
||||||
return
|
return
|
||||||
|
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
|
||||||
|
if diagnosis_id <= 0:
|
||||||
|
show_toast(self, "诊单标识不完整,无法预约。", "warning")
|
||||||
|
return
|
||||||
# Kept lazy to avoid making the two page modules import each other at startup.
|
# Kept lazy to avoid making the two page modules import each other at startup.
|
||||||
from .patients import _AppointmentDialog
|
from .patients import _AppointmentDialog
|
||||||
|
|
||||||
dialog = _AppointmentDialog(record, repository=self.repository, parent=self)
|
dialog = _AppointmentDialog(record, repository=self.repository, parent=self)
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
payload = MappingProxyType(dialog.payload())
|
current = self.table.current_data()
|
||||||
|
if (
|
||||||
|
not _canonical_allowed(self.permissions, "tcm.diagnosis/guahao")
|
||||||
|
or not callable(getattr(self.repository, method_name, None))
|
||||||
|
or _as_int(first_value(current, "diagnosis_id", "id", default=0)) != diagnosis_id
|
||||||
|
):
|
||||||
|
show_toast(self, "权限或当前诊单已变化,本次未预约。", "warning")
|
||||||
|
return
|
||||||
|
appointment_payload = dict(dialog.payload())
|
||||||
|
# The doctor appointment contract names the diagnosis owner ``patient_id``.
|
||||||
|
appointment_payload["patient_id"] = diagnosis_id
|
||||||
|
payload = MappingProxyType(appointment_payload)
|
||||||
self._run_mutation(
|
self._run_mutation(
|
||||||
lambda: invoke(
|
lambda: invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"book_patient_appointment",
|
method_name,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
**payload,
|
**payload,
|
||||||
),
|
),
|
||||||
@@ -2289,9 +2338,8 @@ class ConsultationsPage(QWidget):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
|
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
|
||||||
patient_id = _as_int(first_value(record, "patient_id", "source_patient_id", default=0))
|
if diagnosis_id <= 0:
|
||||||
if diagnosis_id <= 0 or patient_id <= 0:
|
show_toast(self, "诊单标识不完整,无法创建订单。", "warning")
|
||||||
show_toast(self, "患者或诊单标识不完整,无法创建订单。", "warning")
|
|
||||||
return
|
return
|
||||||
dialog = _DiagnosisOrderDialog(record, self)
|
dialog = _DiagnosisOrderDialog(record, self)
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
@@ -2302,12 +2350,14 @@ class ConsultationsPage(QWidget):
|
|||||||
or not callable(getattr(self.repository, method_name, None))
|
or not callable(getattr(self.repository, method_name, None))
|
||||||
or not callable(getattr(self.repository, qr_method_name, None))
|
or not callable(getattr(self.repository, qr_method_name, None))
|
||||||
or _as_int(first_value(current, "diagnosis_id", "id", default=0)) != diagnosis_id
|
or _as_int(first_value(current, "diagnosis_id", "id", default=0)) != diagnosis_id
|
||||||
or _as_int(first_value(current, "patient_id", "source_patient_id", default=0))
|
|
||||||
!= patient_id
|
|
||||||
):
|
):
|
||||||
show_toast(self, "权限或当前诊单已变化,本次未创建订单。", "warning")
|
show_toast(self, "权限或当前诊单已变化,本次未创建订单。", "warning")
|
||||||
return
|
return
|
||||||
payload = MappingProxyType(dialog.payload())
|
order_payload = dict(dialog.payload())
|
||||||
|
# The order endpoint also names the diagnosis owner ``patient_id``.
|
||||||
|
# Derive it from the selected row so a source patient id can never leak here.
|
||||||
|
order_payload["patient_id"] = diagnosis_id
|
||||||
|
payload = MappingProxyType(order_payload)
|
||||||
snapshot = deepcopy(record)
|
snapshot = deepcopy(record)
|
||||||
generation = self._begin_order_flow()
|
generation = self._begin_order_flow()
|
||||||
run_async(
|
run_async(
|
||||||
@@ -2321,7 +2371,6 @@ class ConsultationsPage(QWidget):
|
|||||||
result,
|
result,
|
||||||
snapshot,
|
snapshot,
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
patient_id,
|
|
||||||
generation,
|
generation,
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._diagnosis_order_create_error(error, generation),
|
on_error=lambda error: self._diagnosis_order_create_error(error, generation),
|
||||||
@@ -2346,7 +2395,6 @@ class ConsultationsPage(QWidget):
|
|||||||
self,
|
self,
|
||||||
generation: int,
|
generation: int,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
patient_id: int,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if generation != self._order_flow_generation:
|
if generation != self._order_flow_generation:
|
||||||
return False
|
return False
|
||||||
@@ -2356,23 +2404,18 @@ class ConsultationsPage(QWidget):
|
|||||||
if method_name is None or not callable(getattr(self.repository, method_name, None)):
|
if method_name is None or not callable(getattr(self.repository, method_name, None)):
|
||||||
return False
|
return False
|
||||||
current = self.table.current_data()
|
current = self.table.current_data()
|
||||||
return bool(
|
return _as_int(first_value(current, "diagnosis_id", "id", default=0)) == diagnosis_id
|
||||||
_as_int(first_value(current, "diagnosis_id", "id", default=0)) == diagnosis_id
|
|
||||||
and _as_int(first_value(current, "patient_id", "source_patient_id", default=0))
|
|
||||||
== patient_id
|
|
||||||
)
|
|
||||||
|
|
||||||
def _diagnosis_order_created(
|
def _diagnosis_order_created(
|
||||||
self,
|
self,
|
||||||
result: Any,
|
result: Any,
|
||||||
record: Any,
|
record: Any,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
patient_id: int,
|
|
||||||
generation: int,
|
generation: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
if generation != self._order_flow_generation:
|
if generation != self._order_flow_generation:
|
||||||
return
|
return
|
||||||
if not self._order_flow_guard(generation, diagnosis_id, patient_id):
|
if not self._order_flow_guard(generation, diagnosis_id):
|
||||||
self.banner.show_message("权限或当前诊单已变化,未展示付款二维码。", "danger")
|
self.banner.show_message("权限或当前诊单已变化,未展示付款二维码。", "danger")
|
||||||
self._finish_order_flow(generation, refresh=True)
|
self._finish_order_flow(generation, refresh=True)
|
||||||
return
|
return
|
||||||
@@ -2390,7 +2433,6 @@ class ConsultationsPage(QWidget):
|
|||||||
lambda retry_order_no: self._request_order_qr(
|
lambda retry_order_no: self._request_order_qr(
|
||||||
retry_order_no,
|
retry_order_no,
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
patient_id,
|
|
||||||
generation,
|
generation,
|
||||||
dialog,
|
dialog,
|
||||||
)
|
)
|
||||||
@@ -2400,7 +2442,6 @@ class ConsultationsPage(QWidget):
|
|||||||
self._request_order_qr(
|
self._request_order_qr(
|
||||||
order_no,
|
order_no,
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
patient_id,
|
|
||||||
generation,
|
generation,
|
||||||
dialog,
|
dialog,
|
||||||
)
|
)
|
||||||
@@ -2409,7 +2450,6 @@ class ConsultationsPage(QWidget):
|
|||||||
self,
|
self,
|
||||||
order_no: str,
|
order_no: str,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
patient_id: int,
|
|
||||||
generation: int,
|
generation: int,
|
||||||
dialog: _DiagnosisOrderQrDialog,
|
dialog: _DiagnosisOrderQrDialog,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2418,7 +2458,7 @@ class ConsultationsPage(QWidget):
|
|||||||
if order_no != dialog.order_no:
|
if order_no != dialog.order_no:
|
||||||
dialog.set_failure("订单号已变化,无法生成付款二维码。", retryable=False)
|
dialog.set_failure("订单号已变化,无法生成付款二维码。", retryable=False)
|
||||||
return
|
return
|
||||||
if not self._order_flow_guard(generation, diagnosis_id, patient_id):
|
if not self._order_flow_guard(generation, diagnosis_id):
|
||||||
dialog.set_failure(
|
dialog.set_failure(
|
||||||
"权限或当前诊单已变化,无法生成付款二维码。",
|
"权限或当前诊单已变化,无法生成付款二维码。",
|
||||||
retryable=False,
|
retryable=False,
|
||||||
@@ -2438,14 +2478,12 @@ class ConsultationsPage(QWidget):
|
|||||||
on_success=lambda result: self._diagnosis_order_qr_success(
|
on_success=lambda result: self._diagnosis_order_qr_success(
|
||||||
result,
|
result,
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
patient_id,
|
|
||||||
generation,
|
generation,
|
||||||
dialog,
|
dialog,
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._diagnosis_order_qr_error(
|
on_error=lambda error: self._diagnosis_order_qr_error(
|
||||||
error,
|
error,
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
patient_id,
|
|
||||||
generation,
|
generation,
|
||||||
dialog,
|
dialog,
|
||||||
),
|
),
|
||||||
@@ -2456,13 +2494,12 @@ class ConsultationsPage(QWidget):
|
|||||||
self,
|
self,
|
||||||
result: Any,
|
result: Any,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
patient_id: int,
|
|
||||||
generation: int,
|
generation: int,
|
||||||
dialog: _DiagnosisOrderQrDialog,
|
dialog: _DiagnosisOrderQrDialog,
|
||||||
) -> None:
|
) -> None:
|
||||||
if dialog is not self._order_qr_dialog or generation != self._order_flow_generation:
|
if dialog is not self._order_qr_dialog or generation != self._order_flow_generation:
|
||||||
return
|
return
|
||||||
if not self._order_flow_guard(generation, diagnosis_id, patient_id):
|
if not self._order_flow_guard(generation, diagnosis_id):
|
||||||
dialog.set_failure(
|
dialog.set_failure(
|
||||||
"权限或当前诊单已变化,未展示付款二维码。",
|
"权限或当前诊单已变化,未展示付款二维码。",
|
||||||
retryable=False,
|
retryable=False,
|
||||||
@@ -2479,13 +2516,12 @@ class ConsultationsPage(QWidget):
|
|||||||
self,
|
self,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
patient_id: int,
|
|
||||||
generation: int,
|
generation: int,
|
||||||
dialog: _DiagnosisOrderQrDialog,
|
dialog: _DiagnosisOrderQrDialog,
|
||||||
) -> None:
|
) -> None:
|
||||||
if dialog is not self._order_qr_dialog or generation != self._order_flow_generation:
|
if dialog is not self._order_qr_dialog or generation != self._order_flow_generation:
|
||||||
return
|
return
|
||||||
retryable = self._order_flow_guard(generation, diagnosis_id, patient_id)
|
retryable = self._order_flow_guard(generation, diagnosis_id)
|
||||||
message = (
|
message = (
|
||||||
f"付款二维码生成失败:{friendly_error(error)}"
|
f"付款二维码生成失败:{friendly_error(error)}"
|
||||||
if retryable
|
if retryable
|
||||||
@@ -2904,6 +2940,31 @@ class ConsultationsPage(QWidget):
|
|||||||
return
|
return
|
||||||
self.video_requested.emit(payload)
|
self.video_requested.emit(payload)
|
||||||
|
|
||||||
|
def _request_watch_call(self) -> None:
|
||||||
|
method_name = self._repository_methods.get("watch_call")
|
||||||
|
record = self.table.current_data()
|
||||||
|
if (
|
||||||
|
not _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall")
|
||||||
|
or method_name is None
|
||||||
|
or record is None
|
||||||
|
):
|
||||||
|
return
|
||||||
|
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
|
||||||
|
user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0))
|
||||||
|
assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0))
|
||||||
|
if diagnosis_id <= 0 or user_id <= 0 or assistant_id != user_id:
|
||||||
|
self.banner.show_message("仅当前诊单指派的医助可旁观通话。", "warning")
|
||||||
|
return
|
||||||
|
if _watch_state(record) != "live":
|
||||||
|
self.banner.show_message("医生尚未接通或未同步房间号,请稍后再试。", "warning")
|
||||||
|
return
|
||||||
|
self.watch_requested.emit(
|
||||||
|
{
|
||||||
|
"diagnosis_id": diagnosis_id,
|
||||||
|
"patient_name": display_text(first_value(record, "patient_name"), "患者"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _poll_refresh(self) -> None:
|
def _poll_refresh(self) -> None:
|
||||||
"""Refresh rows and chip counts without showing the table mask."""
|
"""Refresh rows and chip counts without showing the table mask."""
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ class ShellWindow(QMainWindow):
|
|||||||
|
|
||||||
logout_requested = Signal()
|
logout_requested = Signal()
|
||||||
video_requested = Signal(dict)
|
video_requested = Signal(dict)
|
||||||
|
watch_requested = Signal(dict)
|
||||||
page_changed = Signal(str)
|
page_changed = Signal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -913,6 +914,8 @@ class ShellWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if hasattr(page, "video_requested"):
|
if hasattr(page, "video_requested"):
|
||||||
page.video_requested.connect(lambda payload: self.video_requested.emit(payload))
|
page.video_requested.connect(lambda payload: self.video_requested.emit(payload))
|
||||||
|
if hasattr(page, "watch_requested"):
|
||||||
|
page.watch_requested.connect(lambda payload: self.watch_requested.emit(payload))
|
||||||
index = self.stack.addWidget(page)
|
index = self.stack.addWidget(page)
|
||||||
self.pages[item.key] = page
|
self.pages[item.key] = page
|
||||||
self.page_titles[index] = title
|
self.page_titles[index] = title
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ from .launcher import (
|
|||||||
VideoCallLauncher,
|
VideoCallLauncher,
|
||||||
VideoCallRequest,
|
VideoCallRequest,
|
||||||
VideoTicketError,
|
VideoTicketError,
|
||||||
|
VideoWatchLauncher,
|
||||||
|
VideoWatchRequest,
|
||||||
launch_video_call,
|
launch_video_call,
|
||||||
|
launch_video_watch,
|
||||||
normalize_backend_ticket,
|
normalize_backend_ticket,
|
||||||
|
normalize_backend_watch_ticket,
|
||||||
require_supported_backend,
|
require_supported_backend,
|
||||||
)
|
)
|
||||||
from .lifecycle import OrderedCallLifecycle
|
from .lifecycle import OrderedCallLifecycle
|
||||||
@@ -17,7 +21,11 @@ __all__ = [
|
|||||||
"VideoCallLauncher",
|
"VideoCallLauncher",
|
||||||
"VideoCallRequest",
|
"VideoCallRequest",
|
||||||
"VideoTicketError",
|
"VideoTicketError",
|
||||||
|
"VideoWatchLauncher",
|
||||||
|
"VideoWatchRequest",
|
||||||
"launch_video_call",
|
"launch_video_call",
|
||||||
|
"launch_video_watch",
|
||||||
"normalize_backend_ticket",
|
"normalize_backend_ticket",
|
||||||
|
"normalize_backend_watch_ticket",
|
||||||
"require_supported_backend",
|
"require_supported_backend",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -261,6 +261,70 @@ class VideoCallRequest:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VideoWatchRequest:
|
||||||
|
"""Validated receive-only room ticket for an assigned medical assistant."""
|
||||||
|
|
||||||
|
sdk_app_id: int
|
||||||
|
user_id: str
|
||||||
|
user_sig: str = field(repr=False)
|
||||||
|
diagnosis_id: Identifier
|
||||||
|
room_id: int | None = None
|
||||||
|
str_room_id: str | None = None
|
||||||
|
patient_name: str = ""
|
||||||
|
backend_mode: BackendMode = BackendMode.EMBEDDED
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
|
||||||
|
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
|
||||||
|
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
|
||||||
|
object.__setattr__(self, "diagnosis_id", _identifier(self.diagnosis_id, "diagnosisId"))
|
||||||
|
numeric_room = self.room_id
|
||||||
|
string_room = str(self.str_room_id or "").strip()
|
||||||
|
if numeric_room is not None:
|
||||||
|
if isinstance(numeric_room, bool):
|
||||||
|
raise VideoTicketError("roomId must be a positive integer")
|
||||||
|
try:
|
||||||
|
numeric_room = int(numeric_room)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise VideoTicketError("roomId must be a positive integer") from exc
|
||||||
|
if numeric_room <= 0:
|
||||||
|
raise VideoTicketError("roomId must be a positive integer")
|
||||||
|
object.__setattr__(self, "room_id", numeric_room)
|
||||||
|
if numeric_room is None and not string_room:
|
||||||
|
raise VideoTicketError("backend watch ticket is missing roomId or strRoomId")
|
||||||
|
if numeric_room is not None and string_room:
|
||||||
|
raise VideoTicketError("backend watch ticket has conflicting room identifiers")
|
||||||
|
object.__setattr__(self, "str_room_id", string_room or None)
|
||||||
|
object.__setattr__(self, "patient_name", str(self.patient_name or "").strip())
|
||||||
|
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
|
||||||
|
|
||||||
|
def to_web_config(self) -> dict[str, Any]:
|
||||||
|
"""Return the in-memory receive-only Web companion configuration."""
|
||||||
|
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"SDKAppID": self.sdk_app_id,
|
||||||
|
"userID": self.user_id,
|
||||||
|
"userSig": self.user_sig,
|
||||||
|
"diagnosisId": self.diagnosis_id,
|
||||||
|
"patientName": self.patient_name,
|
||||||
|
}
|
||||||
|
if self.room_id is not None:
|
||||||
|
config["roomId"] = self.room_id
|
||||||
|
else:
|
||||||
|
config["strRoomId"] = self.str_room_id
|
||||||
|
return config
|
||||||
|
|
||||||
|
def safe_log_context(self) -> dict[str, Any]:
|
||||||
|
"""Return non-secret watch metadata suitable for structured logs."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"diagnosis_id": self.diagnosis_id,
|
||||||
|
"room_kind": "numeric" if self.room_id is not None else "string",
|
||||||
|
"backend_mode": self.backend_mode.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalize_backend_ticket(
|
def normalize_backend_ticket(
|
||||||
ticket: Any,
|
ticket: Any,
|
||||||
*,
|
*,
|
||||||
@@ -327,6 +391,70 @@ def normalize_backend_ticket(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_backend_watch_ticket(
|
||||||
|
ticket: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
diagnosis_id: Any,
|
||||||
|
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
|
||||||
|
) -> VideoWatchRequest:
|
||||||
|
"""Normalize the canonical ``tcm.diagnosis/watchCall`` response."""
|
||||||
|
|
||||||
|
if not isinstance(ticket, Mapping):
|
||||||
|
raise VideoTicketError("backend watch ticket must be a mapping")
|
||||||
|
payload = _ticket_payload(ticket)
|
||||||
|
payload_diagnosis = _read_aliases(
|
||||||
|
payload,
|
||||||
|
("diagnosisId", "diagnosis_id"),
|
||||||
|
"diagnosisId",
|
||||||
|
_identifier,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
normalized_diagnosis = _merge_identifier(
|
||||||
|
payload_diagnosis,
|
||||||
|
diagnosis_id,
|
||||||
|
"diagnosisId",
|
||||||
|
)
|
||||||
|
room_id = _read_aliases(
|
||||||
|
payload,
|
||||||
|
("roomId", "room_id"),
|
||||||
|
"roomId",
|
||||||
|
lambda value, field_name: _sdk_app_id(value, field_name),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
str_room_id = _read_aliases(
|
||||||
|
payload,
|
||||||
|
("strRoomId", "str_room_id"),
|
||||||
|
"strRoomId",
|
||||||
|
_non_empty_string,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
return VideoWatchRequest(
|
||||||
|
sdk_app_id=_read_aliases(
|
||||||
|
payload,
|
||||||
|
("SDKAppID", "sdkAppId", "sdkAppID"),
|
||||||
|
"SDKAppID",
|
||||||
|
_sdk_app_id,
|
||||||
|
),
|
||||||
|
user_id=_read_aliases(
|
||||||
|
payload,
|
||||||
|
("userID", "userId"),
|
||||||
|
"userID",
|
||||||
|
_non_empty_string,
|
||||||
|
),
|
||||||
|
user_sig=_read_aliases(
|
||||||
|
payload,
|
||||||
|
("userSig", "user_sig"),
|
||||||
|
"userSig",
|
||||||
|
_non_empty_string,
|
||||||
|
),
|
||||||
|
diagnosis_id=normalized_diagnosis,
|
||||||
|
room_id=room_id,
|
||||||
|
str_room_id=str_room_id,
|
||||||
|
patient_name=str(payload.get("patientName", payload.get("patient_name", "")) or ""),
|
||||||
|
backend_mode=BackendMode.parse(backend_mode),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class VideoCallLauncher:
|
class VideoCallLauncher:
|
||||||
"""Small composition root that defers the optional Qt import until launch."""
|
"""Small composition root that defers the optional Qt import until launch."""
|
||||||
@@ -403,3 +531,48 @@ def launch_video_call(
|
|||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class VideoWatchLauncher:
|
||||||
|
"""Launcher for the receive-only assistant watch surface."""
|
||||||
|
|
||||||
|
backend_mode: BackendMode | str = BackendMode.EMBEDDED
|
||||||
|
local_dist: str | Path | None = None
|
||||||
|
remote_url: str | None = None
|
||||||
|
logger: Any = None
|
||||||
|
|
||||||
|
def launch(self, ticket: Mapping[str, Any], *, diagnosis_id: Any) -> Any:
|
||||||
|
require_supported_backend(self.backend_mode)
|
||||||
|
request = normalize_backend_watch_ticket(
|
||||||
|
ticket,
|
||||||
|
diagnosis_id=diagnosis_id,
|
||||||
|
backend_mode=self.backend_mode,
|
||||||
|
)
|
||||||
|
from .window import open_video_watch
|
||||||
|
|
||||||
|
return open_video_watch(
|
||||||
|
request,
|
||||||
|
local_dist=self.local_dist,
|
||||||
|
remote_url=self.remote_url,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def launch_video_watch(
|
||||||
|
ticket: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
diagnosis_id: Any,
|
||||||
|
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
|
||||||
|
local_dist: str | Path | None = None,
|
||||||
|
remote_url: str | None = None,
|
||||||
|
logger: Any = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Validate a server-issued room ticket and open a receive-only window."""
|
||||||
|
|
||||||
|
return VideoWatchLauncher(
|
||||||
|
backend_mode=backend_mode,
|
||||||
|
local_dist=local_dist,
|
||||||
|
remote_url=remote_url,
|
||||||
|
logger=logger,
|
||||||
|
).launch(ticket, diagnosis_id=diagnosis_id)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from urllib.parse import parse_qsl, urlsplit
|
|||||||
from .launcher import (
|
from .launcher import (
|
||||||
VideoCallRequest,
|
VideoCallRequest,
|
||||||
VideoTicketError,
|
VideoTicketError,
|
||||||
|
VideoWatchRequest,
|
||||||
require_supported_backend,
|
require_supported_backend,
|
||||||
)
|
)
|
||||||
from .lifecycle import OrderedCallLifecycle
|
from .lifecycle import OrderedCallLifecycle
|
||||||
@@ -178,7 +179,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
message = json.loads(payload)
|
message = json.loads(payload)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return
|
return
|
||||||
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
|
if isinstance(message, Mapping) and message.get("source") in {
|
||||||
|
"doctor-call",
|
||||||
|
"assistant-watch",
|
||||||
|
}:
|
||||||
self._callback(message)
|
self._callback(message)
|
||||||
|
|
||||||
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
|
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
|
||||||
@@ -189,9 +193,9 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
request: VideoCallRequest,
|
request: VideoCallRequest | VideoWatchRequest,
|
||||||
location: CompanionLocation,
|
location: CompanionLocation,
|
||||||
lifecycle: OrderedCallLifecycle,
|
lifecycle: OrderedCallLifecycle | None,
|
||||||
*,
|
*,
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -199,6 +203,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self.request = request
|
self.request = request
|
||||||
self.location = location
|
self.location = location
|
||||||
self.lifecycle = lifecycle
|
self.lifecycle = lifecycle
|
||||||
|
self._watch_mode = isinstance(request, VideoWatchRequest)
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
try:
|
try:
|
||||||
self._policy = TrustedDocumentPolicy.from_url(
|
self._policy = TrustedDocumentPolicy.from_url(
|
||||||
@@ -216,7 +221,9 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._legacy_grants: list[tuple[Any, Any]] = []
|
self._legacy_grants: list[tuple[Any, Any]] = []
|
||||||
self._permission_grants: list[Any] = []
|
self._permission_grants: list[Any] = []
|
||||||
|
|
||||||
self.setWindowTitle("视频面诊")
|
title = "旁观视频通话" if self._watch_mode else "视频面诊"
|
||||||
|
patient_name = getattr(request, "patient_name", "")
|
||||||
|
self.setWindowTitle(f"{title} · {patient_name}" if patient_name else title)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||||
self.resize(1120, 760)
|
self.resize(1120, 760)
|
||||||
self.setMinimumSize(760, 520)
|
self.setMinimumSize(760, 520)
|
||||||
@@ -271,7 +278,12 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._page.permissionRequested.connect(self._grant_media_permission)
|
self._page.permissionRequested.connect(self._grant_media_permission)
|
||||||
|
|
||||||
def _permission_context_is_trusted(self, origin: Any) -> bool:
|
def _permission_context_is_trusted(self, origin: Any) -> bool:
|
||||||
if self._closing or self._released or not self._media_active:
|
if (
|
||||||
|
self._watch_mode
|
||||||
|
or self._closing
|
||||||
|
or self._released
|
||||||
|
or not self._media_active
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
if not self._policy.allows_main_document(self._page.url().toString()):
|
if not self._policy.allows_main_document(self._page.url().toString()):
|
||||||
return False
|
return False
|
||||||
@@ -326,7 +338,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self.close()
|
self.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self._watch_mode:
|
||||||
|
self._start_watch_companion()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if self.lifecycle is None:
|
||||||
|
raise VideoWindowError("video call lifecycle is unavailable")
|
||||||
start_future = self.lifecycle.start()
|
start_future = self.lifecycle.start()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -370,6 +388,28 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
"""
|
"""
|
||||||
self._page.runJavaScript(script, self._after_injection)
|
self._page.runJavaScript(script, self._after_injection)
|
||||||
|
|
||||||
|
def _start_watch_companion(self) -> None:
|
||||||
|
"""Inject receive-only room credentials without enabling capture."""
|
||||||
|
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
|
self._media_active = True
|
||||||
|
config_json = json.dumps(
|
||||||
|
self.request.to_web_config(),
|
||||||
|
ensure_ascii=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
script = f"""
|
||||||
|
(() => {{
|
||||||
|
if (!window.doctorWatch || typeof window.doctorWatch.start !== 'function') {{
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
void window.doctorWatch.start({config_json}).catch(() => undefined);
|
||||||
|
return true;
|
||||||
|
}})()
|
||||||
|
"""
|
||||||
|
self._page.runJavaScript(script, self._after_injection)
|
||||||
|
|
||||||
def _after_injection(self, result: Any) -> None:
|
def _after_injection(self, result: Any) -> None:
|
||||||
self._injected = result is not False
|
self._injected = result is not False
|
||||||
if not self._injected:
|
if not self._injected:
|
||||||
@@ -379,9 +419,12 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
|
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
|
||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
|
expected_source = "assistant-watch" if self._watch_mode else "doctor-call"
|
||||||
|
if message.get("source") != expected_source:
|
||||||
|
return
|
||||||
event = str(message.get("event", ""))
|
event = str(message.get("event", ""))
|
||||||
room_id = message.get("roomId", message.get("room_id"))
|
room_id = message.get("roomId", message.get("room_id"))
|
||||||
if room_id not in (None, ""):
|
if room_id not in (None, "") and self.lifecycle is not None:
|
||||||
self.lifecycle.bind_room(room_id)
|
self.lifecycle.bind_room(room_id)
|
||||||
if event == "room":
|
if event == "room":
|
||||||
return
|
return
|
||||||
@@ -414,9 +457,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._closing = True
|
self._closing = True
|
||||||
self._media_active = False
|
self._media_active = False
|
||||||
if self._injected and not self._companion_ended and not self._released:
|
if self._injected and not self._companion_ended and not self._released:
|
||||||
self._page.runJavaScript(
|
script = (
|
||||||
"void window.doctorCall?.hangup?.().catch(() => undefined)"
|
"void window.doctorWatch?.leave?.().catch(() => undefined)"
|
||||||
|
if self._watch_mode
|
||||||
|
else "void window.doctorCall?.hangup?.().catch(() => undefined)"
|
||||||
)
|
)
|
||||||
|
self._page.runJavaScript(script)
|
||||||
|
if self.lifecycle is not None:
|
||||||
self.lifecycle.end(self._close_reason)
|
self.lifecycle.end(self._close_reason)
|
||||||
self._release_webengine()
|
self._release_webengine()
|
||||||
|
|
||||||
@@ -550,6 +597,58 @@ class VideoCallWindow:
|
|||||||
wait = wait_for_lifecycle
|
wait = wait_for_lifecycle
|
||||||
|
|
||||||
|
|
||||||
|
class VideoWatchWindow:
|
||||||
|
"""Facade for one receive-only assistant watch session."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
request: VideoWatchRequest,
|
||||||
|
*,
|
||||||
|
local_dist: str | Path | None = None,
|
||||||
|
remote_url: str | None = None,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
self.backend_mode = require_supported_backend(request.backend_mode)
|
||||||
|
except VideoTicketError as error:
|
||||||
|
raise VideoWindowError(str(error)) from error
|
||||||
|
if not WEBENGINE_AVAILABLE:
|
||||||
|
raise VideoWindowError(
|
||||||
|
"embedded video is unavailable and automatic browser fallback is disabled"
|
||||||
|
)
|
||||||
|
if QApplication is None or QApplication.instance() is None:
|
||||||
|
raise VideoWindowError("embedded video requires an active QApplication")
|
||||||
|
self.request = request
|
||||||
|
self.logger = logger or _LOGGER
|
||||||
|
self.location = resolve_companion_location(
|
||||||
|
local_dist=local_dist,
|
||||||
|
remote_url=remote_url,
|
||||||
|
)
|
||||||
|
self._session: Any = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qt_window(self) -> Any:
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
def open(self) -> VideoWatchWindow:
|
||||||
|
self._session = _EmbeddedVideoWindow(
|
||||||
|
self.request,
|
||||||
|
self.location,
|
||||||
|
None,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
self._session.show()
|
||||||
|
self._session.raise_()
|
||||||
|
self._session.activateWindow()
|
||||||
|
return self
|
||||||
|
|
||||||
|
show = open
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._session is not None:
|
||||||
|
self._session.close()
|
||||||
|
|
||||||
|
|
||||||
def open_video_call(
|
def open_video_call(
|
||||||
request: VideoCallRequest,
|
request: VideoCallRequest,
|
||||||
*,
|
*,
|
||||||
@@ -573,13 +672,34 @@ def open_video_call(
|
|||||||
).open()
|
).open()
|
||||||
|
|
||||||
|
|
||||||
|
def open_video_watch(
|
||||||
|
request: VideoWatchRequest,
|
||||||
|
*,
|
||||||
|
local_dist: str | Path | None = None,
|
||||||
|
remote_url: str | None = None,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
) -> VideoWatchWindow:
|
||||||
|
"""Create and immediately open a trusted receive-only watch window."""
|
||||||
|
|
||||||
|
if not isinstance(request, VideoWatchRequest):
|
||||||
|
raise VideoTicketError("request must be a VideoWatchRequest")
|
||||||
|
return VideoWatchWindow(
|
||||||
|
request,
|
||||||
|
local_dist=local_dist,
|
||||||
|
remote_url=remote_url,
|
||||||
|
logger=logger,
|
||||||
|
).open()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CompanionLocation",
|
"CompanionLocation",
|
||||||
"TrustedDocumentPolicy",
|
"TrustedDocumentPolicy",
|
||||||
"VideoCallWindow",
|
"VideoCallWindow",
|
||||||
|
"VideoWatchWindow",
|
||||||
"VideoWindowError",
|
"VideoWindowError",
|
||||||
"WEBENGINE_AVAILABLE",
|
"WEBENGINE_AVAILABLE",
|
||||||
"open_video_call",
|
"open_video_call",
|
||||||
|
"open_video_watch",
|
||||||
"resolve_companion_location",
|
"resolve_companion_location",
|
||||||
"webengine_unavailable_reason",
|
"webengine_unavailable_reason",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import (
|
|||||||
QApplication,
|
QApplication,
|
||||||
QDialog,
|
QDialog,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QToolButton,
|
QToolButton,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -20,6 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
from doctor_workstation.ui.pages.consultations import (
|
from doctor_workstation.ui.pages.consultations import (
|
||||||
ConsultationsPage,
|
ConsultationsPage,
|
||||||
_video_payload,
|
_video_payload,
|
||||||
@@ -117,6 +119,166 @@ def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
|
|||||||
assert payload["patient_id"] == 301
|
assert payload["patient_id"] == 301
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_order_dialogs_use_production_diagnosis_contract(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
create_dialog = consultations_module._DiagnosisCreateDialog()
|
||||||
|
assert create_dialog.gender.itemData(create_dialog.gender.findText("女")) == 0
|
||||||
|
create_dialog.patient_name.setText("林晓岚")
|
||||||
|
create_dialog.phone.setText("13800138000")
|
||||||
|
create_dialog.gender.setCurrentIndex(create_dialog.gender.findData(0))
|
||||||
|
create_dialog.diagnosis_type.setText("复诊")
|
||||||
|
create_dialog.local_hospital_name.setText("杭州市第一人民医院")
|
||||||
|
create_dialog.local_hospital_diagnosis.setText("2型糖尿病")
|
||||||
|
payload = create_dialog.payload()
|
||||||
|
assert payload["gender"] == 0
|
||||||
|
assert payload["local_hospital_name"] == "杭州市第一人民医院"
|
||||||
|
create_dialog.close()
|
||||||
|
|
||||||
|
order_dialog = consultations_module._DiagnosisOrderDialog(_row(), None)
|
||||||
|
order_dialog.order_type.setCurrentIndex(order_dialog.order_type.findData(2))
|
||||||
|
order_dialog.amount.setValue(88.5)
|
||||||
|
assert order_dialog.payload()["patient_id"] == 501
|
||||||
|
order_dialog.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_appointment_uses_doctor_route_capability_and_diagnosis_owner(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def create_diagnosis_appointment(
|
||||||
|
self, payload: dict[str, Any] | None = None, **fields: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
calls.append(dict(payload or fields))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
class AcceptedAppointmentDialog:
|
||||||
|
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self) -> Any:
|
||||||
|
return QDialog.DialogCode.Accepted
|
||||||
|
|
||||||
|
def payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"diagnosis_id": 501,
|
||||||
|
"patient_id": 301,
|
||||||
|
"doctor_id": 77,
|
||||||
|
"appointment_date": "2026-08-12",
|
||||||
|
"appointment_time": "09:00-09:30",
|
||||||
|
}
|
||||||
|
|
||||||
|
page = ConsultationsPage(
|
||||||
|
Repository(),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
|
||||||
|
)
|
||||||
|
page.table.set_rows([_row()])
|
||||||
|
page.table.selectRow(0)
|
||||||
|
monkeypatch.setattr(patients_module, "_AppointmentDialog", AcceptedAppointmentDialog)
|
||||||
|
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
|
page._book_selected_appointment()
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
{
|
||||||
|
"diagnosis_id": 501,
|
||||||
|
"patient_id": 501,
|
||||||
|
"doctor_id": 77,
|
||||||
|
"appointment_date": "2026-08-12",
|
||||||
|
"appointment_time": "09:00-09:30",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_assigned_assistant_can_enter_receive_only_live_watch(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
class Repository:
|
||||||
|
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
|
return {"diagnosis_id": diagnosis_id}
|
||||||
|
|
||||||
|
page = ConsultationsPage(
|
||||||
|
Repository(),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||||
|
current_user={"id": 2001, "name": "周医助"},
|
||||||
|
)
|
||||||
|
row = _row(
|
||||||
|
assistant_id=2001,
|
||||||
|
video_call_hint={"state": "live", "label": "通话中", "room_id": 9001},
|
||||||
|
)
|
||||||
|
page.table.set_rows([row])
|
||||||
|
page.table.selectRow(0)
|
||||||
|
requested: list[dict[str, Any]] = []
|
||||||
|
page.watch_requested.connect(requested.append)
|
||||||
|
|
||||||
|
watch_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||||
|
assert watch_cell is not None
|
||||||
|
watch_buttons = [
|
||||||
|
button
|
||||||
|
for button in watch_cell.findChildren(QToolButton)
|
||||||
|
if button.text() == "进入旁观"
|
||||||
|
]
|
||||||
|
assert len(watch_buttons) == 1
|
||||||
|
assert watch_buttons[0].isEnabled()
|
||||||
|
assert "不会开启摄像头与麦克风" in watch_buttons[0].toolTip()
|
||||||
|
watch_buttons[0].click()
|
||||||
|
|
||||||
|
assert requested == [{"diagnosis_id": 501, "patient_name": "林晓岚"}]
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_watch_entry_is_disabled_until_room_is_live_and_hidden_from_other_assistants(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
class Repository:
|
||||||
|
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
|
||||||
|
return {"diagnosis_id": diagnosis_id}
|
||||||
|
|
||||||
|
pending = ConsultationsPage(
|
||||||
|
Repository(),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||||
|
current_user={"id": 2001},
|
||||||
|
)
|
||||||
|
pending.table.set_rows(
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
assistant_id=2001,
|
||||||
|
video_call_hint={"state": "pending_room", "label": "接通中"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pending_cell = pending.table_host.fixed.indexWidget(pending.table_host.model.index(0, 10))
|
||||||
|
pending_button = next(
|
||||||
|
button
|
||||||
|
for button in pending_cell.findChildren(QToolButton)
|
||||||
|
if button.text() == "进入旁观"
|
||||||
|
)
|
||||||
|
assert not pending_button.isEnabled()
|
||||||
|
pending.close()
|
||||||
|
|
||||||
|
other = ConsultationsPage(
|
||||||
|
Repository(),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||||
|
current_user={"id": 2002},
|
||||||
|
)
|
||||||
|
other.table.set_rows(
|
||||||
|
[_row(assistant_id=2001, video_call_hint={"state": "live", "label": "通话中"})]
|
||||||
|
)
|
||||||
|
other_cell = other.table_host.fixed.indexWidget(other.table_host.model.index(0, 10))
|
||||||
|
assert all(button.text() != "进入旁观" for button in other_cell.findChildren(QToolButton))
|
||||||
|
assert any(label.text() == "通话中" for label in other_cell.findChildren(QLabel))
|
||||||
|
other.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
|
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
|
||||||
row = _row(
|
row = _row(
|
||||||
appointment_id=None,
|
appointment_id=None,
|
||||||
@@ -914,7 +1076,7 @@ def test_menu_handlers_call_only_permission_gated_real_repository_methods(
|
|||||||
("logs", 501),
|
("logs", 501),
|
||||||
(
|
(
|
||||||
"order",
|
"order",
|
||||||
{"patient_id": 301, "order_type": 2, "amount": 88.5, "remark": "复诊"},
|
{"patient_id": 501, "order_type": 2, "amount": 88.5, "remark": "复诊"},
|
||||||
),
|
),
|
||||||
("order_qr", "O-1"),
|
("order_qr", "O-1"),
|
||||||
]
|
]
|
||||||
@@ -1065,14 +1227,14 @@ def test_diagnosis_order_qr_failure_retries_without_creating_a_second_order(
|
|||||||
assert dialog.retry_button.isEnabled()
|
assert dialog.retry_button.isEnabled()
|
||||||
assert "生成失败" in dialog.status_label.text()
|
assert "生成失败" in dialog.status_label.text()
|
||||||
assert calls == [
|
assert calls == [
|
||||||
("create", (301, 2, 88.5, "复诊")),
|
("create", (501, 2, 88.5, "复诊")),
|
||||||
("qr", "PAY-RETRY-1"),
|
("qr", "PAY-RETRY-1"),
|
||||||
]
|
]
|
||||||
|
|
||||||
dialog.retry_button.click()
|
dialog.retry_button.click()
|
||||||
|
|
||||||
assert calls == [
|
assert calls == [
|
||||||
("create", (301, 2, 88.5, "复诊")),
|
("create", (501, 2, 88.5, "复诊")),
|
||||||
("qr", "PAY-RETRY-1"),
|
("qr", "PAY-RETRY-1"),
|
||||||
("qr", "PAY-RETRY-1"),
|
("qr", "PAY-RETRY-1"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class _CancellationRepository:
|
|||||||
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
|
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
|
||||||
del appointment_id
|
del appointment_id
|
||||||
|
|
||||||
|
def create_diagnosis_appointment(self, payload: Any = None, **fields: Any) -> None:
|
||||||
|
del payload, fields
|
||||||
|
|
||||||
|
|
||||||
class _FullMenuRepository(_CancellationRepository):
|
class _FullMenuRepository(_CancellationRepository):
|
||||||
def generate_video_qrcode(
|
def generate_video_qrcode(
|
||||||
@@ -468,6 +471,8 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
|||||||
"confirm_qr": True,
|
"confirm_qr": True,
|
||||||
"appointment_logs": True,
|
"appointment_logs": True,
|
||||||
"create_order": True,
|
"create_order": True,
|
||||||
|
"watch_call": False,
|
||||||
|
"watch_user_id": 0,
|
||||||
}
|
}
|
||||||
page.close()
|
page.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ class RecordingClient:
|
|||||||
return {}
|
return {}
|
||||||
if endpoint == "doctor.appointment/availableSlots":
|
if endpoint == "doctor.appointment/availableSlots":
|
||||||
return {"slots": [{"time": "09:00", "available": True}]}
|
return {"slots": [{"time": "09:00", "available": True}]}
|
||||||
|
if endpoint == "tcm.diagnosis/watchCall":
|
||||||
|
return {
|
||||||
|
"sdkAppId": 1400123456,
|
||||||
|
"userId": "doctor_20",
|
||||||
|
"userSig": "short-lived",
|
||||||
|
"roomId": 9001,
|
||||||
|
}
|
||||||
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
|
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
|
||||||
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
|
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
|
||||||
return {"lists": [], "count": 0, "extend": {"scope": {"label": "server"}}}
|
return {"lists": [], "count": 0, "extend": {"scope": {"label": "server"}}}
|
||||||
@@ -200,7 +207,26 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
|||||||
repository.assign_patient(5, 20, is_inherit=1)
|
repository.assign_patient(5, 20, is_inherit=1)
|
||||||
repository.fill_patient_id_card(5, "410000199001010000")
|
repository.fill_patient_id_card(5, "410000199001010000")
|
||||||
repository.book_patient_appointment({"diagnosis_id": 5, "appointment_date": "2026-08-10"})
|
repository.book_patient_appointment({"diagnosis_id": 5, "appointment_date": "2026-08-10"})
|
||||||
|
repository.create_diagnosis_appointment(
|
||||||
|
{
|
||||||
|
"diagnosis_id": 5,
|
||||||
|
"patient_id": 5,
|
||||||
|
"doctor_id": 9,
|
||||||
|
"appointment_date": "2026-08-11",
|
||||||
|
}
|
||||||
|
)
|
||||||
repository.cancel_patient_appointment(7)
|
repository.cancel_patient_appointment(7)
|
||||||
|
watch_ticket = repository.get_assistant_watch_ticket(5)
|
||||||
|
repository.create_diagnosis(
|
||||||
|
{
|
||||||
|
"patient_name": " 林晓岚 ",
|
||||||
|
"phone": "13800138000",
|
||||||
|
"gender": 0,
|
||||||
|
"age": 36,
|
||||||
|
"diagnosis_type": "复诊",
|
||||||
|
"local_hospital_name": "杭州市第一人民医院",
|
||||||
|
}
|
||||||
|
)
|
||||||
repository.update_diagnosis(5, {"clinical_diagnosis": "气虚证"})
|
repository.update_diagnosis(5, {"clinical_diagnosis": "气虚证"})
|
||||||
repository.list_appointment_rosters(
|
repository.list_appointment_rosters(
|
||||||
doctor_id=1,
|
doctor_id=1,
|
||||||
@@ -238,7 +264,28 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
|||||||
},
|
},
|
||||||
) in client.post_calls
|
) in client.post_calls
|
||||||
assert ("tcm.diagnosis/edit", {"id": 5, "clinical_diagnosis": "气虚证"}) in client.post_calls
|
assert ("tcm.diagnosis/edit", {"id": 5, "clinical_diagnosis": "气虚证"}) in client.post_calls
|
||||||
|
assert (
|
||||||
|
"doctor.appointment/create",
|
||||||
|
{
|
||||||
|
"diagnosis_id": 5,
|
||||||
|
"patient_id": 5,
|
||||||
|
"doctor_id": 9,
|
||||||
|
"appointment_date": "2026-08-11",
|
||||||
|
},
|
||||||
|
) in client.post_calls
|
||||||
|
assert (
|
||||||
|
"tcm.diagnosis/add",
|
||||||
|
{
|
||||||
|
"patient_name": "林晓岚",
|
||||||
|
"phone": "13800138000",
|
||||||
|
"gender": 0,
|
||||||
|
"age": 36,
|
||||||
|
"diagnosis_type": "复诊",
|
||||||
|
"local_hospital_name": "杭州市第一人民医院",
|
||||||
|
},
|
||||||
|
) in client.post_calls
|
||||||
assert slots == {"slots": [{"time": "09:00", "available": True}]}
|
assert slots == {"slots": [{"time": "09:00", "available": True}]}
|
||||||
|
assert watch_ticket["roomId"] == 9001
|
||||||
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
|
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
|
||||||
assert {
|
assert {
|
||||||
"tcm.prescriptionOrder/paidPayOrders",
|
"tcm.prescriptionOrder/paidPayOrders",
|
||||||
@@ -250,6 +297,7 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
|||||||
"tcm.diagnosis/assignLogList",
|
"tcm.diagnosis/assignLogList",
|
||||||
"doctor.roster/lists",
|
"doctor.roster/lists",
|
||||||
"doctor.appointment/availableSlots",
|
"doctor.appointment/availableSlots",
|
||||||
|
"tcm.diagnosis/watchCall",
|
||||||
} <= get_endpoints
|
} <= get_endpoints
|
||||||
|
|
||||||
|
|
||||||
@@ -270,6 +318,34 @@ def test_remote_note_rejects_local_material_references(unsafe_reference: str) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("changes", "message"),
|
||||||
|
[
|
||||||
|
({"gender": 2}, "gender"),
|
||||||
|
({"local_hospital_name": ""}, "local_hospital_name"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_remote_diagnosis_create_rejects_invalid_production_dto_before_transport(
|
||||||
|
changes: dict[str, Any], message: str
|
||||||
|
) -> None:
|
||||||
|
client = RecordingClient()
|
||||||
|
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||||
|
payload = {
|
||||||
|
"patient_name": "林晓岚",
|
||||||
|
"phone": "13800138000",
|
||||||
|
"gender": 0,
|
||||||
|
"age": 36,
|
||||||
|
"diagnosis_type": "复诊",
|
||||||
|
"local_hospital_name": "杭州市第一人民医院",
|
||||||
|
**changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
repository.create_diagnosis(payload)
|
||||||
|
|
||||||
|
assert not client.post_calls
|
||||||
|
|
||||||
|
|
||||||
def test_remote_dynamic_menu_preserves_json_metadata_but_drops_runtime_objects() -> None:
|
def test_remote_dynamic_menu_preserves_json_metadata_but_drops_runtime_objects() -> None:
|
||||||
"""Future menu fields pass through safely without evaluating arbitrary values."""
|
"""Future menu fields pass through safely without evaluating arbitrary values."""
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from doctor_workstation.video.launcher import ( # noqa: E402
|
|||||||
VideoCallLauncher,
|
VideoCallLauncher,
|
||||||
VideoCallRequest,
|
VideoCallRequest,
|
||||||
VideoTicketError,
|
VideoTicketError,
|
||||||
|
VideoWatchRequest,
|
||||||
normalize_backend_ticket,
|
normalize_backend_ticket,
|
||||||
|
normalize_backend_watch_ticket,
|
||||||
)
|
)
|
||||||
from doctor_workstation.video.lifecycle import OrderedCallLifecycle # noqa: E402
|
from doctor_workstation.video.lifecycle import OrderedCallLifecycle # noqa: E402
|
||||||
from doctor_workstation.video.security import ( # noqa: E402
|
from doctor_workstation.video.security import ( # noqa: E402
|
||||||
@@ -57,6 +59,58 @@ def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalizes_receive_only_assistant_watch_ticket() -> None:
|
||||||
|
request = normalize_backend_watch_ticket(
|
||||||
|
{
|
||||||
|
"sdkAppId": 1400123456,
|
||||||
|
"userId": "doctor_20",
|
||||||
|
"userSig": "short-lived-watch-ticket",
|
||||||
|
"strRoomId": " diagnosis-room-501 ",
|
||||||
|
"patientName": "林晓岚",
|
||||||
|
},
|
||||||
|
diagnosis_id=501,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request == VideoWatchRequest(
|
||||||
|
sdk_app_id=1400123456,
|
||||||
|
user_id="doctor_20",
|
||||||
|
user_sig="short-lived-watch-ticket",
|
||||||
|
diagnosis_id=501,
|
||||||
|
str_room_id="diagnosis-room-501",
|
||||||
|
patient_name="林晓岚",
|
||||||
|
)
|
||||||
|
assert request.to_web_config() == {
|
||||||
|
"SDKAppID": 1400123456,
|
||||||
|
"userID": "doctor_20",
|
||||||
|
"userSig": "short-lived-watch-ticket",
|
||||||
|
"diagnosisId": 501,
|
||||||
|
"patientName": "林晓岚",
|
||||||
|
"strRoomId": "diagnosis-room-501",
|
||||||
|
}
|
||||||
|
assert "short-lived-watch-ticket" not in repr(request)
|
||||||
|
assert "short-lived-watch-ticket" not in str(request.safe_log_context())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"room_fields",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"roomId": 9001, "strRoomId": "room-9001"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_missing_or_conflicting_watch_room(room_fields: dict[str, object]) -> None:
|
||||||
|
with pytest.raises(VideoTicketError, match="room"):
|
||||||
|
normalize_backend_watch_ticket(
|
||||||
|
{
|
||||||
|
"sdkAppId": 1400123456,
|
||||||
|
"userId": "doctor_20",
|
||||||
|
"userSig": "short-lived-watch-ticket",
|
||||||
|
**room_fields,
|
||||||
|
},
|
||||||
|
diagnosis_id=501,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_accepts_uppercase_aliases_and_nested_backend_envelope() -> None:
|
def test_accepts_uppercase_aliases_and_nested_backend_envelope() -> None:
|
||||||
request = VideoCallRequest.from_backend_ticket(
|
request = VideoCallRequest.from_backend_ticket(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
:root{font-family:Inter,PingFang SC,Microsoft YaHei,system-ui,sans-serif;color:#f7f8fa;background:#0b0f14;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0;overflow:hidden}button,input{font:inherit}.call-stage{position:relative;width:100%;height:100%;min-height:420px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(39,74,83,.22),transparent 38%),#0b0f14}.call-kit,.call-stage :is(.TUICallKit-desktop,.TUICallKit-mobile,#tuicallkit-id){width:100%!important;height:100%!important;max-width:none!important;max-height:none!important}.status-card{position:absolute;inset:50% auto auto 50%;display:grid;grid-template-columns:12px minmax(0,1fr);gap:18px;width:min(520px,calc(100% - 48px));padding:30px 32px;transform:translate(-50%,-50%);border:1px solid rgba(255,255,255,.09);border-radius:20px;background:#131920e6;box-shadow:0 24px 70px #00000052;backdrop-filter:blur(18px)}.eyebrow{margin:0 0 12px;color:#8f9ba8;font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase}.status-card h1{margin:0;font-size:clamp(22px,3.2vw,34px);font-weight:600;line-height:1.25}.status-hint{margin:14px 0 0;color:#9aa5b1;font-size:14px}.status-dot{width:10px;height:10px;margin-top:5px;border-radius:50%;background:#77818c;box-shadow:0 0 0 5px #77818c1f}.status-dot--starting,.status-dot--live{background:#52c99a;box-shadow:0 0 0 5px #52c99a24}.status-dot--error{background:#f26d6d;box-shadow:0 0 0 5px #f26d6d24}.live-status{position:absolute;z-index:20;top:18px;left:50%;display:flex;align-items:center;gap:10px;padding:9px 14px;transform:translate(-50%);border:1px solid rgba(255,255,255,.1);border-radius:999px;background:#0b0f14c2;color:#e8edf2;font-size:13px;backdrop-filter:blur(14px)}.live-status .status-dot{width:7px;height:7px;margin:0;box-shadow:none}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Inter,PingFang SC,Microsoft YaHei,system-ui,sans-serif;color:#f7f8fa;background:#0b0f14;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0;overflow:hidden}button,input{font:inherit}.call-stage{position:relative;width:100%;height:100%;min-height:420px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(39,74,83,.22),transparent 38%),#0b0f14}.call-kit,.call-stage :is(.TUICallKit-desktop,.TUICallKit-mobile,#tuicallkit-id){width:100%!important;height:100%!important;max-width:none!important;max-height:none!important}.watch-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;width:100%;height:100%;padding:64px 18px 18px}.watch-tile{position:relative;min-height:260px;overflow:hidden;border:1px solid rgba(255,255,255,.1);border-radius:14px;background:#10171f}.watch-tile__caption{position:absolute;z-index:2;top:12px;left:12px;padding:6px 10px;border-radius:6px;background:#080c11b8;color:#eef3f7;font-size:12px}.watch-tile__view{width:100%;height:100%}.status-card{position:absolute;inset:50% auto auto 50%;display:grid;grid-template-columns:12px minmax(0,1fr);gap:18px;width:min(520px,calc(100% - 48px));padding:30px 32px;transform:translate(-50%,-50%);border:1px solid rgba(255,255,255,.09);border-radius:20px;background:#131920e6;box-shadow:0 24px 70px #00000052;backdrop-filter:blur(18px)}.eyebrow{margin:0 0 12px;color:#8f9ba8;font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase}.status-card h1{margin:0;font-size:clamp(22px,3.2vw,34px);font-weight:600;line-height:1.25}.status-hint{margin:14px 0 0;color:#9aa5b1;font-size:14px}.status-dot{width:10px;height:10px;margin-top:5px;border-radius:50%;background:#77818c;box-shadow:0 0 0 5px #77818c1f}.status-dot--starting,.status-dot--live{background:#52c99a;box-shadow:0 0 0 5px #52c99a24}.status-dot--error{background:#f26d6d;box-shadow:0 0 0 5px #f26d6d24}.live-status{position:absolute;z-index:20;top:18px;left:50%;display:flex;align-items:center;gap:10px;padding:9px 14px;transform:translate(-50%);border:1px solid rgba(255,255,255,.1);border-radius:999px;background:#0b0f14c2;color:#e8edf2;font-size:13px;backdrop-filter:blur(14px)}.live-status .status-dot{width:7px;height:7px;margin:0;box-shadow:none}
|
||||||
+84
-84
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<title>视频面诊</title>
|
<title>视频面诊</title>
|
||||||
<script type="module" crossorigin src="./assets/index-le5ZH3pL.js"></script>
|
<script type="module" crossorigin src="./assets/index-DluArHub.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BuO_uDya.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CG-V4g-D.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+1
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@trtc/calls-uikit-vue": "4.4.6",
|
"@trtc/calls-uikit-vue": "4.4.6",
|
||||||
|
"trtc-sdk-v5": "5.15.3-beta.12",
|
||||||
"vue": "3.5.13"
|
"vue": "3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@trtc/calls-uikit-vue": "4.4.6",
|
"@trtc/calls-uikit-vue": "4.4.6",
|
||||||
|
"trtc-sdk-v5": "5.15.3-beta.12",
|
||||||
"vue": "3.5.13"
|
"vue": "3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TUICallKit } from '@trtc/calls-uikit-vue'
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
mode: Readonly<Ref<string>>
|
||||||
phase: Readonly<Ref<string>>
|
phase: Readonly<Ref<string>>
|
||||||
statusText: Readonly<Ref<string>>
|
statusText: Readonly<Ref<string>>
|
||||||
}>()
|
}>()
|
||||||
@@ -11,15 +12,18 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<main class="call-stage">
|
<main class="call-stage">
|
||||||
<TUICallKit
|
<TUICallKit
|
||||||
|
v-if="mode.value === 'call'"
|
||||||
class="call-kit"
|
class="call-kit"
|
||||||
:allowed-minimized="false"
|
:allowed-minimized="false"
|
||||||
:allowed-full-screen="true"
|
:allowed-full-screen="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section v-else id="watch-grid" class="watch-grid" aria-label="旁观视频画面" />
|
||||||
|
|
||||||
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
||||||
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">中医视频面诊</p>
|
<p class="eyebrow">{{ mode.value === 'watch' ? '医助旁观' : '中医视频面诊' }}</p>
|
||||||
<h1>{{ statusText.value }}</h1>
|
<h1>{{ statusText.value }}</h1>
|
||||||
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
|
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+18
@@ -16,12 +16,30 @@ interface DoctorCallApi {
|
|||||||
hangup(): Promise<void>
|
hangup(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DoctorWatchConfig {
|
||||||
|
SDKAppID?: number | string
|
||||||
|
sdkAppId?: number | string
|
||||||
|
userID?: string
|
||||||
|
userId?: string
|
||||||
|
userSig: string
|
||||||
|
diagnosisId: number | string
|
||||||
|
roomId?: number | string
|
||||||
|
strRoomId?: string
|
||||||
|
patientName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoctorWatchApi {
|
||||||
|
start(config: DoctorWatchConfig): Promise<void>
|
||||||
|
leave(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
interface QtVideoBridge {
|
interface QtVideoBridge {
|
||||||
notify?: (payload: string) => void
|
notify?: (payload: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
doctorCall: DoctorCallApi
|
doctorCall: DoctorCallApi
|
||||||
|
doctorWatch: DoctorWatchApi
|
||||||
qtVideoBridge?: QtVideoBridge
|
qtVideoBridge?: QtVideoBridge
|
||||||
qt?: { webChannelTransport?: unknown }
|
qt?: { webChannelTransport?: unknown }
|
||||||
QWebChannel?: new (
|
QWebChannel?: new (
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import {
|
|||||||
TUICallKitAPI,
|
TUICallKitAPI,
|
||||||
TUICallType,
|
TUICallType,
|
||||||
} from '@trtc/calls-uikit-vue'
|
} from '@trtc/calls-uikit-vue'
|
||||||
|
import TRTC from 'trtc-sdk-v5'
|
||||||
|
import type { TRTCStreamType } from 'trtc-sdk-v5'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||||
|
type CompanionMode = 'call' | 'watch'
|
||||||
|
|
||||||
interface NormalizedCallConfig {
|
interface NormalizedCallConfig {
|
||||||
SDKAppID: number
|
SDKAppID: number
|
||||||
@@ -21,7 +24,7 @@ interface NormalizedCallConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BridgeMessage {
|
interface BridgeMessage {
|
||||||
source: 'doctor-call'
|
source: 'doctor-call' | 'assistant-watch'
|
||||||
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
|
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
|
||||||
diagnosisId?: number | string
|
diagnosisId?: number | string
|
||||||
status?: string
|
status?: string
|
||||||
@@ -31,6 +34,7 @@ interface BridgeMessage {
|
|||||||
|
|
||||||
const phase = ref<CallPhase>('ready')
|
const phase = ref<CallPhase>('ready')
|
||||||
const statusText = ref('等待桌面端发起视频面诊')
|
const statusText = ref('等待桌面端发起视频面诊')
|
||||||
|
const mode = ref<CompanionMode>('call')
|
||||||
let activeConfig: NormalizedCallConfig | null = null
|
let activeConfig: NormalizedCallConfig | null = null
|
||||||
let endNotified = false
|
let endNotified = false
|
||||||
let starting = false
|
let starting = false
|
||||||
@@ -129,6 +133,7 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
|||||||
function safeErrorMessage(error: unknown): string {
|
function safeErrorMessage(error: unknown): string {
|
||||||
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
|
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
|
||||||
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
|
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
|
||||||
|
if (watchConfig?.userSig) message = message.split(watchConfig.userSig).join('[REDACTED]')
|
||||||
return message
|
return message
|
||||||
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
|
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
|
||||||
.slice(0, 400)
|
.slice(0, 400)
|
||||||
@@ -279,9 +284,219 @@ async function hangup(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.doctorCall = { start, hangup }
|
window.doctorCall = { start, hangup }
|
||||||
|
|
||||||
|
interface NormalizedWatchConfig {
|
||||||
|
SDKAppID: number
|
||||||
|
userID: string
|
||||||
|
userSig: string
|
||||||
|
diagnosisId: number | string
|
||||||
|
roomId?: number
|
||||||
|
strRoomId?: string
|
||||||
|
patientName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchTile = {
|
||||||
|
wrap: HTMLElement
|
||||||
|
userId: string
|
||||||
|
streamType: TRTCStreamType
|
||||||
|
}
|
||||||
|
|
||||||
|
let watchTrtc: ReturnType<typeof TRTC.create> | null = null
|
||||||
|
let watchConfig: NormalizedWatchConfig | null = null
|
||||||
|
let watchStarting = false
|
||||||
|
let watchEnded = true
|
||||||
|
const watchTiles = new Map<string, WatchTile>()
|
||||||
|
|
||||||
|
function normalizeWatchConfig(config: DoctorWatchConfig): NormalizedWatchConfig {
|
||||||
|
if (!config || typeof config !== 'object') throw new Error('旁观配置无效')
|
||||||
|
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
|
||||||
|
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) {
|
||||||
|
throw new Error('SDKAppID 必须是正整数')
|
||||||
|
}
|
||||||
|
const rawDiagnosisId = config.diagnosisId
|
||||||
|
if (
|
||||||
|
rawDiagnosisId === undefined
|
||||||
|
|| rawDiagnosisId === null
|
||||||
|
|| (typeof rawDiagnosisId === 'string' && rawDiagnosisId.trim() === '')
|
||||||
|
) {
|
||||||
|
throw new Error('diagnosisId 不能为空')
|
||||||
|
}
|
||||||
|
const numericRoom = config.roomId == null ? undefined : Number(config.roomId)
|
||||||
|
const strRoomId = typeof config.strRoomId === 'string' ? config.strRoomId.trim() : ''
|
||||||
|
if (numericRoom !== undefined && (!Number.isSafeInteger(numericRoom) || numericRoom <= 0)) {
|
||||||
|
throw new Error('roomId 必须是正整数')
|
||||||
|
}
|
||||||
|
if (numericRoom === undefined && !strRoomId) throw new Error('缺少房间号')
|
||||||
|
if (numericRoom !== undefined && strRoomId) throw new Error('房间号配置冲突')
|
||||||
|
return {
|
||||||
|
SDKAppID,
|
||||||
|
userID: cleanString(config.userID ?? config.userId, 'userID'),
|
||||||
|
userSig: cleanString(config.userSig, 'userSig'),
|
||||||
|
diagnosisId: typeof rawDiagnosisId === 'string' ? rawDiagnosisId.trim() : rawDiagnosisId,
|
||||||
|
...(numericRoom !== undefined ? { roomId: numericRoom } : { strRoomId }),
|
||||||
|
patientName: typeof config.patientName === 'string' ? config.patientName.trim() : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchTileKey(userId: string, streamType: TRTCStreamType): string {
|
||||||
|
return `${userId}\u0000${String(streamType)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchRemoteLabel(userId: string): string {
|
||||||
|
if (userId.startsWith('patient_')) return '患者'
|
||||||
|
if (userId.startsWith('doctor_')) return '医护'
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onWatchVideoAvailable(event: {
|
||||||
|
userId: string
|
||||||
|
streamType: TRTCStreamType
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!watchTrtc || event.streamType !== TRTC.TYPE.STREAM_TYPE_MAIN) return
|
||||||
|
const grid = document.getElementById('watch-grid')
|
||||||
|
if (!grid) return
|
||||||
|
const key = watchTileKey(event.userId, event.streamType)
|
||||||
|
if (watchTiles.has(key)) return
|
||||||
|
const wrap = document.createElement('article')
|
||||||
|
wrap.className = 'watch-tile'
|
||||||
|
const caption = document.createElement('div')
|
||||||
|
caption.className = 'watch-tile__caption'
|
||||||
|
caption.textContent = watchRemoteLabel(event.userId)
|
||||||
|
const view = document.createElement('div')
|
||||||
|
view.className = 'watch-tile__view'
|
||||||
|
wrap.append(caption, view)
|
||||||
|
grid.appendChild(wrap)
|
||||||
|
watchTiles.set(key, { wrap, userId: event.userId, streamType: event.streamType })
|
||||||
|
try {
|
||||||
|
await watchTrtc.startRemoteVideo({
|
||||||
|
userId: event.userId,
|
||||||
|
streamType: event.streamType,
|
||||||
|
view,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
wrap.remove()
|
||||||
|
watchTiles.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onWatchVideoUnavailable(event: {
|
||||||
|
userId: string
|
||||||
|
streamType: TRTCStreamType
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!watchTrtc) return
|
||||||
|
const key = watchTileKey(event.userId, event.streamType)
|
||||||
|
const tile = watchTiles.get(key)
|
||||||
|
if (!tile) return
|
||||||
|
try {
|
||||||
|
await watchTrtc.stopRemoteVideo({ userId: tile.userId, streamType: tile.streamType })
|
||||||
|
} catch {
|
||||||
|
// The stream may already have been removed by the SDK.
|
||||||
|
}
|
||||||
|
tile.wrap.remove()
|
||||||
|
watchTiles.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupWatch(notify = false): Promise<void> {
|
||||||
|
const trtc = watchTrtc
|
||||||
|
watchTrtc = null
|
||||||
|
if (trtc) {
|
||||||
|
trtc.off(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, onWatchVideoAvailable)
|
||||||
|
trtc.off(TRTC.EVENT.REMOTE_VIDEO_UNAVAILABLE, onWatchVideoUnavailable)
|
||||||
|
for (const [, tile] of watchTiles) {
|
||||||
|
try {
|
||||||
|
await trtc.stopRemoteVideo({ userId: tile.userId, streamType: tile.streamType })
|
||||||
|
} catch {
|
||||||
|
// Best-effort shutdown.
|
||||||
|
}
|
||||||
|
tile.wrap.remove()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await trtc.exitRoom()
|
||||||
|
} catch {
|
||||||
|
// A room may already have ended remotely.
|
||||||
|
}
|
||||||
|
trtc.destroy()
|
||||||
|
}
|
||||||
|
watchTiles.clear()
|
||||||
|
document.getElementById('watch-grid')?.replaceChildren()
|
||||||
|
if (notify && watchConfig && !watchEnded) {
|
||||||
|
watchEnded = true
|
||||||
|
emit({
|
||||||
|
source: 'assistant-watch',
|
||||||
|
event: 'hangup',
|
||||||
|
diagnosisId: watchConfig.diagnosisId,
|
||||||
|
status: 'left',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watchConfig = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWatch(config: DoctorWatchConfig): Promise<void> {
|
||||||
|
if (watchStarting || watchTrtc || (activeConfig && !endNotified)) {
|
||||||
|
throw new Error('已有视频会话正在进行')
|
||||||
|
}
|
||||||
|
watchStarting = true
|
||||||
|
watchEnded = false
|
||||||
|
mode.value = 'watch'
|
||||||
|
phase.value = 'starting'
|
||||||
|
statusText.value = '正在以只读模式进入房间'
|
||||||
|
try {
|
||||||
|
const normalized = normalizeWatchConfig(config)
|
||||||
|
watchConfig = normalized
|
||||||
|
await nextTick()
|
||||||
|
const trtc = TRTC.create()
|
||||||
|
watchTrtc = trtc
|
||||||
|
trtc.on(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, onWatchVideoAvailable)
|
||||||
|
trtc.on(TRTC.EVENT.REMOTE_VIDEO_UNAVAILABLE, onWatchVideoUnavailable)
|
||||||
|
await trtc.enterRoom({
|
||||||
|
sdkAppId: normalized.SDKAppID,
|
||||||
|
userId: normalized.userID,
|
||||||
|
userSig: normalized.userSig,
|
||||||
|
autoReceiveAudio: true,
|
||||||
|
autoReceiveVideo: true,
|
||||||
|
...(normalized.roomId !== undefined
|
||||||
|
? { roomId: normalized.roomId }
|
||||||
|
: { strRoomId: normalized.strRoomId as string }),
|
||||||
|
})
|
||||||
|
phase.value = 'connected'
|
||||||
|
statusText.value = normalized.patientName
|
||||||
|
? `正在旁观 ${normalized.patientName} 的通话`
|
||||||
|
: '正在旁观视频通话'
|
||||||
|
emit({
|
||||||
|
source: 'assistant-watch',
|
||||||
|
event: 'status',
|
||||||
|
diagnosisId: normalized.diagnosisId,
|
||||||
|
status: 'joined',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = safeErrorMessage(error)
|
||||||
|
phase.value = 'error'
|
||||||
|
statusText.value = message
|
||||||
|
emit({
|
||||||
|
source: 'assistant-watch',
|
||||||
|
event: 'error',
|
||||||
|
diagnosisId: watchConfig?.diagnosisId,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
await cleanupWatch(false)
|
||||||
|
watchEnded = true
|
||||||
|
throw new Error(message)
|
||||||
|
} finally {
|
||||||
|
watchStarting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveWatch(): Promise<void> {
|
||||||
|
await cleanupWatch(true)
|
||||||
|
phase.value = 'ended'
|
||||||
|
statusText.value = '已离开旁观房间'
|
||||||
|
}
|
||||||
|
|
||||||
|
window.doctorWatch = { start: startWatch, leave: leaveWatch }
|
||||||
initializeQtWebChannel()
|
initializeQtWebChannel()
|
||||||
|
|
||||||
createApp(App, {
|
createApp(App, {
|
||||||
|
mode: readonly(mode),
|
||||||
phase: readonly(phase),
|
phase: readonly(phase),
|
||||||
statusText: readonly(statusText),
|
statusText: readonly(statusText),
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
@@ -43,6 +43,41 @@ input {
|
|||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 64px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-tile {
|
||||||
|
position: relative;
|
||||||
|
min-height: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #10171f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-tile__caption {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(8, 12, 17, 0.72);
|
||||||
|
color: #eef3f7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-tile__view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 50% auto auto 50%;
|
inset: 50% auto auto 50%;
|
||||||
|
|||||||
Reference in New Issue
Block a user