更新
This commit is contained in:
@@ -7,7 +7,7 @@ admin-table presentation cannot leak into another workstation page.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
@@ -75,12 +75,9 @@ TEXT = QColor("#1A1C1F")
|
||||
SECONDARY = QColor("#606163")
|
||||
PLACEHOLDER = QColor("#8E8F90")
|
||||
_INVALID_INDEX = QModelIndex()
|
||||
# The last two columns are the frozen right-hand pane. They were 120 + 410,
|
||||
# which pinned 532 px of the table. Measured against what they actually hold:
|
||||
# the widest video cell is "暂无通话" at 56 px, and the action cell - two inline
|
||||
# buttons plus 更多, all two-character labels - reports a 145 px sizeHint. So
|
||||
# 265 px of the action column was permanently blank on every row while the data
|
||||
# columns to its left were horizontally scrolling.
|
||||
# The last two columns form the compact frozen pane. Video status and ending
|
||||
# time occupy separate lines; actions keep two inline links plus 更多, with
|
||||
# the remaining actions in its menu so clinical columns retain more space.
|
||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
|
||||
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
|
||||
_BLUE_WARNING = QColor("#A9691D")
|
||||
@@ -1609,6 +1606,7 @@ class DiagnosisTableHost(QFrame):
|
||||
self,
|
||||
*,
|
||||
action_policy: Mapping[str, bool] | None = None,
|
||||
video_entry_allowed: Callable[[Any], bool] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
tech_blue: bool = False,
|
||||
) -> None:
|
||||
@@ -1618,6 +1616,7 @@ class DiagnosisTableHost(QFrame):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(0)
|
||||
self.action_policy = dict(action_policy or {})
|
||||
self.video_entry_allowed = video_entry_allowed
|
||||
self.force_open_prescription = False
|
||||
if tech_blue:
|
||||
self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10]
|
||||
@@ -1633,6 +1632,9 @@ class DiagnosisTableHost(QFrame):
|
||||
self._fixed_height_timer.timeout.connect(self._sync_fixed_height)
|
||||
self.main.setObjectName("DiagnosisMainTable")
|
||||
self.fixed.setObjectName("DiagnosisFixedTable")
|
||||
# Index widgets supply their own margins. Inherited item padding would
|
||||
# shrink a 92px video cell to 76px and clip its wrapped status text.
|
||||
self.fixed.setStyleSheet("QTableView#DiagnosisFixedTable::item { padding: 0; }")
|
||||
for view in (self.main, self.fixed):
|
||||
view.setModel(self.model)
|
||||
view.setSelectionModel(self.selection)
|
||||
@@ -1786,11 +1788,11 @@ class DiagnosisTableHost(QFrame):
|
||||
host.hovered_row.connect(self._set_hover_row)
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(3, 2, 3, 2)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
video_capable = self.action_policy.get("video_call", False)
|
||||
entry_allowed = self.video_entry_allowed is None or self.video_entry_allowed(record)
|
||||
call_state = video_call_state(record)
|
||||
is_text = appointment_type_value(record) == "text"
|
||||
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
|
||||
if video_capable and entry_allowed and _appointment_active(record) and (is_text or call_state == "live"):
|
||||
button = QToolButton(host)
|
||||
button.setText("图文沟通" if is_text else "进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
@@ -1803,10 +1805,17 @@ class DiagnosisTableHost(QFrame):
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, item=record: self.video_requested.emit(item)
|
||||
)
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignCenter)
|
||||
else:
|
||||
status = "—"
|
||||
if video_capable:
|
||||
if not entry_allowed:
|
||||
# Match the admin's public status without turning an active
|
||||
# doctor's room into a receiver entry for an unrelated user.
|
||||
if _appointment_active(record) and can_appointment_video(appointment_type_value(record)):
|
||||
status = {"live": "通话中", "pending_room": "接通中", "none": "—"}.get(
|
||||
call_state, first_value(record, "video_call_hint.label", default="—")
|
||||
)
|
||||
elif video_capable:
|
||||
if call_state == "pending_room":
|
||||
status = "等待接通"
|
||||
elif _appointment_active(record):
|
||||
@@ -1816,10 +1825,17 @@ class DiagnosisTableHost(QFrame):
|
||||
_appointment_status(record),
|
||||
"未挂号" if not _has_appointment(record) else "—",
|
||||
)
|
||||
label = QLabel(display_text(status), host)
|
||||
status_text = display_text(status)
|
||||
label = QLabel(status_text.replace(" · ", "\n", 1), host)
|
||||
label.setProperty("fixedMuted", True)
|
||||
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
label.setToolTip(status_text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setWordWrap(True)
|
||||
# Fill the cell instead of centering the label at its sizeHint:
|
||||
# its wrapped height can grow when the available width changes.
|
||||
label.setMinimumWidth(0)
|
||||
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||
layout.addWidget(label)
|
||||
return host
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from threading import Event
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
@@ -2338,8 +2339,14 @@ class _RichMessage(QTextBrowser):
|
||||
self.document().contentsChanged.connect(self._fit)
|
||||
|
||||
def set_payload(self, raw: Any) -> None:
|
||||
render_chat_payload(self, raw, role=self._role)
|
||||
QTimer.singleShot(0, self._fit)
|
||||
# Markdown parsing and block formatting both emit contentsChanged. Fit
|
||||
# once after the complete update instead of laying out each mutation.
|
||||
self._fitting = True
|
||||
try:
|
||||
render_chat_payload(self, raw, role=self._role)
|
||||
finally:
|
||||
self._fitting = False
|
||||
self._fit()
|
||||
|
||||
def resizeEvent(self, event) -> None: # type: ignore[override]
|
||||
super().resizeEvent(event)
|
||||
@@ -3177,6 +3184,8 @@ class _AiStreamWorker(QRunnable):
|
||||
if self.is_cancelled():
|
||||
return
|
||||
self.signals.event.emit(event)
|
||||
if isinstance(event, Mapping) and event.get("event") == "done":
|
||||
return
|
||||
except Exception as error: # UI boundary: render transport/domain errors in place.
|
||||
if not self.is_cancelled():
|
||||
self.signals.error.emit(error)
|
||||
@@ -3517,6 +3526,7 @@ class AiConsultDialog(QDialog):
|
||||
self._detail: Mapping[str, Any] = {}
|
||||
self._workspace_sections: dict[str, dict[str, Any]] = {}
|
||||
self._generation = 0
|
||||
self._session_closed = False
|
||||
self._image_generation = 0
|
||||
self._image_preview: QWidget | None = None
|
||||
# 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。
|
||||
@@ -3528,8 +3538,12 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_worker: _AiStreamWorker | None = None
|
||||
self._stream_bubble: _ChatBubble | None = None
|
||||
self._stream_text = ""
|
||||
self._stream_prompt = ""
|
||||
self._pending_chunks: list[str] = []
|
||||
self._stream_meta: dict[str, Any] = {}
|
||||
self._stream_started_at: float | None = None
|
||||
self._stream_first_delta_at: float | None = None
|
||||
self._stream_ended_at: float | None = None
|
||||
self._patient_ai_context: str = ""
|
||||
self._patient_ai_context_labels: list[str] = []
|
||||
self._patient_ai_context_bubble: _ChatBubble | None = None
|
||||
@@ -3546,6 +3560,9 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_timeout_timer = QTimer(self)
|
||||
self._stream_timeout_timer.setSingleShot(True)
|
||||
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
|
||||
self._stream_elapsed_timer = QTimer(self)
|
||||
self._stream_elapsed_timer.setInterval(1000)
|
||||
self._stream_elapsed_timer.timeout.connect(self._update_stream_elapsed)
|
||||
self.setObjectName("AiConsultDialog")
|
||||
self.setWindowTitle("问诊详情")
|
||||
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
||||
@@ -3562,6 +3579,7 @@ class AiConsultDialog(QDialog):
|
||||
self.setStyleSheet(AI_CONSULT_QSS)
|
||||
mark_business_dialog(self, "AiConsultDialog")
|
||||
self._build()
|
||||
self.finished.connect(self._end_session)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override]
|
||||
# QLineEdit emits returnPressed before the key reaches QDialog. The
|
||||
@@ -3585,6 +3603,7 @@ class AiConsultDialog(QDialog):
|
||||
source_title: str = "问诊列表",
|
||||
) -> None:
|
||||
self._cancel_stream()
|
||||
self._session_closed = False
|
||||
self.diagnosis_id = diagnosis_id
|
||||
self.patient_id = _as_int(patient_id)
|
||||
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
|
||||
@@ -3792,7 +3811,7 @@ class AiConsultDialog(QDialog):
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, text=prompt: self._ask(text)
|
||||
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
|
||||
)
|
||||
chips.addWidget(button, 1)
|
||||
more = QPushButton("更多")
|
||||
@@ -3803,7 +3822,7 @@ class AiConsultDialog(QDialog):
|
||||
for label, prompt in QUICK_PROMPTS[3:]:
|
||||
action = more_menu.addAction(label)
|
||||
action.triggered.connect(
|
||||
lambda _checked=False, text=prompt: self._ask(text)
|
||||
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
|
||||
)
|
||||
more.setMenu(more_menu)
|
||||
chips.addWidget(more, 1)
|
||||
@@ -3996,7 +4015,7 @@ class AiConsultDialog(QDialog):
|
||||
for index, (kind, label, prompt) in enumerate(TOOL_ITEMS):
|
||||
card = _ClickCard()
|
||||
card.setObjectName("AiConsultToolCard")
|
||||
card.clicked.connect(lambda text=prompt: self._ask(text))
|
||||
card.clicked.connect(lambda text=prompt: self._ask(text, replace_active=True))
|
||||
inner = QVBoxLayout(card)
|
||||
inner.setContentsMargins(8, 10, 8, 10)
|
||||
inner.setSpacing(6)
|
||||
@@ -5976,11 +5995,14 @@ class AiConsultDialog(QDialog):
|
||||
|
||||
def _use_suggestion(self, question: str) -> None:
|
||||
self.input.setText(question)
|
||||
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}")
|
||||
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}", replace_active=True)
|
||||
|
||||
def _ask(self, prompt: str) -> None:
|
||||
def _ask(self, prompt: str, *, replace_active: bool = False) -> None:
|
||||
text = prompt.strip()
|
||||
if self._asking or not text:
|
||||
if not text:
|
||||
return
|
||||
if self._asking and (not replace_active or text == self._stream_prompt):
|
||||
show_toast(self, "AI 正在生成,请稍候或点击其他快捷工具切换。", "info")
|
||||
return
|
||||
if self.diagnosis_id <= 0:
|
||||
show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning")
|
||||
@@ -5997,8 +6019,12 @@ class AiConsultDialog(QDialog):
|
||||
"warning",
|
||||
)
|
||||
return
|
||||
self._cancel_stream()
|
||||
self._cancel_stream(replaced=self._asking)
|
||||
self._stream_started_at = monotonic()
|
||||
self._stream_prompt = text
|
||||
self._follow_chat = True
|
||||
self.tabs.setCurrentIndex(0)
|
||||
self._set_chat_filter("all")
|
||||
self.input.clear()
|
||||
self._append_bubble(
|
||||
"doctor", text, time_text=datetime.now().strftime("%H:%M")
|
||||
@@ -6019,6 +6045,8 @@ class AiConsultDialog(QDialog):
|
||||
self._pending_chunks.clear()
|
||||
self._stream_meta.clear()
|
||||
self._asking = True
|
||||
self._update_stream_elapsed()
|
||||
self._stream_elapsed_timer.start()
|
||||
self.send_button.setEnabled(False)
|
||||
generation = self._generation
|
||||
stream_generation = self._stream_generation
|
||||
@@ -6047,26 +6075,65 @@ class AiConsultDialog(QDialog):
|
||||
def _stream_is_current(self, generation: int, stream_generation: int) -> bool:
|
||||
return generation == self._generation and stream_generation == self._stream_generation
|
||||
|
||||
def _update_stream_elapsed(self) -> None:
|
||||
if self._stream_bubble is None:
|
||||
return
|
||||
model = display_text(
|
||||
first_value(self._stream_meta, "model_label", "model_key"), ""
|
||||
)
|
||||
if self._stream_started_at is None:
|
||||
self._stream_bubble.set_time_text(_join_meta(model, datetime.now().strftime("%H:%M")))
|
||||
return
|
||||
now = self._stream_ended_at if self._stream_ended_at is not None else monotonic()
|
||||
elapsed = max(0.0, now - self._stream_started_at)
|
||||
if self._stream_ended_at is None:
|
||||
status = (
|
||||
f"生成中 · 已用时 {int(elapsed)} 秒"
|
||||
if self._stream_first_delta_at is not None else f"已等待 {int(elapsed)} 秒"
|
||||
)
|
||||
self._stream_bubble.set_time_text(_join_meta(model, status))
|
||||
return
|
||||
first_delta = (
|
||||
f"首字 {max(0.0, self._stream_first_delta_at - self._stream_started_at):.1f} 秒"
|
||||
if self._stream_first_delta_at is not None else ""
|
||||
)
|
||||
self._stream_bubble.set_time_text(
|
||||
_join_meta(model, first_delta, f"耗时 {elapsed:.1f} 秒", datetime.now().strftime("%H:%M"))
|
||||
)
|
||||
|
||||
def _stop_stream_clock(self) -> None:
|
||||
if self._stream_ended_at is None:
|
||||
self._stream_ended_at = monotonic()
|
||||
self._stream_elapsed_timer.stop()
|
||||
self._update_stream_elapsed()
|
||||
|
||||
def _stream_event(
|
||||
self,
|
||||
generation: int,
|
||||
stream_generation: int,
|
||||
event: Any,
|
||||
) -> None:
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
|
||||
return
|
||||
payload = dict(event) if isinstance(event, Mapping) else {}
|
||||
kind = str(payload.get("event") or "").lower()
|
||||
if kind == "start":
|
||||
self._stream_meta.update(payload)
|
||||
self._update_stream_elapsed()
|
||||
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
|
||||
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
|
||||
return
|
||||
if kind == "delta":
|
||||
chunk = payload.get("text")
|
||||
if isinstance(chunk, str) and chunk:
|
||||
if self._stream_first_delta_at is None:
|
||||
self._stream_first_delta_at = monotonic()
|
||||
self._update_stream_elapsed()
|
||||
self._pending_chunks.append(chunk)
|
||||
if not self._flush_timer.isActive():
|
||||
if not self._stream_text:
|
||||
# The first received text replaces the waiting state now.
|
||||
self._flush_stream_chunks()
|
||||
elif not self._flush_timer.isActive():
|
||||
self._flush_timer.start()
|
||||
return
|
||||
if kind != "done":
|
||||
@@ -6074,6 +6141,7 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_meta.update(payload)
|
||||
self._stream_completed = True
|
||||
self._stream_timeout_timer.stop()
|
||||
self._stop_stream_clock()
|
||||
if not self._stream_text and not self._pending_chunks:
|
||||
answer = first_value(payload, "answer", "content", default="")
|
||||
if answer not in (None, ""):
|
||||
@@ -6083,17 +6151,15 @@ class AiConsultDialog(QDialog):
|
||||
if not self._stream_text:
|
||||
self._stream_text = "未返回分析内容。"
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
model = display_text(
|
||||
first_value(self._stream_meta, "model_label", "model_key"),
|
||||
"",
|
||||
)
|
||||
self._stream_bubble.set_time_text(
|
||||
_join_meta(model, datetime.now().strftime("%H:%M"))
|
||||
)
|
||||
self._stream_bubble.set_provenance(self._stream_meta)
|
||||
self._stream_bubble.finalize_clinical_analysis()
|
||||
# A terminal event completes the UI request even if transport cleanup
|
||||
# has not returned yet. The next click must not depend on finished.
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
def _flush_stream_chunks(self) -> None:
|
||||
self._flush_timer.stop()
|
||||
if not self._pending_chunks or self._stream_bubble is None:
|
||||
return
|
||||
self._stream_text += "".join(self._pending_chunks)
|
||||
@@ -6108,9 +6174,10 @@ class AiConsultDialog(QDialog):
|
||||
stream_generation: int,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
|
||||
return
|
||||
self._stream_timeout_timer.stop()
|
||||
self._stop_stream_clock()
|
||||
self._flush_stream_chunks()
|
||||
message = friendly_error(error)
|
||||
if self._stream_text:
|
||||
@@ -6119,7 +6186,8 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_text = message
|
||||
if self._stream_bubble is not None:
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
def _stream_finished(
|
||||
self,
|
||||
@@ -6130,6 +6198,8 @@ class AiConsultDialog(QDialog):
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
return
|
||||
self._stream_timeout_timer.stop()
|
||||
self._flush_stream_chunks()
|
||||
self._stop_stream_clock()
|
||||
if self._stream_worker is worker:
|
||||
self._stream_worker = None
|
||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
|
||||
@@ -6141,7 +6211,6 @@ class AiConsultDialog(QDialog):
|
||||
):
|
||||
self._stream_text = AI_STREAM_SILENT_TEXT
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
@@ -6151,34 +6220,53 @@ class AiConsultDialog(QDialog):
|
||||
self._flush_stream_chunks()
|
||||
bubble = self._stream_bubble
|
||||
text = self._stream_text
|
||||
self._stop_stream_clock()
|
||||
self._cancel_stream()
|
||||
if bubble is not None:
|
||||
bubble.set_payload(
|
||||
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
|
||||
)
|
||||
bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
|
||||
def _cancel_stream(self) -> None:
|
||||
def _cancel_stream(self, *, replaced: bool = False) -> None:
|
||||
if replaced and self._asking:
|
||||
self._flush_stream_chunks()
|
||||
self._stop_stream_clock()
|
||||
if self._stream_bubble is not None:
|
||||
notice = "已停止本次生成,已切换到新的请求。"
|
||||
self._stream_bubble.set_payload(
|
||||
f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice
|
||||
)
|
||||
self._stream_generation += 1
|
||||
self._flush_timer.stop()
|
||||
self._stream_timeout_timer.stop()
|
||||
self._stream_elapsed_timer.stop()
|
||||
if self._stream_worker is not None:
|
||||
self._stream_worker.cancel()
|
||||
self._stream_worker = None
|
||||
self._stream_bubble = None
|
||||
self._pending_chunks.clear()
|
||||
self._stream_text = ""
|
||||
self._stream_prompt = ""
|
||||
self._stream_completed = False
|
||||
self._stream_meta.clear()
|
||||
self._stream_started_at = None
|
||||
self._stream_first_delta_at = None
|
||||
self._stream_ended_at = None
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
def closeEvent(self, event: Any) -> None: # type: ignore[override]
|
||||
def _end_session(self) -> None:
|
||||
if self._session_closed:
|
||||
return
|
||||
self._session_closed = True
|
||||
self._generation += 1
|
||||
self._image_generation += 1
|
||||
_abort_pending_thumbnails(self)
|
||||
self._close_image_preview()
|
||||
self._cancel_stream()
|
||||
|
||||
def closeEvent(self, event: Any) -> None: # type: ignore[override]
|
||||
self._end_session()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _open_full_record(self) -> None:
|
||||
|
||||
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core import UserProfile
|
||||
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||
from .. import icons
|
||||
from ..consultations_style import consultations_stylesheet
|
||||
@@ -1493,6 +1494,7 @@ class ConsultationsPage(QWidget):
|
||||
table_wrap_layout.setSpacing(0)
|
||||
self.table_host = DiagnosisTableHost(
|
||||
tech_blue=True,
|
||||
video_entry_allowed=self._can_enter_list_call,
|
||||
action_policy={
|
||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||
@@ -3053,8 +3055,11 @@ class ConsultationsPage(QWidget):
|
||||
_as_int(payload.get(key), 0) > 0
|
||||
for key in ("appointment_id", "patient_id", "diagnosis_id")
|
||||
)
|
||||
entry_allowed = has_record and self._can_enter_list_call(record)
|
||||
self.video_button.setVisible(entry_allowed)
|
||||
self.call_toolbar_button.setVisible(entry_allowed)
|
||||
self.video_button.setEnabled(
|
||||
has_record
|
||||
entry_allowed
|
||||
and is_video_available(record)
|
||||
and (appointment_type_value(record) == "text" or video_call_is_live(record))
|
||||
and valid_ids
|
||||
@@ -3496,6 +3501,28 @@ class ConsultationsPage(QWidget):
|
||||
self._checked_changed(len(self.table_host.selected_records()))
|
||||
self._selection_changed()
|
||||
|
||||
def _can_enter_list_call(self, record: Any) -> bool:
|
||||
if not self._native_video_capable or record is None:
|
||||
return False
|
||||
# Text appointments retain their IM entry; this restriction concerns
|
||||
# receiving an existing video call from the diagnosis list.
|
||||
if appointment_type_value(record) == "text":
|
||||
return True
|
||||
if not can_appointment_video(appointment_type_value(record)):
|
||||
return False
|
||||
roles = UserProfile.from_dict({
|
||||
"role_ids": first_value(self.current_user, "role_ids", "role_id", "roles", default=[])
|
||||
}).role_ids
|
||||
if 1 in roles:
|
||||
return False
|
||||
user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0))
|
||||
assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0))
|
||||
return bool(
|
||||
user_id > 0
|
||||
and assistant_id == user_id
|
||||
and _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall")
|
||||
)
|
||||
|
||||
def _request_video(self) -> None:
|
||||
record = self.table.current_data()
|
||||
if not self._native_video_capable:
|
||||
@@ -3503,6 +3530,9 @@ class ConsultationsPage(QWidget):
|
||||
return
|
||||
if record is None or not is_video_available(record):
|
||||
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
||||
return
|
||||
if not self._can_enter_list_call(record):
|
||||
self.banner.show_message("仅本诊单指派且具有旁观权限的医助可进入视频会话。", "warning")
|
||||
return
|
||||
if appointment_type_value(record) != "text" and not video_call_is_live(record):
|
||||
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
||||
|
||||
@@ -321,6 +321,11 @@ QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionCurrentAssistant {
|
||||
color: #273244;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#StatusBadge {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
@@ -4394,8 +4399,17 @@ class ReceptionPage(QWidget):
|
||||
actions.setContentsMargins(0, 0, 0, 0)
|
||||
actions.setSpacing(14)
|
||||
actions.addStretch(1)
|
||||
patient_head.addLayout(actions)
|
||||
patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom)
|
||||
action_group = QVBoxLayout()
|
||||
action_group.setContentsMargins(0, 0, 0, 0)
|
||||
action_group.setSpacing(6)
|
||||
self.current_assistant_label = QLabel("当前医助:—", hero)
|
||||
self.current_assistant_label.setObjectName("ReceptionCurrentAssistant")
|
||||
self.current_assistant_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.current_assistant_label.setWordWrap(True)
|
||||
action_group.addWidget(self.current_assistant_label)
|
||||
action_group.addLayout(actions)
|
||||
patient_head.addLayout(action_group)
|
||||
patient_head.setAlignment(action_group, Qt.AlignmentFlag.AlignBottom)
|
||||
|
||||
self.complete_button = _ReceptionCompleteButton(hero)
|
||||
self.complete_button.clicked.connect(self._complete_appointment)
|
||||
@@ -7885,9 +7899,14 @@ class ReceptionPage(QWidget):
|
||||
)
|
||||
)
|
||||
)
|
||||
self.appointment_labels["assistant"].setText(
|
||||
display_text(first_value(appointment, "assistant_name"))
|
||||
)
|
||||
# reception normalises these fields from the diagnosis's current
|
||||
# assistant, matching the recipient of the notification action.
|
||||
assistant_name = display_text(first_value(appointment, "assistant_name"), "")
|
||||
assistant_id = _as_int(first_value(appointment, "assistant_id"), None)
|
||||
missing_name = "姓名暂缺" if assistant_id else "未分配" if assistant_id == 0 else "—"
|
||||
self.current_assistant_label.setText(f"当前医助:{assistant_name or missing_name}")
|
||||
self.current_assistant_label.setToolTip(self.current_assistant_label.text())
|
||||
self.appointment_labels["assistant"].setText(assistant_name or "—")
|
||||
self.appointment_labels["type"].setText(
|
||||
appointment_type_description(appointment_type_value(appointment))
|
||||
)
|
||||
@@ -8821,6 +8840,8 @@ class ReceptionPage(QWidget):
|
||||
)
|
||||
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
|
||||
self.patient_meta_label.setToolTip("")
|
||||
self.current_assistant_label.setText("当前医助:正在加载…" if seed else "当前医助:—")
|
||||
self.current_assistant_label.setToolTip("")
|
||||
self.detail_status.set_status("—", "neutral")
|
||||
for label in getattr(self, "appointment_labels", {}).values():
|
||||
label.setText("—")
|
||||
@@ -8886,6 +8907,8 @@ class ReceptionPage(QWidget):
|
||||
return
|
||||
self.patient_meta_label.setText(message)
|
||||
self.patient_meta_label.setToolTip("")
|
||||
self.current_assistant_label.setText("当前医助:—")
|
||||
self.current_assistant_label.setToolTip("")
|
||||
self._set_case_placeholder(message)
|
||||
self.prescription_hint.setText("处方:患者详情加载失败")
|
||||
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
|
||||
|
||||
@@ -2,13 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from threading import Event
|
||||
from time import monotonic
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtCore import QPoint, Qt, QThread
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||
from doctor_workstation.ui.dialogs.ai_consult import (
|
||||
AiConsultDialog,
|
||||
@@ -1577,6 +1580,356 @@ def test_answered_stream_replaces_the_pending_placeholder(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_clock_covers_preparation_first_text_and_completion(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
now = [100.0]
|
||||
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
|
||||
dialog._ask("请生成问诊小结")
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
assert "已等待 0 秒" in bubble.stamp.text()
|
||||
assert dialog._stream_elapsed_timer.isActive()
|
||||
|
||||
now[0] = 103.0
|
||||
dialog._stream_elapsed_timer.timeout.emit()
|
||||
assert "已等待 3 秒" in bubble.stamp.text()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "start"})
|
||||
assert "已等待 3 秒" in bubble.stamp.text()
|
||||
|
||||
now[0] = 104.2
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"})
|
||||
# No timer flush or done event is needed to display the first text.
|
||||
assert bubble._raw_payload == "问诊小结"
|
||||
assert "生成中" in bubble.stamp.text()
|
||||
assert "已用时 4 秒" in bubble.stamp.text()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"})
|
||||
assert dialog._flush_timer.isActive()
|
||||
|
||||
now[0] = 108.7
|
||||
dialog._stream_event(
|
||||
generation, stream_generation, {"event": "done", "model_label": "千问"}
|
||||
)
|
||||
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
|
||||
assert "千问" in bubble.stamp.text()
|
||||
assert "首字 4.2 秒" in bubble.stamp.text()
|
||||
assert "耗时 8.7 秒" in bubble.stamp.text()
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
assert not dialog._flush_timer.isActive()
|
||||
stamp = bubble.stamp.text()
|
||||
now[0] = 130.0
|
||||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||
dialog._update_stream_elapsed()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"})
|
||||
assert bubble.stamp.text() == stamp
|
||||
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
|
||||
assert dialog.send_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"])
|
||||
def test_stream_clock_stops_on_every_exit(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
ending: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
now = [100.0]
|
||||
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
|
||||
dialog._ask("请总结病情")
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None
|
||||
now[0] = 112.4
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
worker = dialog._stream_worker
|
||||
if ending == "error":
|
||||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||
dialog._stream_finished(generation, stream_generation, worker)
|
||||
elif ending == "timeout":
|
||||
dialog._stream_timed_out()
|
||||
elif ending == "silent":
|
||||
dialog._stream_finished(generation, stream_generation, worker)
|
||||
elif ending == "switch":
|
||||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||
else:
|
||||
dialog.close()
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
assert not dialog._stream_timeout_timer.isActive()
|
||||
assert not dialog._asking
|
||||
assert dialog.send_button.isEnabled()
|
||||
if ending in {"error", "timeout", "silent"}:
|
||||
assert "耗时 12.4 秒" in bubble.stamp.text()
|
||||
stamp = bubble.stamp.text()
|
||||
now[0] = 140.0
|
||||
dialog._update_stream_elapsed()
|
||||
assert bubble.stamp.text() == stamp
|
||||
if ending == "switch":
|
||||
dialog._ask("新患者的问题")
|
||||
assert dialog._stream_bubble is not None
|
||||
assert "已等待 0 秒" in dialog._stream_bubble.stamp.text()
|
||||
assert dialog._stream_first_delta_at is None
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Exercise HTTP parsing, repository, real worker and Qt delivery together."""
|
||||
release = Event()
|
||||
waiting_for_end = Event()
|
||||
|
||||
class DelayedStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
first = (
|
||||
'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n'
|
||||
'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n'
|
||||
).encode()
|
||||
# A network chunk may end partway through a Chinese character.
|
||||
split = first.index("先".encode()) + 1
|
||||
yield first[:split]
|
||||
yield first[split:]
|
||||
waiting_for_end.set()
|
||||
if not release.wait(5):
|
||||
raise AssertionError("UI did not receive the first streamed text")
|
||||
yield (
|
||||
'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n'
|
||||
'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n'
|
||||
).encode()
|
||||
|
||||
client = ApiClient(
|
||||
"https://example.test",
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, stream=DelayedStream()
|
||||
)
|
||||
),
|
||||
)
|
||||
dialog = AiConsultDialog(RemoteDoctorRepository(client))
|
||||
dialog.diagnosis_id = 501
|
||||
dialog._context_notice_shown = True
|
||||
delivered_on = []
|
||||
original_event = dialog._stream_event
|
||||
|
||||
def record_event(*args: Any) -> None:
|
||||
delivered_on.append(QThread.currentThread())
|
||||
original_event(*args)
|
||||
|
||||
monkeypatch.setattr(dialog, "_stream_event", record_event)
|
||||
dialog.show()
|
||||
try:
|
||||
dialog._ask("请生成问诊小结")
|
||||
deadline = monotonic() + 3
|
||||
while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline:
|
||||
QTest.qWait(10)
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None and bubble.body is not None
|
||||
assert waiting_for_end.is_set()
|
||||
assert bubble.body.toPlainText() == "先收到的内容"
|
||||
assert not dialog._stream_completed
|
||||
assert dialog._asking
|
||||
assert "生成中" in bubble.stamp.text()
|
||||
release.set()
|
||||
deadline = monotonic() + 3
|
||||
while dialog._asking and monotonic() < deadline:
|
||||
QTest.qWait(10)
|
||||
assert dialog._stream_completed
|
||||
assert not dialog._asking
|
||||
assert bubble.body.toPlainText() == "先收到的内容,然后完成。"
|
||||
assert delivered_on and all(thread == application.thread() for thread in delivered_on)
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
finally:
|
||||
release.set()
|
||||
dialog.close()
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal", ["done", "error"])
|
||||
def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
terminal: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
|
||||
assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6
|
||||
previous = []
|
||||
for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True):
|
||||
# Exercise the text surface the doctor clicks, including event propagation.
|
||||
target = next(child for child in card.findChildren(QLabel) if child.text() == label)
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||
worker = dialog._stream_worker
|
||||
assert worker is not None and worker.prompt == prompt
|
||||
assert dialog._asking
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
for old_generation, old_stream_generation, old_worker in previous:
|
||||
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
||||
assert dialog._stream_worker is worker
|
||||
assert dialog._asking
|
||||
previous.append((generation, stream_generation, worker))
|
||||
if terminal == "done":
|
||||
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"})
|
||||
else:
|
||||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||
# The transport may take longer to close; it must not keep the UI locked.
|
||||
assert not dialog._asking
|
||||
assert dialog.send_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_clicking_another_quick_tool_switches_the_active_request(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
notices = []
|
||||
monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1]))
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
|
||||
QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton)
|
||||
old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble
|
||||
old_generation, old_stream_generation = dialog._generation, dialog._stream_generation
|
||||
assert old_worker is not None and old_bubble is not None
|
||||
dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"})
|
||||
|
||||
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
|
||||
worker, bubble = dialog._stream_worker, dialog._stream_bubble
|
||||
assert worker is not None and worker is not old_worker
|
||||
assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2]
|
||||
assert old_worker.is_cancelled()
|
||||
assert "已收到的分析" in old_bubble._raw_payload
|
||||
assert "已停止" in old_bubble._raw_payload
|
||||
assert bubble is not None and bubble is not old_bubble
|
||||
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
assert "已等待 0 秒" in bubble.stamp.text()
|
||||
assert dialog._asking
|
||||
|
||||
dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"})
|
||||
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误"))
|
||||
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
||||
assert dialog._stream_worker is worker
|
||||
assert dialog._asking
|
||||
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
|
||||
# Repeated clicks on the same running tool do not submit duplicate requests.
|
||||
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
|
||||
assert dialog._stream_worker is worker
|
||||
assert not worker.is_cancelled()
|
||||
assert notices and "正在生成" in notices[-1]
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_worker_does_not_wait_for_another_event_after_done() -> None:
|
||||
continued_after_done = []
|
||||
|
||||
class DoneThenIdleRepository:
|
||||
def stream_diagnosis_ai(self, *args: Any, **kwargs: Any):
|
||||
yield {"event": "done", "answer": "已完成"}
|
||||
continued_after_done.append(True)
|
||||
raise AssertionError("the terminal event must end iteration")
|
||||
|
||||
worker = ai_consult_module._AiStreamWorker(
|
||||
DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom"
|
||||
)
|
||||
events, errors, finished = [], [], []
|
||||
worker.signals.event.connect(events.append)
|
||||
worker.signals.error.connect(errors.append)
|
||||
worker.signals.finished.connect(lambda: finished.append(True))
|
||||
worker.run()
|
||||
assert [event["event"] for event in events] == ["done"]
|
||||
assert continued_after_done == []
|
||||
assert errors == []
|
||||
assert finished == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"])
|
||||
def test_quick_actions_switch_requests_and_reveal_the_chat(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
entry: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
dialog._ask("之前的请求")
|
||||
old_worker = dialog._stream_worker
|
||||
assert old_worker is not None
|
||||
dialog.tabs.setCurrentIndex(1)
|
||||
dialog._set_chat_filter("doctor")
|
||||
if entry == "tool":
|
||||
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0]
|
||||
prompt = ai_consult_module.TOOL_ITEMS[0][2]
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||
elif entry == "chip":
|
||||
target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情")
|
||||
prompt = ai_consult_module.QUICK_PROMPTS[0][1]
|
||||
target.click()
|
||||
elif entry == "menu":
|
||||
more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多")
|
||||
prompt = ai_consult_module.QUICK_PROMPTS[3][1]
|
||||
more.menu().actions()[0].trigger()
|
||||
else:
|
||||
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0]
|
||||
prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}"
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||
assert old_worker.is_cancelled()
|
||||
assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt
|
||||
assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0
|
||||
assert dialog._chat_filter == "all"
|
||||
assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"])
|
||||
def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
exit_action: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
dialog._ask("请分析病情")
|
||||
worker = dialog._stream_worker
|
||||
assert worker is not None
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
if exit_action == "back":
|
||||
next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click()
|
||||
elif exit_action == "side":
|
||||
dialog.findChild(QPushButton, "AiConsultSideClose").click()
|
||||
elif exit_action == "escape":
|
||||
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||
else:
|
||||
getattr(dialog, exit_action)()
|
||||
assert not dialog.isVisible()
|
||||
assert worker.is_cancelled()
|
||||
assert not dialog._asking
|
||||
assert not dialog._stream_timeout_timer.isActive()
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
closed_generation = dialog._generation
|
||||
assert closed_generation > generation
|
||||
dialog.close()
|
||||
assert dialog._generation == closed_generation
|
||||
|
||||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||
dialog.show()
|
||||
dialog._ask("新患者的问题")
|
||||
new_worker, bubble = dialog._stream_worker, dialog._stream_bubble
|
||||
assert new_worker is not None and not new_worker.is_cancelled()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"})
|
||||
dialog._stream_finished(generation, stream_generation, worker)
|
||||
assert dialog._stream_worker is new_worker
|
||||
assert dialog._asking
|
||||
assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_window_can_be_maximized(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Signal
|
||||
from PySide6.QtCore import QDate, QEvent, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core import PermissionSet, UserProfile
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||
from doctor_workstation.ui.pages.consultations import (
|
||||
@@ -669,7 +669,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_native_call_does_not_reuse_video_qr_permission(
|
||||
def test_list_video_entry_uses_watch_permission_not_video_qr_permission(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
class VideoRepository:
|
||||
@@ -686,13 +686,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
|
||||
pass
|
||||
|
||||
live_row = _row(
|
||||
assistant_id=66,
|
||||
video_call_hint={
|
||||
"state": "live",
|
||||
"label": "视频通话进行中",
|
||||
"start_time": 1787102100,
|
||||
}
|
||||
)
|
||||
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
|
||||
page = ConsultationsPage(
|
||||
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||
current_user={"id": 66, "role_id": 2},
|
||||
)
|
||||
emitted: list[dict[str, Any]] = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page.table.set_rows([live_row])
|
||||
@@ -738,10 +742,13 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
pass
|
||||
|
||||
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
|
||||
page = ConsultationsPage(
|
||||
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||
current_user={"id": 66, "role_id": 2},
|
||||
)
|
||||
emitted: list[dict[str, Any]] = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page.table.set_rows([_row(video_call_hint=hint)])
|
||||
page.table.set_rows([_row(assistant_id=66, video_call_hint=hint)])
|
||||
page.table.selectRow(0)
|
||||
application.processEvents()
|
||||
|
||||
@@ -752,6 +759,92 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
|
||||
assert emitted == []
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user", [
|
||||
{"id": 66, "role_id": 1},
|
||||
{"id": 66, "role_id": "1"},
|
||||
{"id": 66, "role_ids": [1, 2]},
|
||||
{"id": 66, "role_id": "1,2"},
|
||||
{"id": 66, "roles": [{"id": 1}]},
|
||||
{"id": 66, "role_id": 1, "root": True},
|
||||
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
|
||||
])
|
||||
@pytest.mark.parametrize("state,status", [
|
||||
("live", "通话中"), ("pending_room", "接通中"), ("none", "—"),
|
||||
])
|
||||
def test_doctor_video_column_is_readonly_even_with_full_permission(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
user: Any,
|
||||
state: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
|
||||
# Even a mixed-role doctor assigned to this row must not get the receiver entry.
|
||||
row = _row(assistant_id=66, video_call_hint={"state": state})
|
||||
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=user)
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||
assert cell.findChildren(QToolButton) == []
|
||||
assert status in [label.text() for label in cell.findChildren(QLabel)]
|
||||
assert page.call_toolbar_button.isHidden()
|
||||
assert not page.video_button.isEnabled()
|
||||
page._row_video(row)
|
||||
page.video_button.setEnabled(True)
|
||||
page.video_button.click()
|
||||
assert emitted == []
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user,permissions,assistant_id,allowed", [
|
||||
({"id": 66, "role_id": 2}, ["tcm.diagnosis/watchCall"], 66, True),
|
||||
({"id": 66, "role_id": 2}, ["tcm.diagnosis/videoQr"], 66, False),
|
||||
({"id": 66, "role_id": 2}, [], 66, False),
|
||||
({"id": 66, "role_id": 2}, ["*"], 77, False),
|
||||
({"id": 66, "role_id": 0, "root": True}, ["*"], 77, False),
|
||||
({"id": 66, "role_id": 0, "root": True}, ["*"], 66, True),
|
||||
({"id": 0, "role_id": 2}, ["*"], 0, False),
|
||||
(None, ["*"], 66, False),
|
||||
])
|
||||
def test_list_video_entry_requires_the_assigned_user_and_watch_permission(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
user: Any,
|
||||
permissions: list[str],
|
||||
assistant_id: int,
|
||||
allowed: bool,
|
||||
) -> None:
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
|
||||
row = _row(assistant_id=assistant_id, video_call_hint={"state": "live"})
|
||||
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(permissions), current_user=user)
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||
assert bool(cell.findChildren(QToolButton)) is allowed
|
||||
assert page.video_button.isEnabled() is allowed
|
||||
page._request_video()
|
||||
assert bool(emitted) is allowed
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1121,6 +1121,76 @@ def test_appointment_history_phone_is_fail_closed_without_phone_plain(
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("locked", [False, True])
|
||||
def test_doctor_can_save_clinical_fields_without_resubmitting_masked_identity(
|
||||
application: QApplication,
|
||||
locked: bool,
|
||||
) -> None:
|
||||
class MaskedRepository(VisualRepository):
|
||||
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise AssertionError("a masked phone must not be checked as plaintext")
|
||||
|
||||
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise AssertionError("a masked identity must not be checked as plaintext")
|
||||
|
||||
repository = MaskedRepository(locked=locked)
|
||||
for source in (repository.detail["diagnosis"], repository.detail["patient"]):
|
||||
source["phone"] = "156****7862"
|
||||
source["id_card"] = "1101**********1234"
|
||||
dialog = _open_dialog(
|
||||
application, (1024, 640), mode="edit", repository=repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/detail", "tcm.diagnosis/edit"]),
|
||||
)
|
||||
saved = []
|
||||
dialog.saved.connect(lambda: saved.append(True))
|
||||
try:
|
||||
assert dialog.edit_fields["phone"].isReadOnly()
|
||||
assert dialog.edit_fields["phone"].toPlainText() == "156****7862"
|
||||
assert dialog.edit_fields["id_card"].isReadOnly()
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("口渴较前改善")
|
||||
dialog._save()
|
||||
assert saved == [True]
|
||||
assert len(repository.updates) == 1
|
||||
payload = repository.updates[0]
|
||||
assert payload["chief_complaint"] == "口渴较前改善"
|
||||
assert "phone" not in payload and "id_card" not in payload
|
||||
assert "156****7862" not in str(payload)
|
||||
assert "1101**********1234" not in str(payload)
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_plain_phone_permission_keeps_validation_and_duplicate_checks(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
class PlainRepository(VisualRepository):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.phone_checks = []
|
||||
|
||||
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self.phone_checks.append(dict(payload))
|
||||
return {"exists": False}
|
||||
|
||||
repository = PlainRepository()
|
||||
dialog = _open_dialog(
|
||||
application, (1024, 640), mode="edit", repository=repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
|
||||
)
|
||||
try:
|
||||
assert not dialog.edit_fields["phone"].isReadOnly()
|
||||
dialog.edit_fields["phone"].setPlainText("156****7862")
|
||||
dialog._save()
|
||||
assert not repository.phone_checks
|
||||
assert not repository.updates
|
||||
dialog.edit_fields["phone"].setPlainText("15600007862")
|
||||
dialog._save()
|
||||
assert repository.phone_checks == [{"id": 501, "phone": "15600007862"}]
|
||||
assert repository.updates[-1]["phone"] == "15600007862"
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_order_detail_action_requires_exact_permission(application: QApplication) -> None:
|
||||
repository = VisualRepository()
|
||||
dialog = _open_dialog(
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||
from doctor_workstation.ui.reception_style import body_family
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1710, 920)])
|
||||
@pytest.mark.parametrize("with_channel", [False, True])
|
||||
def test_public_video_status_fits_frozen_cell_with_inherited_item_padding(
|
||||
application: QApplication, tech_blue: bool, size: tuple[int, int],
|
||||
with_channel: bool,
|
||||
) -> None:
|
||||
root = QWidget()
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(13)
|
||||
root.setFont(font)
|
||||
# Match GLOBAL_QSS without changing the application's theme between tests.
|
||||
root.setStyleSheet("QTableView::item { padding: 6px 8px; border-bottom: 1px solid #E1E9F4; }")
|
||||
layout = QVBoxLayout(root)
|
||||
host = DiagnosisTableHost(
|
||||
parent=root, tech_blue=tech_blue,
|
||||
action_policy={"video_call": True, "view": True, "edit": True},
|
||||
video_entry_allowed=lambda _record: False,
|
||||
)
|
||||
layout.addWidget(host)
|
||||
statuses = [
|
||||
("ended", "视频已结束 · 10-17 14:19"),
|
||||
("ended", "视频已结束 · 10-17 14:27"),
|
||||
("ended", "视频已结束"),
|
||||
("live", "通话中"),
|
||||
("pending_room", "接通中"),
|
||||
("missed", "上次通话未接听"),
|
||||
("cancelled", "上次通话已取消"),
|
||||
("none", "—"),
|
||||
]
|
||||
rows = [
|
||||
{
|
||||
"id": index + 1, "patient_id": index + 100, "patient_name": "测试患者",
|
||||
"gender": 1, "age": 50, "has_appointment": 1,
|
||||
"appointment_id": index + 200, "appointment_status": 1,
|
||||
"appointment_type": "video",
|
||||
"latest_appointment_channel_text": "自媒体4H" if with_channel else "",
|
||||
"appointments": [{"id": index + 200, "status": 1, "doctor_name": "测试医生",
|
||||
"time_text": "2026-10-17 14:15", "appointment_type": "video"}],
|
||||
"video_call_hint": {"state": state, "label": text},
|
||||
}
|
||||
for index, (state, text) in enumerate(statuses * 4)
|
||||
]
|
||||
try:
|
||||
host.set_rows(rows)
|
||||
root.resize(*size)
|
||||
root.show()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
for index, (_state, text) in enumerate(statuses):
|
||||
cell = host.fixed.indexWidget(host.model.index(index, 10))
|
||||
assert not cell.findChildren(QToolButton)
|
||||
label = cell.findChild(QLabel)
|
||||
assert label is not None
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert cell.rect().contains(label.geometry())
|
||||
assert abs(label.geometry().center().y() - cell.rect().center().y()) <= 1
|
||||
assert label.toolTip() == text
|
||||
if " · " in text:
|
||||
assert label.text() == text.replace(" · ", "\n")
|
||||
assert label.fontMetrics().horizontalAdvance("10-17 14:19") <= label.width()
|
||||
assert cell.width() >= host.fixed.columnWidth(10) - 2
|
||||
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||
for fraction in (2, 3):
|
||||
host.main.verticalScrollBar().setValue(host.main.verticalScrollBar().maximum() // fraction)
|
||||
application.processEvents()
|
||||
assert host.fixed.verticalScrollBar().value() == host.main.verticalScrollBar().value()
|
||||
row = host.main.indexAt(host.main.viewport().rect().center()).row()
|
||||
assert row >= 0
|
||||
assert host.main.visualRect(host.model.index(row, 0)).top() == host.fixed.visualRect(
|
||||
host.model.index(row, 10)
|
||||
).top()
|
||||
finally:
|
||||
root.close()
|
||||
root.deleteLater()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
@@ -7,7 +7,7 @@ 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 import PermissionSet, UserProfile
|
||||
from doctor_workstation.core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
@@ -76,13 +76,18 @@ def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
|
||||
@pytest.mark.parametrize("current_user", [
|
||||
None,
|
||||
{"id": 66, "role_id": 1},
|
||||
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
|
||||
])
|
||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch, current_user):
|
||||
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(["*"]))
|
||||
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=current_user)
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
|
||||
Reference in New Issue
Block a user