This commit is contained in:
Your Name
2026-09-01 10:42:16 +08:00
parent 456dd667df
commit 5bd5eae62d
358 changed files with 2276 additions and 495 deletions
+1 -1
View File
@@ -7,5 +7,5 @@ __version__ = "1.2.0"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
DEBUG_MODE = False
DEBUG_MODE = True
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
@@ -0,0 +1,123 @@
"""Confirmation and optional doctor note for completing an appointment."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QLabel,
QPlainTextEdit,
QVBoxLayout,
QWidget,
)
from ..theme import mark_business_dialog
from ..widgets import MessageBanner
COMPLETION_NOTE_LIMIT = 500
class AppointmentCompleteDialog(QDialog):
"""Keep an unsaved note available if completion or note saving fails."""
submitted = Signal(str)
def __init__(self, *, can_note: bool, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._can_note = can_note
self._busy = False
self._completed = False
self.setWindowTitle("完成问诊")
self.resize(460, 350 if can_note else 230)
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 22, 24, 18)
layout.setSpacing(10)
title = QLabel("完成问诊", self)
title.setProperty("dialogRole", "title")
layout.addWidget(title)
prompt = QLabel("是否添加医生备注?" if can_note else "确认完成该挂号吗?", self)
layout.addWidget(prompt)
self.note_edit = QPlainTextEdit(self)
self.note_edit.setObjectName("AppointmentCompleteNote")
self.note_edit.setAccessibleName("完成问诊医生备注")
self.note_edit.setPlaceholderText("填写完成备注,将追加到医生备注时间轴")
self.note_edit.setMinimumHeight(100)
self.note_edit.setVisible(can_note)
self.note_edit.textChanged.connect(self._limit_note)
layout.addWidget(self.note_edit)
self.note_counter = QLabel(f"0 / {COMPLETION_NOTE_LIMIT}", self)
self.note_counter.setObjectName("AppointmentCompleteNoteCounter")
self.note_counter.setProperty("dialogRole", "subtitle")
self.note_counter.setAlignment(Qt.AlignmentFlag.AlignRight)
self.note_counter.setVisible(can_note)
layout.addWidget(self.note_counter)
hint = QLabel("系统会再次核对服务端挂号状态;完成后不可撤销。", self)
hint.setProperty("dialogRole", "subtitle")
hint.setWordWrap(True)
layout.addWidget(hint)
self.banner = MessageBanner(parent=self)
layout.addWidget(self.banner)
self.buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self
)
self.confirm_button = self.buttons.button(QDialogButtonBox.StandardButton.Ok)
self.confirm_button.setText("确认完成")
self.confirm_button.setProperty("variant", "primary")
self.confirm_button.setAutoDefault(False)
self.cancel_button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
self.cancel_button.setText("取消")
self.cancel_button.setAutoDefault(False)
self.buttons.accepted.connect(self._submit)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
mark_business_dialog(self, "AppointmentCompleteDialog")
def _limit_note(self) -> None:
text = self.note_edit.toPlainText()
if len(text) > COMPLETION_NOTE_LIMIT:
text = text[:COMPLETION_NOTE_LIMIT]
cursor_position = self.note_edit.textCursor().position()
self.note_edit.blockSignals(True)
self.note_edit.setPlainText(text)
cursor = self.note_edit.textCursor()
cursor.setPosition(min(cursor_position, self.note_edit.document().characterCount() - 1))
self.note_edit.setTextCursor(cursor)
self.note_edit.blockSignals(False)
self.note_counter.setText(f"{len(text)} / {COMPLETION_NOTE_LIMIT}")
def _submit(self) -> None:
if not self._busy and not self._completed:
self.submitted.emit(self.note_edit.toPlainText().strip() if self._can_note else "")
def set_busy(self, busy: bool) -> None:
self._busy = busy
self.note_edit.setReadOnly(busy or self._completed)
self.confirm_button.setEnabled(not busy and not self._completed)
self.confirm_button.setText("正在完成…" if busy else "确认完成")
self.cancel_button.setEnabled(not busy)
if busy:
self.banner.clear()
def show_error(self, message: str) -> None:
self.set_busy(False)
self.banner.show_message(message, "danger")
def show_completed_warning(self, message: str) -> None:
self._completed = True
self.set_busy(False)
self.confirm_button.hide()
self.cancel_button.setText("关闭")
self.banner.show_message(f"{message}。可复制上方备注,稍后在医生备注中补录。", "warning")
def reject(self) -> None:
if not self._busy:
super().reject()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API
if self._busy:
event.ignore()
else:
super().closeEvent(event)
@@ -4,10 +4,12 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from html import escape
from math import ceil
from typing import Any
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QTextDocument, QTextOption
from PySide6.QtWidgets import (
QComboBox,
QDateTimeEdit,
@@ -23,6 +25,9 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QStackedWidget,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QVBoxLayout,
QWidget,
)
@@ -381,26 +386,81 @@ def _formula(value: Any) -> str:
def _order_warnings(row: Any) -> list[str]:
if not _truthy(first_value(row, "has_prescription_order", default=False)):
"""Match the PC list's linked-order checks, including blank herb rows."""
raw = getattr(row, "raw", None)
source = raw if isinstance(raw, Mapping) and raw else row
try:
has_order = float(get_value(source, "has_prescription_order", 0) or 0) == 1
except (TypeError, ValueError):
has_order = False
if not has_order:
return []
herbs = get_value(row, "herbs", None) or []
if not isinstance(herbs, (list, tuple)) or not herbs:
herbs = get_value(source, "herbs", None)
herbs = herbs if isinstance(herbs, (list, tuple)) else []
names = [str(get_value(herb, "name", "") or "").strip() for herb in herbs]
names = [name for name in names if name]
if not names:
return ["请开方,当前处方药材为空白"]
seen: set[str] = set()
duplicate: list[str] = []
for herb in herbs:
name = str(first_value(herb, "name", "medicine_name", default="")).strip()
for name in names:
key = "".join(name.split()).lower()
if key and key in seen and name not in duplicate:
duplicate.append(name)
seen.add(key)
return [f"已有关联业务订单,存在重复药材:{'、'.join(duplicate)}"] if duplicate else []
return [f"已有关联业务订单,当前处方存在重复药材:{'、'.join(duplicate)}"] if duplicate else []
def _sn_cell(_value: Any, row: Any) -> str:
return str(first_value(row, "sn", "prescription_no", "id", default="—"))
class _PrescriptionNumberDelegate(QStyledItemDelegate):
"""Paint visible PC-style reminders without changing the sortable SN value."""
def document(self, option: QStyleOptionViewItem, index: QModelIndex, width: int) -> QTextDocument:
row = index.data(Qt.ItemDataRole.UserRole)
number = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
record_id = display_text(first_value(row, "id", "prescription_id", default="—"))
document = QTextDocument()
document.setDocumentMargin(0)
document.setDefaultFont(option.font)
text_option = document.defaultTextOption()
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
document.setDefaultTextOption(text_option)
paragraphs = [
f'<p style="margin:0;color:#315CF4;font-size:12px;font-weight:600">{escape(number)}</p>',
f'<p style="margin:2px 0 0;color:#7481A3;font-size:11px;font-weight:400">ID: {escape(record_id)}</p>',
]
paragraphs.extend(
'<p style="margin:2px 0 0;color:#DC2626;font-size:12px;font-weight:400">'
+ escape(warning)
+ "</p>"
for warning in _order_warnings(row)
)
document.setHtml("".join(paragraphs))
document.setTextWidth(max(1, width - 16))
return document
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
styled = QStyleOptionViewItem(option)
self.initStyleOption(styled, index)
styled.text = ""
self.parent().style().drawControl(QStyle.ControlElement.CE_ItemViewItem, styled, painter)
document = self.document(option, index, option.rect.width())
painter.save()
painter.setClipRect(option.rect)
painter.translate(option.rect.left() + 8, option.rect.top() + 6)
document.drawContents(painter)
painter.restore()
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
width = self.parent().columnWidth(index.column())
document = self.document(option, index, width)
return QSize(width, max(36, ceil(document.size().height()) + 12))
def _patient_cell(_value: Any, row: Any) -> str:
gender = first_value(row, "gender", default=None)
gender_text = "男" if gender in (1, "1") else "女" if gender in (0, "0") else "未知"
@@ -698,7 +758,7 @@ class PrescriptionsPage(QWidget):
self.table = SortableTable(
[
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
TableColumn("sn", "处方编号", 174, _sn_cell),
TableColumn("sn", "处方编号", 260, _sn_cell),
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
TableColumn("prescription_type", "处方类型", 96),
TableColumn("is_system_auto", "来源", 88, _source_cell),
@@ -713,6 +773,9 @@ class PrescriptionsPage(QWidget):
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setItemDelegateForColumn(1, _PrescriptionNumberDelegate(self.table))
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
self.table.model().layoutChanged.connect(self.table.resizeRowsToContents)
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
@@ -729,6 +792,10 @@ class PrescriptionsPage(QWidget):
layout.addWidget(self.stack, 1)
return card
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
if column == 1:
self.table.resizeRowsToContents()
def _action_button(
self,
text: str,
@@ -910,8 +977,11 @@ class PrescriptionsPage(QWidget):
font.setWeight(QFont.Weight.DemiBold)
sn_item.setFont(font)
warnings = _order_warnings(row)
if warnings:
sn_item.setToolTip("\n".join(warnings))
description = "\n".join(
[sn_item.text(), f"ID: {first_value(row, 'id', 'prescription_id', default='—')}", *warnings]
)
sn_item.setToolTip(description)
sn_item.setData(Qt.ItemDataRole.AccessibleTextRole, description)
prescription_type = display_text(
first_value(row, "prescription_type", default="—")
@@ -1002,6 +1072,7 @@ class PrescriptionsPage(QWidget):
actions.addStretch(1)
self.table.setCellWidget(row_index, 2, actions_host)
self._sync_row_mutation_actions()
self.table.resizeRowsToContents()
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
+400 -46
View File
@@ -34,10 +34,12 @@ from PySide6.QtGui import (
QFont,
QFontMetrics,
QIcon,
QKeySequence,
QPainter,
QPainterPath,
QPen,
QPixmap,
QShortcut,
QTextCursor,
QTextLayout,
QTextOption,
@@ -72,6 +74,7 @@ from ..diagnosis_drawer import DailyRecordPanel
from ..diagnosis_editors import FlowLayout
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import present_ai_consult
from ..dialogs.appointment_complete import COMPLETION_NOTE_LIMIT, AppointmentCompleteDialog
from ..dialogs.prescription_ai import (
can_open_diagnosis_ai_report,
can_use_diagnosis_ai_assistant,
@@ -112,6 +115,12 @@ _AI_AUTOMATIC_REQUEST_SLOTS = BoundedSemaphore(2)
_AI_GENERATION_POOL = QThreadPool()
_AI_GENERATION_POOL.setMaxThreadCount(4)
_AI_GENERATION_POOL.setExpiryTimeout(30_000)
# Keep explicit recovery reads independent of automatic analysis workers.
_RECEPTION_REFRESH_POOL = QThreadPool()
_RECEPTION_REFRESH_POOL.setMaxThreadCount(3)
_RECEPTION_REFRESH_POOL.setExpiryTimeout(30_000)
_REFRESH_TIMEOUT_MS = 30_000
_REFRESH_COOLDOWN_MS = 1_500
AI_MEDICAL_DISCLAIMER = (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
@@ -157,6 +166,32 @@ QCalendarWidget#ReceptionDateCalendar {
border: 1px solid #DDE5FA;
border-radius: 10px;
}
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton {
min-height: 32px;
max-height: 32px;
padding: 0;
color: #5469F0;
background-color: #FFFFFF;
border: 1px solid #E4E9F6;
border-radius: 8px;
font-size: 12px;
}
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:hover {
background-color: #EEF1FF;
border-color: #C5CEFF;
}
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:pressed {
background-color: #E2E7FF;
border-color: #9EACFF;
}
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:focus {
border-color: #5469F0;
}
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:disabled {
color: #8A93A8;
background-color: #F7F8FB;
border-color: #E4E9F6;
}
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] {
min-height: 32px;
max-height: 32px;
@@ -306,6 +341,24 @@ QWidget#ReceptionPage QPushButton#ReceptionCompleteButton {
font-size: 13px;
font-weight: 500;
}
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:hover {
color: #CF4656;
background-color: #FFF0F2;
border-color: #EFA3AD;
}
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:pressed {
color: #BF3949;
background-color: #FFE4E8;
border-color: #E58A98;
}
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:focus {
border-color: #CF4656;
}
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:disabled {
color: #A8AFBF;
background-color: #F7F8FB;
border-color: #E5E8EF;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs {
background-color: #FFFFFF;
border-bottom: 1px solid #E6EAF5;
@@ -2090,6 +2143,26 @@ def _is_local_material_reference(value: str) -> bool:
)
class _ReceptionCompleteButton(QPushButton):
"""Completion action with a pointer only while it can be activated."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__("结束问诊", parent)
self.setObjectName("ReceptionCompleteButton")
self.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
self.setIconSize(QSize(14, 14))
self.setCursor(Qt.CursorShape.PointingHandCursor)
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
super().changeEvent(event)
if event.type() == QEvent.Type.EnabledChange:
self.setCursor(
Qt.CursorShape.PointingHandCursor
if self.isEnabled()
else Qt.CursorShape.ArrowCursor
)
class _NoteAttachmentPreview(QPushButton):
"""Responsive inline thumbnail that remains clickable for a full preview."""
@@ -3587,6 +3660,8 @@ class ReceptionPage(QWidget):
self._queue_query: dict[str, Any] | None = None
self._queue_query_key: tuple[Any, ...] | None = None
self._detail_loading = False
self._completion_pending = False
self._completion_dialog: AppointmentCompleteDialog | None = None
self._detail_requests: set[tuple[int, int]] = set()
self._detail_cancel_events: dict[tuple[int, int], Event] = {}
self._detail_failed_requests: set[tuple[int, int]] = set()
@@ -3679,6 +3754,12 @@ class ReceptionPage(QWidget):
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(5_000)
self.poll_timer.timeout.connect(self._poll_queue)
self._refresh_cooldown = QTimer(self)
self._refresh_cooldown.setSingleShot(True)
self._refresh_cooldown.timeout.connect(self._finish_refresh_cooldown)
self.refresh_shortcut = QShortcut(QKeySequence("F5"), self)
self.refresh_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self.refresh_shortcut.activated.connect(self._refresh_workspace)
def _ensure_diagnosis_dialog(self) -> DiagnosisDialog:
dialog = getattr(self, "diagnosis_dialog", None)
@@ -3705,6 +3786,14 @@ class ReceptionPage(QWidget):
title.setObjectName("ReceptionQueueTitle")
header_layout.addWidget(title)
header_layout.addStretch(1)
self.refresh_button = QPushButton("刷新", header)
self.refresh_button.setObjectName("ReceptionRefreshButton")
self.refresh_button.setFixedWidth(58)
self.refresh_button.setAccessibleName("刷新接诊台")
self.refresh_button.setToolTip("刷新队列、当前患者和已保存的 AI 报告(F5)")
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.refresh_button.clicked.connect(self._refresh_workspace)
header_layout.addWidget(self.refresh_button)
self.queue_date_button = _ReceptionDateButton(self._queue_date, header)
self.queue_date_button.setObjectName("ReceptionDateButton")
self.queue_date_button.setFixedWidth(118)
@@ -3756,7 +3845,7 @@ class ReceptionPage(QWidget):
self.queue_list.verticalScrollBar().valueChanged.connect(self._on_queue_scroll)
self.queue_stack.addWidget(self.queue_list)
self.queue_empty = EmptyState("队列为空", "当前筛选下没有待处理患者。", "重新加载")
self.queue_empty.action_requested.connect(lambda: self.refresh())
self.queue_empty.action_requested.connect(self._refresh_workspace)
self.queue_stack.addWidget(self.queue_empty)
layout.addWidget(self.queue_stack, 1)
self.queue_loading_indicator = _ReceptionQueueLoading(panel)
@@ -3827,10 +3916,7 @@ class ReceptionPage(QWidget):
patient_head.addLayout(identity, 1)
patient_head.addStretch(1)
self.complete_button = QPushButton("结束问诊", hero)
self.complete_button.setObjectName("ReceptionCompleteButton")
self.complete_button.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
self.complete_button.setIconSize(QSize(14, 14))
self.complete_button = _ReceptionCompleteButton(hero)
self.complete_button.clicked.connect(self._complete_appointment)
self.complete_button.setVisible(self._can_complete)
patient_head.addWidget(self.complete_button)
@@ -5267,6 +5353,7 @@ class ReceptionPage(QWidget):
appointment_id: int,
diagnosis_id: int | None,
force: bool,
read_only_refresh: bool = False,
) -> None:
if not force and _same_id(patient_id, self._ai_analysis_patient_id):
qwen_state = self._ai_analysis_model_states["qwen"]
@@ -5313,7 +5400,7 @@ class ReceptionPage(QWidget):
self._patient_ai_list_requests.discard(cancelled_key)
self._patient_ai_list_cancel_events.pop(cancelled_key, None)
self._patient_ai_list_epochs.pop(cancelled_key, None)
if self._patient_ai_request_pending(patient_id, "qwen"):
if not read_only_refresh and self._patient_ai_request_pending(patient_id, "qwen"):
self._sync_ai_analysis_view()
return
request_key = (request_generation, appointment_id, patient_id)
@@ -5333,8 +5420,10 @@ class ReceptionPage(QWidget):
patient_id,
) or cancel_event.is_set():
return _ASYNC_REQUEST_CANCELLED
automatic_slot = _AI_AUTOMATIC_REQUEST_SLOTS.acquire(blocking=False)
if not automatic_slot:
automatic_slot = not read_only_refresh and _AI_AUTOMATIC_REQUEST_SLOTS.acquire(
blocking=False
)
if not read_only_refresh and not automatic_slot:
return _ASYNC_REQUEST_DEFERRED
try:
self._patient_ai_list_started.add(request_key)
@@ -5347,15 +5436,18 @@ class ReceptionPage(QWidget):
return _ASYNC_REQUEST_CANCELLED
return method(patient_id)
finally:
_AI_AUTOMATIC_REQUEST_SLOTS.release()
if automatic_slot:
_AI_AUTOMATIC_REQUEST_SLOTS.release()
run_async(
runner = self._run_refresh_read if read_only_refresh else run_async
runner(
request,
on_success=lambda result: self._apply_patient_ai_report_list(
result,
request_generation,
appointment_id,
patient_id,
read_only_refresh=read_only_refresh,
),
on_error=lambda error: self._patient_ai_report_list_error(
error,
@@ -5377,6 +5469,8 @@ class ReceptionPage(QWidget):
request_generation: int,
appointment_id: int,
patient_id: int,
*,
read_only_refresh: bool = False,
) -> None:
request_key = (request_generation, appointment_id, patient_id)
authoritative = request_key in self._patient_ai_list_requests
@@ -5426,6 +5520,12 @@ class ReceptionPage(QWidget):
)
return
if read_only_refresh:
self._set_ai_analysis_state(
"missing", "暂无已保存的患者报告;刷新不会自动生成,可点击重新分析。"
)
return
if not self._can_ai_regenerate:
self._set_ai_analysis_state(
"missing",
@@ -6217,14 +6317,111 @@ class ReceptionPage(QWidget):
self._reset_queue_state()
self.refresh()
def refresh(self, silent: bool = False) -> None:
def _run_refresh_read(
self,
function: Any,
*,
on_success: Any,
on_error: Any,
on_finished: Any,
priority: int = 0,
) -> None:
"""Bound GUI waiting; a timed-out worker may finish but cannot apply data.
Running HTTP calls are not forcibly terminated. The fixed-size pool
bounds concurrency, and expired queued reads never call the backend.
"""
settled = Event()
timer = QTimer(self)
timer.setSingleShot(True)
timer.destroyed.connect(lambda _object=None: settled.set())
def settle(callback: Any, result: Any) -> None:
if settled.is_set():
return
settled.set()
timer.stop()
timer.deleteLater()
try:
callback(result)
finally:
on_finished()
def request() -> Any:
if settled.is_set():
return _ASYNC_REQUEST_CANCELLED
return function()
timer.timeout.connect(
lambda: settle(on_error, TimeoutError("刷新超时,请检查网络后再次点击刷新。"))
)
timer.start(_REFRESH_TIMEOUT_MS)
run_async(
request,
on_success=lambda result: settle(on_success, result),
on_error=lambda error: settle(on_error, error),
on_finished=lambda: settle(
on_error, RuntimeError("刷新未返回有效结果,请重试。")
),
pool=_RECEPTION_REFRESH_POOL,
priority=priority,
)
def _cancel_patient_ai_reads(self, patient_id: int | None) -> None:
# Reads are normally shared across selection generations. Explicit
# refresh revokes their authority, but never forgets an in-flight POST.
for key in list(self._patient_ai_list_requests):
if patient_id is not None and not _same_id(key[2], patient_id):
continue
cancel_event = self._patient_ai_list_cancel_events.pop(key, None)
if cancel_event is not None:
cancel_event.set()
self._patient_ai_list_requests.discard(key)
self._patient_ai_list_epochs.pop(key, None)
self._patient_ai_list_started.discard(key)
def _refresh_workspace(self) -> None:
"""Recover reads without replaying writes or clearing the doctor's draft."""
if self._refresh_cooldown.isActive():
return
if self._note_busy or self._completion_pending:
show_toast(self, "正在提交,请等待提交结束后再刷新。", "warning")
return
if self._completion_dialog is not None and self._completion_dialog.isVisible():
show_toast(self, "请先关闭完成问诊窗口,再刷新接诊台。", "warning")
return
if self._selected_appointment_id is not None and not self.notify_button.isEnabled():
show_toast(self, "正在通知医助,请稍后刷新。", "warning")
return
self.refresh_button.setEnabled(False)
self.refresh_button.setCursor(Qt.CursorShape.ArrowCursor)
self._refresh_cooldown.start(_REFRESH_COOLDOWN_MS)
context = self._selection_context()
self._cancel_patient_ai_reads(context[3] if context is not None else None)
# Invalidate a pending daily-range result before the fresh detail arrives.
self._daily_generation += 1
self._daily_loading = False
self.daily_panel.set_loading(False)
record = self._selected_record
if record is not None:
self._load_detail(record, clear=False, read_only_refresh=True)
# A stuck queue must not prevent refreshing the selected patient's detail.
self.refresh(workspace_refresh=True)
def _finish_refresh_cooldown(self) -> None:
self.refresh_button.setEnabled(True)
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
def refresh(self, silent: bool = False, *, workspace_refresh: bool = False) -> None:
"""Replace the queue with page one using a GUI-thread query snapshot."""
if silent and self._queue_loading:
if silent and self._queue_loading and not workspace_refresh:
return
selected_date = self._queue_date or date.today().isoformat()
page_size = self._queue_page_size
if silent:
if silent or workspace_refresh:
loaded = max(len(self._queue_records), self._queue_page * self._queue_page_size)
if loaded > page_size:
page_size = loaded
@@ -6239,12 +6436,15 @@ class ReceptionPage(QWidget):
}
query_key = self._query_key(query)
if query_key != self._queue_query_key:
self._clear_selection()
if not workspace_refresh:
self._clear_selection()
self._reset_queue_state()
self._queue_query = dict(query)
self._queue_query["page_size"] = self._queue_page_size
self._queue_query_key = query_key
self._request_queue_page(query, append=False, silent=silent)
self._request_queue_page(
query, append=False, silent=silent, workspace_refresh=workspace_refresh
)
def _poll_queue(self) -> None:
"""Do not let the timer supersede an explicit or slower queue request."""
@@ -6401,6 +6601,7 @@ class ReceptionPage(QWidget):
*,
append: bool,
silent: bool,
workspace_refresh: bool = False,
) -> None:
self._queue_generation += 1
generation = self._queue_generation
@@ -6412,12 +6613,18 @@ class ReceptionPage(QWidget):
frozen_query = dict(query)
page_no = int(frozen_query["page_no"])
query_key = self._query_key(frozen_query)
run_async(
lambda frozen_query=frozen_query: invoke(
def request() -> Any:
if generation != self._queue_generation:
return _ASYNC_REQUEST_CANCELLED
return invoke(
self.repository,
"list_appointments",
**frozen_query,
),
)
runner = self._run_refresh_read if workspace_refresh else run_async
runner(
request,
on_success=lambda result: self._apply_queue(
result,
generation,
@@ -6425,6 +6632,7 @@ class ReceptionPage(QWidget):
append=append,
query_key=query_key,
silent=silent,
workspace_refresh=workspace_refresh,
),
on_error=lambda error: self._queue_error(error, generation, silent=silent),
on_finished=lambda: self._queue_finished(generation),
@@ -6439,6 +6647,7 @@ class ReceptionPage(QWidget):
append: bool = False,
query_key: tuple[Any, ...] | None = None,
silent: bool = False,
workspace_refresh: bool = False,
) -> None:
if generation != self._queue_generation or query_key not in (None, self._queue_query_key):
return
@@ -6480,27 +6689,38 @@ class ReceptionPage(QWidget):
),
-1,
)
if row_to_select < 0 and records:
preserve_selection = (workspace_refresh or silent) and selected_id is not None
if row_to_select < 0 and records and not preserve_selection:
row_to_select = 0
self.queue_list.blockSignals(True)
self._sync_queue_rows(records, append=append)
if row_to_select >= 0 and self.queue_list.currentRow() != row_to_select:
self.queue_list.setCurrentRow(row_to_select)
elif row_to_select < 0:
self.queue_list.setCurrentRow(-1)
self.queue_list.blockSignals(False)
self._sync_queue_row_selection()
self.queue_summary.setText(f"已加载 {len(records)} / 共 {self._queue_total} 位患者")
self.queue_stack.setCurrentIndex(0 if records else 1)
self.queue_banner.clear()
if not records:
self._clear_selection()
if row_to_select < 0:
# The patient may have left the queue. Keep the detail and unsaved
# draft attached to that patient until the doctor selects another.
if preserve_selection:
self.queue_banner.show_message(
"当前患者已不在筛选队列,已保留详情与未保存内容,可手动选择其他患者。",
"info",
)
else:
self._clear_selection()
return
chosen = records[row_to_select]
if selected_id is not None and _same_id(_record_id(chosen), selected_id):
self._selected_record = chosen
if not append and not silent:
if not append and not silent and not workspace_refresh:
self._load_detail(chosen, silent=True, clear=False)
else:
self._select_record(chosen, silent=True)
self._select_record(chosen, silent=True, read_only_refresh=workspace_refresh)
def _update_queue_filter_counts(self, result: Any, records: list[Any]) -> None:
extend = get_value(result, "extend", None)
@@ -6573,7 +6793,9 @@ class ReceptionPage(QWidget):
if isinstance(row, QueueRow):
row.set_selected(item is current)
def _select_record(self, record: Any, *, silent: bool = False) -> None:
def _select_record(
self, record: Any, *, silent: bool = False, read_only_refresh: bool = False
) -> None:
appointment_id = _record_id(record)
if appointment_id is None:
self._clear_selection()
@@ -6589,9 +6811,16 @@ class ReceptionPage(QWidget):
self._selected_record = record
self._selected_appointment_id = appointment_id
self._selected_detail = None
self._load_detail(record, silent=silent, clear=True)
self._load_detail(record, silent=silent, clear=True, read_only_refresh=read_only_refresh)
def _load_detail(self, record: Any, silent: bool = False, *, clear: bool = True) -> None:
def _load_detail(
self,
record: Any,
silent: bool = False,
*,
clear: bool = True,
read_only_refresh: bool = False,
) -> None:
"""Start a new detail generation even while an older request is running."""
appointment_id = _record_id(record)
@@ -6614,9 +6843,12 @@ class ReceptionPage(QWidget):
self.detail_banner.show_message("正在加载患者详情…", "info")
self._detail_loading = True
self._detail_requests.add(request_key)
run_async(
runner = self._run_refresh_read if read_only_refresh else run_async
runner(
lambda: self._fetch_detail_bundle(record, appointment_id, cancel_event),
on_success=lambda bundle: self._apply_detail(bundle, generation, appointment_id),
on_success=lambda bundle: self._apply_detail(
bundle, generation, appointment_id, read_only_refresh=read_only_refresh
),
on_error=lambda error: self._detail_error(error, generation, appointment_id),
on_finished=lambda: self._detail_finished(generation, appointment_id),
priority=generation,
@@ -6877,7 +7109,12 @@ class ReceptionPage(QWidget):
self.daily_panel.set_loading(False)
def _apply_detail(
self, bundle: Any, generation: int, appointment_id: int | None = None
self,
bundle: Any,
generation: int,
appointment_id: int | None = None,
*,
read_only_refresh: bool = False,
) -> None:
expected_id = appointment_id or self._selected_appointment_id
if (
@@ -6966,7 +7203,9 @@ class ReceptionPage(QWidget):
supports_patient_reports = self._can_patient_ai_read and callable(
getattr(self.repository, "list_patient_ai_reports", None)
) and callable(getattr(self.repository, "generate_patient_ai_report", None))
if patient_id is None and supports_patient_reports:
if read_only_refresh:
self._refresh_saved_ai_reports(expected_id, diagnosis_id, patient_id)
elif patient_id is None and supports_patient_reports:
self._ai_analysis_generation += 1
self._ai_analysis_loading = False
self._ai_analysis_diagnosis_id = None
@@ -6988,6 +7227,37 @@ class ReceptionPage(QWidget):
patient_id=patient_id,
)
def _refresh_saved_ai_reports(
self, appointment_id: int, diagnosis_id: int | None, patient_id: int | None
) -> None:
self._cancel_patient_ai_reads(patient_id)
if self._can_patient_ai_read and patient_id is not None and callable(
getattr(self.repository, "list_patient_ai_reports", None)
):
# A generation is a write with an uncertain outcome while running.
# Keep its single-flight identity and do not race it with an older GET.
if any(
self._patient_ai_request_pending(patient_id, model)
for model in AI_ANALYSIS_MODELS
):
self._ai_analysis_operation_error = "报告生成任务仍在处理中,刷新不会重复生成。"
self._sync_ai_analysis_view()
return
self._load_patient_ai_reports(
patient_id,
appointment_id=appointment_id,
diagnosis_id=diagnosis_id,
force=True,
read_only_refresh=True,
)
elif not self._can_ai_analysis:
self._set_ai_analysis_state("permission")
elif not self._ai_analysis_payloads and not self._ai_analysis_requests:
# The legacy analysis endpoint is a POST despite its get_* name.
self._set_ai_analysis_state(
"error", "当前数据源无法只读刷新 AI 报告;如需生成分析,请点击重试。"
)
def _render_identity(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
patient_name = first_value(
appointment,
@@ -8168,7 +8438,10 @@ class ReceptionPage(QWidget):
self.history_button.setEnabled(appointment_id is not None)
self.more_button.setEnabled(appointment_id is not None)
self.complete_button.setEnabled(
self._can_complete and appointment_id is not None and status in RECEPTION_STATUSES
self._can_complete
and not self._completion_pending
and appointment_id is not None
and status in RECEPTION_STATUSES
)
note_enabled = self._can_note and diagnosis_id is not None and not self._note_busy
self.note_edit.setEnabled(note_enabled)
@@ -8180,6 +8453,10 @@ class ReceptionPage(QWidget):
self, error: Exception, generation: int, appointment_id: int | None = None
) -> None:
expected_id = appointment_id or self._selected_appointment_id
if expected_id is not None:
cancel_event = self._detail_cancel_events.get((generation, expected_id))
if cancel_event is not None:
cancel_event.set()
if (
generation == self._detail_generation
and expected_id is not None
@@ -8734,6 +9011,12 @@ class ReceptionPage(QWidget):
self._load_detail(self._selected_record, silent=True, clear=False)
def _complete_appointment(self) -> None:
if self._completion_pending:
return
if self._completion_dialog is not None:
self._completion_dialog.raise_()
self._completion_dialog.activateWindow()
return
if not self._can_complete:
show_toast(self, "当前账号没有完成接诊权限。", "danger")
return
@@ -8748,24 +9031,75 @@ class ReceptionPage(QWidget):
if status not in RECEPTION_STATUSES:
show_toast(self, "仅待接诊或已过号记录可以完成接诊。", "danger")
return
answer = QMessageBox.question(
self,
"确认完成接诊",
"系统会再次核对服务端挂号状态;完成后不可撤销。确认继续吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
dialog = AppointmentCompleteDialog(can_note=self._can_note, parent=self)
self._completion_dialog = dialog
dialog.submitted.connect(
lambda note: self._submit_completion(dialog, generation, appointment_id, note)
)
if answer != QMessageBox.StandardButton.Yes:
dialog.finished.connect(lambda _result: self._close_completion_dialog(dialog))
dialog.open()
def _close_completion_dialog(self, dialog: AppointmentCompleteDialog) -> None:
if self._completion_dialog is dialog:
self._completion_dialog = None
dialog.deleteLater()
def _submit_completion(
self,
dialog: AppointmentCompleteDialog,
generation: int,
appointment_id: int,
note: str,
) -> None:
if self._completion_pending or self._completion_dialog is not dialog:
return
if not self._context_current(generation, appointment_id):
dialog.show_error("当前患者已切换或详情已更新,请关闭窗口后重新操作。")
return
self._completion_pending = True
dialog.set_busy(True)
self.complete_button.setEnabled(False)
run_async(
lambda: self._complete_after_revalidation(appointment_id),
on_success=lambda _result: self._appointment_completed(generation, appointment_id),
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200),
on_finished=lambda: self._restore_action_state(generation, appointment_id),
lambda: self._complete_after_revalidation(appointment_id, note),
on_success=lambda warning: self._completion_succeeded(
dialog, generation, appointment_id, warning
),
on_error=lambda error: self._completion_failed(dialog, error),
on_finished=self._completion_finished,
)
def _complete_after_revalidation(self, appointment_id: int) -> Any:
def _completion_succeeded(
self,
dialog: AppointmentCompleteDialog,
generation: int,
appointment_id: int,
warning: str,
) -> None:
if self._completion_dialog is dialog:
if warning:
dialog.show_completed_warning(warning)
else:
dialog.set_busy(False)
dialog.accept()
self._appointment_completed(generation, appointment_id, warning)
def _completion_failed(self, dialog: AppointmentCompleteDialog, error: Exception) -> None:
if self._completion_dialog is dialog:
dialog.show_error(friendly_error(error))
def _completion_finished(self) -> None:
self._completion_pending = False
if self._selected_appointment_id is not None:
self._restore_action_state(self._detail_generation, self._selected_appointment_id)
def _complete_after_revalidation(self, appointment_id: int, note: str = "") -> str:
if not self._can_complete:
raise ValueError("当前账号没有完成接诊权限。")
note = note.strip()
if note and not self._can_note:
raise ValueError("当前账号没有添加医生备注权限。")
if len(note) > COMPLETION_NOTE_LIMIT:
raise ValueError(f"备注不能超过 {COMPLETION_NOTE_LIMIT} 字。")
detail = invoke(
self.repository,
"reception_detail",
@@ -8784,15 +9118,35 @@ class ReceptionPage(QWidget):
raise ValueError("服务端挂号记录与当前患者不一致,已停止完成操作")
if status not in RECEPTION_STATUSES:
raise ValueError("挂号状态已变化,请刷新队列后重试")
return invoke(
invoke(
self.repository,
"complete_appointment",
appointment_id=appointment_id,
id=appointment_id,
)
def _appointment_completed(self, generation: int, appointment_id: int) -> None:
show_toast(self, "接诊已完成。", "success")
if note:
# Use the freshly validated diagnosis, never the patient's ID or
# mutable selection: notes belong to the diagnosis timeline.
diagnosis = get_value(detail, "diagnosis", None) or {}
diagnosis_id = _as_int(first_value(diagnosis, "id", "diagnosis_id", default=None))
if diagnosis_id is None or diagnosis_id <= 0:
return "问诊已完成,但该预约没有关联诊单,备注未保存"
try:
invoke(
self.repository,
"add_doctor_note",
diagnosis_id=diagnosis_id,
content=note,
)
except Exception:
return "问诊已完成,但备注未保存"
return ""
def _appointment_completed(
self, generation: int, appointment_id: int, warning: str = ""
) -> None:
show_toast(self, warning or "接诊已完成。", "warning" if warning else "success")
if self._context_current(generation, appointment_id):
self._clear_selection()
self.refresh(silent=True)