Compare commits

...
1 Commits
Author SHA1 Message Date
Your Name de990a921b 更新 2026-08-11 10:02:40 +08:00
25 changed files with 1412 additions and 174 deletions
+164 -7
View File
@@ -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()
+16 -3
View File
@@ -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."""
+3
View File
@@ -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)
+128 -8
View File
@@ -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",
] ]
+165 -3
View File
@@ -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"),
] ]
+5
View File
@@ -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()
+76
View File
@@ -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."""
+54
View File
@@ -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
View File
@@ -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}
+1
View File
@@ -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}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+5 -1
View File
@@ -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>
+18
View File
@@ -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 (
+216 -1
View File
@@ -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')
+35
View File
@@ -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%;