This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
+1 -1
View File
@@ -7,5 +7,5 @@ __version__ = "1.4.2"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
DEBUG_MODE = False
DEBUG_MODE = True
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
+39 -19
View File
@@ -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)
+19 -11
View File
@@ -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(
+34 -1
View File
@@ -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),
)
+26 -3
View File
@@ -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
+44 -7
View File
@@ -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()