"""Appointment queue page matching ``admin/src/views/tcm/appointment/list.vue``.""" from __future__ import annotations from collections.abc import Mapping, Sequence from copy import deepcopy from datetime import date, timedelta from html import escape from types import MappingProxyType from typing import Any from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QButtonGroup, QCheckBox, QComboBox, QDateEdit, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QMessageBox, QPushButton, QTabBar, QTextEdit, QVBoxLayout, QWidget, ) from ..dialogs import DiagnosisDialog from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult from ..dialogs.prescription import ( PrescriptionDetailDialog, PrescriptionEditorDialog, build_prescription_clinical_diagnosis, build_prescription_visit_no, ) from ..dialogs.prescription_ai import ( can_open_diagnosis_ai_report, present_diagnosis_ai_report, ) from ..theme import mark_business_dialog from ..widgets import ( MessageBanner, PageHeader, Pager, SortableTable, TableColumn, display_text, first_value, friendly_error, gender_text, get_value, invoke, page_items, page_total, run_async, show_toast, ) STATUS_TABS: tuple[tuple[str, str | int], ...] = ( ("待接诊", 1), ("全部", "all"), ("已过号", 4), ("已取消", 2), ("已完成", 3), ) DATE_PRESETS: tuple[tuple[str, str], ...] = ( ("昨天挂号", "yesterday"), ("前天挂号", "day_before"), ("当天挂号", "today"), ("明天挂号", "tomorrow"), ("后天挂号", "day_after"), ("不限", ""), ) LIST_POLL_MS = 20_000 NOTE_LIMIT = 500 TABLE_CELL_VERTICAL_PADDING = 10 _SEMANTIC_COLORS = { "success": "#159C79", "warning": "#C17A16", "danger": "#EC5266", "info": "#4776EE", "muted": "#7481A3", } APPOINTMENTS_LIGHT_QSS = """ /* 页头此前被压到只剩面包屑(26px),与其余列表页的“面包屑+标题+副标题” 骨架不一致。这里给它与 PageHeader 自然高度相符的空间。 */ #AppointmentsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; } #AppointmentsPage QFrame#AppointmentFilterPanel { min-height: 80px; max-height: 80px; background-color: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 10px; } #AppointmentsPage QFrame#AppointmentMainCard { background-color: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 10px; } #AppointmentsPage QPushButton[appointmentStat="true"] { min-height: 30px; max-height: 30px; padding: 0 7px; color: #59698E; background-color: #F8F9FD; border: 0; border-radius: 8px; font-size: 12px; } #AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #5265F6; background-color: #F0F2FF; } #AppointmentsPage QPushButton[appointmentStat="true"]:checked { color: #FFFFFF; background-color: #5265F6; border-color: #5265F6; font-weight: 600; } /* 这一排全是筛选片,此前按业务语义分别染成紫/绿/琥珀,一行出现四种底色, 而且颜色和“是否选中”这一真正需要区分的状态互相打架。筛选片一律保持中性, 只有“待分配医助”在确有待办时才提示为琥珀色。 */ #AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"] { color: #C17A16; background-color: #FFF8ED; border-color: #F0DCB6; } #AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 30px; max-height: 30px; background-color: #FFFFFF; border: 1px solid #DDE3F0; border-radius: 7px; } #AppointmentsPage QLabel#FilterRowLabel { color: #405074; font-size: 12px; font-weight: 600; } #AppointmentsPage QLabel#FilterDivider { color: #E2E7F4; padding: 0 2px; } #AppointmentsPage QTableWidget#AppointmentTable::item { padding: 5px 7px; } #AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section { min-height: 34px; background-color: #F8FAFF; } #AppointmentsPage QTableWidget#AppointmentTable { gridline-color: #E9EDF5; selection-background-color: #FFFFFF; selection-color: #15224A; } #AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator { width: 13px; height: 13px; background-color: #FFFFFF; border: 1px solid #CBD3E7; border-radius: 2px; } #AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background-color: #5265F6; border-color: #5265F6; } #AppointmentsPage QLabel[tableAppointmentStatus="true"] { min-height: 18px; max-height: 18px; padding: 0 6px; color: #5265F6; background-color: #EEF1FF; border-radius: 4px; font-size: 10px; font-weight: 600; } #AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #B97715; background-color: #FFF4DF; } #AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #59698E; font-size: 10px; } #AppointmentsPage QPushButton[tableCancelAction="true"] { min-height: 18px; max-height: 18px; padding: 0 5px; color: #EC5266; background-color: #FFF4F6; border: 0; border-radius: 4px; font-size: 10px; } #AppointmentsPage QWidget[appointmentImHost="true"] { background-color: transparent; } #AppointmentsPage QPushButton[appointmentImAction="true"] { min-width: 74px; min-height: 26px; max-height: 26px; padding: 0 9px; color: #5265F6; background-color: #F0F2FF; border: 1px solid #D7DEFF; border-radius: 7px; font-size: 11px; font-weight: 600; } #AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background-color: #5265F6; border-color: #5265F6; } #AppointmentsPage QPushButton[appointmentImAction="true"]:focus { border-color: #8D9BFF; } #AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #98A3BC; background-color: #F7F8FC; border-color: #E4E8F2; } #AppointmentsPage QWidget[appointmentInfoHost="true"] { background-color: #FFFFFF; } #AppointmentsPage QPushButton[compactAction="true"] { min-height: 28px; max-height: 28px; padding: 0 8px; border-radius: 7px; font-size: 11px; } #AppointmentsPage QPushButton[variant="chip"] { min-height: 34px; max-height: 34px; padding: 0 12px; color: #7481A3; background-color: #F8FAFF; border: 1px solid #E2E7F4; border-radius: 8px; } #AppointmentsPage QPushButton[variant="chip"]:hover { color: #15224A; background-color: #F0F3FC; border-color: #5265F6; } #AppointmentsPage QPushButton[variant="chip"]:checked { color: #FFFFFF; background-color: #5265F6; border-color: #5265F6; } #AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; } #AppointmentsPage QTabBar#AppointmentStatusTabs::tab { min-width: 62px; min-height: 28px; padding: 0 7px; color: #7481A3; background-color: transparent; border: 0; border-bottom: 2px solid transparent; } #AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #15224A; background-color: #F0F3FC; } #AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected { color: #3C4FD9; background-color: #EEF1FF; border-bottom-color: #5265F6; } #AppointmentsPage QPushButton[filterChoice="true"] { min-height: 28px; max-height: 28px; padding: 0 10px; color: #405074; background-color: transparent; border: 1px solid transparent; border-radius: 7px; } #AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #5265F6; background-color: #F0F1FF; border-color: #E0E4FF; } """ 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 _canonical_allowed(permissions: Any, code: str, *, default: bool = True) -> bool: if permissions is None: return default for method_name in ("allows", "has", "can", "contains", "has_permission"): method = getattr(permissions, method_name, None) if callable(method): try: return bool(method(code)) except (TypeError, ValueError): continue raw = permissions for attr in ("codes", "permissions", "values"): candidate = getattr(permissions, attr, None) if candidate is not None and not callable(candidate): raw = candidate break if isinstance(raw, Mapping): nested = first_value(raw, "codes", "permissions", "values", default=None) if nested is not None: raw = nested if isinstance(raw, Mapping): available = {str(key) for key, enabled in raw.items() if enabled} elif isinstance(raw, str): available = {raw} else: try: available = {str(item) for item in raw} except TypeError: return False if "*" in available or code in available: return True return any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available) def _supports_native_video(repository: Any) -> bool: return all( callable(getattr(repository, method_name, None)) for method_name in ("get_call_ticket", "start_call", "bind_call_room", "end_call") ) def _is_admin_user(user: Any) -> bool: """Match admin: neither doctor (1) nor assistant (2).""" role_id = first_value(user, "role_id", "role_ids", default=None) if isinstance(role_id, Sequence) and not isinstance(role_id, (str, bytes, bytearray)): values = {_as_int(item, -1) for item in role_id} return 1 not in values and 2 not in values value = _as_int(role_id, -1) return value not in {1, 2} def _mask_phone(value: Any, *, can_plain: bool) -> str: text = str(value or "").strip() if not text: return "—" if can_plain or "*" in text: return text digits = "".join(character for character in text if character.isdigit()) if len(digits) >= 7: return f"{digits[:3]}****{digits[-4:]}" if len(text) > 4: return f"{text[:2]}***{text[-2:]}" return "****" def _diagnosis_id(row: Any) -> int: """Admin appointment rows often store diagnosis id in ``patient_id``.""" explicit = _as_int(first_value(row, "diagnosis_id", default=0)) if explicit > 0: return explicit return _as_int(first_value(row, "patient_id", default=0)) def _video_patient_id(row: Any) -> int: """Return only the appointment row's explicitly separated patient owner.""" return _as_int(first_value(row, "source_patient_id", default=0)) def _appointment_id(row: Any) -> int: return _as_int(first_value(row, "id", "appointment_id", default=0)) def _status_value(row: Any) -> int: return _as_int(first_value(row, "status", default=0)) _IM_CONSULT_ACTIVE_STATUSES = frozenset({1, 4}) def _im_consult_status_error(row: Any) -> str: """Return why a queue row cannot open the doctor's IM consultation.""" status = _status_value(row) if status in _IM_CONSULT_ACTIVE_STATUSES: return "" if status == 2: return "已取消的挂号不可进入 IM 问诊" if status == 3: return "已完成的挂号不可再进入 IM 问诊" return "当前挂号状态不可进入 IM 问诊" def prescription_action_label(row: Any) -> str: audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1) voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0) explicit = first_value(row, "current_has_prescription", default=None) if explicit is None and _as_int(first_value(row, "current_prescription_id", default=0)) <= 0: explicit = first_value(row, "has_prescription", default=None) has_prescription = ( _as_bool(explicit) if explicit is not None else _as_int( first_value(row, "current_prescription_id", "prescription_id", default=0), 0, ) > 0 or audit in {0, 1, 2} ) if not has_prescription: return "开方" return "查看" if audit == 1 and voided != 1 else "编辑处方" def _patient_cell(_value: Any, row: Any, *, can_plain: bool) -> str: phone = _mask_phone( first_value(row, "patient_phone", "phone", default=""), can_plain=can_plain, ) extras = " ".join( part for part in ( gender_text(first_value(row, "gender", "gender_desc", default=None)), ( f"{first_value(row, 'age')}岁" if first_value(row, "age", default=None) not in (None, "") else "" ), ( f"{first_value(row, 'height')}cm" if first_value(row, "height", default=None) not in (None, "") else "" ), ( f"{first_value(row, 'weight')}kg" if first_value(row, "weight", default=None) not in (None, "") else "" ), ) if part and part != "—" ) return f"{display_text(first_value(row, 'patient_name'), '—')}\n{phone}" + ( f"\n{extras}" if extras else "" ) def _datetime_cell(_value: Any, row: Any) -> str: return ( f"{display_text(first_value(row, 'appointment_date'), '—')}\n" f"{display_text(first_value(row, 'appointment_time'), '—')}" ) def _confirmed_cell(_value: Any, row: Any) -> str: return ( "已确认" if _as_bool(first_value(row, "diagnosis_confirmed", default=False)) else "未确认" ) def _prescription_cell(_value: Any, row: Any) -> str: has_rx = _as_bool(first_value(row, "has_prescription", default=False)) label = "已开方" if has_rx else "未开方" if has_rx and _as_int(first_value(row, "prescription_is_system_auto"), 0) == 1: return f"{label}\n系统代开" return label def _status_cell(_value: Any, row: Any) -> str: return display_text( first_value(row, "status_desc"), str(first_value(row, "status", default="—")) ) def _gender_age_cell(_value: Any, row: Any) -> str: gender = gender_text(first_value(row, "gender", "gender_desc", default=None)) age = first_value(row, "age", default=None) age_text = f"{age}岁" if age not in (None, "") else "—" return f"{gender} · {age_text}" def _appointment_info_cell(_value: Any, row: Any) -> str: status = _status_cell(None, row) doctor = display_text(first_value(row, "doctor_name"), "未分配") date_text = display_text(first_value(row, "appointment_date"), "—") time_text = display_text(first_value(row, "appointment_time"), "—") channel = display_text( first_value(row, "channel_name", "channel_source_name", "source_name"), "—" ) return f"{status} {doctor}\n{date_text} {time_text}\n最近渠道:{channel}" def _revisit_cell(_value: Any, row: Any) -> str: revisit = first_value( row, "revisit_time", "review_time", "revisit_date", "review_date", default="", ) return display_text(revisit, "无") def _unserved_cell(_value: Any, row: Any) -> str: value = first_value( row, "unserved_days", "unservice_days", "not_served_days", default=None, ) return display_text(value, "—") def _offset_date(preset: str) -> date | None: today = date.today() offsets = { "yesterday": -1, "day_before": -2, "today": 0, "tomorrow": 1, "day_after": 2, } if preset not in offsets: return None return today + timedelta(days=offsets[preset]) def _flatten_departments( nodes: Sequence[Any], *, prefix: str = "", ) -> list[tuple[int, str]]: result: list[tuple[int, str]] = [] for node in nodes: if not isinstance(node, Mapping): continue dept_id = _as_int(first_value(node, "id", default=0)) name = str(first_value(node, "name", "label", default="")).strip() if dept_id <= 0 or not name: continue label = f"{prefix}{name}" if not prefix else f"{prefix} / {name}" result.append((dept_id, label if not prefix else f"{prefix}/{name}")) children = first_value(node, "children", "child", default=[]) if isinstance(children, Sequence) and not isinstance(children, (str, bytes)): child_prefix = name if not prefix else f"{prefix}/{name}" result.extend(_flatten_departments(children, prefix=child_prefix)) return result def _stable_signature_value(value: Any) -> Any: """Return a deterministic, order-independent representation of API data.""" if value is None or isinstance(value, (bool, int, str)): return value if isinstance(value, float): return repr(value) if isinstance(value, Mapping): items = ( (str(key), _stable_signature_value(item)) for key, item in value.items() ) return tuple(sorted(items, key=lambda pair: pair[0])) if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return tuple(_stable_signature_value(item) for item in value) if isinstance(value, (set, frozenset)): frozen = (_stable_signature_value(item) for item in value) return tuple(sorted(frozen, key=repr)) dataclass_fields = getattr(type(value), "__dataclass_fields__", None) if isinstance(dataclass_fields, Mapping): return ( type(value).__qualname__, tuple( (name, _stable_signature_value(getattr(value, name, None))) for name in dataclass_fields ), ) state = getattr(value, "__dict__", None) if isinstance(state, Mapping): return (type(value).__qualname__, _stable_signature_value(state)) isoformat = getattr(value, "isoformat", None) if callable(isoformat): try: return (type(value).__qualname__, isoformat()) except (TypeError, ValueError): pass return (type(value).__qualname__, str(value)) def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any) -> tuple[Any, ...]: """Build the stable render signature used to short-circuit unchanged polling.""" return ( "appointments-render-v1", total, _stable_signature_value(rows), _stable_signature_value(extend), ) class AppointmentsPage(QWidget): """Desktop appointment list with status tabs, filters, call and prescription.""" video_requested = Signal(dict) def __init__( self, repository: Any, permissions: Any = None, current_user: Any = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.setObjectName("AppointmentsPage") self.setStyleSheet(APPOINTMENTS_LIGHT_QSS) self.repository = repository self.permissions = permissions self.current_user = current_user self._page = 1 self._page_size = 15 self._generation = 0 self._dept_generation = 0 self._dict_generation = 0 self._action_generation = 0 self._prescription_generation = 0 self._loading = False self._mutation_pending = False self._prescription_busy = False self._status: str | int = 1 self._date_preset = "today" self._assistant_unassigned = False self._start_date = date.today().isoformat() self._end_date = self._start_date self._status_counts: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0} self._dict_labels: dict[str, dict[str, str]] = {} self._last_render_signature: tuple[Any, ...] | None = None self._responsive_narrow: bool | None = None self._can_plain_phone = _canonical_allowed( permissions, "tcm.diagnosis/phonePlain", default=False ) self._native_video_capable = _supports_native_video(repository) self._is_admin = _is_admin_user(current_user) root = QVBoxLayout(self) root.setContentsMargins(18, 3, 6, 8) root.setSpacing(4) # 其余列表页都有“面包屑 + 标题 + 副标题”,这一页此前把标题隐藏了, # 导致同一套列表页有两种页头形态。保留页头以对齐全局页面骨架。 self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。") root.addWidget(self.header) self.filter_panel = self._build_filter_panel() root.addWidget(self.filter_panel) self.banner = MessageBanner() root.addWidget(self.banner) self.content_host = self._build_content() root.addWidget(self.content_host, 1) self.poll_timer = QTimer(self) self.poll_timer.setInterval(LIST_POLL_MS) self.poll_timer.timeout.connect(lambda: self.refresh(silent=True)) self._apply_responsive_layout() def _diagnosis_dialog(self) -> DiagnosisDialog: dialog = getattr(self, "_diagnosis_dialog_impl", None) if dialog is None: dialog = DiagnosisDialog(self.repository, self, permissions=self.permissions) dialog.saved.connect(lambda: self.refresh(silent=True)) self._diagnosis_dialog_impl = dialog return dialog def _build_filter_panel(self) -> QWidget: frame = QFrame() frame.setObjectName("AppointmentFilterPanel") layout = QVBoxLayout(frame) layout.setContentsMargins(6, 4, 8, 6) layout.setSpacing(4) date_row = QHBoxLayout() date_row.setSpacing(4) self.date_buttons: dict[str, QPushButton] = {} self._date_stat_labels: dict[str, str] = {} for source_label, preset in DATE_PRESETS: label = "全部" if preset == "" else source_label button = QPushButton(f"{label} 0") button.setCheckable(True) button.setProperty("appointmentStat", True) button.setMinimumWidth(0) button.clicked.connect( lambda _checked=False, value=preset: self._set_date_preset(value) ) self.date_buttons[preset] = button self._date_stat_labels[preset] = label date_row.addWidget(button, 1) self.date_buttons["today"].setChecked(True) self.date_overflow_button = QPushButton("日期") self.date_overflow_button.setProperty("appointmentStat", True) date_menu = QMenu(self.date_overflow_button) for source_label, preset in DATE_PRESETS: action = date_menu.addAction(source_label if preset else "不限日期") action.triggered.connect( lambda _checked=False, value=preset: self._set_date_preset(value) ) date_menu.addSeparator() date_menu.addAction("自定义日期").triggered.connect(self._open_custom_date) self.date_overflow_button.setMenu(date_menu) date_row.addWidget(self.date_overflow_button) self.pending_stat_button = QPushButton("待预约 0") self.pending_stat_button.setProperty("appointmentStat", True) self.pending_stat_button.setMinimumWidth(0) self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1)) date_row.addWidget(self.pending_stat_button, 1) self.completed_stat_button = QPushButton("已完成 0") self.completed_stat_button.setProperty("appointmentStat", True) self.completed_stat_button.setMinimumWidth(0) self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3)) date_row.addWidget(self.completed_stat_button, 1) self.unassigned_stat_button = QPushButton("待分配医助 0") self.unassigned_stat_button.setCheckable(True) self.unassigned_stat_button.setProperty("appointmentStat", True) self.unassigned_stat_button.setProperty("appointmentStatKind", "warning") self.unassigned_stat_button.setMinimumWidth(0) self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter) date_row.addWidget(self.unassigned_stat_button, 1) self.patient_input = QLineEdit() self.patient_input.setObjectName("AppointmentPatientSearch") self.patient_input.setPlaceholderText("患者姓名 / 手机号") self.patient_input.setClearButtonEnabled(True) self.patient_input.returnPressed.connect(self._search) date_row.addWidget(self.patient_input, 2) search = QPushButton("查询") search.setProperty("variant", "primary") search.clicked.connect(self._search) date_row.addWidget(search) layout.addLayout(date_row) filter_row = QHBoxLayout() filter_row.setSpacing(5) self.status_filter_label = QLabel("挂号状态:") self.status_filter_label.setObjectName("FilterRowLabel") filter_row.addWidget(self.status_filter_label) self.tab_bar = QTabBar() self.tab_bar.setObjectName("AppointmentStatusTabs") self._tab_indexes: dict[str | int, int] = {} for label, value in STATUS_TABS: index = self.tab_bar.addTab(label) self.tab_bar.setTabData(index, value) self._tab_indexes[value] = index self.tab_bar.currentChanged.connect(self._tab_changed) filter_row.addWidget(self.tab_bar) self.filter_dividers: list[QLabel] = [] divider = QLabel("│") divider.setObjectName("FilterDivider") self.filter_dividers.append(divider) filter_row.addWidget(divider) self.confirm_filter_label = QLabel("确认状态:") self.confirm_filter_label.setObjectName("FilterRowLabel") filter_row.addWidget(self.confirm_filter_label) self.confirmed_filter = QComboBox(frame) self.confirmed_filter.addItem("全部", "") self.confirmed_filter.addItem("已确认", "1") self.confirmed_filter.addItem("未确认", "0") self.confirmed_filter.hide() self.confirmed_filter.currentIndexChanged.connect(self._confirmed_combo_changed) self.confirmed_group = QButtonGroup(self) self.confirmed_group.setExclusive(True) self.confirmed_buttons: dict[str, QPushButton] = {} for label, value in (("全部", ""), ("已确认", "1"), ("未确认", "0")): button = QPushButton(label) button.setCheckable(True) button.setProperty("filterChoice", True) button.clicked.connect( lambda _checked=False, selected=value: self._set_confirmed_choice(selected) ) self.confirmed_group.addButton(button) self.confirmed_buttons[value] = button filter_row.addWidget(button) self.confirmed_buttons[""].setChecked(True) divider = QLabel("│") divider.setObjectName("FilterDivider") self.filter_dividers.append(divider) filter_row.addWidget(divider) self.more_filters_button = QPushButton("更多筛选") self.more_filters_button.setCheckable(True) self.more_filters_button.setProperty("filterChoice", True) self.more_filters_button.clicked.connect(self._toggle_advanced_filters) filter_row.addWidget(self.more_filters_button) self.dept_filter = QComboBox() self.dept_filter.addItem("全部部门", "") self.dept_filter.setMinimumWidth(150) self.dept_filter.currentIndexChanged.connect(self._search) self.dept_filter.hide() filter_row.addWidget(self.dept_filter) self.doctor_input = QLineEdit(frame) self.doctor_input.setPlaceholderText("医生") self.doctor_input.setClearButtonEnabled(True) self.doctor_input.setMaximumWidth(120) self.doctor_input.hide() self.doctor_input.returnPressed.connect(self._search) filter_row.addWidget(self.doctor_input) filter_row.addStretch(1) custom = QPushButton("自定义日期") custom.setProperty("variant", "ghost") custom.clicked.connect(self._open_custom_date) custom.hide() self.custom_date_button = custom filter_row.addWidget(custom) reset = QPushButton("重置") reset.setProperty("variant", "ghost") reset.clicked.connect(self._reset_filters) reset.hide() self.reset_filter_button = reset filter_row.addWidget(reset) layout.addLayout(filter_row) return frame def _build_content(self) -> QWidget: host = QWidget() host.setMinimumHeight(0) layout = QHBoxLayout(host) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) self.content_layout = layout self.table_card = self._build_table_card() layout.addWidget(self.table_card, 1) return host def _build_table_card(self) -> QWidget: card = QFrame() card.setObjectName("AppointmentMainCard") card.setMinimumHeight(0) layout = QVBoxLayout(card) layout.setContentsMargins(4, 8, 8, 8) layout.setSpacing(8) compatibility_host = QWidget(card) compatibility_host.hide() compatibility_actions = QHBoxLayout(compatibility_host) compatibility_actions.setContentsMargins(0, 0, 0, 0) self.edit_button = self._action_button("编辑患者", "tcm.diagnosis/edit", self._edit_patient) compatibility_actions.addWidget(self.edit_button) self.qr_button = self._action_button( "视频二维码", "tcm.diagnosis/videoQr", self._request_video_qr ) compatibility_actions.addWidget(self.qr_button) self.call_button = self._action_button( "通话", "doctor.appointment/prescription", self._request_video ) compatibility_actions.addWidget(self.call_button) self.complete_button = self._action_button( "完成", "doctor.appointment/complete", self._complete_selected ) compatibility_actions.addWidget(self.complete_button) self.prescription_button = self._action_button( "开方", "tcm.diagnosis/kaifang", self._open_prescription ) compatibility_actions.addWidget(self.prescription_button) self.case_button = self._action_button("病历", "tcm.diagnosis/kaifang", self._view_case) compatibility_actions.addWidget(self.case_button) self.ai_button = QPushButton("AI 报告", compatibility_host) self.ai_button.setProperty("variant", "secondary") self.ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions)) self.ai_button.setEnabled(False) self.ai_button.clicked.connect(self._open_ai_report) compatibility_actions.addWidget(self.ai_button) self.cancel_button = self._action_button( "取消挂号", "doctor.appointment/cancel", self._cancel_selected, danger=True, ) compatibility_actions.addWidget(self.cancel_button) for index in range(compatibility_actions.count()): widget = compatibility_actions.itemAt(index).widget() if isinstance(widget, QPushButton): widget.setProperty("compactAction", True) actions = QHBoxLayout() actions.setSpacing(6) self.toolbar_edit_button = QPushButton("编辑患者", card) self.toolbar_edit_button.setProperty("variant", "primary") self.toolbar_edit_button.setProperty("compactAction", True) self.toolbar_edit_button.setVisible( _canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False) ) self.toolbar_edit_button.setEnabled(False) self.toolbar_edit_button.clicked.connect(self._edit_patient) actions.addWidget(self.toolbar_edit_button) self.toolbar_qr_button = QPushButton("视频二维码", card) self.toolbar_qr_button.setProperty("variant", "secondary") self.toolbar_qr_button.setProperty("compactAction", True) self.toolbar_qr_button.setVisible( _canonical_allowed(self.permissions, "tcm.diagnosis/videoQr", default=False) ) self.toolbar_qr_button.setEnabled(False) self.toolbar_qr_button.clicked.connect(self._request_video_qr) actions.addWidget(self.toolbar_qr_button) self.toolbar_ai_consult_button = QPushButton("AI 分析", card) self.toolbar_ai_consult_button.setProperty("variant", "secondary") self.toolbar_ai_consult_button.setProperty("compactAction", True) self.toolbar_ai_consult_button.setVisible(can_open_ai_consult(self.permissions)) self.toolbar_ai_consult_button.setEnabled(False) self.toolbar_ai_consult_button.clicked.connect(self._open_ai_consult) actions.addWidget(self.toolbar_ai_consult_button) self.toolbar_cancel_button = QPushButton("取消挂号", card) self.toolbar_cancel_button.setProperty("variant", "danger") self.toolbar_cancel_button.setProperty("compactAction", True) self.toolbar_cancel_button.setVisible( _canonical_allowed(self.permissions, "doctor.appointment/cancel", default=False) ) self.toolbar_cancel_button.setEnabled(False) self.toolbar_cancel_button.clicked.connect(self._cancel_selected) actions.addWidget(self.toolbar_cancel_button) actions.addStretch(1) refresh = QPushButton("刷新", card) refresh.setProperty("variant", "ghost") refresh.setProperty("compactAction", True) refresh.clicked.connect(lambda: self.refresh()) actions.addWidget(refresh) layout.addLayout(actions) self.table = SortableTable( [ TableColumn("_selected", "", 36, alignment=Qt.AlignmentFlag.AlignCenter), TableColumn("id", "ID", 66), TableColumn( "patient_name", "患者", 100, ), TableColumn("gender", "性别 / 年龄", 98, formatter=_gender_age_cell), TableColumn("appointment_date", "挂号信息", 202, formatter=_appointment_info_cell), TableColumn("diagnosis_confirmed", "确认", 90, formatter=_confirmed_cell), TableColumn("revisit_time", "复诊", 100, formatter=_revisit_cell), TableColumn("assistant_name", "助理", 110), TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell), TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell), TableColumn( "_im_consult", "IM 问诊", 104, alignment=Qt.AlignmentFlag.AlignCenter, ), ] ) self.table.setObjectName("AppointmentTable") self.table.setMinimumHeight(0) self.table.setWordWrap(True) self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) header = self.table.horizontalHeader() header.setFixedHeight(34) header.setStretchLastSection(False) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed) self.table.verticalHeader().setDefaultSectionSize(60) self.table.itemSelectionChanged.connect(self._selection_changed) self.table.itemDoubleClicked.connect(lambda _item: self._open_detail()) layout.addWidget(self.table, 1) self.pager = Pager(self._page_size) self.pager.setMaximumHeight(38) self.pager.page_changed.connect(self._page_changed) layout.addWidget(self.pager) return card def _apply_responsive_layout(self) -> None: """Collapse only the date filters when horizontal space is limited.""" narrow = self.width() < 1120 for preset in ("yesterday", "day_before", "tomorrow", "day_after"): self.date_buttons[preset].setVisible(not narrow) self.date_overflow_button.setVisible(narrow) self.status_filter_label.setVisible(not narrow) self.confirm_filter_label.setVisible(not narrow) self._responsive_narrow = narrow def resizeEvent(self, event: Any) -> None: super().resizeEvent(event) self._apply_responsive_layout() def _run_video_row_action(self, row: Any, callback: Any) -> None: appointment_id = _appointment_id(row) for row_index in range(self.table.rowCount()): cell = self.table.item(row_index, 0) if ( cell is not None and _appointment_id(cell.data(Qt.ItemDataRole.UserRole)) == appointment_id ): self.table.selectRow(row_index) break callback() def _action_button( self, label: str, permission: str, slot: Any, *, danger: bool = False, ) -> QPushButton: # Permission visibility is evaluated before the toolbar layout adopts # the button. Give it a real owner up front so setVisible(True) cannot # create a transient top-level HWND on Windows. button = QPushButton(label, self) button.setProperty("variant", "danger" if danger else "secondary") button.setVisible(_canonical_allowed(self.permissions, permission, default=False)) button.setEnabled(False) button.clicked.connect(slot) button.setProperty("permission", permission) return button def _set_status_from_stat(self, status: int) -> None: index = self._tab_indexes.get(status) if index is not None: self.tab_bar.setCurrentIndex(index) return self._status = status self._page = 1 self.refresh() def _toggle_unassigned_filter(self, checked: bool) -> None: self._assistant_unassigned = bool(checked) self._page = 1 self.refresh() def _set_confirmed_choice(self, value: str) -> None: index = self.confirmed_filter.findData(value) self.confirmed_filter.setCurrentIndex(max(0, index)) def _confirmed_combo_changed(self, _index: int = 0) -> None: value = str(self.confirmed_filter.currentData() or "") for key, button in self.confirmed_buttons.items(): button.setChecked(key == value) self._search() def _toggle_advanced_filters(self, checked: bool) -> None: self.dept_filter.setVisible(checked) self.doctor_input.setVisible(checked and self._is_admin) self.custom_date_button.setVisible(checked) self.reset_filter_button.setVisible(checked) def _tab_changed(self, index: int) -> None: if index < 0: return value = self.tab_bar.tabData(index) self._status = "all" if value == "all" else _as_int(value, 1) self._page = 1 self.refresh() def _set_date_preset(self, preset: str) -> None: self._date_preset = preset for key, button in self.date_buttons.items(): button.setChecked(key == preset) overflow_labels = { "yesterday": "昨天", "day_before": "前天", "tomorrow": "明天", "day_after": "后天", } self.date_overflow_button.setText(overflow_labels.get(preset, "日期")) target = _offset_date(preset) if target is None: self._start_date = "" self._end_date = "" else: text = target.isoformat() self._start_date = text self._end_date = text self._page = 1 self.refresh() def _open_custom_date(self) -> None: dialog = QDialog(self) dialog.setWindowTitle("自定义日期") dialog.resize(440, 300) layout = QVBoxLayout(dialog) layout.setContentsMargins(24, 22, 24, 18) layout.setSpacing(12) title = QLabel("自定义日期") title.setProperty("dialogRole", "title") layout.addWidget(title) start = QDateEdit( QDate.fromString(self._start_date or date.today().isoformat(), "yyyy-MM-dd") ) start.setCalendarPopup(True) start.setDisplayFormat("yyyy-MM-dd") end = QDateEdit(QDate.fromString(self._end_date or date.today().isoformat(), "yyyy-MM-dd")) end.setCalendarPopup(True) end.setDisplayFormat("yyyy-MM-dd") layout.addWidget(QLabel("开始日期")) layout.addWidget(start) layout.addWidget(QLabel("结束日期")) layout.addWidget(end) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok) primary_button.setText("应用日期") primary_button.setProperty("variant", "primary") buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) mark_business_dialog(dialog, "AppointmentCustomDateDialog") if dialog.exec() != QDialog.DialogCode.Accepted: return start_date = start.date().toString("yyyy-MM-dd") end_date = end.date().toString("yyyy-MM-dd") if start_date > end_date: show_toast(self, "开始日期不能晚于结束日期。", "warning") return self._date_preset = "" for button in self.date_buttons.values(): button.setChecked(False) self.date_buttons[""].setChecked(True) self.date_overflow_button.setText("自定义") self._start_date = start_date self._end_date = end_date self._page = 1 self.refresh() def _search(self) -> None: self._page = 1 self.refresh() def _reset_filters(self) -> None: self.patient_input.clear() self.doctor_input.clear() self._assistant_unassigned = False self.unassigned_stat_button.setChecked(False) self.confirmed_filter.setCurrentIndex(0) self.dept_filter.setCurrentIndex(0) self._status = 1 index = self._tab_indexes.get(1, 0) self.tab_bar.blockSignals(True) self.tab_bar.setCurrentIndex(index) self.tab_bar.blockSignals(False) self._set_date_preset("today") def _page_changed(self, page: int) -> None: self._page = max(1, page) self.refresh() def _query_filters(self) -> dict[str, Any]: filters: dict[str, Any] = { "include_status_counts": 1, "patient_name": self.patient_input.text().strip(), } if self._status != "all": filters["status"] = self._status if self._start_date: filters["start_date"] = self._start_date if self._end_date: filters["end_date"] = self._end_date if not self._start_date and not self._end_date: filters["diag_scope_relax"] = 1 confirmed = self.confirmed_filter.currentData() if confirmed not in (None, ""): filters["diagnosis_confirmed"] = confirmed dept_id = self.dept_filter.currentData() if dept_id not in (None, ""): filters["assistant_dept_id"] = dept_id if self._is_admin: doctor_name = self.doctor_input.text().strip() if doctor_name: filters["doctor_name"] = doctor_name if self._assistant_unassigned: filters["assistant_unassigned"] = 1 return filters def refresh(self, *, silent: bool = False) -> None: if self._loading and not silent: return self._generation += 1 generation = self._generation self._loading = True if not silent: self.banner.show_message("正在加载挂号列表…", "info") filters = self._query_filters() page = self._page page_size = self._page_size run_async( lambda: invoke( self.repository, "list_appointments", page_no=page, page_size=page_size, **filters, ), on_success=lambda result: self._loaded(result, generation, silent), on_error=lambda error: self._load_error(error, generation, silent), on_finished=lambda: self._load_finished(generation), ) def _loaded(self, result: Any, generation: int, silent: bool) -> None: if generation != self._generation: return rows = page_items(result) total = page_total(result) extend = get_value(result, "extend", {}) or {} signature = ( self._page, _stable_signature_value(self._query_filters()), *_appointment_result_signature(rows, total, extend), ) if signature == self._last_render_signature: if not silent: self.banner.clear() return self._last_render_signature = signature status_count = get_value(extend, "status_count", {}) or {} if isinstance(status_count, Mapping): for key in (1, 2, 3, 4): value = status_count.get(key, status_count.get(str(key))) if isinstance(value, (int, float)): self._status_counts[key] = int(value) self._update_tab_badges() self._update_stat_chips(rows, total, extend) self.table.set_rows(rows) self._install_table_selectors() self._install_appointment_info_cells() self._install_im_consult_actions() self._fit_table_rows() for row_index in range(self.table.rowCount()): source_item = self.table.item(row_index, 0) source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None confirmed_kind = ( "success" if _as_bool(first_value(source, "diagnosis_confirmed", default=False)) else "warning" ) prescription_kind = ( "success" if _as_bool(first_value(source, "has_prescription", default=False)) else "muted" ) for column, kind in ( (5, confirmed_kind), (8, prescription_kind), ): item = self.table.item(row_index, column) if item is not None: item.setForeground(QBrush(QColor(_SEMANTIC_COLORS[kind]))) self.pager.update_state(self._page, total) if not silent: self.banner.clear() if rows and self.table.currentRow() < 0: self.table.selectRow(0) self._selection_changed() def _update_stat_chips( self, rows: Sequence[Any], total: int, extend: Any, ) -> None: date_counts = first_value(extend, "date_counts", "date_count", default={}) if not isinstance(date_counts, Mapping): date_counts = {} visible_dates: dict[str, int] = {} for row in rows: key = str(first_value(row, "appointment_date", default="") or "") if key: visible_dates[key] = visible_dates.get(key, 0) + 1 for preset, button in self.date_buttons.items(): label = self._date_stat_labels[preset] if preset == "": count = total else: target = _offset_date(preset) date_key = target.isoformat() if target is not None else "" raw = date_counts.get( preset, date_counts.get(date_key, visible_dates.get(date_key, 0)), ) count = _as_int(raw) button.setText(f"{label} {count}") self.pending_stat_button.setText(f"待预约 {self._status_counts.get(1, 0)}") self.completed_stat_button.setText(f"已完成 {self._status_counts.get(3, 0)}") unassigned = first_value( extend, "unassigned_count", "assistant_unassigned_count", default=None, ) if unassigned is None: unassigned = sum( 1 for row in rows if _as_int(first_value(row, "assistant_id", default=0)) <= 0 and not str(first_value(row, "assistant_name", default="") or "").strip() ) unassigned_count = _as_int(unassigned) self.unassigned_stat_button.setText(f"待分配医助 {unassigned_count}") # 只有真的有待办时才点亮,避免筛选栏常态就是一排彩色。 self.unassigned_stat_button.setProperty("hasPending", unassigned_count > 0) style = self.unassigned_stat_button.style() style.unpolish(self.unassigned_stat_button) style.polish(self.unassigned_stat_button) def _install_table_selectors(self) -> None: for row_index in range(self.table.rowCount()): host = QWidget(self.table) layout = QHBoxLayout(host) layout.setContentsMargins(0, 0, 0, 0) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) selector = QCheckBox(host) selector.setProperty("appointmentSelector", True) selector.setToolTip("选择患者") selector.clicked.connect( lambda checked=False, index=row_index: ( self.table.selectRow(index) if checked else None ) ) layout.addWidget(selector) self.table.setCellWidget(row_index, 0, host) item = self.table.item(row_index, 0) if item is not None: item.setText("") def _install_appointment_info_cells(self) -> None: for row_index in range(self.table.rowCount()): source_item = self.table.item(row_index, 0) row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None host = QWidget(self.table) host.setProperty("appointmentInfoHost", True) host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) layout = QVBoxLayout(host) layout.setContentsMargins(7, 4, 5, 3) layout.setSpacing(1) heading = QHBoxLayout() heading.setContentsMargins(0, 0, 0, 0) heading.setSpacing(5) status_text = _status_cell(None, row) status = QLabel(status_text, host) status.setProperty("tableAppointmentStatus", True) status.setProperty( "tableAppointmentStatusKind", "warning" if "挂号" in status_text or _status_value(row) == 1 else "primary", ) heading.addWidget(status) doctor = QLabel(display_text(first_value(row, "doctor_name"), "未分配"), host) heading.addWidget(doctor) heading.addStretch(1) if _status_value(row) == 1 and _canonical_allowed( self.permissions, "doctor.appointment/cancel", default=False ): cancel = QPushButton("取消", host) cancel.setProperty("tableCancelAction", True) cancel.clicked.connect( lambda _checked=False, source=row: self._run_video_row_action( source, self._cancel_selected ) ) heading.addWidget(cancel) layout.addLayout(heading) date_label = QLabel( f"{display_text(first_value(row, 'appointment_date'), '—')} " f"{display_text(first_value(row, 'appointment_time'), '—')}", host, ) date_label.setProperty("tableAppointmentMeta", True) layout.addWidget(date_label) channel = display_text( first_value(row, "channel_name", "channel_source_name", "source_name"), "—", ) channel_label = QLabel(f"最近渠道:{channel}", host) channel_label.setProperty("tableAppointmentMeta", True) layout.addWidget(channel_label) self.table.setCellWidget(row_index, 4, host) patient = self.table.item(row_index, 2) if patient is not None: font = patient.font() font.setBold(True) patient.setFont(font) def _install_im_consult_actions(self) -> None: """Render a doctor-initiated IM entry for every appointment row.""" action_column = 10 has_permission = _canonical_allowed( self.permissions, "doctor.appointment/prescription", default=False, ) for row_index in range(self.table.rowCount()): source_item = self.table.item(row_index, 0) row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None host = QWidget(self.table) host.setProperty("appointmentImHost", True) host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) layout = QHBoxLayout(host) layout.setContentsMargins(5, 0, 5, 0) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) button = QPushButton("IM 问诊", host) button.setObjectName("AppointmentImConsultButton") button.setProperty("appointmentImAction", True) patient_name = display_text(first_value(row, "patient_name"), "患者") button.setAccessibleName(f"与{patient_name}进行 IM 问诊") identifiers_ready = ( _appointment_id(row) > 0 and _diagnosis_id(row) > 0 and _video_patient_id(row) > 0 ) status_error = _im_consult_status_error(row) enabled = ( has_permission and self._native_video_capable and identifiers_ready and not status_error ) button.setEnabled(enabled) if not has_permission: tooltip = "当前账号无 IM 问诊权限" elif not self._native_video_capable: tooltip = "当前工作站未配置完整的视频问诊能力" elif not identifiers_ready: tooltip = "患者、诊单或挂号标识不完整" elif status_error: tooltip = status_error else: tooltip = "打开患者 IM,可发送消息并从会话中发起视频" button.setToolTip(tooltip) button.clicked.connect( lambda _checked=False, source=row: self._run_video_row_action( source, self._request_video, ) ) layout.addWidget(button) self.table.setCellWidget(row_index, action_column, host) item = self.table.item(row_index, action_column) if item is not None: item.setText("") def _fit_table_rows(self) -> None: """Give each appointment row enough height for its real line count.""" line_height = max(16, self.table.fontMetrics().lineSpacing()) for row_index in range(self.table.rowCount()): line_count = max( ( (item.text().count("\n") + 1) for column in range(self.table.columnCount()) if (item := self.table.item(row_index, column)) is not None ), default=1, ) height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING self.table.setRowHeight(row_index, max(60, min(66, height))) def _load_error(self, error: Exception, generation: int, silent: bool) -> None: if generation != self._generation: return if silent: return self.banner.show_message(friendly_error(error), "danger") def _load_finished(self, generation: int) -> None: if generation == self._generation: self._loading = False def _update_tab_badges(self) -> None: for label, value in STATUS_TABS: index = self._tab_indexes.get(value) if index is None: continue if value == "all": self.tab_bar.setTabText(index, label) continue count = self._status_counts.get(_as_int(value), 0) text = f"{label} ({count})" if count else label self.tab_bar.setTabText(index, text) def _selection_changed(self) -> None: row = self.table.current_data() has_row = row is not None status = _status_value(row) if has_row else 0 not_completed = has_row and status != 3 self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0) self.qr_button.setEnabled( not_completed and _video_patient_id(row) > 0 and _as_int(first_value(row, "doctor_id", default=0)) > 0 ) self.call_button.setEnabled( has_row and not _im_consult_status_error(row) and self._native_video_capable and _appointment_id(row) > 0 and _diagnosis_id(row) > 0 and _video_patient_id(row) > 0 ) self.complete_button.setEnabled(not_completed and _appointment_id(row) > 0) self.prescription_button.setEnabled(has_row and _diagnosis_id(row) > 0) self.prescription_button.setText(prescription_action_label(row) if has_row else "开方") self.case_button.setEnabled(has_row and _appointment_id(row) > 0) self.ai_button.setEnabled(has_row and _diagnosis_id(row) > 0) self.toolbar_ai_consult_button.setEnabled( has_row and can_open_ai_consult(self.permissions) and _diagnosis_id(row) > 0 ) self.cancel_button.setEnabled(has_row and status == 1) self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0) self.toolbar_qr_button.setEnabled( not_completed and _video_patient_id(row) > 0 and _as_int(first_value(row, "doctor_id", default=0)) > 0 ) self.toolbar_cancel_button.setEnabled(has_row and status == 1) def _current_row(self) -> Any | None: return self.table.current_data() def _edit_patient(self) -> None: if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False): return row = self._current_row() diagnosis_id = _diagnosis_id(row) if diagnosis_id <= 0: show_toast(self, "该预约没有关联诊单信息。", "warning") return self._diagnosis_dialog().open_for(diagnosis_id, editable=True, seed=row) def _open_ai_report(self) -> None: if not can_open_diagnosis_ai_report(self.permissions): show_toast(self, "当前账号没有查看 AI 报告权限。", "warning") return row = self._current_row() diagnosis_id = _diagnosis_id(row) if diagnosis_id <= 0: show_toast(self, "该预约没有关联诊单信息。", "warning") return payload = dict(row) if isinstance(row, Mapping) else {} payload.update( { "id": diagnosis_id, "diagnosis_id": diagnosis_id, "patient_name": first_value(row, "patient_name", default="患者"), } ) present_diagnosis_ai_report(self.repository, self.permissions, self, payload) def open_selected_ai_consult(self) -> bool: """Open the diagnosis-scoped assistant for the selected appointment.""" row = self._current_row() diagnosis_id = _diagnosis_id(row) if ( row is None or diagnosis_id <= 0 or not can_open_ai_consult(self.permissions) ): return False present_ai_consult( self.repository, self.permissions, self, diagnosis_id=diagnosis_id, patient_id=_video_patient_id(row), seed=row, source_title="问诊列表", ) return True def _open_ai_consult(self) -> None: if not self.open_selected_ai_consult(): show_toast(self, "请先选择一条有关联诊单的问诊记录。", "warning") def _request_video(self) -> None: if not _canonical_allowed( self.permissions, "doctor.appointment/prescription", default=False ): return if not self._native_video_capable: show_toast(self, "当前工作站未配置完整的视频问诊能力。", "warning") return row = self._current_row() if row is None: return status_error = _im_consult_status_error(row) if status_error: show_toast(self, f"{status_error}。", "warning") return appointment_id = _appointment_id(row) diagnosis_id = _diagnosis_id(row) patient_id = _video_patient_id(row) if appointment_id <= 0 or diagnosis_id <= 0 or patient_id <= 0: show_toast(self, "患者、诊单或挂号标识不完整,无法进入 IM 问诊。", "warning") return self.video_requested.emit( { "source": "appointments", "appointment_id": appointment_id, "patient_id": patient_id, "diagnosis_id": diagnosis_id, "patient_name": first_value(row, "patient_name", default="患者"), # Open IM first. The doctor explicitly starts video from the # conversation instead of requiring an already-live call. "mode": "im", "record": row, } ) def _request_video_qr(self) -> None: if not _canonical_allowed(self.permissions, "tcm.diagnosis/videoQr", default=False): return required_methods = ( "get_mini_program_config", "generate_video_qrcode", "download_public_image", ) if not all(callable(getattr(self.repository, name, None)) for name in required_methods): show_toast(self, "当前仓库未提供视频二维码能力。", "warning") return row = self._current_row() if row is None or _status_value(row) == 3: return diagnosis_id = _diagnosis_id(row) patient_id = _video_patient_id(row) doctor_id = _as_int(first_value(row, "doctor_id", default=0)) share_user_id = _as_int(first_value(self.current_user, "id", default=0)) if diagnosis_id <= 0 or patient_id <= 0 or doctor_id <= 0 or share_user_id <= 0: show_toast(self, "患者或不完整的医生/用户标识,无法生成二维码。", "warning") return snapshot = deepcopy(row) self._action_generation += 1 generation = self._action_generation self.banner.show_message("正在生成视频二维码…", "info") def _worker() -> dict[str, Any]: config = invoke(self.repository, "get_mini_program_config") if not str(first_value(config, "app_id", default="") or "").strip(): raise ValueError("小程序未配置,请先在管理后台配置小程序信息") generated = invoke( self.repository, "generate_video_qrcode", diagnosis_id=diagnosis_id, doctor_id=doctor_id, patient_id=patient_id, share_user_id=share_user_id, ) if not isinstance(generated, Mapping): raise ValueError("服务器未返回有效的二维码结果") result = dict(generated) url = str(result.get("qrcode_url") or "").strip() if not url: raise ValueError("服务器未返回可用的二维码地址") try: result["_image_bytes"] = invoke(self.repository, "download_public_image", url=url) except Exception as exc: result["_image_error"] = friendly_error(exc) return result run_async( _worker, on_success=lambda result: self._show_qr(snapshot, result, generation), on_error=lambda error: self._action_error(error, generation), ) def _show_qr(self, row: Any, result: Any, generation: int) -> None: if generation != self._action_generation: return url = str(first_value(result, "qrcode_url", default="") or "").strip() if not url: self.banner.show_message("服务器未返回可用的二维码地址。", "danger") return self.banner.clear() dialog = self._build_qr_dialog(row, url, result) dialog.exec() def _build_qr_dialog(self, row: Any, url: str, result: Any) -> QDialog: """Build an in-app QR preview with external-open and retry fallbacks.""" dialog = QDialog(self) dialog.setWindowTitle("小程序二维码") dialog.setObjectName("VideoQrDialog") dialog.resize(470, 590) layout = QVBoxLayout(dialog) layout.setContentsMargins(24, 22, 24, 18) layout.setSpacing(12) title = QLabel("视频问诊二维码") title.setProperty("dialogRole", "title") title.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title) message = QLabel( f"患者:{display_text(first_value(row, 'patient_name'), '患者')}\n" "请使用微信扫描下方二维码进入视频问诊" ) message.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(message) image_label = QLabel() image_label.setObjectName("VideoQrImage") image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) image_label.setWordWrap(True) image_label.setFixedSize(300, 300) image_bytes = first_value(result, "_image_bytes", default=b"") pixmap = QPixmap() loaded = isinstance(image_bytes, (bytes, bytearray)) and pixmap.loadFromData( bytes(image_bytes) ) if loaded: image_label.setPixmap( pixmap.scaled( 280, 280, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) ) else: reason = str(first_value(result, "_image_error", default="") or "").strip() image_label.setText( "二维码图片加载失败\n请重新生成或在浏览器中打开" + (f"\n{reason}" if reason else "") ) image_row = QHBoxLayout() image_row.addStretch(1) image_row.addWidget(image_label) image_row.addStretch(1) layout.addLayout(image_row) link = QLabel(f'打开服务器二维码') link.setOpenExternalLinks(True) link.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(link) field = QLineEdit(url) field.setObjectName("VideoQrUrl") field.setReadOnly(True) layout.addWidget(field) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) retry_button = buttons.addButton("重新生成", QDialogButtonBox.ButtonRole.ActionRole) open_button = buttons.addButton("浏览器打开", QDialogButtonBox.ButtonRole.ActionRole) retry_button.setProperty("variant", "secondary") open_button.setProperty("variant", "primary") open_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromUserInput(url))) retry_button.clicked.connect(dialog.accept) retry_button.clicked.connect(lambda: QTimer.singleShot(0, self._request_video_qr)) buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭") buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) mark_business_dialog(dialog, "VideoQrDialog") for button in (retry_button, open_button): button.style().unpolish(button) button.style().polish(button) return dialog def _complete_selected(self) -> None: if not _canonical_allowed(self.permissions, "doctor.appointment/complete", default=False): return row = self._current_row() appointment_id = _appointment_id(row) if appointment_id <= 0 or self._mutation_pending: return can_note = _canonical_allowed( self.permissions, "doctor.appointment/addDoctorNote", default=False ) note = "" if can_note: dialog = QDialog(self) dialog.setWindowTitle("完成问诊") dialog.resize(500, 360) layout = QVBoxLayout(dialog) layout.setContentsMargins(24, 22, 24, 18) layout.setSpacing(12) title = QLabel("完成问诊") title.setProperty("dialogRole", "title") layout.addWidget(title) prompt = QLabel("是否添加医生备注?") prompt.setProperty("dialogRole", "subtitle") layout.addWidget(prompt) editor = QTextEdit() editor.setPlaceholderText("填写完成备注,将追加到医生备注时间轴") editor.setMaximumHeight(120) layout.addWidget(editor) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok) primary_button.setText("确认完成") primary_button.setProperty("variant", "primary") buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) mark_business_dialog(dialog, "AppointmentCompleteDialog") if dialog.exec() != QDialog.DialogCode.Accepted: return note = editor.toPlainText().strip()[:NOTE_LIMIT] else: answer = QMessageBox.question( self, "完成问诊", "确认完成该挂号吗?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel, ) if answer != QMessageBox.StandardButton.Yes: return diagnosis_id = _diagnosis_id(row) snapshot_note = note self._mutation_pending = True self._action_generation += 1 generation = self._action_generation self.banner.show_message("正在完成问诊…", "info") def _worker() -> str: invoke(self.repository, "complete_appointment", appointment_id=appointment_id) if snapshot_note and diagnosis_id > 0: try: invoke( self.repository, "add_doctor_note", diagnosis_id=diagnosis_id, content=snapshot_note, ) except Exception: return "问诊已完成,但备注未保存" elif snapshot_note and diagnosis_id <= 0: return "问诊已完成,但该预约没有关联诊单,备注未保存" return "" run_async( _worker, on_success=lambda warning: self._complete_done(warning, generation), on_error=lambda error: self._action_error(error, generation), on_finished=lambda: self._mutation_finished(generation), ) def _complete_done(self, warning: str, generation: int) -> None: if generation != self._action_generation: return if warning: self.banner.show_message(warning, "warning") else: self.banner.show_message("操作成功。", "success") self.refresh(silent=True) def _cancel_selected(self) -> None: if not _canonical_allowed(self.permissions, "doctor.appointment/cancel", default=False): return row = self._current_row() appointment_id = _appointment_id(row) if appointment_id <= 0 or _status_value(row) != 1 or self._mutation_pending: return answer = QMessageBox.question( self, "取消挂号", "确定要取消该挂号吗?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel, ) if answer != QMessageBox.StandardButton.Yes: return self._mutation_pending = True self._action_generation += 1 generation = self._action_generation self.banner.show_message("正在取消挂号…", "info") run_async( lambda: invoke( self.repository, "cancel_diagnosis_appointment", appointment_id=appointment_id, ), on_success=lambda _result: self._cancel_done(generation), on_error=lambda error: self._action_error(error, generation), on_finished=lambda: self._mutation_finished(generation), ) def _cancel_done(self, generation: int) -> None: if generation != self._action_generation: return self.banner.show_message("取消成功。", "success") self.refresh(silent=True) def _mutation_finished(self, generation: int) -> None: if generation == self._action_generation: self._mutation_pending = False self._selection_changed() def _action_error(self, error: Exception, generation: int) -> None: if generation == self._action_generation: self.banner.show_message(friendly_error(error), "danger") def _open_prescription(self) -> None: if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False): return row = self._current_row() if _diagnosis_id(row) <= 0 or _appointment_id(row) <= 0: show_toast(self, "该预约缺少诊单信息,请先编辑患者。", "warning") return mode = "view" if prescription_action_label(row) == "查看" else "open" self._begin_prescription_load(row, mode=mode) def _view_case(self) -> None: if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False): return row = self._current_row() appointment_id = _appointment_id(row) if appointment_id <= 0: show_toast(self, "预约信息不完整。", "warning") return self._prescription_generation += 1 generation = self._prescription_generation self._prescription_busy = True self.banner.show_message("正在加载病历处方…", "info") run_async( lambda: invoke( self.repository, "get_prescription_by_appointment", appointment_id=appointment_id, ), on_success=lambda existing: self._case_loaded(existing, generation), on_error=lambda error: self._prescription_error(error, generation), on_finished=lambda: self._prescription_finished(generation), ) def _case_loaded(self, existing: Any, generation: int) -> None: if generation != self._prescription_generation: return self.banner.clear() if existing is None or _as_int(first_value(existing, "id", default=0)) <= 0: show_toast(self, "该预约暂无病历记录,请先开方。", "warning") return dialog = PrescriptionDetailDialog( existing, initial_tab="case", can_open_diagnosis=_canonical_allowed( self.permissions, "tcm.diagnosis/readonlyDetail", default=False ), parent=self, ) dialog.diagnosis_requested.connect(self._open_diagnosis_id) dialog.exec() def _begin_prescription_load(self, row: Any, *, mode: str = "open") -> None: snapshot = deepcopy(row) self._prescription_generation += 1 generation = self._prescription_generation self._prescription_busy = True self.prescription_button.setEnabled(False) self.banner.show_message("正在核对诊单处方上下文…", "info") appointment_id = _appointment_id(snapshot) run_async( lambda: invoke( self.repository, "get_prescription_by_appointment", appointment_id=appointment_id, ), on_success=lambda existing: self._prescription_loaded( existing, snapshot, generation, mode, ), on_error=lambda error: self._prescription_error(error, generation), on_finished=lambda: self._prescription_finished(generation), ) def _prescription_loaded( self, existing: Any, row: Any, generation: int, mode: str = "open", ) -> None: if generation != self._prescription_generation: return self.banner.clear() if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0: approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1 voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1 actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0)) expected_id = _as_int( first_value(row, "current_prescription_id", default=0) ) actual_appointment_id = _as_int( first_value(existing, "appointment_id", default=0) ) expected_appointment_id = _appointment_id(row) identity_changed = ( (expected_id > 0 and actual_id != expected_id) or ( actual_appointment_id > 0 and expected_appointment_id > 0 and actual_appointment_id != expected_appointment_id ) ) if mode == "view" and (not approved or voided or identity_changed): self.banner.show_message( "当前挂号的处方状态已变化,请刷新列表后重试。", "warning", ) return if not approved or voided: self._open_existing_prescription_editor(existing) else: dialog = PrescriptionDetailDialog( existing, can_open_diagnosis=_canonical_allowed( self.permissions, "tcm.diagnosis/readonlyDetail", default=False ), parent=self, ) dialog.diagnosis_requested.connect(self._open_diagnosis_id) dialog.exec() return if mode == "view": self.banner.show_message( "当前挂号没有可查看的处方,请刷新列表后重试。", "warning", ) return self._begin_case_record_load(row) def _open_existing_prescription_editor(self, prescription: Any) -> None: prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0)) if prescription_id <= 0: self.banner.show_message("处方编号不完整,无法编辑。", "danger") return dialog = PrescriptionEditorDialog( self.repository, prescription, mode="edit", current_user=self.current_user, parent=self, ) diagnosis_signal = getattr(dialog, "diagnosis_requested", None) if diagnosis_signal is not None: diagnosis_signal.connect(self._open_diagnosis_id) if dialog.exec() != QDialog.DialogCode.Accepted: return frozen_payload = MappingProxyType(dialog.payload()) self._mutation_pending = True self._action_generation += 1 generation = self._action_generation self.banner.show_message("正在保存处方…", "info") run_async( lambda: invoke( self.repository, "update_prescription", prescription=prescription_id, changes=frozen_payload, ), on_success=lambda _result: self._prescription_updated(generation), on_error=lambda error: self._action_error(error, generation), on_finished=lambda: self._mutation_finished(generation), ) def _prescription_updated(self, generation: int) -> None: if generation != self._action_generation: return self.banner.show_message("处方已保存并重新进入待审核。", "success") self.refresh(silent=True) def _begin_case_record_load(self, row: Any) -> None: snapshot = deepcopy(row) diagnosis_id = _diagnosis_id(snapshot) self._prescription_generation += 1 generation = self._prescription_generation self._prescription_busy = True self.prescription_button.setEnabled(False) self.banner.show_message("正在生成不可变病例快照…", "info") run_async( lambda: invoke( self.repository, "get_diagnosis_detail", diagnosis_id=diagnosis_id, ), on_success=lambda detail: self._case_record_loaded(detail, snapshot, generation), on_error=lambda error: self._prescription_error(error, generation), on_finished=lambda: self._prescription_finished(generation), ) def _case_record_loaded(self, detail: Any, row: Any, generation: int) -> None: if generation != self._prescription_generation: return self.banner.clear() actual_id = _as_int(first_value(detail, "id", "diagnosis_id", default=0)) expected = _diagnosis_id(row) if actual_id != expected: self.banner.show_message("服务端诊单与当前行不一致,已停止开方。", "danger") return self._open_prescription_editor(row, deepcopy(detail)) def _prescription_seed(self, record: Any, case_record: Any) -> dict[str, Any]: diagnosis = get_value(case_record, "diagnosis", None) or case_record or {} patient = get_value(case_record, "patient", None) or {} def authoritative(*keys: str, default: Any = None) -> Any: return first_value( diagnosis, *keys, default=first_value( patient, *keys, default=first_value(record, *keys, default=default) ), ) gender = authoritative("gender", default=1) if _as_int(gender, 1) == 2: gender = 0 diagnosis_id = _diagnosis_id(record) appointment_id = _appointment_id(record) return { "diagnosis_id": diagnosis_id, "appointment_id": appointment_id, "case_record": deepcopy(case_record), "patient_name": authoritative("patient_name", "name", default=""), "phone": authoritative("phone", "patient_phone", default=""), "gender": gender, "age": authoritative("age", default=None), "visit_no": build_prescription_visit_no( diagnosis_id=diagnosis_id, appointment_id=appointment_id ), "tongue": authoritative("tongue", "tongue_coating", default=""), "tongue_image": authoritative("tongue_image", default=""), "pulse": authoritative("pulse", default=""), "pulse_condition": authoritative("pulse_condition", default=""), "clinical_diagnosis": build_prescription_clinical_diagnosis( diagnosis, patient, record, case_record ), "doctor_name": first_value( self.current_user, "name", "real_name", default=first_value(record, "doctor_name") ), } def _open_diagnosis_id(self, diagnosis_id: int) -> None: if diagnosis_id > 0: self._diagnosis_dialog().open_view_only(diagnosis_id) def _open_prescription_editor(self, record: Any, case_record: Any) -> None: seed = self._prescription_seed(record, case_record) dialog = PrescriptionEditorDialog( self.repository, seed, mode="add", current_user=self.current_user, parent=self, ) diagnosis_signal = getattr(dialog, "diagnosis_requested", None) if diagnosis_signal is not None: diagnosis_signal.connect(self._open_diagnosis_id) if dialog.exec() != QDialog.DialogCode.Accepted: return payload = dialog.payload() payload["diagnosis_id"] = seed["diagnosis_id"] payload["appointment_id"] = seed["appointment_id"] payload["case_record"] = deepcopy(case_record) frozen_payload = MappingProxyType(payload) self._mutation_pending = True self._action_generation += 1 generation = self._action_generation self.banner.show_message("正在提交处方…", "info") run_async( lambda: invoke(self.repository, "create_prescription", prescription=frozen_payload), on_success=lambda _result: self._prescription_created(generation), on_error=lambda error: self._action_error(error, generation), on_finished=lambda: self._mutation_finished(generation), ) def _prescription_created(self, generation: int) -> None: if generation != self._action_generation: return self.banner.show_message("处方已开具并提交审核。", "success") self.refresh(silent=True) def _prescription_error(self, error: Exception, generation: int) -> None: if generation == self._prescription_generation: self.banner.show_message(friendly_error(error), "danger") def _prescription_finished(self, generation: int) -> None: if generation == self._prescription_generation: self._prescription_busy = False self._selection_changed() def _open_detail(self) -> None: row = self._current_row() appointment_id = _appointment_id(row) if appointment_id <= 0: return if not callable(getattr(self.repository, "get_appointment_detail", None)): show_toast(self, "当前仓库未提供挂号详情能力。", "warning") return self._action_generation += 1 generation = self._action_generation diagnosis_id = _diagnosis_id(row) self.banner.show_message("正在加载挂号详情…", "info") def _worker() -> dict[str, Any]: detail = dict( invoke(self.repository, "get_appointment_detail", appointment_id=appointment_id) ) target = diagnosis_id or _as_int(detail.get("patient_id") or detail.get("diagnosis_id")) if target > 0 and callable(getattr(self.repository, "get_diagnosis_detail", None)): try: diagnosis = invoke( self.repository, "get_diagnosis_detail", diagnosis_id=target, ) if isinstance(diagnosis, Mapping): merged = dict(detail) merged.update(dict(diagnosis)) return merged except Exception: return detail return detail run_async( _worker, on_success=lambda detail: self._show_detail(detail, generation), on_error=lambda error: self._action_error(error, generation), ) def _show_detail(self, detail: Mapping[str, Any], generation: int) -> None: if generation != self._action_generation: return self.banner.clear() dialog = QDialog(self) dialog.setWindowTitle("挂号详情") dialog.setObjectName("AppointmentDetailDialog") dialog.resize(720, 560) layout = QVBoxLayout(dialog) layout.setContentsMargins(24, 22, 24, 18) layout.setSpacing(12) title = QLabel("挂号详情") title.setProperty("dialogRole", "title") layout.addWidget(title) body = QTextEdit() body.setReadOnly(True) body.setHtml(self._detail_html(detail)) layout.addWidget(body) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭") buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) mark_business_dialog(dialog, "AppointmentDetailDialog") dialog.exec() def _detail_html(self, detail: Mapping[str, Any]) -> str: def cell(label: str, value: Any) -> str: return ( f"" f"{escape(label)}" f"{escape(display_text(value))}" ) period = first_value(detail, "period", default="") period_text = ( "上午" if period == "morning" else "下午" if period == "afternoon" else display_text(period) ) sections = [ ( "预约信息", [ ("挂号ID", first_value(detail, "id")), ("状态", first_value(detail, "status_desc")), ("医生", first_value(detail, "doctor_name")), ( "预约时间", f"{display_text(first_value(detail, 'appointment_date'))} " f"{display_text(first_value(detail, 'appointment_time'))}", ), ("时段", period_text), ( "预约类型", first_value(detail, "appointment_type_desc", "appointment_type_text"), ), ], ), ( "患者信息", [ ("姓名", first_value(detail, "patient_name")), ("手机号", first_value(detail, "phone", "patient_phone")), ("身份证号", first_value(detail, "id_card")), ("性别", gender_text(first_value(detail, "gender"))), ("年龄", first_value(detail, "age")), ("诊断日期", first_value(detail, "diagnosis_date")), ("身高(cm)", first_value(detail, "height")), ("体重(kg)", first_value(detail, "weight")), ("地区", first_value(detail, "region")), ], ), ( "备注", [("备注", first_value(detail, "remark") or "无")], ), ] parts = ["
"] for title, rows in sections: parts.append(f"

{escape(title)}

") for label, value in rows: parts.append(cell(label, value)) parts.append("
") parts.append("
") return "".join(parts) def _load_departments(self) -> None: if not callable(getattr(self.repository, "list_departments", None)): return self._dept_generation += 1 generation = self._dept_generation run_async( lambda: invoke(self.repository, "list_departments"), on_success=lambda rows: self._departments_loaded(rows, generation), on_error=lambda _error: None, ) def _departments_loaded(self, rows: Any, generation: int) -> None: if generation != self._dept_generation: return current = self.dept_filter.currentData() self.dept_filter.blockSignals(True) self.dept_filter.clear() self.dept_filter.addItem("全部部门", "") sequence = rows if isinstance(rows, Sequence) and not isinstance(rows, (str, bytes)) else [] for dept_id, label in _flatten_departments(sequence): self.dept_filter.addItem(label, dept_id) index = self.dept_filter.findData(current) self.dept_filter.setCurrentIndex(max(0, index)) self.dept_filter.blockSignals(False) def showEvent(self, event: Any) -> None: super().showEvent(event) self._load_departments() if not self.poll_timer.isActive(): self.poll_timer.start() if self.table.rowCount() == 0 and not self._loading: self.refresh() def hideEvent(self, event: Any) -> None: self.poll_timer.stop() super().hideEvent(event) __all__ = ["AppointmentsPage", "prescription_action_label"]