更新
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")
|
||||
|
||||
Reference in New Issue
Block a user