更新
This commit is contained in:
@@ -7,5 +7,5 @@ __version__ = "1.4.2"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = False
|
||||
DEBUG_MODE = True
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -412,7 +412,8 @@ class ApplicationController(QObject):
|
||||
self.current_repository: Any = None
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self._pending_im_request: tuple[str, object] | None = None
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
@@ -725,7 +726,8 @@ class ApplicationController(QObject):
|
||||
call.close()
|
||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
self.video_pending.clear()
|
||||
self._pending_im_request = None
|
||||
for dialog in self.demo_video_dialogs.values():
|
||||
dialog.close()
|
||||
self.demo_video_dialogs.clear()
|
||||
@@ -746,7 +748,8 @@ class ApplicationController(QObject):
|
||||
if parent is None or self.current_repository is None:
|
||||
return
|
||||
patient_id = payload.get("patient_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
appointment_id = int(payload.get("appointment_id") or 0)
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
@@ -754,8 +757,16 @@ class ApplicationController(QObject):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
|
||||
call_key = str(diagnosis_id)
|
||||
existing_call = self.video_calls.get(call_key)
|
||||
call_key = f"{diagnosis_id}:{appointment_id}"
|
||||
if open_im and self._pending_im_request is not None:
|
||||
pending_key, pending_marker = self._pending_im_request
|
||||
if pending_key != call_key:
|
||||
# Retire a previous selection before any early return, including
|
||||
# when the selected IM window is already open.
|
||||
if self.video_pending.get(pending_key) is pending_marker:
|
||||
self.video_pending.pop(pending_key, None)
|
||||
self._pending_im_request = None
|
||||
existing_call = self.video_calls.get(call_key)
|
||||
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
||||
qt_window = getattr(existing_call, "qt_window", None)
|
||||
if qt_window is not None:
|
||||
@@ -801,13 +812,18 @@ class ApplicationController(QObject):
|
||||
)
|
||||
repository = self.current_repository
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
self.video_pending[call_key] = marker
|
||||
if open_im:
|
||||
self._pending_im_request = (call_key, marker)
|
||||
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
appointment_id=appointment_id,
|
||||
)
|
||||
if appointment_id and int(ticket.raw.get("appointment_id") or 0) != appointment_id:
|
||||
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
@@ -868,10 +884,12 @@ class ApplicationController(QObject):
|
||||
parent: QWidget,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self.shell_window is parent:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self._pending_im_request == (call_key, marker):
|
||||
self._pending_im_request = None
|
||||
if self.shell_window is parent:
|
||||
show_toast(
|
||||
parent,
|
||||
f"视频准备失败:{friendly_error(error)}",
|
||||
@@ -892,9 +910,11 @@ class ApplicationController(QObject):
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
if self._pending_im_request == (call_key, marker):
|
||||
self._pending_im_request = None
|
||||
if (
|
||||
self.shell_window is None
|
||||
or self.current_repository is not repository
|
||||
@@ -919,8 +939,8 @@ class ApplicationController(QObject):
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id, current_key=call_key: (
|
||||
self._open_video_diagnosis(current_id, call_key=current_key)
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -942,7 +962,7 @@ class ApplicationController(QObject):
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any, *, call_key: str) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
@@ -951,7 +971,7 @@ class ApplicationController(QObject):
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
call = self.video_calls.get(call_key)
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Appointment medium labels, separate from first-visit/follow-up diagnosis type."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
APPOINTMENT_MODES = (("视频问诊", "video"), ("图文问诊", "text"))
|
||||
|
||||
|
||||
def appointment_type_description(value: Any) -> str:
|
||||
if value is None or isinstance(value, str) and not value.strip():
|
||||
return "视频问诊"
|
||||
return {"video": "视频问诊", "text": "图文问诊", "phone": "电话问诊"}.get(str(value), "未知")
|
||||
|
||||
|
||||
def can_appointment_video(value: Any) -> bool:
|
||||
"""UI hint only; actual call permission must come from the server ticket."""
|
||||
return value is None or isinstance(value, str) and (not value.strip() or value == "video")
|
||||
|
||||
|
||||
def appointment_type_value(record: Any) -> Any:
|
||||
"""Read appointment rows and diagnosis-list projections without visit-type aliases."""
|
||||
def read(value: Any, key: str) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return value.get(key)
|
||||
result = getattr(value, key, None)
|
||||
raw = getattr(value, "raw", None)
|
||||
return raw.get(key) if result is None and isinstance(raw, Mapping) else result
|
||||
|
||||
appointment_id = read(record, "appointment_id") or read(record, "latest_appointment_id")
|
||||
rows = read(record, "appointments")
|
||||
if isinstance(rows, (list, tuple)) and rows:
|
||||
if appointment_id:
|
||||
for row in rows:
|
||||
if str(read(row, "id")) == str(appointment_id):
|
||||
return read(row, "appointment_type")
|
||||
elif len(rows) == 1:
|
||||
return read(rows[0], "appointment_type")
|
||||
else:
|
||||
return "unknown"
|
||||
for key in ("appointment_type", "latest_appointment_type"):
|
||||
value = read(record, key)
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
for key in ("appointment", "latest_appointment"):
|
||||
nested = read(record, key)
|
||||
if nested is not None:
|
||||
return read(nested, "appointment_type")
|
||||
return None
|
||||
@@ -3022,7 +3022,7 @@ class DemoDoctorRepository:
|
||||
raise ValueError("order_no is required")
|
||||
return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"}
|
||||
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
|
||||
"""Return non-production placeholder credentials for UI demonstration."""
|
||||
|
||||
with self._lock:
|
||||
@@ -3036,21 +3036,22 @@ class DemoDoctorRepository:
|
||||
assistant_id="assistant_2001",
|
||||
diagnosis_id=diagnosis_id,
|
||||
is_lochost_vod=False,
|
||||
raw={"demo": True},
|
||||
raw={"demo": True, "appointment_id": appointment_id},
|
||||
)
|
||||
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int = 2
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0
|
||||
) -> dict[str, Any]:
|
||||
"""Create a mutable demo call record."""
|
||||
|
||||
with self._lock:
|
||||
self.get_call_ticket(patient_id, diagnosis_id)
|
||||
self.get_call_ticket(patient_id, diagnosis_id, appointment_id=appointment_id)
|
||||
record = {
|
||||
"id": self._next_call_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"call_type": call_type,
|
||||
"call_type": call_type,
|
||||
"appointment_id": appointment_id,
|
||||
"status": "ringing",
|
||||
"room_id": "",
|
||||
}
|
||||
|
||||
@@ -712,10 +712,10 @@ class DoctorRepository(Protocol):
|
||||
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
|
||||
"""Generate the payment mini-program QR code for a generic order."""
|
||||
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
|
||||
"""Return short-lived call credentials."""
|
||||
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0) -> Any:
|
||||
"""Create a call record."""
|
||||
|
||||
def end_call(
|
||||
@@ -2629,22 +2629,51 @@ class RemoteDoctorRepository:
|
||||
)
|
||||
return result
|
||||
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
|
||||
def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
|
||||
"""Obtain short-lived Tencent credentials for a consultation call."""
|
||||
|
||||
result = _require_mapping(
|
||||
self.client.post(
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
{"patient_id": patient_id, "diagnosis_id": diagnosis_id},
|
||||
result = _require_mapping(
|
||||
self.client.post(
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
{"patient_id": patient_id, "diagnosis_id": diagnosis_id, "appointment_id": appointment_id},
|
||||
),
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
)
|
||||
ticket = CallTicket.from_dict(result)
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
)
|
||||
policy_fields = {
|
||||
"appointment_id", "appointmentId", "appointment_type", "appointment_type_desc",
|
||||
"can_video_call", "can_audio_call", "call_disabled_reason",
|
||||
}
|
||||
if appointment_id > 0 and not policy_fields.intersection(result):
|
||||
# Older signature endpoints authenticate the patient/diagnosis but
|
||||
# do not echo the appointment. Keep the requested chat context;
|
||||
# never treat it as server authorization for an audio/video call.
|
||||
if (
|
||||
str(result.get("diagnosis_id", "")).strip() != str(diagnosis_id)
|
||||
or str(result.get("patient_id", "")).strip() != str(patient_id)
|
||||
or result.get("patientUserId") != f"patient_{patient_id}"
|
||||
):
|
||||
raise ValueError("聊天凭证与当前患者或诊单不匹配,请重新打开聊天窗口。")
|
||||
result = dict(result)
|
||||
result.update(
|
||||
appointment_id=appointment_id,
|
||||
can_video_call=False,
|
||||
can_audio_call=False,
|
||||
call_disabled_reason="暂未获取到本次挂号的通话权限,仍可图文聊天",
|
||||
)
|
||||
if appointment_id > 0:
|
||||
returned_id = result.get("appointment_id")
|
||||
if (
|
||||
isinstance(returned_id, bool)
|
||||
or not isinstance(returned_id, (int, str))
|
||||
or str(returned_id).strip() != str(appointment_id)
|
||||
):
|
||||
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
|
||||
ticket = CallTicket.from_dict(result)
|
||||
if ticket.diagnosis_id is None:
|
||||
ticket.diagnosis_id = diagnosis_id
|
||||
return ticket
|
||||
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0) -> Any:
|
||||
"""Create the server-side call record before ringing participants."""
|
||||
|
||||
payload = self.client.post(
|
||||
@@ -2652,7 +2681,8 @@ class RemoteDoctorRepository:
|
||||
{
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"call_type": call_type,
|
||||
"call_type": call_type,
|
||||
"appointment_id": appointment_id,
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, Mapping):
|
||||
|
||||
@@ -42,7 +42,8 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .widgets import (
|
||||
from ..core.appointment_modes import APPOINTMENT_MODES
|
||||
from .widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
friendly_error,
|
||||
@@ -972,13 +973,24 @@ class AppointmentDrawer(QDialog):
|
||||
type_container = QWidget()
|
||||
type_layout = QHBoxLayout(type_container)
|
||||
type_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.appointment_type_radio = QRadioButton("视频问诊")
|
||||
self.appointment_type_radio.setChecked(True)
|
||||
type_layout.addWidget(self.appointment_type_radio)
|
||||
self.appointment_type_radio = QRadioButton("视频问诊")
|
||||
self.appointment_type_radio.setChecked(True)
|
||||
type_layout.addWidget(self.appointment_type_radio)
|
||||
self.text_appointment_type_radio = QRadioButton("图文问诊")
|
||||
type_layout.addWidget(self.text_appointment_type_radio)
|
||||
self.appointment_type_group = QButtonGroup(self)
|
||||
self.appointment_type_group.addButton(self.appointment_type_radio, 0)
|
||||
self.appointment_type_group.addButton(self.text_appointment_type_radio, 1)
|
||||
type_layout.addStretch(1)
|
||||
self._add_form_row("预约类型:", type_container)
|
||||
self.appointment_type = QComboBox(self)
|
||||
self.appointment_type.addItem("视频问诊", "video")
|
||||
for label, value in APPOINTMENT_MODES:
|
||||
self.appointment_type.addItem(label, value)
|
||||
self.appointment_type_group.idClicked.connect(self.appointment_type.setCurrentIndex)
|
||||
self.appointment_type.currentIndexChanged.connect(
|
||||
lambda index: self.appointment_type_group.button(index).setChecked(True)
|
||||
if self.appointment_type_group.button(index) is not None else None
|
||||
)
|
||||
self.appointment_type.hide()
|
||||
|
||||
patient_container = QWidget()
|
||||
|
||||
@@ -26,9 +26,9 @@ from PySide6.QtCore import (
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QIcon,
|
||||
QLinearGradient,
|
||||
QMouseEvent,
|
||||
@@ -61,8 +61,13 @@ from PySide6.QtWidgets import (
|
||||
QWidgetItem,
|
||||
)
|
||||
|
||||
from . import icons
|
||||
from .reception_style import TECH_BLUE, body_family, heading_family
|
||||
from ..core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
can_appointment_video,
|
||||
)
|
||||
from . import icons
|
||||
from .reception_style import TECH_BLUE, body_family, heading_family
|
||||
from .widgets import display_text, first_value, gender_text, get_value
|
||||
|
||||
PRIMARY = QColor("#4F63D9")
|
||||
@@ -112,7 +117,7 @@ def _blue_appointment_text(record: Any, appointment: Any) -> tuple[str, str]:
|
||||
This returns display strings without mutating either repository record.
|
||||
"""
|
||||
|
||||
current_id = _as_int(first_value(record, "appointment_id", default=0))
|
||||
current_id = _as_int(first_value(record, "appointment_id", "latest_appointment_id", default=0))
|
||||
appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0))
|
||||
current = current_id > 0 and appointment_id == current_id
|
||||
doctor = first_value(appointment, "doctor_name", "appointment_doctor_name")
|
||||
@@ -130,6 +135,11 @@ def _blue_appointment_text(record: Any, appointment: Any) -> tuple[str, str]:
|
||||
when = time_text if date_text and date_text in time_text else " ".join(
|
||||
part for part in (date_text, time_text) if part
|
||||
)
|
||||
mode = appointment_type_value(appointment)
|
||||
if current and get_value(appointment, "appointment_type", None) is None:
|
||||
mode = appointment_type_value(record)
|
||||
if appointment_id > 0:
|
||||
when = f"{when} · {appointment_type_description(mode)}"
|
||||
return display_text(doctor, "—"), when
|
||||
|
||||
|
||||
@@ -297,7 +307,8 @@ def _appointments(record: Any) -> list[Any]:
|
||||
return [
|
||||
{
|
||||
"id": first_value(record, "appointment_id"),
|
||||
"status": first_value(record, "appointment_status"),
|
||||
"status": first_value(record, "appointment_status"),
|
||||
"appointment_type": appointment_type_value(record),
|
||||
"doctor_name": first_value(
|
||||
record, "appointment_doctor_name", "doctor_name", default=""
|
||||
),
|
||||
@@ -392,7 +403,8 @@ def _video_ids_complete(record: Any) -> bool:
|
||||
appointment_id = _as_int(
|
||||
first_value(
|
||||
record,
|
||||
"appointment_id",
|
||||
"appointment_id",
|
||||
"latest_appointment_id",
|
||||
default=first_value(
|
||||
_appointments(record)[0] if _appointments(record) else None,
|
||||
"id",
|
||||
@@ -770,7 +782,8 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
part
|
||||
for part in (
|
||||
display_text(first_value(apt, "doctor_name"), "-"),
|
||||
display_text(first_value(apt, "time_text", "appointment_time"), "-"),
|
||||
display_text(first_value(apt, "time_text", "appointment_time"), "-"),
|
||||
appointment_type_description(appointment_type_value(apt)),
|
||||
)
|
||||
if part
|
||||
)
|
||||
@@ -1776,12 +1789,14 @@ class DiagnosisTableHost(QFrame):
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
video_capable = self.action_policy.get("video_call", False)
|
||||
call_state = video_call_state(record)
|
||||
if video_capable and _appointment_active(record) and call_state == "live":
|
||||
button = QToolButton(host)
|
||||
button.setText("进入视频问诊")
|
||||
is_text = appointment_type_value(record) == "text"
|
||||
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
|
||||
button = QToolButton(host)
|
||||
button.setText("图文沟通" if is_text else "进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||
button.setToolTip("发送文字、图片和文件;不支持音视频通话" if is_text
|
||||
else "医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||
button.setEnabled(_video_ids_complete(record))
|
||||
if not button.isEnabled():
|
||||
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
|
||||
@@ -1900,7 +1915,8 @@ class DiagnosisTableHost(QFrame):
|
||||
"cancel_assign", item
|
||||
),
|
||||
)
|
||||
if self.action_policy.get("video_qr", False) and _appointment_active(record):
|
||||
if (self.action_policy.get("video_qr", False) and _appointment_active(record)
|
||||
and can_appointment_video(appointment_type_value(record))):
|
||||
_add_menu_action(
|
||||
menu,
|
||||
"视频二维码",
|
||||
|
||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||
from ..diagnosis_drawer import (
|
||||
DIAGNOSIS_QSS,
|
||||
CaseGrid,
|
||||
@@ -3589,7 +3590,7 @@ class DiagnosisDialog(QDialog):
|
||||
first_value(row, "assistant_name"),
|
||||
first_value(row, "appointment_date"),
|
||||
first_value(row, "appointment_time", "period"),
|
||||
first_value(row, "appointment_type_text", "appointment_type"),
|
||||
appointment_type_description(appointment_type_value(row)),
|
||||
" / ".join(
|
||||
part
|
||||
for part in (
|
||||
|
||||
@@ -10,7 +10,7 @@ from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QPixmap
|
||||
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
@@ -36,8 +36,13 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..appointments_style import appointments_stylesheet
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ...core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
can_appointment_video,
|
||||
)
|
||||
from ..appointments_style import appointments_stylesheet
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
||||
from ..dialogs.prescription import (
|
||||
PrescriptionDetailDialog,
|
||||
@@ -49,11 +54,11 @@ from ..dialogs.prescription_ai import (
|
||||
can_open_diagnosis_ai_report,
|
||||
present_diagnosis_ai_report,
|
||||
)
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..icons import icon
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..reception_style import heading_family
|
||||
from ..theme import mark_business_dialog
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..icons import icon
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..reception_style import heading_family
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import (
|
||||
MessageBanner,
|
||||
PageHeader,
|
||||
@@ -312,7 +317,8 @@ def _appointment_info_cell(_value: Any, row: Any) -> str:
|
||||
channel = display_text(
|
||||
first_value(row, "channel_name", "channel_source_name", "source_name"), "—"
|
||||
)
|
||||
return f"{status} {doctor}\n{date_text} {time_text}\n最近渠道:{channel}"
|
||||
mode = appointment_type_description(appointment_type_value(row))
|
||||
return f"{status} {doctor}\n{date_text} {time_text} · {mode}\n最近渠道:{channel}"
|
||||
|
||||
|
||||
def _revisit_cell(_value: Any, row: Any) -> str:
|
||||
@@ -1273,7 +1279,16 @@ class AppointmentsPage(QWidget):
|
||||
host,
|
||||
)
|
||||
date_label.setProperty("tableAppointmentMeta", True)
|
||||
layout.addWidget(date_label)
|
||||
date_row = QHBoxLayout()
|
||||
date_row.setContentsMargins(0, 0, 0, 0)
|
||||
date_row.setSpacing(6)
|
||||
date_row.addWidget(date_label)
|
||||
mode_label = QLabel(appointment_type_description(appointment_type_value(row)), host)
|
||||
mode_label.setObjectName("AppointmentModeLabel")
|
||||
mode_label.setProperty("tableAppointmentMeta", True)
|
||||
date_row.addWidget(mode_label)
|
||||
date_row.addStretch(1)
|
||||
layout.addLayout(date_row)
|
||||
channel = display_text(
|
||||
first_value(row, "channel_name", "channel_source_name", "source_name"),
|
||||
"—",
|
||||
@@ -1310,7 +1325,8 @@ class AppointmentsPage(QWidget):
|
||||
layout.setContentsMargins(5, 0, 5, 0)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
button = QPushButton("IM 问诊", host)
|
||||
is_text = appointment_type_value(row) == "text"
|
||||
button = QPushButton("图文沟通" if is_text else "IM 问诊", host)
|
||||
button.setObjectName("AppointmentImConsultButton")
|
||||
button.setProperty("appointmentImAction", True)
|
||||
patient_name = display_text(first_value(row, "patient_name"), "患者")
|
||||
@@ -1338,7 +1354,8 @@ class AppointmentsPage(QWidget):
|
||||
elif status_error:
|
||||
tooltip = status_error
|
||||
else:
|
||||
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
|
||||
tooltip = ("打开图文问诊,可发送文字、图片和文件;不支持音视频通话"
|
||||
if is_text else "打开患者 IM,可发送消息;通话能力由本次挂号决定")
|
||||
button.setToolTip(tooltip)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, source=row: self._run_video_row_action(
|
||||
@@ -1425,8 +1442,9 @@ class AppointmentsPage(QWidget):
|
||||
status = _status_value(row) if has_row else 0
|
||||
not_completed = has_row and status != 3
|
||||
self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||
self.qr_button.setEnabled(
|
||||
not_completed
|
||||
self.qr_button.setEnabled(
|
||||
not_completed
|
||||
and can_appointment_video(appointment_type_value(row))
|
||||
and _video_patient_id(row) > 0
|
||||
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
||||
)
|
||||
@@ -1448,8 +1466,9 @@ class AppointmentsPage(QWidget):
|
||||
)
|
||||
self.cancel_button.setEnabled(has_row and status == 1)
|
||||
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||
self.toolbar_qr_button.setEnabled(
|
||||
not_completed
|
||||
self.toolbar_qr_button.setEnabled(
|
||||
not_completed
|
||||
and can_appointment_video(appointment_type_value(row))
|
||||
and _video_patient_id(row) > 0
|
||||
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
||||
)
|
||||
@@ -1537,7 +1556,8 @@ class AppointmentsPage(QWidget):
|
||||
self.video_requested.emit(
|
||||
{
|
||||
"source": "appointments",
|
||||
"appointment_id": appointment_id,
|
||||
"appointment_id": appointment_id,
|
||||
"appointment_type": appointment_type_value(row),
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_name": first_value(row, "patient_name", default="患者"),
|
||||
@@ -1559,9 +1579,12 @@ class AppointmentsPage(QWidget):
|
||||
if not all(callable(getattr(self.repository, name, None)) for name in required_methods):
|
||||
show_toast(self, "当前仓库未提供视频二维码能力。", "warning")
|
||||
return
|
||||
row = self._current_row()
|
||||
if row is None or _status_value(row) == 3:
|
||||
return
|
||||
row = self._current_row()
|
||||
if row is None or _status_value(row) == 3:
|
||||
return
|
||||
if not can_appointment_video(appointment_type_value(row)):
|
||||
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
|
||||
return
|
||||
diagnosis_id = _diagnosis_id(row)
|
||||
patient_id = _video_patient_id(row)
|
||||
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
|
||||
@@ -2214,7 +2237,7 @@ class AppointmentsPage(QWidget):
|
||||
("时段", period_text),
|
||||
(
|
||||
"预约类型",
|
||||
first_value(detail, "appointment_type_desc", "appointment_type_text"),
|
||||
appointment_type_description(appointment_type_value(detail)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -38,7 +38,8 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import icons
|
||||
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||
from .. import icons
|
||||
from ..consultations_style import consultations_stylesheet
|
||||
from ..diagnosis_index_widgets import (
|
||||
DiagnosisChip,
|
||||
@@ -226,7 +227,7 @@ def _appointment_status(record: Any) -> Any:
|
||||
|
||||
|
||||
def _appointment_id(record: Any) -> int:
|
||||
value = first_value(record, "appointment_id", default=None)
|
||||
value = first_value(record, "appointment_id", "latest_appointment_id", default=None)
|
||||
if value is None:
|
||||
value = first_value(get_value(record, "latest_appointment", None), "id", default=None)
|
||||
if value is None:
|
||||
@@ -289,7 +290,8 @@ def _video_payload(record: Any) -> dict[str, Any]:
|
||||
|
||||
return {
|
||||
"source": "consultations",
|
||||
"appointment_id": _appointment_id(record),
|
||||
"appointment_id": _appointment_id(record),
|
||||
"appointment_type": appointment_type_value(record),
|
||||
"patient_id": first_value(record, "patient_id", "source_patient_id"),
|
||||
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
|
||||
"patient_name": first_value(record, "patient_name", default="患者"),
|
||||
@@ -2655,7 +2657,11 @@ class ConsultationsPage(QWidget):
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def _request_video_qr(self) -> None:
|
||||
def _request_video_qr(self) -> None:
|
||||
record = self.table.current_data()
|
||||
if record is not None and not can_appointment_video(appointment_type_value(record)):
|
||||
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
|
||||
return
|
||||
self._request_qr(
|
||||
capability="video_qr",
|
||||
permission="tcm.diagnosis/videoQr",
|
||||
@@ -3050,11 +3056,11 @@ class ConsultationsPage(QWidget):
|
||||
self.video_button.setEnabled(
|
||||
has_record
|
||||
and is_video_available(record)
|
||||
and video_call_is_live(record)
|
||||
and (appointment_type_value(record) == "text" or video_call_is_live(record))
|
||||
and valid_ids
|
||||
)
|
||||
self.call_toolbar_button.setEnabled(self.video_button.isEnabled())
|
||||
self.video_qr_toolbar_button.setEnabled(has_record)
|
||||
self.video_qr_toolbar_button.setEnabled(has_record and can_appointment_video(appointment_type_value(record)))
|
||||
self.complete_toolbar_button.setEnabled(has_record)
|
||||
self.case_toolbar_button.setEnabled(has_record)
|
||||
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
|
||||
@@ -3498,7 +3504,7 @@ class ConsultationsPage(QWidget):
|
||||
if record is None or not is_video_available(record):
|
||||
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
||||
return
|
||||
if not video_call_is_live(record):
|
||||
if appointment_type_value(record) != "text" and not video_call_is_live(record):
|
||||
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
||||
return
|
||||
payload = _video_payload(record)
|
||||
|
||||
@@ -43,13 +43,18 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.appointment_modes import (
|
||||
APPOINTMENT_MODES,
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
)
|
||||
from .. import icons
|
||||
from ..appointment_drawer import AppointmentDrawer
|
||||
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
||||
from ..dialogs.ai_consult import can_open_ai_consult
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..patient_orders_style import patient_orders_stylesheet
|
||||
from ..patient_progress_style import patient_progress_stylesheet
|
||||
from ..patients_style import patient_list_stylesheet, patients_chrome_stylesheet
|
||||
@@ -499,7 +504,8 @@ class _LegacyAppointmentDialog(QDialog):
|
||||
form = QFormLayout()
|
||||
form.setVerticalSpacing(10)
|
||||
self.appointment_type = QComboBox()
|
||||
self.appointment_type.addItem("视频问诊", "video")
|
||||
for label, value in APPOINTMENT_MODES:
|
||||
self.appointment_type.addItem(label, value)
|
||||
form.addRow("预约类型 *", self.appointment_type)
|
||||
self.channel_source = QComboBox()
|
||||
self.channel_source.addItem("正在加载渠道…", "")
|
||||
@@ -1367,12 +1373,14 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
||||
painter.setBrush(QColor(fill))
|
||||
painter.drawRoundedRect(badge, 3, 3)
|
||||
draw(status, badge.toRect(), color, True)
|
||||
elif index.column() == 4:
|
||||
value = display_text(first_value(row, "appointment_time_text"))
|
||||
date_text, separator, time_text = value.partition(" ")
|
||||
if separator and time_text:
|
||||
draw(date_text, top)
|
||||
draw(time_text, bottom)
|
||||
elif index.column() == 4:
|
||||
value = display_text(first_value(row, "appointment_time_text"))
|
||||
date_text, separator, time_text = value.partition(" ")
|
||||
if separator and time_text:
|
||||
draw(date_text, top)
|
||||
has_appointment = _as_int(first_value(row, "appointment_id", default=0)) > 0
|
||||
mode = appointment_type_description(appointment_type_value(row)) if has_appointment else ""
|
||||
draw(f"{time_text} · {mode}" if mode else time_text, bottom)
|
||||
else:
|
||||
draw(value, rect)
|
||||
else:
|
||||
@@ -3005,7 +3013,7 @@ class PatientProgressWorkspace(QWidget):
|
||||
"appointment_time",
|
||||
"预约时间",
|
||||
95,
|
||||
lambda value, _row: display_text(value)[:5],
|
||||
lambda value, row: f"{display_text(value)[:5]} · {appointment_type_description(appointment_type_value(row))}",
|
||||
),
|
||||
TableColumn(
|
||||
"ahead_count",
|
||||
|
||||
@@ -73,6 +73,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||
from .. import icons
|
||||
from ..diagnosis_drawer import DailyRecordPanel
|
||||
from ..diagnosis_editors import FlowLayout
|
||||
@@ -7824,6 +7825,7 @@ class ReceptionPage(QWidget):
|
||||
meta_parts = [f"{gender} · {display_text(age)}岁", phone]
|
||||
if visit_id not in (None, ""):
|
||||
meta_parts.append(f"就诊号:{display_text(visit_id)}")
|
||||
meta_parts.append(appointment_type_description(appointment_type_value(appointment)))
|
||||
self.patient_meta_label.setText(" | ".join(meta_parts))
|
||||
self.patient_meta_label.setToolTip(" · ".join(condition_parts))
|
||||
status_number = (
|
||||
@@ -7866,15 +7868,7 @@ class ReceptionPage(QWidget):
|
||||
display_text(first_value(appointment, "assistant_name"))
|
||||
)
|
||||
self.appointment_labels["type"].setText(
|
||||
display_text(
|
||||
first_value(
|
||||
appointment,
|
||||
"appointment_type_text",
|
||||
"type_text",
|
||||
"appointment_type",
|
||||
"type",
|
||||
)
|
||||
)
|
||||
appointment_type_description(appointment_type_value(appointment))
|
||||
)
|
||||
self.appointment_labels["channel"].setText(
|
||||
display_text(
|
||||
@@ -8928,6 +8922,12 @@ class ReceptionPage(QWidget):
|
||||
self.video_button.setEnabled(
|
||||
appointment_id is not None and diagnosis_id is not None and patient_id is not None
|
||||
)
|
||||
is_text = appointment_type_value(appointment) == "text"
|
||||
self.video_button.setText("图文沟通" if is_text else "IM 问诊")
|
||||
self.video_button.setToolTip(
|
||||
"发送文字、图片和文件;图文问诊不支持音视频通话" if is_text
|
||||
else "打开患者 IM,会话通话能力由本次挂号决定"
|
||||
)
|
||||
self.edit_button.setEnabled(diagnosis_id is not None)
|
||||
report_enabled = (
|
||||
self._can_ai_report
|
||||
@@ -9684,6 +9684,7 @@ class ReceptionPage(QWidget):
|
||||
payload = {
|
||||
"source": "reception",
|
||||
"appointment_id": appointment_id,
|
||||
"appointment_type": appointment_type_value(appointment),
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_name": first_value(
|
||||
|
||||
@@ -85,6 +85,18 @@ def _normalized_key(key: Any) -> str:
|
||||
return "".join(character for character in str(key).lower() if character.isalnum())
|
||||
|
||||
|
||||
def _appointment_id(value: Any) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise VideoTicketError("appointment_id must be a nonnegative integer") from exc
|
||||
if isinstance(value, bool) or parsed < 0 or str(value) != str(parsed):
|
||||
raise VideoTicketError("appointment_id must be a nonnegative integer")
|
||||
return parsed
|
||||
|
||||
|
||||
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
|
||||
|
||||
|
||||
@@ -138,7 +150,8 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
|
||||
if hasattr(ticket, attribute_name)
|
||||
}
|
||||
if adapted:
|
||||
return adapted
|
||||
# Preserve server policy and identities; model attributes alone omit them.
|
||||
return {**(dict(raw) if isinstance(raw, Mapping) else {}), **adapted}
|
||||
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
|
||||
|
||||
|
||||
@@ -199,8 +212,15 @@ class VideoCallRequest:
|
||||
patient_id: Identifier | None = None
|
||||
call_record_id: Identifier | None = None
|
||||
backend_mode: BackendMode = BackendMode.EMBEDDED
|
||||
appointment_id: int = 0
|
||||
appointment_type: Any = None
|
||||
appointment_type_desc: str = ""
|
||||
can_video_call: bool = False
|
||||
can_audio_call: bool = False
|
||||
call_disabled_reason: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "appointment_id", _appointment_id(self.appointment_id))
|
||||
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"))
|
||||
@@ -257,6 +277,13 @@ class VideoCallRequest:
|
||||
"userSig": self.user_sig,
|
||||
"targetUserId": self.target_user_id,
|
||||
"diagnosisId": self.diagnosis_id,
|
||||
"patientId": self.patient_id,
|
||||
"appointmentId": self.appointment_id,
|
||||
"appointment_type": self.appointment_type,
|
||||
"appointment_type_desc": self.appointment_type_desc,
|
||||
"can_video_call": self.can_video_call is True,
|
||||
"can_audio_call": self.can_audio_call is True,
|
||||
"call_disabled_reason": self.call_disabled_reason,
|
||||
}
|
||||
|
||||
def safe_log_context(self) -> dict[str, Any]:
|
||||
@@ -340,6 +367,12 @@ def normalize_backend_ticket(
|
||||
diagnosis_id=normalized_diagnosis,
|
||||
patient_id=normalized_patient,
|
||||
call_record_id=payload_call_record,
|
||||
appointment_id=_appointment_id(payload.get("appointment_id", 0)),
|
||||
appointment_type=payload.get("appointment_type"),
|
||||
appointment_type_desc=str(payload.get("appointment_type_desc") or ""),
|
||||
can_video_call=payload.get("can_video_call") is True,
|
||||
can_audio_call=payload.get("can_audio_call") is True,
|
||||
call_disabled_reason=str(payload.get("call_disabled_reason") or ""),
|
||||
backend_mode=BackendMode.parse(backend_mode),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from .launcher import VideoCallRequest
|
||||
from .launcher import VideoCallRequest, normalize_backend_ticket
|
||||
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
@@ -288,7 +288,29 @@ class OrderedCallLifecycle:
|
||||
with self._lock:
|
||||
return self.bound_room_id or self._claimed_room_id
|
||||
|
||||
def start(self) -> Future[bool]:
|
||||
def refresh_call_policy(self) -> Future[dict[str, Any]]:
|
||||
"""Fetch policy for this exact immutable consultation on the worker."""
|
||||
def operation() -> dict[str, Any]:
|
||||
ticket = self.repository.get_call_ticket(
|
||||
patient_id=self.request.patient_id,
|
||||
diagnosis_id=self.request.diagnosis_id,
|
||||
appointment_id=self.request.appointment_id,
|
||||
)
|
||||
refreshed = normalize_backend_ticket(
|
||||
ticket, patient_id=self.request.patient_id,
|
||||
diagnosis_id=self.request.diagnosis_id,
|
||||
)
|
||||
if (refreshed.appointment_id != self.request.appointment_id
|
||||
or refreshed.target_user_id != self.request.target_user_id
|
||||
or refreshed.user_id != self.request.user_id
|
||||
or refreshed.sdk_app_id != self.request.sdk_app_id):
|
||||
raise ValueError("当前问诊已变更,请重新打开聊天窗口")
|
||||
return refreshed.to_web_config()
|
||||
return self._worker.submit("refresh_policy", operation)
|
||||
|
||||
def start(self, *, call_type: int = 2) -> Future[bool]:
|
||||
if type(call_type) is not int or call_type not in (1, 2):
|
||||
raise ValueError("call_type must be 1 or 2")
|
||||
with self._lock:
|
||||
if self._start_future is not None:
|
||||
return self._start_future
|
||||
@@ -297,7 +319,8 @@ class OrderedCallLifecycle:
|
||||
raise ValueError("video repository does not implement start_call")
|
||||
payload: dict[str, Any] = {
|
||||
"diagnosis_id": self.request.diagnosis_id,
|
||||
"call_type": 2,
|
||||
"call_type": call_type,
|
||||
"appointment_id": self.request.appointment_id,
|
||||
}
|
||||
if self.request.patient_id is not None:
|
||||
payload["patient_id"] = self.request.patient_id
|
||||
|
||||
@@ -279,6 +279,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
call_ended = Signal(str) # type: ignore[misc]
|
||||
call_error = Signal(str) # type: ignore[misc]
|
||||
_start_completed = Signal(bool) # type: ignore[misc]
|
||||
_policy_completed = Signal(str, object) # type: ignore[misc]
|
||||
_room_completed = Signal(str, bool, str) # type: ignore[misc]
|
||||
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
||||
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
|
||||
@@ -369,6 +370,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._connect_permissions()
|
||||
|
||||
self._start_completed.connect(self._on_lifecycle_started)
|
||||
self._policy_completed.connect(self._on_policy_completed)
|
||||
self._room_completed.connect(self._on_room_completed)
|
||||
self._screenshot_completed.connect(self._on_screenshot_completed)
|
||||
self._transcription_completed.connect(self._on_transcription_completed)
|
||||
@@ -509,7 +511,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
QTimer.singleShot(0, self._open_diagnosis_safely)
|
||||
return
|
||||
if event == "call-start-request":
|
||||
self._start_call_cycle()
|
||||
self._start_call_cycle(call_type=message.get("callType", 2))
|
||||
return
|
||||
if event == "call-policy-request":
|
||||
self._refresh_call_policy(str(message.get("requestId") or ""))
|
||||
return
|
||||
if event == "screenshot":
|
||||
self._save_screenshot(str(message.get("dataUrl") or ""))
|
||||
@@ -934,16 +939,48 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
"本次本地录音未正常结束,文件已保留以便排查。",
|
||||
)
|
||||
|
||||
def _start_call_cycle(self) -> None:
|
||||
if self._closing or self._start_requested:
|
||||
return
|
||||
def _prepare_call_cycle(self) -> None:
|
||||
if self._call_cycle_closed:
|
||||
self.lifecycle = self._lifecycle_factory()
|
||||
self._lifecycles.append(self.lifecycle)
|
||||
self._call_cycle_closed = False
|
||||
|
||||
def _refresh_call_policy(self, request_id: str) -> None:
|
||||
if not request_id or self._closing or self._shutdown_requested:
|
||||
return
|
||||
self._prepare_call_cycle()
|
||||
def completed(future: Future[dict[str, Any]]) -> None:
|
||||
try:
|
||||
policy = future.result()
|
||||
except Exception:
|
||||
policy = {"call_disabled_reason": "无法确认本次挂号通话权限,请重新打开聊天窗口"}
|
||||
with suppress(RuntimeError):
|
||||
self._policy_completed.emit(request_id, policy)
|
||||
try:
|
||||
self.lifecycle.refresh_call_policy().add_done_callback(completed)
|
||||
except Exception:
|
||||
self._on_policy_completed(request_id, {})
|
||||
|
||||
def _on_policy_completed(self, request_id: str, policy: dict[str, Any]) -> None:
|
||||
if self._closing or self._shutdown_requested or self._released:
|
||||
return
|
||||
# Send policy only; refreshed credentials never enter logs or notices.
|
||||
payload = {key: policy.get(key) for key in (
|
||||
"appointmentId", "appointment_type", "can_video_call", "can_audio_call",
|
||||
"call_disabled_reason", "appointment_type_desc",
|
||||
)}
|
||||
self._page.runJavaScript(
|
||||
"window.doctorConsultation?.callPolicyResult?.("
|
||||
+ json.dumps(request_id) + "," + json.dumps(payload, ensure_ascii=True) + ");"
|
||||
)
|
||||
|
||||
def _start_call_cycle(self, *, call_type: int = 2) -> None:
|
||||
if self._closing or self._shutdown_requested or self._start_requested:
|
||||
return
|
||||
self._prepare_call_cycle()
|
||||
self._start_requested = True
|
||||
try:
|
||||
future = self.lifecycle.start()
|
||||
future = self.lifecycle.start(call_type=call_type)
|
||||
except Exception:
|
||||
self._start_requested = False
|
||||
self._on_lifecycle_started(False)
|
||||
@@ -1149,8 +1186,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._closing = True
|
||||
self._media_active = False
|
||||
self._shutdown_timer.stop()
|
||||
if self._start_requested and not self._call_cycle_closed:
|
||||
self.lifecycle.end(self._close_reason)
|
||||
# Also retire policy-only workers for IM sessions without a live call.
|
||||
self.lifecycle.end(self._close_reason)
|
||||
self._abort_local_audio_recording("")
|
||||
self._release_webengine()
|
||||
|
||||
|
||||
@@ -278,8 +278,10 @@ def test_field_order_density_and_conditional_channel_row(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["video", "text"])
|
||||
def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
application: QApplication,
|
||||
mode: str,
|
||||
) -> None:
|
||||
repository = _VisualRepository(today_conflict=True)
|
||||
host, drawer = _show_drawer(
|
||||
@@ -318,6 +320,10 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
assert unavailable.status_label.isVisible()
|
||||
assert unavailable.accessibleName() == "10:00-10:30 已约"
|
||||
available.click()
|
||||
if mode == "text":
|
||||
drawer.text_appointment_type_radio.click()
|
||||
assert drawer.text_appointment_type_radio.isChecked()
|
||||
assert not drawer.appointment_type_radio.isChecked()
|
||||
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
|
||||
assert drawer.ok_button.isEnabled()
|
||||
|
||||
@@ -328,7 +334,7 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
"appointment_date": tomorrow,
|
||||
"appointment_time": "09:30-10:00",
|
||||
"period": "all",
|
||||
"appointment_type": "video",
|
||||
"appointment_type": mode,
|
||||
"remark": "",
|
||||
"channel_source": "online",
|
||||
"channel_source_detail": "",
|
||||
|
||||
@@ -742,7 +742,7 @@ def test_im_entry_does_not_require_a_live_video_hint(
|
||||
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
|
||||
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
|
||||
assert waiting.isEnabled()
|
||||
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
|
||||
assert waiting.toolTip() == "打开患者 IM,可发送消息;通话能力由本次挂号决定"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@@ -52,14 +52,14 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
|
||||
row = _row()
|
||||
before = copy.deepcopy(row)
|
||||
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30 · 视频问诊",
|
||||
)
|
||||
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||
"—", "2026-08-06 时间 —",
|
||||
"—", "2026-08-06 时间 — · 视频问诊",
|
||||
)
|
||||
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30 · 视频问诊")
|
||||
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||
for missing_id in (0, "", None):
|
||||
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||
@@ -70,13 +70,13 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
|
||||
host.set_rows([row])
|
||||
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||
assert blue.model.index(0, 4).data().splitlines() == [
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30 · 视频问诊", "— · 2026-08-06 时间 — · 视频问诊",
|
||||
]
|
||||
assert blue.model.index(0, 9).data() == "—"
|
||||
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||
blue.set_rows([{**row, "appointments": []}])
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30 · 视频问诊"
|
||||
legacy.close()
|
||||
blue.close()
|
||||
|
||||
|
||||
@@ -279,8 +279,10 @@ def test_workspace_workers_use_gui_thread_query_snapshots(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["video", "text"])
|
||||
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
application: QApplication,
|
||||
mode: str,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
@@ -317,6 +319,7 @@ def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
"doctor_id": 77,
|
||||
}
|
||||
dialog = _AppointmentDialog(row, repository=Repository())
|
||||
dialog.appointment_type.setCurrentIndex(dialog.appointment_type.findData(mode))
|
||||
application.processEvents()
|
||||
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
|
||||
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
|
||||
@@ -339,7 +342,7 @@ def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
"appointment_date": tomorrow,
|
||||
"appointment_time": "09:30-10:00",
|
||||
"period": "all",
|
||||
"appointment_type": "video",
|
||||
"appointment_type": mode,
|
||||
"remark": "复诊预约",
|
||||
"channel_source": "online",
|
||||
"channel_source_detail": "",
|
||||
|
||||
@@ -2565,11 +2565,14 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [None, "text"])
|
||||
def test_video_payload_keeps_three_identifiers_distinct(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
mode: str | None,
|
||||
) -> None:
|
||||
detail = _detail(41, name="视频患者")
|
||||
detail["appointment"]["appointment_type"] = mode
|
||||
|
||||
class Repository:
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
@@ -2586,6 +2589,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
||||
{
|
||||
"source": "reception",
|
||||
"appointment_id": 41,
|
||||
"appointment_type": mode,
|
||||
"patient_id": 141,
|
||||
"diagnosis_id": 241,
|
||||
"patient_name": "视频患者",
|
||||
@@ -2593,6 +2597,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
||||
"record": detail["appointment"],
|
||||
}
|
||||
]
|
||||
assert page.video_button.text() == ("图文沟通" if mode == "text" else "IM 问诊")
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@@ -476,7 +476,7 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCall",
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2, "appointment_id": 0},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Appointment policy must survive repository, launcher and lifecycle boundaries."""
|
||||
import logging
|
||||
from types import MethodType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.models import CallTicket
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.video.launcher import normalize_backend_ticket
|
||||
from doctor_workstation.video.lifecycle import OrderedCallLifecycle
|
||||
|
||||
|
||||
def ticket(**policy):
|
||||
return CallTicket.from_dict({
|
||||
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
|
||||
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
|
||||
"appointment_id": 456, "appointment_type": "text", **policy,
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, False, 0, 1, "true", "1", []])
|
||||
def test_only_actual_server_true_authorizes_calls(value):
|
||||
request = normalize_backend_ticket(ticket(can_video_call=value, can_audio_call=value))
|
||||
assert request.appointment_id == 456
|
||||
assert request.appointment_type == "text"
|
||||
assert request.patient_id == 8
|
||||
assert request.to_web_config()["can_video_call"] is False
|
||||
assert request.to_web_config()["can_audio_call"] is False
|
||||
|
||||
|
||||
def test_raw_policy_preserved_and_missing_policy_denies():
|
||||
request = normalize_backend_ticket(ticket())
|
||||
assert not request.can_video_call and not request.can_audio_call
|
||||
request = normalize_backend_ticket(ticket(appointment_type="phone", can_audio_call=True))
|
||||
assert request.can_audio_call and not request.can_video_call
|
||||
|
||||
|
||||
def test_repository_forwards_exact_appointment_and_media_type():
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def post(self, endpoint, payload):
|
||||
self.calls.append((endpoint, payload))
|
||||
return {"call_record_id": 99} if endpoint.endswith("startCall") else ticket().raw
|
||||
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
assert repository.get_call_ticket(8, 123, appointment_id=456).raw["appointment_type"] == "text"
|
||||
repository.start_call(123, 8, call_type=1, appointment_id=456)
|
||||
assert client.calls == [
|
||||
("tcm.diagnosis/getCallSignature", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}),
|
||||
("tcm.diagnosis/startCall", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456, "call_type": 1}),
|
||||
]
|
||||
|
||||
|
||||
def test_lifecycle_refreshes_exact_identity_and_uses_actual_media():
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.appointment_id = 456
|
||||
|
||||
def get_call_ticket(self, **payload):
|
||||
self.calls.append(payload)
|
||||
return ticket(appointment_id=self.appointment_id, can_audio_call=True)
|
||||
|
||||
def start_call(self, **payload):
|
||||
self.calls.append(payload)
|
||||
return {"call_record_id": 99}
|
||||
|
||||
def end_call(self, **payload):
|
||||
return {}
|
||||
|
||||
repository = Repository()
|
||||
lifecycle = OrderedCallLifecycle(normalize_backend_ticket(ticket()), repository, logging.getLogger(__name__))
|
||||
try:
|
||||
assert lifecycle.refresh_call_policy().result(timeout=2)["can_audio_call"] is True
|
||||
assert repository.calls[-1] == {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}
|
||||
assert lifecycle.start(call_type=1).result(timeout=2)
|
||||
assert repository.calls[-1]["call_type"] == 1
|
||||
assert repository.calls[-1]["appointment_id"] == 456
|
||||
repository.appointment_id = 457
|
||||
with pytest.raises(ValueError, match="当前问诊已变更"):
|
||||
lifecycle.refresh_call_policy().result(timeout=2)
|
||||
finally:
|
||||
lifecycle.end("test").result(timeout=2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_controller(monkeypatch):
|
||||
"""Exercise the real controller callbacks without launching Qt or Tencent."""
|
||||
from doctor_workstation import app as app_module
|
||||
|
||||
queued = []
|
||||
launched = []
|
||||
previews = []
|
||||
diagnoses = []
|
||||
dialog = object()
|
||||
|
||||
def get_call_ticket(patient_id, diagnosis_id, *, appointment_id):
|
||||
return ticket(patient_id=patient_id, diagnosis_id=diagnosis_id, appointment_id=appointment_id)
|
||||
|
||||
def launch_video_call(_ticket, **kwargs):
|
||||
window = SimpleNamespace(
|
||||
show=lambda: None,
|
||||
raise_=lambda: None,
|
||||
activateWindow=lambda: None,
|
||||
destroyed=SimpleNamespace(connect=lambda _callback: None),
|
||||
)
|
||||
call = SimpleNamespace(open_im=kwargs["open_im"], qt_window=window, close=lambda: None)
|
||||
launched.append((call, kwargs))
|
||||
return call
|
||||
|
||||
def open_diagnosis_by_id(diagnosis_id, *, modeless):
|
||||
diagnoses.append((diagnosis_id, modeless))
|
||||
return dialog
|
||||
|
||||
monkeypatch.setattr(app_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
monkeypatch.setattr(app_module, "launch_video_call", launch_video_call)
|
||||
monkeypatch.setattr(app_module, "show_toast", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(app_module, "_build_video_patient_case", lambda *_args, **_kwargs: {})
|
||||
monkeypatch.setattr(app_module, "WEBENGINE_AVAILABLE", True)
|
||||
monkeypatch.setattr(app_module, "QTimer", SimpleNamespace(singleShot=lambda _delay, callback: callback()))
|
||||
controller = SimpleNamespace(
|
||||
shell_window=SimpleNamespace(open_diagnosis_by_id=open_diagnosis_by_id),
|
||||
current_repository=SimpleNamespace(get_call_ticket=get_call_ticket),
|
||||
current_demo_mode=False,
|
||||
video_calls={}, video_pending={}, demo_video_dialogs={}, _pending_im_request=None,
|
||||
config=SimpleNamespace(video_mode="embedded", video_web_url=""),
|
||||
_show_video_preview=lambda window, diagnosis_dialog: previews.append((window, diagnosis_dialog)),
|
||||
)
|
||||
for method in ("_request_video", "_launch_video", "_video_ticket_error", "_open_video_diagnosis"):
|
||||
setattr(controller, method, MethodType(getattr(app_module.ApplicationController, method), controller))
|
||||
|
||||
def request(appointment_id, diagnosis_id=123):
|
||||
controller._request_video({
|
||||
"patient_id": 8, "diagnosis_id": diagnosis_id,
|
||||
"appointment_id": appointment_id, "mode": "im",
|
||||
})
|
||||
|
||||
def complete(index):
|
||||
function, callbacks = queued[index]
|
||||
callbacks["on_success"](function())
|
||||
|
||||
return SimpleNamespace(
|
||||
controller=controller, request=request, complete=complete, queued=queued,
|
||||
launched=launched, previews=previews, diagnoses=diagnoses, dialog=dialog,
|
||||
)
|
||||
|
||||
|
||||
def test_diagnosis_callback_previews_exact_appointment_session(chat_controller):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.complete(0)
|
||||
call, launch_args = case.launched[0]
|
||||
case.controller.video_calls["123:457"] = SimpleNamespace(qt_window=object())
|
||||
|
||||
launch_args["on_open_diagnosis"]()
|
||||
assert case.diagnoses == [(123, True)]
|
||||
assert case.previews == [(call.qt_window, case.dialog)]
|
||||
|
||||
case.controller.video_calls.pop("123:456")
|
||||
launch_args["on_open_diagnosis"]()
|
||||
assert len(case.previews) == 1 # A closed session cannot borrow another appointment's video.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("second_diagnosis", [123, 124])
|
||||
@pytest.mark.parametrize("completion_order", [(0, 1), (1, 0)])
|
||||
def test_latest_im_selection_retires_pending_callbacks(chat_controller, second_diagnosis, completion_order):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.request(457, diagnosis_id=second_diagnosis)
|
||||
assert list(case.controller.video_pending) == [f"{second_diagnosis}:457"]
|
||||
|
||||
for index in completion_order:
|
||||
case.complete(index)
|
||||
|
||||
assert list(case.controller.video_calls) == [f"{second_diagnosis}:457"]
|
||||
assert len(case.launched) == 1
|
||||
assert case.controller.video_pending == {}
|
||||
assert case.controller._pending_im_request is None
|
||||
|
||||
|
||||
def test_reselecting_open_im_also_retires_other_pending_selection(chat_controller):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.complete(0)
|
||||
original_call = case.launched[0][0]
|
||||
case.request(457)
|
||||
# The previous page may still be finishing its native shutdown. Treat a
|
||||
# reactivated current page like any other current selection.
|
||||
case.controller.video_calls["123:456"] = original_call
|
||||
case.request(456)
|
||||
case.complete(1)
|
||||
assert list(case.controller.video_calls) == ["123:456"]
|
||||
assert case.controller.video_pending == {}
|
||||
assert len(case.launched) == 1
|
||||
|
||||
|
||||
def test_repeated_pending_im_selection_does_not_duplicate_request(chat_controller):
|
||||
case = chat_controller
|
||||
case.request(456)
|
||||
case.request(456)
|
||||
assert len(case.queued) == 1
|
||||
case.complete(0)
|
||||
assert list(case.controller.video_calls) == ["123:456"]
|
||||
|
||||
|
||||
def legacy_signature_repository(**changes):
|
||||
response = {
|
||||
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
|
||||
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
|
||||
**changes,
|
||||
}
|
||||
repository = RemoteDoctorRepository(SimpleNamespace(post=lambda *_args: response))
|
||||
repository.patient_detail = lambda _diagnosis_id: {}
|
||||
return repository, response
|
||||
|
||||
|
||||
def test_legacy_signature_opens_im_with_requested_context_and_no_media(chat_controller):
|
||||
case = chat_controller
|
||||
repository, response = legacy_signature_repository()
|
||||
case.controller.current_repository = repository
|
||||
case.request(456)
|
||||
case.complete(0)
|
||||
assert list(case.controller.video_calls) == ["123:456"]
|
||||
assert case.launched[0][1]["open_im"] is True
|
||||
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
|
||||
assert request.appointment_id == 456
|
||||
assert request.target_user_id == "patient_8"
|
||||
assert request.can_video_call is False and request.can_audio_call is False
|
||||
assert request.call_disabled_reason
|
||||
assert "appointment_id" not in response # Never mutate the HTTP response/shared cache.
|
||||
lifecycle = OrderedCallLifecycle(request, repository, logging.getLogger(__name__))
|
||||
try:
|
||||
refreshed = lifecycle.refresh_call_policy().result(timeout=2)
|
||||
assert refreshed["appointmentId"] == 456
|
||||
assert refreshed["can_video_call"] is False and refreshed["can_audio_call"] is False
|
||||
finally:
|
||||
lifecycle.end("test").result(timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"diagnosis_id": 124}, {"patient_id": 9}, {"patientUserId": "patient_9"},
|
||||
{"diagnosis_id": None}, {"patient_id": None}, {"patientUserId": None},
|
||||
])
|
||||
def test_legacy_signature_requires_full_matching_identity(changes):
|
||||
repository, _ = legacy_signature_repository(**changes)
|
||||
with pytest.raises(ValueError, match="患者或诊单不匹配"):
|
||||
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("returned_id", [457, 0, None, "", True, -1, 456.9, 456.0, "bad"])
|
||||
def test_explicit_wrong_or_invalid_appointment_is_never_replaced(returned_id):
|
||||
repository, _ = legacy_signature_repository(appointment_id=returned_id)
|
||||
with pytest.raises(ValueError, match="本次挂号不匹配"):
|
||||
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fragment", [
|
||||
{"can_video_call": True}, {"can_audio_call": True}, {"appointment_type": "text"},
|
||||
{"appointmentId": 457}, {"call_disabled_reason": ""},
|
||||
])
|
||||
def test_partial_policy_is_not_treated_as_legacy_signature(fragment):
|
||||
repository, _ = legacy_signature_repository(**fragment)
|
||||
with pytest.raises(ValueError, match="本次挂号不匹配"):
|
||||
repository.get_call_ticket(8, 123, appointment_id=456)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("returned_id", [456, "456"])
|
||||
def test_modern_exact_appointment_policy_is_preserved(returned_id):
|
||||
repository, _ = legacy_signature_repository(
|
||||
appointment_id=returned_id, appointment_type="video", can_video_call=True, can_audio_call=True,
|
||||
)
|
||||
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
|
||||
assert request.appointment_id == 456
|
||||
assert request.can_video_call is True and request.can_audio_call is True
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Appointment medium stays attached to its own row and opens text chat without RTC."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
can_appointment_video,
|
||||
)
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import _blue_appointment_text
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,label,video", [
|
||||
("text", "图文问诊", False), ("video", "视频问诊", True),
|
||||
(None, "视频问诊", True), (" ", "视频问诊", True),
|
||||
("phone", "电话问诊", False), ("unexpected", "未知", False),
|
||||
])
|
||||
def test_appointment_type_never_uses_diagnosis_visit_type(value, label, video):
|
||||
assert appointment_type_description(value) == label
|
||||
assert can_appointment_video(value) is video
|
||||
assert appointment_type_value({"appointment_type": value, "consultation_type": "复诊"}) == value or value == ""
|
||||
|
||||
|
||||
def test_nested_type_uses_current_appointment_id_not_another_latest():
|
||||
row = {"appointment_id": 12, "latest_appointment_type": "video", "appointments": [
|
||||
{"id": 11, "appointment_type": "video"}, {"id": 12, "appointment_type": "text"},
|
||||
]}
|
||||
assert appointment_type_value(row) == "text"
|
||||
assert "图文问诊" in _blue_appointment_text(row, row["appointments"][1])[1]
|
||||
assert "视频问诊" in _blue_appointment_text(row, row["appointments"][0])[1]
|
||||
assert appointment_type_value({"appointments": row["appointments"]}) == "unknown"
|
||||
|
||||
|
||||
def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application, monkeypatch):
|
||||
monkeypatch.setattr(appointments, "run_async", lambda *_a, **_kw: None)
|
||||
notices = []
|
||||
monkeypatch.setattr(appointments, "show_toast", lambda _parent, message, *_a: notices.append(message))
|
||||
page = appointments.AppointmentsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
rows = [{"id": 71, "patient_id": 501, "diagnosis_id": 501, "source_patient_id": 901,
|
||||
"patient_name": "图文患者", "doctor_id": 7, "status": 1, "appointment_type": "text"},
|
||||
{"id": 72, "patient_id": 502, "diagnosis_id": 502, "source_patient_id": 902,
|
||||
"patient_name": "视频患者", "doctor_id": 8, "status": 1, "appointment_type": "video"}]
|
||||
try:
|
||||
page._loaded({"lists": rows, "count": 2}, page._generation, False)
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page.table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||
text_row = next(i for i in range(2) if page.table.item(i, 0).data(Qt.ItemDataRole.UserRole)["id"] == 71)
|
||||
button = page.table.cellWidget(text_row, 10).findChild(QPushButton, "AppointmentImConsultButton")
|
||||
assert button.text() == "图文沟通" and button.isEnabled()
|
||||
assert page.table.cellWidget(text_row, 4).findChild(QLabel, "AppointmentModeLabel").text() == "图文问诊"
|
||||
button.click()
|
||||
assert emitted[-1]["appointment_id"] == 71
|
||||
assert emitted[-1]["diagnosis_id"] == 501
|
||||
assert emitted[-1]["patient_id"] == 901
|
||||
assert emitted[-1]["appointment_type"] == "text"
|
||||
assert emitted[-1]["mode"] == "im"
|
||||
assert not page.toolbar_qr_button.isEnabled()
|
||||
page._request_video_qr()
|
||||
assert notices[-1] == "本次挂号不支持视频问诊二维码。"
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
|
||||
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
|
||||
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
|
||||
"appointment_status": 1, "latest_appointment_id": 71,
|
||||
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
|
||||
"video_call_hint": {"state": "none"}}
|
||||
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
buttons = page.table_host.findChildren(QToolButton)
|
||||
text_button = next(button for button in buttons if button.text() == "图文沟通")
|
||||
assert text_button.isEnabled()
|
||||
assert page.video_button.isEnabled()
|
||||
assert not page.video_qr_toolbar_button.isEnabled()
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page._request_video()
|
||||
assert emitted[-1]["appointment_id"] == 71
|
||||
assert emitted[-1]["appointment_type"] == "text"
|
||||
assert emitted[-1]["mode"] == "im"
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
@@ -642,6 +642,13 @@ def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
|
||||
"userSig": "short-lived-ticket",
|
||||
"targetUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
"patientId": 8,
|
||||
"appointmentId": 0,
|
||||
"appointment_type": None,
|
||||
"appointment_type_desc": "",
|
||||
"can_video_call": False,
|
||||
"can_audio_call": False,
|
||||
"call_disabled_reason": "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+91
-91
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CNqfE3p9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -60,6 +60,8 @@ const props = defineProps<{
|
||||
chatReady: Readonly<Ref<boolean>>
|
||||
chatBusy: Readonly<Ref<boolean>>
|
||||
notice: Readonly<Ref<string>>
|
||||
canVideoCall: Readonly<Ref<boolean>>
|
||||
callDisabledReason: Readonly<Ref<string>>
|
||||
hasMoreMessages: Readonly<Ref<boolean>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
localRecordingState: Readonly<Ref<string>>
|
||||
@@ -308,6 +310,7 @@ watch(
|
||||
</button>
|
||||
<button
|
||||
class="primary-action"
|
||||
v-if="canVideoCall.value"
|
||||
type="button"
|
||||
:disabled="actionBusy || isCalling || !chatReady.value"
|
||||
@click="runAction(onStartVideo)"
|
||||
@@ -383,6 +386,9 @@ watch(
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
<div v-if="callDisabledReason.value" class="inline-notice" role="status">
|
||||
{{ callDisabledReason.value }}
|
||||
</div>
|
||||
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
type Guard = (callType: number) => Promise<void>
|
||||
|
||||
/** Each SDK entrypoint uses the current conversation, including after async work. */
|
||||
export function installAppointmentCallGuard(api: Record<string, any>): (guard: Guard) => () => void {
|
||||
let activeGuard: Guard | undefined
|
||||
for (const method of ['call', 'calls', 'groupCall']) {
|
||||
const original = api[method]
|
||||
if (typeof original !== 'function') continue
|
||||
api[method] = async function (params: any, ...rest: any[]) {
|
||||
const guard = activeGuard
|
||||
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
|
||||
const type = params?.type === 1 ? 1 : 2
|
||||
const dispatched = { ...params, type }
|
||||
await guard(type)
|
||||
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
|
||||
return original.call(this, dispatched, ...rest)
|
||||
}
|
||||
}
|
||||
return (guard: Guard) => {
|
||||
activeGuard = guard
|
||||
return () => { if (activeGuard === guard) activeGuard = undefined }
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCallPolicy(policy: Record<string, unknown>, type: number): void {
|
||||
if ((type === 1 ? policy.can_audio_call : policy.can_video_call) !== true) {
|
||||
throw new Error(String(policy.call_disabled_reason || '本次挂号不支持该通话方式'))
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -6,6 +6,12 @@ declare module 'tim-upload-plugin' {
|
||||
}
|
||||
|
||||
interface DoctorCallConfig {
|
||||
patientId?: number | string | null
|
||||
appointmentId?: number
|
||||
appointment_type?: unknown
|
||||
can_video_call?: unknown
|
||||
can_audio_call?: unknown
|
||||
call_disabled_reason?: string
|
||||
SDKAppID?: number | string
|
||||
sdkAppId?: number | string
|
||||
userID?: string
|
||||
@@ -53,6 +59,7 @@ interface DoctorConsultationApi {
|
||||
startVideo(): Promise<void>
|
||||
hangup(): Promise<void>
|
||||
hostCallReady(ok: boolean, message?: string): void
|
||||
callPolicyResult(requestId: string, policy: Record<string, unknown>): void
|
||||
recordingResult(ok: boolean, message: string): void
|
||||
roomBindingResult(roomId: string, ok: boolean, message: string): void
|
||||
screenshotResult(ok: boolean, message: string): void
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@trtc/calls-uikit-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import { assertCallPolicy, installAppointmentCallGuard } from './appointment-call-guard'
|
||||
import './style.css'
|
||||
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
@@ -20,6 +21,8 @@ type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'erro
|
||||
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
|
||||
|
||||
interface NormalizedCallConfig {
|
||||
appointmentId: number
|
||||
policy: Record<string, unknown>
|
||||
SDKAppID: number
|
||||
userID: string
|
||||
userSig: string
|
||||
@@ -77,6 +80,7 @@ interface BridgeMessage {
|
||||
event:
|
||||
| 'ready'
|
||||
| 'call-start-request'
|
||||
| 'call-policy-request'
|
||||
| 'status'
|
||||
| 'room'
|
||||
| 'hangup'
|
||||
@@ -86,6 +90,8 @@ interface BridgeMessage {
|
||||
| 'transcription-segment'
|
||||
| 'transcription-stop'
|
||||
diagnosisId?: number | string
|
||||
callType?: number
|
||||
requestId?: string
|
||||
status?: string
|
||||
roomId?: string
|
||||
message?: string
|
||||
@@ -179,6 +185,13 @@ const messages = ref<UiChatMessage[]>([])
|
||||
const chatReady = ref(false)
|
||||
const chatBusy = ref(false)
|
||||
const notice = ref('')
|
||||
const canVideoCall = ref(false)
|
||||
const callDisabledReason = ref('正在确认本次挂号通话权限')
|
||||
const registerCallGuard = installAppointmentCallGuard(TUICallKitAPI)
|
||||
let releaseCallGuard: (() => void) | undefined
|
||||
let contextGeneration = 0
|
||||
let policySequence = 0
|
||||
const pendingCallPolicies = new Map<string, (policy: Record<string, unknown>) => void>()
|
||||
const hasMoreMessages = ref(false)
|
||||
const transcriptionState = ref<TranscriptionState>('idle')
|
||||
const localRecordingState = ref<LocalRecordingState>('idle')
|
||||
@@ -381,6 +394,13 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||
: '患者'
|
||||
return {
|
||||
SDKAppID,
|
||||
appointmentId: Number(config.appointmentId || 0),
|
||||
policy: {
|
||||
can_video_call: config.can_video_call,
|
||||
can_audio_call: config.can_audio_call,
|
||||
appointment_type: config.appointment_type,
|
||||
call_disabled_reason: config.call_disabled_reason,
|
||||
},
|
||||
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||
userSig: cleanString(config.userSig, '用户签名'),
|
||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||
@@ -1740,13 +1760,42 @@ TUICallKitAPI.setCallback({
|
||||
TUICallKitAPI.setLanguage('zh-cn')
|
||||
TUICallKitAPI.enableFloatWindow(false)
|
||||
|
||||
function requestHostCallStart(): Promise<boolean> {
|
||||
function syncCallPolicy(policy: Record<string, unknown>): void {
|
||||
canVideoCall.value = policy.can_video_call === true
|
||||
callDisabledReason.value = policy.appointment_type === 'text'
|
||||
? '图文问诊,不支持音视频通话'
|
||||
: String(policy.call_disabled_reason || (canVideoCall.value ? '' : '本次挂号不支持视频通话'))
|
||||
}
|
||||
|
||||
function requestCallPolicy(): Promise<Record<string, unknown>> {
|
||||
if (!window.qtVideoBridge?.notify) return Promise.reject(new Error('无法确认本次挂号通话权限'))
|
||||
const requestId = `${contextGeneration}:${++policySequence}`
|
||||
return new Promise((resolve) => {
|
||||
pendingCallPolicies.set(requestId, resolve)
|
||||
emit({ source: 'doctor-call', event: 'call-policy-request', requestId, diagnosisId: activeConfig?.diagnosisId })
|
||||
window.setTimeout(() => {
|
||||
const pending = pendingCallPolicies.get(requestId)
|
||||
if (!pending) return
|
||||
pendingCallPolicies.delete(requestId)
|
||||
pending({ call_disabled_reason: '通话权限确认超时,请重试' })
|
||||
}, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
function callPolicyResult(requestId: string, policy: Record<string, unknown>): void {
|
||||
const resolve = pendingCallPolicies.get(requestId)
|
||||
if (!resolve) return
|
||||
pendingCallPolicies.delete(requestId)
|
||||
resolve(policy)
|
||||
}
|
||||
|
||||
function requestHostCallStart(callType = 2): Promise<boolean> {
|
||||
if (!activeConfig) return Promise.resolve(false)
|
||||
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
|
||||
if (!window.qtVideoBridge?.notify) return Promise.resolve(false)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const currentResolver = resolve
|
||||
resolveHostCallReady = currentResolver
|
||||
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
|
||||
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId, callType })
|
||||
window.setTimeout(() => {
|
||||
if (resolveHostCallReady !== currentResolver) return
|
||||
resolveHostCallReady = null
|
||||
@@ -1765,9 +1814,12 @@ function hostCallReady(ok: boolean, message = ''): void {
|
||||
async function startVideo(): Promise<void> {
|
||||
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
const config = activeConfig
|
||||
const generation = contextGeneration
|
||||
starting = true
|
||||
try {
|
||||
if (hangupNotification) await hangupNotification
|
||||
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
|
||||
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
hangupNotification = null
|
||||
callCycleGeneration += 1
|
||||
@@ -1782,8 +1834,6 @@ async function startVideo(): Promise<void> {
|
||||
localRecordingSessionId = ''
|
||||
localRecordingMimeType = ''
|
||||
notice.value = ''
|
||||
const allowed = await requestHostCallStart()
|
||||
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
|
||||
await TUICallKitAPI.init({
|
||||
SDKAppID: activeConfig.SDKAppID,
|
||||
userID: activeConfig.userID,
|
||||
@@ -1791,6 +1841,7 @@ async function startVideo(): Promise<void> {
|
||||
...(chat ? { tim: chat, isFromChat: true } : {}),
|
||||
})
|
||||
await nextTick()
|
||||
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
|
||||
phase.value = 'dialing'
|
||||
statusText.value = '正在呼叫患者'
|
||||
appendVideoCallStatus('dialing', '正在呼叫患者')
|
||||
@@ -1807,11 +1858,12 @@ async function startVideo(): Promise<void> {
|
||||
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, '无法发起视频通话')
|
||||
if (activeConfig !== config || generation !== contextGeneration) throw new Error(message)
|
||||
phase.value = 'error'
|
||||
statusText.value = message
|
||||
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
|
||||
endNotified = true
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig?.diagnosisId, message })
|
||||
throw new Error(message)
|
||||
} finally {
|
||||
starting = false
|
||||
@@ -1867,6 +1919,31 @@ function roomBindingResult(roomId: string, ok: boolean, message: string): void {
|
||||
async function open(config: DoctorCallConfig): Promise<void> {
|
||||
if (activeConfig) await close()
|
||||
activeConfig = normalizeConfig(config)
|
||||
const current = activeConfig
|
||||
const generation = ++contextGeneration
|
||||
syncCallPolicy(current.policy)
|
||||
releaseCallGuard = registerCallGuard(async (type) => {
|
||||
const assertCurrent = () => {
|
||||
if (activeConfig !== current || generation !== contextGeneration) {
|
||||
throw new Error('当前问诊已切换,请重新发起通话')
|
||||
}
|
||||
if (document.visibilityState === 'hidden') throw new Error('请先返回本次挂号的聊天窗口')
|
||||
}
|
||||
const refresh = async () => {
|
||||
assertCurrent()
|
||||
const policy = await requestCallPolicy()
|
||||
assertCurrent()
|
||||
syncCallPolicy(policy)
|
||||
if (policy.appointmentId !== current.appointmentId) throw new Error('本次挂号已变更,请重新打开聊天窗口')
|
||||
assertCallPolicy(policy, type)
|
||||
}
|
||||
await refresh()
|
||||
if (!await requestHostCallStart(type)) throw new Error(notice.value || '服务器未能创建通话记录')
|
||||
assertCurrent()
|
||||
// Refresh again after asynchronous record creation, immediately before SDK dispatch.
|
||||
await refresh()
|
||||
assertCurrent()
|
||||
})
|
||||
mode.value = activeConfig.mode
|
||||
patientName.value = activeConfig.patientName
|
||||
patientCase.value = activeConfig.patientCase
|
||||
@@ -1908,6 +1985,13 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
releaseCallGuard?.()
|
||||
releaseCallGuard = undefined
|
||||
contextGeneration += 1
|
||||
for (const resolve of pendingCallPolicies.values()) resolve({})
|
||||
pendingCallPolicies.clear()
|
||||
resolveHostCallReady?.(false)
|
||||
resolveHostCallReady = null
|
||||
if (!endNotified) await hangup()
|
||||
if (hangupNotification) await hangupNotification
|
||||
unsubscribeTranscriber()
|
||||
@@ -1933,6 +2017,7 @@ window.doctorConsultation = {
|
||||
startVideo,
|
||||
hangup,
|
||||
hostCallReady,
|
||||
callPolicyResult,
|
||||
recordingResult,
|
||||
roomBindingResult,
|
||||
screenshotResult,
|
||||
@@ -1951,6 +2036,8 @@ createApp(App, {
|
||||
chatReady: readonly(chatReady),
|
||||
chatBusy: readonly(chatBusy),
|
||||
notice: readonly(notice),
|
||||
canVideoCall: readonly(canVideoCall),
|
||||
callDisabledReason: readonly(callDisabledReason),
|
||||
hasMoreMessages: readonly(hasMoreMessages),
|
||||
transcriptionState: readonly(transcriptionState),
|
||||
localRecordingState: readonly(localRecordingState),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const ts = require('typescript')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/appointment-call-guard.ts'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const target = { exports: {} }
|
||||
new Function('module', 'exports', compiled)(target, target.exports)
|
||||
const { installAppointmentCallGuard, assertCallPolicy } = target.exports
|
||||
|
||||
for (const method of ['call', 'calls', 'groupCall']) {
|
||||
test(`${method}: current server policy controls every invocation`, async () => {
|
||||
const dispatched = []
|
||||
const api = { [method]: async (params) => dispatched.push(params.type) }
|
||||
const register = installAppointmentCallGuard(api)
|
||||
await assert.rejects(api[method]({ type: 2 }), /请先打开/)
|
||||
let serverPolicy = { can_video_call: true, can_audio_call: true }
|
||||
let refreshes = 0
|
||||
register(async (type) => { refreshes++; assertCallPolicy(serverPolicy, type) })
|
||||
await api[method]({ type: 2 })
|
||||
serverPolicy = { appointment_type: 'text', can_audio_call: false, can_video_call: false }
|
||||
await assert.rejects(api[method]({ type: 2 }))
|
||||
await assert.rejects(api[method]({ type: 1 }))
|
||||
assert.deepEqual(dispatched, [2])
|
||||
assert.equal(refreshes, 3)
|
||||
})
|
||||
|
||||
test(`${method}: close or replacement rejects an in-flight authorization`, async () => {
|
||||
let resume
|
||||
let dispatched = 0
|
||||
const api = { [method]: async () => dispatched++ }
|
||||
const register = installAppointmentCallGuard(api)
|
||||
const release = register(() => new Promise((resolve) => { resume = resolve }))
|
||||
const pending = api[method]({ type: 2 })
|
||||
release()
|
||||
register(async () => {})
|
||||
resume()
|
||||
await assert.rejects(pending, /已切换/)
|
||||
assert.equal(dispatched, 0)
|
||||
release() // Releasing the old window cannot remove the replacement guard.
|
||||
await api[method]({ type: 1 })
|
||||
assert.equal(dispatched, 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('strict booleans fail closed, audio and video permissions remain separate', () => {
|
||||
for (const value of [undefined, null, false, 0, 1, 'true', '1']) {
|
||||
assert.throws(() => assertCallPolicy({ can_video_call: value }, 2))
|
||||
assert.throws(() => assertCallPolicy({ can_audio_call: value }, 1))
|
||||
}
|
||||
assert.doesNotThrow(() => assertCallPolicy({ can_audio_call: true }, 1))
|
||||
assert.throws(() => assertCallPolicy({ can_audio_call: true }, 2))
|
||||
})
|
||||
|
||||
test('an awaiting caller cannot change the authorized media type', async () => {
|
||||
let resume
|
||||
let actual
|
||||
const api = { calls: async (params) => { actual = params.type } }
|
||||
const register = installAppointmentCallGuard(api)
|
||||
register(() => new Promise((resolve) => { resume = resolve }))
|
||||
const params = { type: 1 }
|
||||
const pending = api.calls(params)
|
||||
params.type = 2
|
||||
resume()
|
||||
await pending
|
||||
assert.equal(actual, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user