"""Element-aligned appointment drawer used by patient and diagnosis workspaces.""" from __future__ import annotations from collections.abc import Callable, Mapping, Sequence from contextlib import suppress from types import MappingProxyType from typing import Any from PySide6.QtCore import ( QDate, QEvent, QPoint, QRect, QSize, Qt, QTime, QTimer, ) from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent, QPen, QResizeEvent from PySide6.QtWidgets import ( QButtonGroup, QComboBox, QDialog, QFrame, QGraphicsDropShadowEffect, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QLayout, QLayoutItem, QLineEdit, QPlainTextEdit, QPushButton, QRadioButton, QScrollArea, QSizePolicy, QStackedWidget, QStyle, QToolButton, QVBoxLayout, QWidget, ) from ..core.appointment_modes import APPOINTMENT_MODES from .theme import main_window_target_rect from .widgets import ( display_text, first_value, friendly_error, get_value, invoke, page_items, run_async, ) APPOINTMENT_DRAWER_QSS = r""" QDialog#AppointmentDrawerOverlay { background-color: transparent; color: #1A1C1F; font-size: 14px; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel { background-color: #FFFFFF; border-left: 1px solid #EDEDEE; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader { background-color: #FFFFFF; border: 0; border-bottom: 1px solid #EDEDEE; } QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle { color: #1A1C1F; font-size: 18px; font-weight: 600; } QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose { min-width: 32px; max-width: 32px; min-height: 32px; max-height: 32px; padding: 0; border: 0; border-radius: 4px; background-color: transparent; color: #606163; font-size: 22px; font-weight: 400; } QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover { color: #1A1C1F; background-color: #F0F0F0; } QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody, QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentSlotScroll, QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody > QWidget > QWidget, QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentSlotScroll > QWidget > QWidget { border: 0; background-color: #FFFFFF; } QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent { background-color: #FFFFFF; } QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] { color: #1A1C1F; font-size: 14px; font-weight: 500; } QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] { color: #606163; font-size: 13px; } QDialog#AppointmentDrawerOverlay QComboBox, QDialog#AppointmentDrawerOverlay QLineEdit, QDialog#AppointmentDrawerOverlay QPlainTextEdit { min-height: 30px; padding: 0 11px; border: 1px solid #EDEDEE; border-radius: 9px; background-color: #FFFFFF; color: #1A1C1F; selection-background-color: #EEF1FA; selection-color: #1A1C1F; font-size: 14px; } QDialog#AppointmentDrawerOverlay QPlainTextEdit { padding: 7px 11px; font-size: 14px; } QDialog#AppointmentDrawerOverlay QComboBox:hover, QDialog#AppointmentDrawerOverlay QLineEdit:hover, QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover { border-color: #8B9AD9; } QDialog#AppointmentDrawerOverlay QComboBox:focus, QDialog#AppointmentDrawerOverlay QLineEdit:focus, QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus { border: 2px solid #8B9AD9; } QDialog#AppointmentDrawerOverlay QComboBox::drop-down { width: 28px; border: 0; } QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView { background-color: #FFFFFF; color: #1A1C1F; border: 1px solid #EDEDEE; selection-background-color: #EEF1FA; selection-color: #1A1C1F; outline: 0; font-size: 14px; } QDialog#AppointmentDrawerOverlay QRadioButton { min-height: 24px; spacing: 8px; color: #1A1C1F; } QDialog#AppointmentDrawerOverlay QRadioButton::indicator { width: 12px; height: 12px; border-radius: 7px; border: 1px solid #EDEDEE; background-color: #FFFFFF; } QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover { border-color: #4156C4; } QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked { width: 4px; height: 4px; border: 5px solid #4F63D9; border-radius: 7px; background-color: #FFFFFF; } QDialog#AppointmentDrawerOverlay QRadioButton:focus { color: #4F63D9; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] { min-height: 38px; max-height: 38px; padding: 0; border: 1px solid #EDEDEE; border-radius: 8px; background-color: #FFFFFF; color: #1A1C1F; font-size: 14px; font-weight: 500; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover { color: #4156C4; border-color: #4156C4; background-color: #EEF1FA; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus { border-color: #8B9AD9; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked { color: #FFFFFF; border-color: #4F63D9; background-color: #4F63D9; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel { background-color: #F7F7F7; border: 0; border-radius: 8px; } QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle { color: #1A1C1F; font-size: 15px; font-weight: 500; } QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots { min-height: 24px; max-height: 24px; padding: 0 7px; border: 0; border-radius: 4px; background-color: transparent; color: #4F63D9; font-size: 13px; font-weight: 500; } QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover { background-color: #EEF1FA; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] { min-width: 110px; min-height: 70px; padding: 0 8px; border: 2px solid #EDEDEE; border-radius: 8px; background-color: #FFFFFF; color: #1A1C1F; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime { color: #1A1C1F; font-size: 15px; font-weight: 500; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus { padding: 0 8px; border-radius: 4px; background-color: #F7F7F7; color: #606163; font-size: 13px; font-weight: 400; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus { color: #287B65; background-color: #EAF9F3; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled { color: #4156C4; border-color: #4156C4; background-color: #EEF1FA; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled { border-color: #8B9AD9; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked { color: #FFFFFF; border-color: #4F63D9; background: #4F63D9; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"], QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] { color: #FFFFFF; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] { background-color: rgba(255, 255, 255, 46); } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled { color: #8E8F90; border-color: #EDEDEE; background-color: #F7F7F7; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled, QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled { color: #8E8F90; } QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled { background-color: #F7F7F7; } QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty { background-color: transparent; } QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText { color: #606163; font-size: 14px; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] { background-color: #EEF1FA; border: 1px solid #EDEDEE; border-radius: 9px; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] { background-color: #FFF5E6; border: 1px solid #F6E3C4; border-radius: 9px; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] { background-color: #FFF1F3; border: 1px solid #F7D7DC; border-radius: 9px; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] { background-color: #EAF9F3; border: 1px solid #D4F0E5; border-radius: 9px; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel { color: #4F63D9; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel { color: #A9691D; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel { color: #BE4B58; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel { color: #287B65; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter { background-color: #FFFFFF; border: 0; border-top: 1px solid #EDEDEE; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton { min-height: 30px; max-height: 30px; padding: 0 15px; border: 1px solid #EDEDEE; border-radius: 9px; background-color: #FFFFFF; color: #1A1C1F; font-size: 13px; font-weight: 500; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover { color: #4156C4; border-color: #4156C4; background-color: #EEF1FA; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus { border-color: #8B9AD9; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] { color: #FFFFFF; border-color: #4F63D9; background: #4F63D9; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover { color: #FFFFFF; border-color: #4156C4; background-color: #4156C4; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled { color: #FFFFFF; border-color: #EDEDEE; background-color: #8E8F90; } QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay { background-color: rgba(255, 255, 255, 218); border: 0; } QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText { color: #606163; font-size: 14px; } QDialog#AppointmentDrawerOverlay QScrollBar:vertical { width: 6px; margin: 0; border: 0; background: transparent; } QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical { min-height: 30px; border-radius: 3px; background-color: #EDEDEE; } QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover { background-color: #E4E4E5; } QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical, QDialog#AppointmentDrawerOverlay QScrollBar::sub-line:vertical { height: 0; } """ _APPOINTMENT_DARK_REPLACEMENTS = ( ("color: #FFFFFF", "color: #EEF2FF"), ("selection-color: #134E4A", "selection-color: #EEF2FF"), ("selection-background-color: #A0CFFF", "selection-background-color: #6675F5"), ("selection-background-color: #ECFEFF", "selection-background-color: #29334F"), ("rgba(255, 255, 255, 218)", "rgba(8, 11, 20, 224)"), ("rgba(255, 255, 255, 46)", "rgba(238, 242, 255, 30)"), ("#FFFFFF", "#101626"), ("#F8F9FA", "#151D31"), ("#F5F7FA", "#151D31"), ("#F4F4F5", "#151D31"), ("#F0F9FF", "#151D31"), ("#ECFEFF", "#1B2440"), ("#FDF6EC", "#151D31"), ("#FEF0F0", "#151D31"), ("#F0F9EB", "#151D31"), ("#DCDFE6", "#29334F"), ("#E2EBE8", "#29334F"), ("#D5E5E2", "#29334F"), ("#D9ECFF", "#29334F"), ("#FAECD8", "#29334F"), ("#FDE2E2", "#29334F"), ("#E1F3D8", "#29334F"), ("#134E4A", "#EEF2FF"), ("#5B7A76", "#9AA7C0"), ("#C0C4CC", "#9AA7C0"), ("#0891B2", "#6675F5"), ("#79BBFF", "#6675F5"), ("#66B1FF", "#78A7FF"), ("#A5F3FC", "#6675F5"), ("#A0CFFF", "#29334F"), ("#67C23A", "#49C6A5"), ("#E6A23C", "#E4B967"), ("#F56C6C", "#F07886"), # Run last: the text-color rewrite above also matches the suffix of # ``background-color``. Restore those surfaces after all palette mapping. ("background-color: #EEF2FF", "background-color: #101626"), ("background: #EEF2FF", "background: #101626"), ) # Light is the default application theme. Keep the replacement table available # for a future explicit dark-mode switch, but do not mutate the light source QSS. def _as_int(value: Any, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default def _as_bool(value: Any) -> bool: if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any: for name in names: if callable(getattr(repository, name, None)): return invoke(repository, name, **kwargs) return invoke(repository, names[0], **kwargs) class FlowLayout(QLayout): """Compact wrapping layout based on Qt's canonical Flow Layout example.""" def __init__( self, parent: QWidget | None = None, margin: int = 0, horizontal_spacing: int = 10, vertical_spacing: int = 10, ) -> None: super().__init__(parent) self._items: list[QLayoutItem] = [] self._horizontal_spacing = horizontal_spacing self._vertical_spacing = vertical_spacing self.setContentsMargins(margin, margin, margin, margin) def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt API self._items.append(item) def count(self) -> int: return len(self._items) def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt API return self._items[index] if 0 <= index < len(self._items) else None def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt API return self._items.pop(index) if 0 <= index < len(self._items) else None def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - Qt API return Qt.Orientation(0) def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt API return True def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt API return self._do_layout(QRect(0, 0, width, 0), True) def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt API super().setGeometry(rect) self._do_layout(rect, False) def sizeHint(self) -> QSize: # noqa: N802 - Qt API return self.minimumSize() def minimumSize(self) -> QSize: # noqa: N802 - Qt API size = QSize() for item in self._items: size = size.expandedTo(item.minimumSize()) margins = self.contentsMargins() return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) def _smart_spacing(self, pixel_metric: QStyle.PixelMetric) -> int: parent = self.parent() if isinstance(parent, QWidget): return parent.style().pixelMetric(pixel_metric, None, parent) return 6 def _do_layout(self, rect: QRect, test_only: bool) -> int: left, top, right, bottom = self.getContentsMargins() effective = rect.adjusted(left, top, -right, -bottom) x = effective.x() y = effective.y() line_height = 0 for item in self._items: widget = item.widget() if widget is not None and widget.isHidden(): continue horizontal = self._horizontal_spacing if horizontal < 0: horizontal = self._smart_spacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing) vertical = self._vertical_spacing if vertical < 0: vertical = self._smart_spacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing) next_x = x + item.sizeHint().width() + horizontal if next_x - horizontal > effective.right() and line_height > 0: x = effective.x() y += line_height + vertical next_x = x + item.sizeHint().width() + horizontal line_height = 0 if not test_only: item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) x = next_x line_height = max(line_height, item.sizeHint().height()) return y + line_height - rect.y() + bottom class _Spinner(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._angle = 0 self.setFixedSize(30, 30) self._timer = QTimer(self) self._timer.setInterval(45) self._timer.timeout.connect(self._advance) def start(self) -> None: self._timer.start() self.show() def stop(self) -> None: self._timer.stop() def _advance(self) -> None: self._angle = (self._angle + 24) % 360 self.update() def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API del event painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) pen = QPen(QColor("#4F63D9"), 3) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.drawArc(self.rect().adjusted(4, 4, -4, -4), self._angle * 16, 255 * 16) class _LoadingOverlay(QFrame): def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.setObjectName("AppointmentLoadingOverlay") layout = QVBoxLayout(self) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(10) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.spinner = _Spinner(self) layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignCenter) self.label = QLabel("正在加载…") self.label.setObjectName("AppointmentLoadingText") self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter) self.hide() def show_message(self, text: str) -> None: self.label.setText(text) self.setGeometry(self.parentWidget().rect()) self.raise_() self.show() self.spinner.start() def clear(self) -> None: self.spinner.stop() self.hide() class _Alert(QFrame): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("AppointmentAlert") self.setProperty("kind", "info") layout = QHBoxLayout(self) layout.setContentsMargins(12, 9, 12, 9) layout.setSpacing(9) self.icon = QLabel("i") self.icon.setFixedSize(18, 18) self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter) self.icon.setStyleSheet("font-weight:700;") self.label = QLabel() self.label.setWordWrap(True) layout.addWidget(self.icon) layout.addWidget(self.label, 1) self.hide() def show_message(self, text: str, kind: str = "info") -> None: glyphs = {"info": "i", "warning": "!", "danger": "!", "success": "✓"} self.icon.setText(glyphs.get(kind, "i")) self.label.setText(text) self.setProperty("kind", kind) self.style().unpolish(self) self.style().polish(self) self.show() def clear(self) -> None: self.label.clear() self.hide() class _InlineEmpty(QWidget): def __init__(self, text: str = "请先选择医生", parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("AppointmentInlineEmpty") self.setMinimumHeight(138) layout = QVBoxLayout(self) layout.setContentsMargins(12, 18, 12, 18) layout.setSpacing(5) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.illustration = _EmptyIllustration(self) layout.addWidget(self.illustration, 0, Qt.AlignmentFlag.AlignCenter) self.label = QLabel(text) self.label.setObjectName("AppointmentEmptyText") self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.label.setWordWrap(True) layout.addWidget(self.label) def set_text(self, text: str) -> None: self.label.setText(text) class _EmptyIllustration(QWidget): """Small neutral illustration matching Element's 80 px empty-state footprint.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setFixedSize(80, 60) self.setAccessibleName("空状态插图") def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API del event painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor("#F7F7F7")) painter.drawEllipse(QRect(9, 48, 62, 8)) painter.setPen(QPen(QColor("#EDEDEE"), 1)) painter.setBrush(QColor("#FFFFFF")) painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4) painter.setBrush(QColor("#F0F0F0")) painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4) painter.setPen(QPen(QColor("#606163"), 2)) painter.drawLine(30, 35, 50, 35) painter.drawLine(34, 41, 46, 41) painter.end() class _HoverLiftButton(QPushButton): """Button with a subtle two-pixel lift and blue-tinted shadow on hover.""" def __init__(self, text: str = "", parent: QWidget | None = None) -> None: super().__init__(text, parent) self._lifted = False self._base_position = QPoint() def enterEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API if self.isEnabled() and not self._lifted: self._base_position = self.pos() self.move(self.x(), self.y() - 2) shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(12) shadow.setOffset(0, 4) shadow.setColor(QColor(26, 28, 31, 72)) self.setGraphicsEffect(shadow) self._lifted = True super().enterEvent(event) def leaveEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API if self._lifted: self.move(self._base_position) self.setGraphicsEffect(None) self._lifted = False super().leaveEvent(event) class _SlotCard(_HoverLiftButton): """Two-line time card matching Element's independent time/status typography.""" def __init__(self, time_text: str, status_text: str) -> None: super().__init__() self.setAccessibleName(f"{time_text} {status_text}") layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(6) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.time_label = QLabel(time_text) self.time_label.setObjectName("AppointmentSlotTime") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.time_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) layout.addWidget(self.time_label) self.status_label = QLabel(status_text) self.status_label.setObjectName("AppointmentSlotStatus") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) layout.addWidget(self.status_label) self.toggled.connect(self._sync_label_selection) def _sync_label_selection(self, checked: bool) -> None: # Qt does not reliably resolve ancestor pseudo states for child labels. for label in (self.time_label, self.status_label): label.setProperty("slotSelected", checked) label.style().unpolish(label) label.style().polish(label) label.update() class AppointmentDrawer(QDialog): """Full-window modal overlay with an Element-style 60% right drawer.""" CHANNEL_DETAIL_NAMES = {"自媒体4H", "自媒体3Q", "自媒体3H", "自媒体2H", "自媒体2Q"} def __init__( self, row: Any, repository: Any = None, parent: QWidget | None = None, *, autoload: bool = True, async_runner: Callable[..., Any] | None = None, ) -> None: super().__init__(parent) self.row = row self.repository = repository self._run_async = async_runner or run_async self.diagnosis_id = _as_int(first_value(row, "diagnosis_id", "id")) self._load_generation = 0 self._roster_generation = 0 self._slot_generation = 0 self._setting = False self._initial_loaded = False self._today_blocking = False self._channel_names: dict[str, str] = {} self._doctor_buttons: dict[int, QRadioButton] = {} self._date_buttons: dict[str, _HoverLiftButton] = {} self._slot_buttons: dict[str, _HoverLiftButton] = {} self._slot_available: dict[str, bool] = {} self._slot_restore_selection = "" self._active_loading = "" self._owner = parent.window() if parent is not None else None self.setObjectName("AppointmentDrawerOverlay") self.setWindowTitle("预约问诊") self.setModal(True) self.setWindowModality(Qt.WindowModality.WindowModal) self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) self.setStyleSheet(APPOINTMENT_DRAWER_QSS) self.resize(self._owner.size() if self._owner is not None else QSize(1024, 640)) if self._owner is not None: self._owner.installEventFilter(self) root = QHBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) root.addStretch(1) self.panel = QFrame() self.panel.setObjectName("AppointmentDrawerPanel") self.panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) panel_layout = QVBoxLayout(self.panel) panel_layout.setContentsMargins(0, 0, 0, 0) panel_layout.setSpacing(0) root.addWidget(self.panel) self.header = self._build_header() panel_layout.addWidget(self.header) self.body_scroll = QScrollArea() self.body_scroll.setObjectName("AppointmentDrawerBody") self.body_scroll.setWidgetResizable(True) self.body_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.body_scroll.setFrameShape(QFrame.Shape.NoFrame) self.body_content = QWidget() self.body_content.setObjectName("AppointmentDrawerBodyContent") self.body_layout = QVBoxLayout(self.body_content) self.body_layout.setContentsMargins(16, 16, 16, 20) self.body_layout.setSpacing(14) self.body_scroll.setWidget(self.body_content) panel_layout.addWidget(self.body_scroll, 1) self.footer = self._build_footer() panel_layout.addWidget(self.footer) self._body_viewport = self.body_scroll.viewport() self.loading_overlay = _LoadingOverlay(self._body_viewport) self._body_viewport.installEventFilter(self) self._build_form() self._update_drawer_width() self._sync_channel_detail_visibility() self._show_time_empty("请先选择医生") self._update_submit_state() if autoload: self._load_initial_options() @property def drawer_width(self) -> int: return self.panel.width() @property def doctor_buttons(self) -> Mapping[int, QRadioButton]: return MappingProxyType(self._doctor_buttons) @property def date_buttons(self) -> Mapping[str, QPushButton]: return MappingProxyType(self._date_buttons) @property def slot_buttons(self) -> Mapping[str, QPushButton]: return MappingProxyType(self._slot_buttons) @staticmethod def _rows(value: Any, key: str = "") -> list[Any]: rows = page_items(value) if rows: return rows if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): return list(value) if isinstance(value, Mapping): candidate = value.get(key) if key else None if isinstance(candidate, Sequence) and not isinstance(candidate, (str, bytes)): return list(candidate) data = value.get("data") if isinstance(data, Mapping): candidate = data.get(key) if isinstance(candidate, Sequence) and not isinstance(candidate, (str, bytes)): return list(candidate) return [] def _build_header(self) -> QWidget: header = QFrame() header.setObjectName("AppointmentDrawerHeader") header.setFixedHeight(58) layout = QHBoxLayout(header) layout.setContentsMargins(16, 0, 12, 0) title = QLabel("预约问诊") title.setObjectName("AppointmentDrawerTitle") layout.addWidget(title) layout.addStretch(1) close_button = QToolButton() close_button.setObjectName("AppointmentDrawerClose") close_button.setText("×") close_button.setToolTip("关闭") close_button.clicked.connect(self.reject) layout.addWidget(close_button) self.close_button = close_button return header def _build_footer(self) -> QWidget: footer = QFrame() footer.setObjectName("AppointmentDrawerFooter") layout = QHBoxLayout(footer) layout.setContentsMargins(16, 12, 16, 12) layout.setSpacing(12) layout.addStretch(1) self.cancel_button = QPushButton("取消") self.cancel_button.clicked.connect(self.reject) layout.addWidget(self.cancel_button) self.ok_button = QPushButton("确定") self.ok_button.setProperty("primary", True) self.ok_button.setEnabled(False) self.ok_button.clicked.connect(self.accept) layout.addWidget(self.ok_button) return footer def _build_form(self) -> None: self.banner = _Alert() self.body_layout.addWidget(self.banner) self.conflict_alert = _Alert() self.body_layout.addWidget(self.conflict_alert) self.form_labels: list[QLabel] = [] self.last_visit_label = QLabel("无就诊记录") self.last_visit_label.setProperty("appointmentMuted", True) self.last_visit_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._add_form_row("上次就诊:", self.last_visit_label) method_container = QWidget() method_layout = QHBoxLayout(method_container) method_layout.setContentsMargins(0, 0, 0, 0) method_layout.setSpacing(12) self.appointment_method_radio = QRadioButton("选择时段预约有专医生") self.appointment_method_radio.setChecked(True) method_layout.addWidget(self.appointment_method_radio) method_layout.addStretch(1) self._add_form_row("预约方式:", method_container) type_container = QWidget() type_layout = QHBoxLayout(type_container) type_layout.setContentsMargins(0, 0, 0, 0) self.appointment_type_radio = QRadioButton("视频问诊") self.appointment_type_radio.setChecked(True) type_layout.addWidget(self.appointment_type_radio) self.text_appointment_type_radio = QRadioButton("图文问诊") type_layout.addWidget(self.text_appointment_type_radio) self.appointment_type_group = QButtonGroup(self) self.appointment_type_group.addButton(self.appointment_type_radio, 0) self.appointment_type_group.addButton(self.text_appointment_type_radio, 1) type_layout.addStretch(1) self._add_form_row("预约类型:", type_container) self.appointment_type = QComboBox(self) for label, value in APPOINTMENT_MODES: self.appointment_type.addItem(label, value) self.appointment_type_group.idClicked.connect(self.appointment_type.setCurrentIndex) self.appointment_type.currentIndexChanged.connect( lambda index: self.appointment_type_group.button(index).setChecked(True) if self.appointment_type_group.button(index) is not None else None ) self.appointment_type.hide() patient_container = QWidget() patient_layout = QHBoxLayout(patient_container) patient_layout.setContentsMargins(0, 0, 0, 0) patient_name = display_text( first_value(self.row, "patient_name", "name", default="当前患者") ) self.patient_radio = QRadioButton(patient_name) self.patient_radio.setChecked(True) patient_layout.addWidget(self.patient_radio) patient_layout.addStretch(1) self._add_form_row("选择患者:", patient_container) self.channel_source = QComboBox() self.channel_source.setMaximumWidth(360) self.channel_source.addItem("正在加载渠道…", "") self.channel_source.currentIndexChanged.connect(self._channel_changed) self._add_form_row("渠道来源:", self.channel_source, required=True) self.channel_source_detail = QLineEdit() self.channel_source_detail.setMaximumWidth(360) self.channel_source_detail.setMaxLength(128) self.channel_source_detail.setPlaceholderText("请输入自媒体相关补充内容") self.channel_source_detail.textChanged.connect(self._update_submit_state) self.channel_detail_row = self._add_form_row( "自媒体补充:", self.channel_source_detail, required=True ) self.channel_detail_row.hide() self.doctor_group = QButtonGroup(self) self.doctor_group.setExclusive(True) self.doctor_container = QWidget() self.doctor_flow = FlowLayout( self.doctor_container, horizontal_spacing=12, vertical_spacing=8 ) self.doctor_empty = QLabel("正在加载医生…") self.doctor_empty.setProperty("appointmentMuted", True) self.doctor_flow.addWidget(self.doctor_empty) self._add_form_row("预约医生:", self.doctor_container, align_top=True) self.doctor_combo = QComboBox(self) self.doctor_combo.addItem("正在加载医生…", 0) self.doctor_combo.hide() self.doctor_combo.currentIndexChanged.connect(self._doctor_changed) self.time_container = QWidget() self.time_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) time_layout = QVBoxLayout(self.time_container) time_layout.setContentsMargins(0, 0, 0, 0) time_layout.setSpacing(0) self.time_stack = QStackedWidget() self.time_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) self.time_empty = _InlineEmpty("请先选择医生") self.time_stack.addWidget(self.time_empty) self.appointment_time_container = QWidget() self.appointment_time_container.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum ) appointment_time_layout = QVBoxLayout(self.appointment_time_container) appointment_time_layout.setContentsMargins(0, 0, 0, 0) appointment_time_layout.setSpacing(20) self.date_container = QWidget() self.date_flow = FlowLayout(self.date_container, horizontal_spacing=10, vertical_spacing=10) appointment_time_layout.addWidget(self.date_container) self.slots_panel = QFrame() self.slots_panel.setObjectName("AppointmentSlotsPanel") self.slots_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) self.slots_panel.setFixedHeight(164) slots_panel_layout = QVBoxLayout(self.slots_panel) slots_panel_layout.setContentsMargins(16, 16, 16, 16) slots_panel_layout.setSpacing(14) slots_header = QHBoxLayout() slots_title = QLabel("可预约时段") slots_title.setObjectName("AppointmentSlotsTitle") slots_header.addWidget(slots_title) slots_header.addStretch(1) self.refresh_slots_button = QPushButton("刷新") self.refresh_slots_button.setObjectName("AppointmentRefreshSlots") self.refresh_slots_button.clicked.connect(self._refresh_slots) slots_header.addWidget(self.refresh_slots_button) slots_panel_layout.addLayout(slots_header) self.slot_state_stack = QStackedWidget() self.slot_state_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.slot_state_stack.setFixedHeight(94) self.slot_empty = _InlineEmpty("请选择日期") self.slot_empty.setMinimumHeight(92) self.slot_state_stack.addWidget(self.slot_empty) self.slot_scroll = QScrollArea() self.slot_scroll.setObjectName("AppointmentSlotScroll") self.slot_scroll.setWidgetResizable(True) self.slot_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.slot_scroll.setMinimumHeight(94) self.slot_scroll.setMaximumHeight(450) self.slot_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.slot_content = QWidget() self.slot_flow = FlowLayout(self.slot_content, horizontal_spacing=10, vertical_spacing=10) self.slot_scroll.setWidget(self.slot_content) self._slot_viewport = self.slot_scroll.viewport() self._slot_viewport.installEventFilter(self) self.slot_state_stack.addWidget(self.slot_scroll) slots_panel_layout.addWidget(self.slot_state_stack) appointment_time_layout.addWidget(self.slots_panel) self.time_stack.addWidget(self.appointment_time_container) time_layout.addWidget(self.time_stack) self._add_form_row("预约时间:", self.time_container, align_top=True) self.date_combo = QComboBox(self) self.date_combo.addItem("请先选择医生", "") self.date_combo.hide() self.date_combo.currentIndexChanged.connect(self._date_changed) self.slot_combo = QComboBox(self) self.slot_combo.addItem("请先选择排班日期", "") self.slot_combo.hide() self.slot_combo.currentIndexChanged.connect(self._slot_selection_changed) self.slot_group = QButtonGroup(self) self.slot_group.setExclusive(True) self.remark = QPlainTextEdit() self.remark.setPlaceholderText("请输入备注信息") self.remark.setFixedHeight(70) self._add_form_row("备注:", self.remark, align_top=True) self.body_layout.addStretch(1) def _add_form_row( self, label_text: str, field: QWidget, *, required: bool = False, align_top: bool = False, ) -> QWidget: row = QWidget() layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(12) label = QLabel( f"{label_text[:-1]} *:" if required and label_text.endswith(":") else label_text ) label.setProperty("appointmentLabel", True) label.setFixedWidth(100) label.setAlignment( Qt.AlignmentFlag.AlignRight | (Qt.AlignmentFlag.AlignTop if align_top else Qt.AlignmentFlag.AlignVCenter) ) if align_top: label.setContentsMargins(0, 7, 0, 0) self.form_labels.append(label) layout.addWidget(label) layout.addWidget(field, 1) self.body_layout.addWidget(row) return row def _load_initial_options(self) -> None: self._load_generation += 1 generation = self._load_generation diagnosis_id = self.diagnosis_id today = QDate.currentDate().toString("yyyy-MM-dd") self._set_loading("initial", "正在加载医生、渠道与挂号状态…") def query() -> dict[str, Any]: doctors = _invoke_first(self.repository, ("list_diagnosis_doctors",)) channels = invoke(self.repository, "get_dictionary", dictionary_type="channels") appointments = _invoke_first( self.repository, ("list_appointments",), patient_id=diagnosis_id, start_date=today, end_date=today, page_no=1, page_size=50, ) try: last_visits = _invoke_first( self.repository, ("list_appointments",), patient_id=diagnosis_id, status=3, page_no=1, page_size=1, ) except Exception: last_visits = [] return { "doctors": doctors, "channels": channels, "appointments": appointments, "last_visits": last_visits, } self._run_async( query, on_success=lambda result: self._apply_initial_options(result, generation), on_error=lambda error: self._load_error(error, generation), ) def _apply_initial_options(self, result: Any, generation: int) -> None: if generation != self._load_generation: return doctors = self._rows(get_value(result, "doctors", [])) channels = self._rows(get_value(result, "channels", []), "channels") appointments = self._rows(get_value(result, "appointments", [])) last_visits = self._rows(get_value(result, "last_visits", [])) self._today_blocking = any( _as_int(first_value(item, "status", "appointment_status"), -1) in {1, 4} for item in appointments ) self._set_last_visit(last_visits) self._setting = True try: self._populate_doctors(doctors) self.channel_source.clear() self.channel_source.addItem("请选择渠道来源", "") self._channel_names.clear() ordered_channels = sorted( ( item for item in channels if _as_int(first_value(item, "status", default=1), 1) != 0 ), key=lambda item: ( -_as_int(first_value(item, "sort", default=0)), -_as_int(first_value(item, "id", default=0)), ), ) for channel in ordered_channels: value = str(first_value(channel, "value", "id", default="") or "") name = display_text(first_value(channel, "name", "label"), "") if not value or not name: continue self.channel_source.addItem(name, value) self._channel_names[value] = name finally: self._setting = False self._initial_loaded = bool(doctors and self._channel_names) self._sync_channel_detail_visibility() self._update_conflict_alert() self._clear_loading("initial") if not doctors: self.doctor_empty.setText("暂无可预约医生") self.doctor_empty.show() self._show_time_empty("暂无可预约医生") self.banner.show_message("没有可预约医生,已停止提交。", "warning") elif not self._channel_names: self.banner.show_message("没有可用渠道字典,已停止提交。", "warning") else: self.banner.clear() if not doctors: pass elif self.doctor_combo.currentData(): self._doctor_changed() else: self._show_time_empty("请先选择医生") self._update_submit_state() def _set_last_visit(self, rows: Sequence[Any]) -> None: if rows: item = rows[0] date_text = str(first_value(item, "appointment_date", "date", default="") or "") time_text = str( first_value( item, "appointment_time", "time_text", "appointment_time_text", default="", ) or "" ) rendered = " ".join(part for part in (date_text, time_text) if part) self.last_visit_label.setText(rendered or "无就诊记录") return seeded = first_value( self.row, "last_visit", "last_visit_at", "last_appointment_at", default="", ) self.last_visit_label.setText(display_text(seeded, "无就诊记录")) def _populate_doctors(self, doctors: Sequence[Any]) -> None: self._clear_flow(self.doctor_flow) self._doctor_buttons.clear() self.doctor_group = QButtonGroup(self) self.doctor_group.setExclusive(True) self.doctor_combo.clear() self.doctor_combo.addItem("请选择医生", 0) preferred = _as_int(first_value(self.row, "appointment_doctor_id", "doctor_id", default=0)) selected_index = 0 for doctor in doctors: doctor_id = _as_int(first_value(doctor, "id", "doctor_id")) if doctor_id <= 0: continue name = display_text(first_value(doctor, "name", "doctor_name")) self.doctor_combo.addItem(name, doctor_id) button = QRadioButton(name) button.setProperty("doctorId", doctor_id) button.clicked.connect( lambda _checked=False, value=doctor_id: self._select_doctor(value) ) self.doctor_group.addButton(button, doctor_id) self.doctor_flow.addWidget(button) self._doctor_buttons[doctor_id] = button if doctor_id == preferred: selected_index = self.doctor_combo.count() - 1 if not self._doctor_buttons: self.doctor_empty = QLabel("暂无可预约医生") self.doctor_empty.setProperty("appointmentMuted", True) self.doctor_flow.addWidget(self.doctor_empty) else: self.doctor_combo.setCurrentIndex(selected_index) if selected_index > 0: selected_id = _as_int(self.doctor_combo.currentData()) button = self._doctor_buttons.get(selected_id) if button is not None: button.setChecked(True) def _load_error(self, error: Exception, generation: int) -> None: if generation != self._load_generation: return self._initial_loaded = False self._clear_loading("initial") self.banner.show_message(f"预约基础数据加载失败:{friendly_error(error)}", "danger") self._show_time_empty("基础数据加载失败") self._update_submit_state() def _select_doctor(self, doctor_id: int) -> None: index = self.doctor_combo.findData(doctor_id) if index >= 0 and index != self.doctor_combo.currentIndex(): self.doctor_combo.setCurrentIndex(index) elif index >= 0: self._doctor_changed() def _doctor_changed(self) -> None: if self._setting: return doctor_id = _as_int(self.doctor_combo.currentData()) for value, button in self._doctor_buttons.items(): button.setChecked(value == doctor_id) self._clear_dates() self._clear_slots("请先选择排班日期") if doctor_id <= 0: self._show_time_empty("请先选择医生") self._update_submit_state() return self._show_time_empty("正在加载医生排班…") self._roster_generation += 1 generation = self._roster_generation start = QDate.currentDate().toString("yyyy-MM-dd") end = QDate.currentDate().addDays(6).toString("yyyy-MM-dd") query = MappingProxyType( { "doctor_id": doctor_id, "start_date": start, "end_date": end, "status": 1, "page_no": 1, "page_size": 100, } ) self._set_loading("roster", "正在加载医生排班…") self._run_async( lambda: _invoke_first( self.repository, ("list_appointment_rosters", "appointment_rosters", "roster_lists"), **query, ), on_success=lambda result: self._apply_rosters(result, doctor_id, generation), on_error=lambda error: self._roster_error(error, doctor_id, generation), ) def _apply_rosters(self, result: Any, doctor_id: int, generation: int) -> None: if generation != self._roster_generation or doctor_id != _as_int( self.doctor_combo.currentData() ): return today = QDate.currentDate() end = today.addDays(6) dates: set[str] = set() for item in self._rows(result): raw = str(first_value(item, "date", "appointment_date", "roster_date", default="")) parsed = QDate.fromString(raw[:10], "yyyy-MM-dd") if parsed.isValid() and today <= parsed <= end: dates.add(parsed.toString("yyyy-MM-dd")) ordered = sorted(dates) self._setting = True try: self._populate_dates(ordered) if ordered: today_value = today.toString("yyyy-MM-dd") preferred = next( ( value for value in ordered if not (self._today_blocking and value == today_value) ), ordered[0], ) self.date_combo.setCurrentIndex(self.date_combo.findData(preferred)) self._sync_date_buttons(preferred) finally: self._setting = False self._clear_loading("roster") if not ordered: self._show_time_empty("该医生暂无排班") self.banner.show_message("该医生未来 7 天暂无可预约排班。", "warning") self._update_conflict_alert() self._update_submit_state() return self.banner.clear() self.time_stack.setCurrentWidget(self.appointment_time_container) self._date_changed() def _roster_error(self, error: Exception, doctor_id: int, generation: int) -> None: if generation != self._roster_generation or doctor_id != _as_int( self.doctor_combo.currentData() ): return self._clear_loading("roster") self._show_time_empty("排班加载失败") self.banner.show_message(f"排班加载失败:{friendly_error(error)}", "danger") self._update_submit_state() def _populate_dates(self, dates: Sequence[str]) -> None: self._clear_flow(self.date_flow) self._date_buttons.clear() self.date_combo.clear() self.date_combo.addItem("请选择排班日期", "") week_days = ["日", "一", "二", "三", "四", "五", "六"] for value in dates: parsed = QDate.fromString(value, "yyyy-MM-dd") if not parsed.isValid(): continue label = f"{parsed.toString('MM月dd日')} ({week_days[parsed.dayOfWeek() % 7]})" self.date_combo.addItem(label, value) button = _HoverLiftButton(label) button.setProperty("appointmentDate", True) button.setCheckable(True) button.setFixedSize(130, 40) button.clicked.connect(lambda _checked=False, date=value: self._select_date(date)) self.date_flow.addWidget(button) self._date_buttons[value] = button def _select_date(self, value: str) -> None: index = self.date_combo.findData(value) if index >= 0 and index != self.date_combo.currentIndex(): self.date_combo.setCurrentIndex(index) elif index >= 0: self._date_changed() def _date_changed(self) -> None: if self._setting: return doctor_id = _as_int(self.doctor_combo.currentData()) appointment_date = str(self.date_combo.currentData() or "") self._sync_date_buttons(appointment_date) self._clear_slots( "正在加载可用号源…" if doctor_id and appointment_date else "请选择排班日期" ) self._update_conflict_alert() if doctor_id <= 0 or not appointment_date: self._update_submit_state() return self._request_slots(doctor_id, appointment_date) def _request_slots( self, doctor_id: int, appointment_date: str, *, restore_selection: str = "", ) -> None: self._slot_generation += 1 generation = self._slot_generation self._slot_restore_selection = restore_selection query = MappingProxyType( { "doctor_id": doctor_id, "appointment_date": appointment_date, "period": "all", } ) self.refresh_slots_button.setEnabled(False) self.refresh_slots_button.setText("刷新中…") loading_text = "正在刷新可用号源…" if restore_selection else "正在加载可用号源…" self._set_loading("slots", loading_text) self._run_async( lambda: _invoke_first( self.repository, ("get_available_appointment_slots", "available_appointment_slots"), **query, ), on_success=lambda result: self._apply_slots( result, doctor_id, appointment_date, generation ), on_error=lambda error: self._slot_error(error, doctor_id, appointment_date, generation), ) def _apply_slots( self, result: Any, doctor_id: int, appointment_date: str, generation: int ) -> None: if ( generation != self._slot_generation or doctor_id != _as_int(self.doctor_combo.currentData()) or appointment_date != str(self.date_combo.currentData() or "") ): return rows = self._rows(result, "slots") today = QDate.currentDate().toString("yyyy-MM-dd") now = QTime.currentTime() slots: list[tuple[str, bool, str]] = [] for item in rows: if isinstance(item, str): raw_time = item available = True quota = 1 else: raw_time = str(first_value(item, "time", "appointment_time", default="")) available = _as_bool(first_value(item, "available", default=False)) quota = _as_int(first_value(item, "quota", default=1), 1) if not raw_time: continue parsed = QTime.fromString(raw_time[:5], "HH:mm") expired = appointment_date == today and parsed.isValid() and parsed <= now can_book = available and quota > 0 and not expired # Vue keeps expired slots in the grid and renders every unavailable slot as # “已约”. Expiry still participates in the availability calculation above. status = "可约" if can_book else "已约" slots.append((raw_time, can_book, status)) restore = self._slot_restore_selection self._populate_slots(slots) if restore and self._slot_available.get(restore, False): index = self.slot_combo.findData(restore) if index >= 0: self.slot_combo.setCurrentIndex(index) self._slot_restore_selection = "" self._clear_loading("slots") self.refresh_slots_button.setEnabled(True) self.refresh_slots_button.setText("刷新") if slots: self.banner.clear() self.slot_state_stack.setCurrentWidget(self.slot_scroll) QTimer.singleShot(0, self._update_slot_scroll_height) else: self.slot_empty.set_text("该排班暂无可用号源") self.slot_state_stack.setFixedHeight(94) self.slots_panel.setFixedHeight(164) self.slot_state_stack.setCurrentWidget(self.slot_empty) self._update_submit_state() def _populate_slots(self, slots: Sequence[tuple[str, bool, str]]) -> None: self._clear_flow(self.slot_flow) self._slot_buttons.clear() self._slot_available.clear() self.slot_group = QButtonGroup(self) self.slot_group.setExclusive(True) self._setting = True try: self.slot_combo.clear() self.slot_combo.addItem("请选择可用号源", "") for raw_time, available, status in slots: self.slot_combo.addItem(f"{raw_time} · {status}", raw_time) button = _SlotCard(raw_time, status) button.setProperty("appointmentSlot", True) button.setProperty("availability", "available" if available else "unavailable") button.setProperty( "slotState", "available" if available else "unavailable", ) button.setCheckable(available) button.setEnabled(available) button.setMinimumSize(110, 70) button.clicked.connect( lambda _checked=False, value=raw_time: self._select_slot(value) ) self.slot_group.addButton(button) self.slot_flow.addWidget(button) self._slot_buttons[raw_time] = button self._slot_available[raw_time] = available if not available: opacity = QGraphicsOpacityEffect(button) opacity.setOpacity(0.6) button.setGraphicsEffect(opacity) finally: self._setting = False def _select_slot(self, value: str) -> None: if not self._slot_available.get(value, False): return index = self.slot_combo.findData(value) if index >= 0: self.slot_combo.setCurrentIndex(index) def _slot_selection_changed(self) -> None: if self._setting: return value = str(self.slot_combo.currentData() or "") for slot_value, button in self._slot_buttons.items(): button.setChecked(slot_value == value and self._slot_available.get(slot_value, False)) self._update_submit_state() def _slot_error( self, error: Exception, doctor_id: int, appointment_date: str, generation: int ) -> None: if ( generation != self._slot_generation or doctor_id != _as_int(self.doctor_combo.currentData()) or appointment_date != str(self.date_combo.currentData() or "") ): return self._slot_restore_selection = "" self._clear_loading("slots") self.refresh_slots_button.setEnabled(True) self.refresh_slots_button.setText("刷新") self.slot_empty.set_text("号源加载失败") self.slot_state_stack.setFixedHeight(94) self.slots_panel.setFixedHeight(164) self.slot_state_stack.setCurrentWidget(self.slot_empty) self.banner.show_message(f"号源加载失败:{friendly_error(error)}", "danger") self._update_submit_state() def _refresh_slots(self) -> None: doctor_id = _as_int(self.doctor_combo.currentData()) appointment_date = str(self.date_combo.currentData() or "") if doctor_id <= 0 or not appointment_date: return selected = str(self.slot_combo.currentData() or "") self._clear_slots("正在刷新可用号源…") self._request_slots(doctor_id, appointment_date, restore_selection=selected) def _channel_changed(self) -> None: if self._setting: return self._sync_channel_detail_visibility() self._update_submit_state() def _sync_channel_detail_visibility(self) -> None: requires_detail = self._channel_requires_detail() self.channel_detail_row.setVisible(requires_detail) if not requires_detail and self.channel_source_detail.text(): previous = self.channel_source_detail.blockSignals(True) self.channel_source_detail.clear() self.channel_source_detail.blockSignals(previous) def _channel_requires_detail(self) -> bool: value = str(self.channel_source.currentData() or "") return self._channel_names.get(value, "") in self.CHANNEL_DETAIL_NAMES def _update_conflict_alert(self) -> None: if not self._today_blocking: self.conflict_alert.clear() return today = QDate.currentDate().toString("yyyy-MM-dd") if str(self.date_combo.currentData() or "") == today: self.conflict_alert.show_message( "该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。", "warning", ) else: self.conflict_alert.show_message( "该患者今日已有挂号记录;可为患者预约其他日期的时段。", "info", ) def _update_submit_state(self) -> None: if not hasattr(self, "ok_button"): return date_value = str(self.date_combo.currentData() or "") if hasattr(self, "date_combo") else "" slot_value = str(self.slot_combo.currentData() or "") if hasattr(self, "slot_combo") else "" today_blocked = self._today_blocking and date_value == QDate.currentDate().toString( "yyyy-MM-dd" ) complete = bool( self._initial_loaded and _as_int(self.doctor_combo.currentData()) > 0 and date_value and slot_value and self._slot_available.get(slot_value, False) and self.channel_source.currentData() and not today_blocked and (not self._channel_requires_detail() or self.channel_source_detail.text().strip()) ) self.ok_button.setEnabled(complete) def payload(self) -> dict[str, Any]: # firstvisit.myPatient/createAppointment deliberately names the diagnosis # identifier ``patient_id``. source_patient_id is only used by video. return { "diagnosis_id": self.diagnosis_id, "patient_id": self.diagnosis_id, "doctor_id": _as_int(self.doctor_combo.currentData()), "appointment_date": str(self.date_combo.currentData() or ""), "appointment_time": str(self.slot_combo.currentData() or ""), "period": "all", "appointment_type": str(self.appointment_type.currentData() or "video"), "remark": self.remark.toPlainText().strip(), "channel_source": str(self.channel_source.currentData() or ""), "channel_source_detail": self.channel_source_detail.text().strip(), } def accept(self) -> None: self._update_submit_state() if not self.ok_button.isEnabled(): if self._today_blocking and str( self.date_combo.currentData() or "" ) == QDate.currentDate().toString("yyyy-MM-dd"): self.banner.show_message("该患者今天已有预约或过号,请选择其他日期。", "warning") else: self.banner.show_message("请完整选择医生、排班、可用号源和渠道。", "warning") return super().accept() def reject(self) -> None: self._invalidate_requests() super().reject() def done(self, result: int) -> None: self._invalidate_requests() owner = getattr(self, "_owner", None) if owner is not None: with suppress(RuntimeError): owner.removeEventFilter(self) super().done(result) def showEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API self._sync_to_owner() super().showEvent(event) QTimer.singleShot(0, self._sync_to_owner) def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API painter = QPainter(self) painter.fillRect(self.rect(), QColor(26, 28, 31, 196)) painter.end() super().paintEvent(event) def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API self._update_drawer_width() if hasattr(self, "loading_overlay"): self.loading_overlay.setGeometry(self.body_scroll.viewport().rect()) super().resizeEvent(event) def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API owner = getattr(self, "_owner", None) if ( owner is not None and watched is owner and event.type() in { QEvent.Type.Resize, QEvent.Type.Move, QEvent.Type.Show, } ): QTimer.singleShot(0, self._sync_to_owner) elif watched is getattr(self, "_body_viewport", None) and event.type() in { QEvent.Type.Resize, QEvent.Type.Show, }: try: self.loading_overlay.setGeometry(watched.rect()) except RuntimeError: return False elif watched is getattr(self, "_slot_viewport", None) and event.type() in { QEvent.Type.Resize, QEvent.Type.Show, }: QTimer.singleShot(0, self._update_slot_scroll_height) return super().eventFilter(watched, event) def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt API if not self.panel.geometry().contains(event.position().toPoint()): event.accept() return super().mousePressEvent(event) def _sync_to_owner(self) -> None: target = main_window_target_rect(self) if target is not None: self.setGeometry(target) self._update_drawer_width() return if self._owner is None: self._update_drawer_width() return top_left = self._owner.mapToGlobal(QPoint(0, 0)) self.setGeometry(QRect(top_left, self._owner.size())) self._update_drawer_width() def _update_drawer_width(self) -> None: if not hasattr(self, "panel"): return target = max(1, int(round(self.width() * 0.60))) self.panel.setFixedWidth(target) def _set_loading(self, token: str, message: str) -> None: self._active_loading = token if hasattr(self, "loading_overlay"): self.loading_overlay.show_message(message) def _clear_loading(self, token: str) -> None: if self._active_loading != token: return self._active_loading = "" self.loading_overlay.clear() def _show_time_empty(self, text: str) -> None: self.time_empty.set_text(text) self.time_stack.setCurrentWidget(self.time_empty) def _clear_dates(self) -> None: self._setting = True try: self._clear_flow(self.date_flow) self._date_buttons.clear() self.date_combo.clear() self.date_combo.addItem("请先选择医生", "") finally: self._setting = False def _clear_slots(self, message: str) -> None: self._setting = True try: self._clear_flow(self.slot_flow) self._slot_buttons.clear() self._slot_available.clear() self.slot_combo.clear() self.slot_combo.addItem(message, "") finally: self._setting = False self.slot_empty.set_text(message) self.slot_state_stack.setFixedHeight(94) self.slots_panel.setFixedHeight(164) self.slot_state_stack.setCurrentWidget(self.slot_empty) self._update_submit_state() def _update_slot_scroll_height(self) -> None: if not self._slot_buttons or self.slot_state_stack.currentWidget() is not self.slot_scroll: return viewport_width = max(110, self.slot_scroll.viewport().width()) content_height = max(70, self.slot_flow.heightForWidth(viewport_width)) target = max(94, min(450, content_height + 4)) self.slot_content.setMinimumHeight(content_height) if self.slot_scroll.height() != target: self.slot_scroll.setFixedHeight(target) if self.slot_state_stack.height() != target: self.slot_state_stack.setFixedHeight(target) panel_height = target + 70 if self.slots_panel.height() != panel_height: self.slots_panel.setFixedHeight(panel_height) def _sync_date_buttons(self, selected: str) -> None: for value, button in self._date_buttons.items(): button.setChecked(value == selected) @staticmethod def _clear_flow(layout: FlowLayout) -> None: while layout.count(): item = layout.takeAt(0) widget = item.widget() if item is not None else None if widget is not None: widget.hide() widget.deleteLater() def _invalidate_requests(self) -> None: self._load_generation += 1 self._roster_generation += 1 self._slot_generation += 1 if hasattr(self, "loading_overlay"): self.loading_overlay.clear() __all__ = ["APPOINTMENT_DRAWER_QSS", "AppointmentDrawer", "FlowLayout"]