Files
zyt/app/src/doctor_workstation/ui/appointment_drawer.py
T
2026-10-08 15:49:53 +08:00

1866 lines
71 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]