This commit is contained in:
Your Name
2026-10-17 16:08:24 +08:00
parent c67b3eff69
commit 56f85f7d89
57 changed files with 4170 additions and 57 deletions
@@ -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")