Compare commits
1
Commits
chufang-9-9
...
app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de990a921b |
@@ -40,7 +40,7 @@ from doctor_workstation.ui.widgets import (
|
||||
set_authentication_expired_handler,
|
||||
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
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -66,10 +66,17 @@ class _UnconfiguredRepository:
|
||||
class DemoVideoDialog(QDialog):
|
||||
"""Non-network video-room preview used only by the explicit demo mode."""
|
||||
|
||||
def __init__(self, patient_name: str, parent: QWidget | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
patient_name: str,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
watch_only: bool = False,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._seconds = 0
|
||||
self.setWindowTitle("视频面诊 · 演示模式")
|
||||
self.watch_only = watch_only
|
||||
self.setWindowTitle("旁观视频通话 · 演示模式" if watch_only else "视频面诊 · 演示模式")
|
||||
self.setMinimumSize(760, 520)
|
||||
self.resize(980, 660)
|
||||
self.setModal(False)
|
||||
@@ -88,7 +95,11 @@ class DemoVideoDialog(QDialog):
|
||||
root.setContentsMargins(22, 18, 22, 22)
|
||||
root.setSpacing(14)
|
||||
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;")
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
@@ -112,41 +123,47 @@ class DemoVideoDialog(QDialog):
|
||||
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
|
||||
)
|
||||
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
waiting = QLabel("等待患者接听…")
|
||||
waiting = QLabel("旁观演示画面" if watch_only else "等待患者接听…")
|
||||
waiting.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
waiting.setStyleSheet("font-size:17px;font-weight:600;")
|
||||
stage_layout.addWidget(waiting)
|
||||
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
|
||||
hint = QLabel(
|
||||
"仅观看,不会开启摄像头与麦克风"
|
||||
if watch_only
|
||||
else "生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit"
|
||||
)
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint.setStyleSheet("color:#80948D;font-size:12px;")
|
||||
stage_layout.addWidget(hint)
|
||||
stage_layout.addStretch(1)
|
||||
|
||||
local = QFrame(stage)
|
||||
local.setObjectName("LocalStage")
|
||||
local.setGeometry(24, 24, 178, 112)
|
||||
local_layout = QVBoxLayout(local)
|
||||
local_label = QLabel("医生画面")
|
||||
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
local_label.setStyleSheet("color:#A9BDB6;font-weight:600;")
|
||||
local_layout.addWidget(local_label)
|
||||
if not watch_only:
|
||||
local = QFrame(stage)
|
||||
local.setObjectName("LocalStage")
|
||||
local.setGeometry(24, 24, 178, 112)
|
||||
local_layout = QVBoxLayout(local)
|
||||
local_label = QLabel("医生画面")
|
||||
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
local_label.setStyleSheet("color:#A9BDB6;font-weight:600;")
|
||||
local_layout.addWidget(local_label)
|
||||
root.addWidget(stage, 1)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.addStretch(1)
|
||||
self.mic_button = QPushButton("麦克风 开")
|
||||
self.mic_button.setCheckable(True)
|
||||
self.mic_button.toggled.connect(
|
||||
lambda muted: self.mic_button.setText("麦克风 关" if muted else "麦克风 开")
|
||||
)
|
||||
controls.addWidget(self.mic_button)
|
||||
self.camera_button = QPushButton("摄像头 开")
|
||||
self.camera_button.setCheckable(True)
|
||||
self.camera_button.toggled.connect(
|
||||
lambda off: self.camera_button.setText("摄像头 关" if off else "摄像头 开")
|
||||
)
|
||||
controls.addWidget(self.camera_button)
|
||||
hangup = QPushButton("结束面诊")
|
||||
if not watch_only:
|
||||
self.mic_button = QPushButton("麦克风 开")
|
||||
self.mic_button.setCheckable(True)
|
||||
self.mic_button.toggled.connect(
|
||||
lambda muted: self.mic_button.setText("麦克风 关" if muted else "麦克风 开")
|
||||
)
|
||||
controls.addWidget(self.mic_button)
|
||||
self.camera_button = QPushButton("摄像头 开")
|
||||
self.camera_button.setCheckable(True)
|
||||
self.camera_button.toggled.connect(
|
||||
lambda off: self.camera_button.setText("摄像头 关" if off else "摄像头 开")
|
||||
)
|
||||
controls.addWidget(self.camera_button)
|
||||
hangup = QPushButton("离开旁观" if watch_only else "结束面诊")
|
||||
hangup.setObjectName("Hangup")
|
||||
hangup.clicked.connect(self.close)
|
||||
controls.addWidget(hangup)
|
||||
@@ -179,6 +196,8 @@ class ApplicationController(QObject):
|
||||
self.current_demo_mode = config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
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._restore_generation = 0
|
||||
self._restore_in_progress = False
|
||||
@@ -441,6 +460,7 @@ class ApplicationController(QObject):
|
||||
self.shell_window = ShellWindow(repository, payload, session.permissions)
|
||||
self.shell_window.logout_requested.connect(self._logout)
|
||||
self.shell_window.video_requested.connect(self._request_video)
|
||||
self.shell_window.watch_requested.connect(self._request_watch)
|
||||
self._apply_window_icon(self.shell_window)
|
||||
if self.login_window is not None:
|
||||
self.login_window.hide()
|
||||
@@ -478,6 +498,11 @@ class ApplicationController(QObject):
|
||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||
self.video_calls.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():
|
||||
dialog.close()
|
||||
self.demo_video_dialogs.clear()
|
||||
@@ -508,7 +533,10 @@ class ApplicationController(QObject):
|
||||
if (
|
||||
call_key in self.video_pending
|
||||
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 f"watch:{call_key}" in self.demo_video_dialogs
|
||||
):
|
||||
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
||||
return
|
||||
@@ -630,6 +658,130 @@ class ApplicationController(QObject):
|
||||
if self.video_calls.get(call_key) is call:
|
||||
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:
|
||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||
self.demo_video_dialogs.pop(call_key, None)
|
||||
@@ -672,6 +824,11 @@ class ApplicationController(QObject):
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
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:
|
||||
with suppress(Exception):
|
||||
self.remote_repository.client.close()
|
||||
|
||||
@@ -395,6 +395,7 @@ class Consultation:
|
||||
has_prescription: bool = False
|
||||
unserved_days: int | None = None
|
||||
video_hint: str = ""
|
||||
video_call_hint: JSONDict = field(default_factory=dict)
|
||||
source: int | str | None = None
|
||||
source_text: str = ""
|
||||
remark: str = ""
|
||||
@@ -429,6 +430,12 @@ class Consultation:
|
||||
appointment_status_value = source.get("appointment_status")
|
||||
if appointment_status_value in (None, ""):
|
||||
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(
|
||||
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),
|
||||
@@ -437,8 +444,8 @@ class Consultation:
|
||||
patient_phone=_text(source.get("patient_phone", source.get("phone"))),
|
||||
phone_masked=_text(source.get("phone_masked")),
|
||||
id_card=_text(source.get("id_card")),
|
||||
gender=source.get("gender", source.get("gender_desc")),
|
||||
gender_desc=_text(source.get("gender_desc")),
|
||||
gender=gender_value,
|
||||
gender_desc=gender_description,
|
||||
age=_integer(source.get("age"), None),
|
||||
doctor_id=_integer(source.get("doctor_id", appointment.get("doctor_id")), None),
|
||||
doctor_name=_text(source.get("doctor_name", appointment.get("doctor_name"))),
|
||||
@@ -504,7 +511,13 @@ class Consultation:
|
||||
unserved_days=_integer(
|
||||
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_text=_text(source.get("source_text", source.get("source_name"))),
|
||||
remark=_text(source.get("remark")),
|
||||
|
||||
@@ -29,6 +29,7 @@ from .repository import (
|
||||
_audit_action,
|
||||
_body,
|
||||
_daily_record_body,
|
||||
_diagnosis_create_body,
|
||||
_identified_body,
|
||||
_material_kind,
|
||||
_prescription_payload,
|
||||
@@ -62,6 +63,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
||||
"tcm.diagnosis/setRevisitSlotStartOffset",
|
||||
"tcm.diagnosis/guahaoLogList",
|
||||
"tcm.diagnosis/guahao",
|
||||
"tcm.diagnosis/watchCall",
|
||||
"tcm.diagnosis/order",
|
||||
"tcm.diagnosis/qrcode",
|
||||
"tcm.diagnosis/videoQr",
|
||||
@@ -1455,6 +1457,13 @@ class DemoDoctorRepository:
|
||||
consultation.appointment_time = appointment.appointment_time
|
||||
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]:
|
||||
"""Persist cancellation status 2 across all demo workspaces."""
|
||||
|
||||
@@ -1484,6 +1493,21 @@ class DemoDoctorRepository:
|
||||
raise ValueError("appointment_id must be positive")
|
||||
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]:
|
||||
"""Return the complete readonly diagnosis aggregate."""
|
||||
|
||||
@@ -1645,7 +1669,7 @@ class DemoDoctorRepository:
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new mutable demo diagnosis and matching patient row."""
|
||||
|
||||
body = _body(diagnosis, fields)
|
||||
body = _diagnosis_create_body(diagnosis, fields)
|
||||
with self._lock:
|
||||
diagnosis_id = max((row.id for row in self._consultations), default=500) + 1
|
||||
body["id"] = diagnosis_id
|
||||
|
||||
@@ -223,12 +223,20 @@ class DoctorRepository(Protocol):
|
||||
) -> Any:
|
||||
"""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:
|
||||
"""Cancel an appointment from the patient workspace."""
|
||||
|
||||
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
|
||||
"""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]:
|
||||
"""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))
|
||||
|
||||
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:
|
||||
"""Cancel an appointment through the patient-scoped endpoint."""
|
||||
|
||||
@@ -1548,6 +1563,18 @@ class RemoteDoctorRepository:
|
||||
raise ValueError("appointment_id must be positive")
|
||||
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]:
|
||||
"""Compatibility name for permission-aware readonly diagnosis details."""
|
||||
|
||||
@@ -1603,7 +1630,7 @@ class RemoteDoctorRepository:
|
||||
) -> dict[str, Any]:
|
||||
"""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)
|
||||
return _merge_result(body, result)
|
||||
|
||||
@@ -2285,6 +2312,44 @@ def _body(
|
||||
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_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
|
||||
|
||||
@@ -1284,7 +1284,7 @@ class DiagnosisTableHost(QFrame):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
action_policy: Mapping[str, bool] | None = None,
|
||||
action_policy: Mapping[str, Any] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -1419,8 +1419,51 @@ class DiagnosisTableHost(QFrame):
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(3, 2, 3, 2)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
video_capable = self.action_policy.get("video_call", False)
|
||||
if video_capable and _appointment_active(record):
|
||||
watch_state = str(first_value(record, "video_call_hint.state", default="none") or "none")
|
||||
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.setText("进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
|
||||
@@ -390,6 +390,12 @@ def _watch_cell(_value: Any, row: Any) -> str:
|
||||
}.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]:
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return list(value)
|
||||
@@ -428,7 +434,7 @@ class _DiagnosisCreateDialog(QDialog):
|
||||
form.addRow("手机号 *", self.phone)
|
||||
self.gender = QComboBox()
|
||||
self.gender.addItem("男", 1)
|
||||
self.gender.addItem("女", 2)
|
||||
self.gender.addItem("女", 0)
|
||||
form.addRow("性别 *", self.gender)
|
||||
self.age = QSpinBox()
|
||||
self.age.setRange(0, 150)
|
||||
@@ -445,6 +451,9 @@ class _DiagnosisCreateDialog(QDialog):
|
||||
form.addRow("诊断类型 *", self.diagnosis_type)
|
||||
self.syndrome_type = QLineEdit()
|
||||
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()
|
||||
form.addRow("当地医院诊断 *", self.local_hospital_diagnosis)
|
||||
root.addLayout(form)
|
||||
@@ -471,6 +480,7 @@ class _DiagnosisCreateDialog(QDialog):
|
||||
"fasting_blood_sugar": self.fasting_blood_sugar.value(),
|
||||
"diagnosis_type": self.diagnosis_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 [],
|
||||
"diagnosis_date": QDate.currentDate().toString("yyyy-MM-dd"),
|
||||
"status": 1,
|
||||
@@ -487,6 +497,9 @@ class _DiagnosisCreateDialog(QDialog):
|
||||
if not payload["diagnosis_type"]:
|
||||
self.banner.show_message("请输入诊断类型。", "warning")
|
||||
return
|
||||
if not payload["local_hospital_name"]:
|
||||
self.banner.show_message("请输入当地就诊医院名称。", "warning")
|
||||
return
|
||||
if not payload["local_hospital_diagnosis"]:
|
||||
self.banner.show_message("请输入当地医院诊断。", "warning")
|
||||
return
|
||||
@@ -526,13 +539,14 @@ class _DiagnosisOrderDialog(QDialog):
|
||||
|
||||
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
|
||||
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.setMinimumWidth(480)
|
||||
root = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
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)
|
||||
form.addRow("患者", patient)
|
||||
@@ -561,8 +575,8 @@ class _DiagnosisOrderDialog(QDialog):
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _accept_checked(self) -> None:
|
||||
if self._patient_id <= 0:
|
||||
QMessageBox.warning(self, "创建订单", "患者标识不完整,无法创建订单。")
|
||||
if self._diagnosis_id <= 0:
|
||||
QMessageBox.warning(self, "创建订单", "诊单标识不完整,无法创建订单。")
|
||||
return
|
||||
if self.order_type.currentData() is None:
|
||||
QMessageBox.warning(self, "创建订单", "请选择订单类型。")
|
||||
@@ -571,7 +585,8 @@ class _DiagnosisOrderDialog(QDialog):
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
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()),
|
||||
"amount": float(self.amount.value()),
|
||||
"remark": self.remark.text().strip(),
|
||||
@@ -844,6 +859,7 @@ class ConsultationsPage(QWidget):
|
||||
"""Diagnosis workspace with canonical filters and guarded row actions."""
|
||||
|
||||
video_requested = Signal(dict)
|
||||
watch_requested = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -894,6 +910,10 @@ class ConsultationsPage(QWidget):
|
||||
"appointment_logs": _repository_method_name(repository, "list_appointment_logs"),
|
||||
"create_order": _repository_method_name(repository, "create_diagnosis_order"),
|
||||
"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)
|
||||
@@ -1186,10 +1206,18 @@ class ConsultationsPage(QWidget):
|
||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||
"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"),
|
||||
"delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"),
|
||||
"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(
|
||||
_canonical_allowed(permissions, "tcm.diagnosis/guahao")
|
||||
and self._repository_methods["cancel_appointment"]
|
||||
@@ -1870,6 +1898,7 @@ class ConsultationsPage(QWidget):
|
||||
"confirm_qr": self._request_confirm_qr,
|
||||
"appointment_logs": self._request_appointment_logs,
|
||||
"create_order": self._create_diagnosis_order,
|
||||
"watch_call": self._request_watch_call,
|
||||
"delete": self._delete_selected,
|
||||
}
|
||||
handler = actions.get(action)
|
||||
@@ -2014,22 +2043,42 @@ class ConsultationsPage(QWidget):
|
||||
self._run_mutation(cancel, "医助指派已取消。")
|
||||
|
||||
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
|
||||
record = self.table.current_data()
|
||||
if record is None:
|
||||
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.
|
||||
from .patients import _AppointmentDialog
|
||||
|
||||
dialog = _AppointmentDialog(record, repository=self.repository, parent=self)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
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(
|
||||
lambda: invoke(
|
||||
self.repository,
|
||||
"book_patient_appointment",
|
||||
method_name,
|
||||
payload=payload,
|
||||
**payload,
|
||||
),
|
||||
@@ -2289,9 +2338,8 @@ class ConsultationsPage(QWidget):
|
||||
):
|
||||
return
|
||||
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 or patient_id <= 0:
|
||||
show_toast(self, "患者或诊单标识不完整,无法创建订单。", "warning")
|
||||
if diagnosis_id <= 0:
|
||||
show_toast(self, "诊单标识不完整,无法创建订单。", "warning")
|
||||
return
|
||||
dialog = _DiagnosisOrderDialog(record, self)
|
||||
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, qr_method_name, None))
|
||||
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")
|
||||
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)
|
||||
generation = self._begin_order_flow()
|
||||
run_async(
|
||||
@@ -2321,7 +2371,6 @@ class ConsultationsPage(QWidget):
|
||||
result,
|
||||
snapshot,
|
||||
diagnosis_id,
|
||||
patient_id,
|
||||
generation,
|
||||
),
|
||||
on_error=lambda error: self._diagnosis_order_create_error(error, generation),
|
||||
@@ -2346,7 +2395,6 @@ class ConsultationsPage(QWidget):
|
||||
self,
|
||||
generation: int,
|
||||
diagnosis_id: int,
|
||||
patient_id: int,
|
||||
) -> bool:
|
||||
if generation != self._order_flow_generation:
|
||||
return False
|
||||
@@ -2356,23 +2404,18 @@ class ConsultationsPage(QWidget):
|
||||
if method_name is None or not callable(getattr(self.repository, method_name, None)):
|
||||
return False
|
||||
current = self.table.current_data()
|
||||
return bool(
|
||||
_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
|
||||
)
|
||||
return _as_int(first_value(current, "diagnosis_id", "id", default=0)) == diagnosis_id
|
||||
|
||||
def _diagnosis_order_created(
|
||||
self,
|
||||
result: Any,
|
||||
record: Any,
|
||||
diagnosis_id: int,
|
||||
patient_id: int,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._order_flow_generation:
|
||||
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._finish_order_flow(generation, refresh=True)
|
||||
return
|
||||
@@ -2390,7 +2433,6 @@ class ConsultationsPage(QWidget):
|
||||
lambda retry_order_no: self._request_order_qr(
|
||||
retry_order_no,
|
||||
diagnosis_id,
|
||||
patient_id,
|
||||
generation,
|
||||
dialog,
|
||||
)
|
||||
@@ -2400,7 +2442,6 @@ class ConsultationsPage(QWidget):
|
||||
self._request_order_qr(
|
||||
order_no,
|
||||
diagnosis_id,
|
||||
patient_id,
|
||||
generation,
|
||||
dialog,
|
||||
)
|
||||
@@ -2409,7 +2450,6 @@ class ConsultationsPage(QWidget):
|
||||
self,
|
||||
order_no: str,
|
||||
diagnosis_id: int,
|
||||
patient_id: int,
|
||||
generation: int,
|
||||
dialog: _DiagnosisOrderQrDialog,
|
||||
) -> None:
|
||||
@@ -2418,7 +2458,7 @@ class ConsultationsPage(QWidget):
|
||||
if order_no != dialog.order_no:
|
||||
dialog.set_failure("订单号已变化,无法生成付款二维码。", retryable=False)
|
||||
return
|
||||
if not self._order_flow_guard(generation, diagnosis_id, patient_id):
|
||||
if not self._order_flow_guard(generation, diagnosis_id):
|
||||
dialog.set_failure(
|
||||
"权限或当前诊单已变化,无法生成付款二维码。",
|
||||
retryable=False,
|
||||
@@ -2438,14 +2478,12 @@ class ConsultationsPage(QWidget):
|
||||
on_success=lambda result: self._diagnosis_order_qr_success(
|
||||
result,
|
||||
diagnosis_id,
|
||||
patient_id,
|
||||
generation,
|
||||
dialog,
|
||||
),
|
||||
on_error=lambda error: self._diagnosis_order_qr_error(
|
||||
error,
|
||||
diagnosis_id,
|
||||
patient_id,
|
||||
generation,
|
||||
dialog,
|
||||
),
|
||||
@@ -2456,13 +2494,12 @@ class ConsultationsPage(QWidget):
|
||||
self,
|
||||
result: Any,
|
||||
diagnosis_id: int,
|
||||
patient_id: int,
|
||||
generation: int,
|
||||
dialog: _DiagnosisOrderQrDialog,
|
||||
) -> None:
|
||||
if dialog is not self._order_qr_dialog or generation != self._order_flow_generation:
|
||||
return
|
||||
if not self._order_flow_guard(generation, diagnosis_id, patient_id):
|
||||
if not self._order_flow_guard(generation, diagnosis_id):
|
||||
dialog.set_failure(
|
||||
"权限或当前诊单已变化,未展示付款二维码。",
|
||||
retryable=False,
|
||||
@@ -2479,13 +2516,12 @@ class ConsultationsPage(QWidget):
|
||||
self,
|
||||
error: Exception,
|
||||
diagnosis_id: int,
|
||||
patient_id: int,
|
||||
generation: int,
|
||||
dialog: _DiagnosisOrderQrDialog,
|
||||
) -> None:
|
||||
if dialog is not self._order_qr_dialog or generation != self._order_flow_generation:
|
||||
return
|
||||
retryable = self._order_flow_guard(generation, diagnosis_id, patient_id)
|
||||
retryable = self._order_flow_guard(generation, diagnosis_id)
|
||||
message = (
|
||||
f"付款二维码生成失败:{friendly_error(error)}"
|
||||
if retryable
|
||||
@@ -2904,6 +2940,31 @@ class ConsultationsPage(QWidget):
|
||||
return
|
||||
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:
|
||||
"""Refresh rows and chip counts without showing the table mask."""
|
||||
|
||||
|
||||
@@ -457,6 +457,7 @@ class ShellWindow(QMainWindow):
|
||||
|
||||
logout_requested = Signal()
|
||||
video_requested = Signal(dict)
|
||||
watch_requested = Signal(dict)
|
||||
page_changed = Signal(str)
|
||||
|
||||
def __init__(
|
||||
@@ -913,6 +914,8 @@ class ShellWindow(QMainWindow):
|
||||
)
|
||||
if hasattr(page, "video_requested"):
|
||||
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)
|
||||
self.pages[item.key] = page
|
||||
self.page_titles[index] = title
|
||||
|
||||
@@ -5,8 +5,12 @@ from .launcher import (
|
||||
VideoCallLauncher,
|
||||
VideoCallRequest,
|
||||
VideoTicketError,
|
||||
VideoWatchLauncher,
|
||||
VideoWatchRequest,
|
||||
launch_video_call,
|
||||
launch_video_watch,
|
||||
normalize_backend_ticket,
|
||||
normalize_backend_watch_ticket,
|
||||
require_supported_backend,
|
||||
)
|
||||
from .lifecycle import OrderedCallLifecycle
|
||||
@@ -17,7 +21,11 @@ __all__ = [
|
||||
"VideoCallLauncher",
|
||||
"VideoCallRequest",
|
||||
"VideoTicketError",
|
||||
"VideoWatchLauncher",
|
||||
"VideoWatchRequest",
|
||||
"launch_video_call",
|
||||
"launch_video_watch",
|
||||
"normalize_backend_ticket",
|
||||
"normalize_backend_watch_ticket",
|
||||
"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(
|
||||
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)
|
||||
class VideoCallLauncher:
|
||||
"""Small composition root that defers the optional Qt import until launch."""
|
||||
@@ -403,3 +531,48 @@ def launch_video_call(
|
||||
diagnosis_id=diagnosis_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 (
|
||||
VideoCallRequest,
|
||||
VideoTicketError,
|
||||
VideoWatchRequest,
|
||||
require_supported_backend,
|
||||
)
|
||||
from .lifecycle import OrderedCallLifecycle
|
||||
@@ -178,7 +179,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
message = json.loads(payload)
|
||||
except (TypeError, ValueError):
|
||||
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)
|
||||
|
||||
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__(
|
||||
self,
|
||||
request: VideoCallRequest,
|
||||
request: VideoCallRequest | VideoWatchRequest,
|
||||
location: CompanionLocation,
|
||||
lifecycle: OrderedCallLifecycle,
|
||||
lifecycle: OrderedCallLifecycle | None,
|
||||
*,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
@@ -199,6 +203,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self.request = request
|
||||
self.location = location
|
||||
self.lifecycle = lifecycle
|
||||
self._watch_mode = isinstance(request, VideoWatchRequest)
|
||||
self.logger = logger
|
||||
try:
|
||||
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._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.resize(1120, 760)
|
||||
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)
|
||||
|
||||
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
|
||||
if not self._policy.allows_main_document(self._page.url().toString()):
|
||||
return False
|
||||
@@ -326,7 +338,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self.close()
|
||||
return
|
||||
|
||||
if self._watch_mode:
|
||||
self._start_watch_companion()
|
||||
return
|
||||
|
||||
try:
|
||||
if self.lifecycle is None:
|
||||
raise VideoWindowError("video call lifecycle is unavailable")
|
||||
start_future = self.lifecycle.start()
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
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:
|
||||
self._injected = result is not False
|
||||
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:
|
||||
if self._closing:
|
||||
return
|
||||
expected_source = "assistant-watch" if self._watch_mode else "doctor-call"
|
||||
if message.get("source") != expected_source:
|
||||
return
|
||||
event = str(message.get("event", ""))
|
||||
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)
|
||||
if event == "room":
|
||||
return
|
||||
@@ -414,10 +457,14 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._closing = True
|
||||
self._media_active = False
|
||||
if self._injected and not self._companion_ended and not self._released:
|
||||
self._page.runJavaScript(
|
||||
"void window.doctorCall?.hangup?.().catch(() => undefined)"
|
||||
script = (
|
||||
"void window.doctorWatch?.leave?.().catch(() => undefined)"
|
||||
if self._watch_mode
|
||||
else "void window.doctorCall?.hangup?.().catch(() => undefined)"
|
||||
)
|
||||
self.lifecycle.end(self._close_reason)
|
||||
self._page.runJavaScript(script)
|
||||
if self.lifecycle is not None:
|
||||
self.lifecycle.end(self._close_reason)
|
||||
self._release_webengine()
|
||||
|
||||
def _release_webengine(self) -> None:
|
||||
@@ -550,6 +597,58 @@ class VideoCallWindow:
|
||||
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(
|
||||
request: VideoCallRequest,
|
||||
*,
|
||||
@@ -573,13 +672,34 @@ def open_video_call(
|
||||
).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__ = [
|
||||
"CompanionLocation",
|
||||
"TrustedDocumentPolicy",
|
||||
"VideoCallWindow",
|
||||
"VideoWatchWindow",
|
||||
"VideoWindowError",
|
||||
"WEBENGINE_AVAILABLE",
|
||||
"open_video_call",
|
||||
"open_video_watch",
|
||||
"resolve_companion_location",
|
||||
"webengine_unavailable_reason",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
@@ -20,6 +21,7 @@ from PySide6.QtWidgets import (
|
||||
from doctor_workstation.core import PermissionSet
|
||||
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 patients as patients_module
|
||||
from doctor_workstation.ui.pages.consultations import (
|
||||
ConsultationsPage,
|
||||
_video_payload,
|
||||
@@ -117,6 +119,166 @@ def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
|
||||
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:
|
||||
row = _row(
|
||||
appointment_id=None,
|
||||
@@ -914,7 +1076,7 @@ def test_menu_handlers_call_only_permission_gated_real_repository_methods(
|
||||
("logs", 501),
|
||||
(
|
||||
"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"),
|
||||
]
|
||||
@@ -1065,14 +1227,14 @@ def test_diagnosis_order_qr_failure_retries_without_creating_a_second_order(
|
||||
assert dialog.retry_button.isEnabled()
|
||||
assert "生成失败" in dialog.status_label.text()
|
||||
assert calls == [
|
||||
("create", (301, 2, 88.5, "复诊")),
|
||||
("create", (501, 2, 88.5, "复诊")),
|
||||
("qr", "PAY-RETRY-1"),
|
||||
]
|
||||
|
||||
dialog.retry_button.click()
|
||||
|
||||
assert calls == [
|
||||
("create", (301, 2, 88.5, "复诊")),
|
||||
("create", (501, 2, 88.5, "复诊")),
|
||||
("qr", "PAY-RETRY-1"),
|
||||
("qr", "PAY-RETRY-1"),
|
||||
]
|
||||
|
||||
@@ -36,6 +36,9 @@ class _CancellationRepository:
|
||||
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
|
||||
del appointment_id
|
||||
|
||||
def create_diagnosis_appointment(self, payload: Any = None, **fields: Any) -> None:
|
||||
del payload, fields
|
||||
|
||||
|
||||
class _FullMenuRepository(_CancellationRepository):
|
||||
def generate_video_qrcode(
|
||||
@@ -466,9 +469,11 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
||||
"appointment_cancel": True,
|
||||
"video_qr": True,
|
||||
"confirm_qr": True,
|
||||
"appointment_logs": True,
|
||||
"create_order": True,
|
||||
}
|
||||
"appointment_logs": True,
|
||||
"create_order": True,
|
||||
"watch_call": False,
|
||||
"watch_user_id": 0,
|
||||
}
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ class RecordingClient:
|
||||
return {}
|
||||
if endpoint == "doctor.appointment/availableSlots":
|
||||
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":
|
||||
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
|
||||
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.fill_patient_id_card(5, "410000199001010000")
|
||||
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)
|
||||
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.list_appointment_rosters(
|
||||
doctor_id=1,
|
||||
@@ -238,7 +264,28 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
},
|
||||
) 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 watch_ticket["roomId"] == 9001
|
||||
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
|
||||
assert {
|
||||
"tcm.prescriptionOrder/paidPayOrders",
|
||||
@@ -250,6 +297,7 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
"tcm.diagnosis/assignLogList",
|
||||
"doctor.roster/lists",
|
||||
"doctor.appointment/availableSlots",
|
||||
"tcm.diagnosis/watchCall",
|
||||
} <= 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:
|
||||
"""Future menu fields pass through safely without evaluating arbitrary values."""
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ from doctor_workstation.video.launcher import ( # noqa: E402
|
||||
VideoCallLauncher,
|
||||
VideoCallRequest,
|
||||
VideoTicketError,
|
||||
VideoWatchRequest,
|
||||
normalize_backend_ticket,
|
||||
normalize_backend_watch_ticket,
|
||||
)
|
||||
from doctor_workstation.video.lifecycle import OrderedCallLifecycle # 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:
|
||||
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="color-scheme" content="dark" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-le5ZH3pL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BuO_uDya.css">
|
||||
<script type="module" crossorigin src="./assets/index-DluArHub.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CG-V4g-D.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+1
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@trtc/calls-uikit-vue": "4.4.6",
|
||||
"trtc-sdk-v5": "5.15.3-beta.12",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@trtc/calls-uikit-vue": "4.4.6",
|
||||
"trtc-sdk-v5": "5.15.3-beta.12",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TUICallKit } from '@trtc/calls-uikit-vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
mode: Readonly<Ref<string>>
|
||||
phase: Readonly<Ref<string>>
|
||||
statusText: Readonly<Ref<string>>
|
||||
}>()
|
||||
@@ -11,15 +12,18 @@ defineProps<{
|
||||
<template>
|
||||
<main class="call-stage">
|
||||
<TUICallKit
|
||||
v-if="mode.value === 'call'"
|
||||
class="call-kit"
|
||||
:allowed-minimized="false"
|
||||
: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">
|
||||
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="eyebrow">中医视频面诊</p>
|
||||
<p class="eyebrow">{{ mode.value === 'watch' ? '医助旁观' : '中医视频面诊' }}</p>
|
||||
<h1>{{ statusText.value }}</h1>
|
||||
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
|
||||
</div>
|
||||
|
||||
Vendored
+18
@@ -16,12 +16,30 @@ interface DoctorCallApi {
|
||||
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 {
|
||||
notify?: (payload: string) => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
doctorCall: DoctorCallApi
|
||||
doctorWatch: DoctorWatchApi
|
||||
qtVideoBridge?: QtVideoBridge
|
||||
qt?: { webChannelTransport?: unknown }
|
||||
QWebChannel?: new (
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
TUICallKitAPI,
|
||||
TUICallType,
|
||||
} from '@trtc/calls-uikit-vue'
|
||||
import TRTC from 'trtc-sdk-v5'
|
||||
import type { TRTCStreamType } from 'trtc-sdk-v5'
|
||||
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
type CompanionMode = 'call' | 'watch'
|
||||
|
||||
interface NormalizedCallConfig {
|
||||
SDKAppID: number
|
||||
@@ -21,7 +24,7 @@ interface NormalizedCallConfig {
|
||||
}
|
||||
|
||||
interface BridgeMessage {
|
||||
source: 'doctor-call'
|
||||
source: 'doctor-call' | 'assistant-watch'
|
||||
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
|
||||
diagnosisId?: number | string
|
||||
status?: string
|
||||
@@ -31,6 +34,7 @@ interface BridgeMessage {
|
||||
|
||||
const phase = ref<CallPhase>('ready')
|
||||
const statusText = ref('等待桌面端发起视频面诊')
|
||||
const mode = ref<CompanionMode>('call')
|
||||
let activeConfig: NormalizedCallConfig | null = null
|
||||
let endNotified = false
|
||||
let starting = false
|
||||
@@ -129,6 +133,7 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||
function safeErrorMessage(error: unknown): string {
|
||||
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
|
||||
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
|
||||
if (watchConfig?.userSig) message = message.split(watchConfig.userSig).join('[REDACTED]')
|
||||
return message
|
||||
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
|
||||
.slice(0, 400)
|
||||
@@ -279,9 +284,219 @@ async function hangup(): Promise<void> {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
createApp(App, {
|
||||
mode: readonly(mode),
|
||||
phase: readonly(phase),
|
||||
statusText: readonly(statusText),
|
||||
}).mount('#app')
|
||||
|
||||
@@ -43,6 +43,41 @@ input {
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
|
||||
Reference in New Issue
Block a user