from __future__ import annotations import os from pathlib import Path from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtCore import QDate, QPoint, QSize, Qt from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget, ) from doctor_workstation.ui.appointment_drawer import AppointmentDrawer ROOT = Path(__file__).resolve().parents[1] OUTPUT_DIR = ROOT / "artifacts" / "diagnosis_visual" def _immediate_async( function: Any, *args: Any, on_success: Any = None, on_error: Any = None, on_finished: Any = None, **kwargs: Any, ) -> object: try: result = function(*args, **kwargs) except Exception as error: if on_error: on_error(error) else: if on_success: on_success(result) finally: if on_finished: on_finished() return object() class _DeferredAsync: def __init__(self) -> None: self.calls: list[dict[str, Any]] = [] def __call__( self, function: Any, *args: Any, on_success: Any = None, on_error: Any = None, on_finished: Any = None, **kwargs: Any, ) -> object: self.calls.append( { "function": function, "args": args, "kwargs": kwargs, "on_success": on_success, "on_error": on_error, "on_finished": on_finished, } ) return object() class _ScreenshotRepository: def __init__( self, *, today_conflict: bool = True, doctors: bool = True, rosters: bool = True, slot_error: bool = False, ) -> None: self.today_conflict = today_conflict self.doctors = doctors self.rosters = rosters self.slot_error = slot_error def list_diagnosis_doctors(self) -> list[dict[str, Any]]: if not self.doctors: return [] return [ {"id": 77, "name": "陈医生", "department_name": "中医科"}, {"id": 88, "name": "周医生", "department_name": "内科"}, {"id": 99, "name": "林医生", "department_name": "全科"}, {"id": 106, "name": "宋医生", "department_name": "康复科"}, ] def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]: assert dictionary_type == "channels" return [ {"id": 3, "name": "自媒体4H", "value": "self-4h", "status": 1, "sort": 30}, {"id": 2, "name": "线上复诊", "value": "online", "status": 1, "sort": 20}, {"id": 1, "name": "医生推荐", "value": "doctor", "status": 1, "sort": 10}, ] def list_appointments(self, **kwargs: Any) -> dict[str, Any]: if kwargs.get("status") == 3: return { "lists": [ { "appointment_date": "2026-08-01", "appointment_time": "10:30-11:00", } ], "count": 1, } if self.today_conflict: return {"lists": [{"status": 1}], "count": 1} return {"lists": [], "count": 0} def list_appointment_rosters(self, **_kwargs: Any) -> dict[str, Any]: if not self.rosters: return {"lists": [], "count": 0} today = QDate.currentDate() return { "lists": [ {"date": today.addDays(offset).toString("yyyy-MM-dd")} for offset in range(4) ], "count": 4, } def get_available_appointment_slots(self, **_kwargs: Any) -> dict[str, Any]: if self.slot_error: raise RuntimeError("号源服务暂时不可用") return { "slots": [ {"time": "09:00-09:30", "available": True, "quota": 2}, {"time": "09:30-10:00", "available": False, "quota": 0}, {"time": "10:00-10:30", "available": True, "quota": 1}, {"time": "10:30-11:00", "available": True, "quota": 3}, {"time": "14:00-14:30", "available": False, "quota": 0}, {"time": "14:30-15:00", "available": True, "quota": 2}, {"time": "15:00-15:30", "available": True, "quota": 1}, {"time": "15:30-16:00", "available": False, "quota": 0}, ] } def _background(size: tuple[int, int]) -> QWidget: host = QWidget() host.setObjectName("AppointmentScreenshotHost") host.resize(*size) host.setStyleSheet( "QWidget#AppointmentScreenshotHost { background:#F5F7FA; color:#303133; }" "QFrame#Sidebar { background:#1F2D3D; }" "QFrame#Header, QFrame#Card { background:#FFFFFF; border:1px solid #EBEEF5; }" "QLabel#Nav { color:#DDE5ED; font-size:14px; }" "QLabel#Muted { color:#909399; }" ) root = QHBoxLayout(host) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) sidebar = QFrame() sidebar.setObjectName("Sidebar") sidebar.setFixedWidth(196) side_layout = QVBoxLayout(sidebar) side_layout.setContentsMargins(22, 26, 22, 26) side_layout.setSpacing(18) brand = QLabel("真养堂 · 医生工作站") brand.setObjectName("Nav") side_layout.addWidget(brand) for item in ("诊单列表", "患者管理", "预约挂号", "处方管理", "业务订单"): label = QLabel(item) label.setObjectName("Nav") side_layout.addWidget(label) side_layout.addStretch(1) root.addWidget(sidebar) content = QWidget() content_layout = QVBoxLayout(content) content_layout.setContentsMargins(18, 18, 18, 18) content_layout.setSpacing(14) header = QFrame() header.setObjectName("Header") header.setFixedHeight(58) header_layout = QHBoxLayout(header) header_layout.setContentsMargins(18, 0, 18, 0) header_layout.addWidget(QLabel("诊单列表")) header_layout.addStretch(1) account = QLabel("陈医生 · 中医科") account.setObjectName("Muted") header_layout.addWidget(account) content_layout.addWidget(header) card = QFrame() card.setObjectName("Card") card_layout = QVBoxLayout(card) card_layout.setContentsMargins(20, 18, 20, 18) card_layout.setSpacing(14) card_layout.addWidget(QLabel("患者诊单 / 林晓岚 / 待预约")) for line in ( "林晓岚 女 · 38岁 最近问诊 2026-08-01", "主诉:失眠、多梦,复诊评估调方", "接诊医生:陈医生 医助:赵医助", ): value = QLabel(line) value.setObjectName("Muted") card_layout.addWidget(value) card_layout.addStretch(1) content_layout.addWidget(card, 1) root.addWidget(content, 1) return host def _build_drawer( application: QApplication, size: tuple[int, int], state: str, ) -> tuple[QWidget, AppointmentDrawer]: host = _background(size) host.show() repository = _ScreenshotRepository( doctors=state != "empty_doctors", rosters=state != "empty_roster", slot_error=state == "error", ) deferred = _DeferredAsync() if state == "loading" else None drawer = AppointmentDrawer( { "id": 501, "diagnosis_id": 501, "patient_id": 999, "patient_name": "林晓岚", "doctor_id": 77, }, repository=repository, parent=host, async_runner=deferred or _immediate_async, ) drawer.show() for _ in range(6): application.processEvents() assert drawer.drawer_width == round(size[0] * 0.60) assert drawer.footer.geometry().bottom() == drawer.panel.rect().bottom() assert drawer.ok_button.text() == "确定" if state == "loading": assert deferred is not None and len(deferred.calls) == 1 assert drawer._active_loading == "initial" assert drawer.loading_overlay.isVisible() assert drawer.loading_overlay.label.text() == "正在加载医生、渠道与挂号状态…" assert not drawer.ok_button.isEnabled() return host, drawer if state == "empty_doctors": assert not drawer.doctor_buttons assert drawer.doctor_empty.text() == "暂无可预约医生" assert drawer.time_empty.label.text() == "暂无可预约医生" assert drawer.time_empty.illustration.size() == QSize(80, 60) assert not drawer.ok_button.isEnabled() return host, drawer if state == "empty_roster": assert drawer.doctor_buttons assert not drawer.date_buttons assert drawer.time_empty.label.text() == "该医生暂无排班" assert drawer.time_empty.illustration.size() == QSize(80, 60) assert not drawer.ok_button.isEnabled() return host, drawer if state == "error": assert drawer.banner.property("kind") == "danger" assert "号源加载失败" in drawer.banner.label.text() assert drawer.slot_empty.label.text() == "号源加载失败" assert not drawer.slot_buttons assert not drawer.ok_button.isEnabled() return host, drawer assert [button.text() for button in drawer.doctor_buttons.values()] == [ "陈医生", "周医生", "林医生", "宋医生", ] assert all(button.size() == QSize(130, 40) for button in drawer.date_buttons.values()) future_date = next( value for value in drawer.date_buttons if value > QDate.currentDate().toString("yyyy-MM-dd") ) if drawer.date_combo.currentData() != future_date: drawer.date_buttons[future_date].click() application.processEvents() drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online")) first_available = next(button for button in drawer.slot_buttons.values() if button.isEnabled()) first_available.click() drawer.remark.setPlainText("复诊预约,请医生提前查看近期睡眠记录。") drawer._update_slot_scroll_height() for _ in range(4): application.processEvents() unavailable = next(button for button in drawer.slot_buttons.values() if not button.isEnabled()) assert unavailable.status_label.text() == "已约" assert unavailable.status_label.isVisible() if state == "refreshing": deferred = _DeferredAsync() drawer._run_async = deferred drawer.refresh_slots_button.click() for _ in range(3): application.processEvents() assert len(deferred.calls) == 1 assert drawer.loading_overlay.isVisible() assert drawer.loading_overlay.label.text() == "正在刷新可用号源…" assert drawer.refresh_slots_button.text() == "刷新中…" assert not drawer.refresh_slots_button.isEnabled() assert drawer.slot_empty.label.text() == "正在刷新可用号源…" elif state == "keyboard_focus": focus_target = next( button for button in drawer.date_buttons.values() if not button.isChecked() ) drawer.activateWindow() focus_target.setFocus(Qt.FocusReason.TabFocusReason) application.processEvents() assert focus_target.hasFocus() assert application.focusWidget() is focus_target else: assert state == "default" assert drawer.ok_button.isEnabled() return host, drawer def _render(application: QApplication, size: tuple[int, int], *, state: str = "default") -> Path: host, drawer = _build_drawer(application, size, state) canvas = QPixmap(*size) canvas.fill(QColor("#F5F7FA")) painter = QPainter(canvas) origin = QPoint(0, 0) host.render(painter, origin) drawer.render(painter, origin) painter.end() OUTPUT_DIR.mkdir(parents=True, exist_ok=True) state_suffix = "" if state == "default" else f"_{state}" path = OUTPUT_DIR / f"appointment_drawer{state_suffix}_{size[0]}x{size[1]}.png" if not canvas.save(str(path), "PNG"): raise RuntimeError(f"Could not save {path}") image = QImage(str(path)) rendered_size = (image.width(), image.height()) if rendered_size != size: raise RuntimeError(f"Unexpected image size for {path}: {rendered_size}") drawer.close() host.close() application.processEvents() return path def _load_cjk_font(application: QApplication) -> None: candidates = ( Path(r"C:\Windows\Fonts\msyh.ttc"), Path(r"C:\Windows\Fonts\simsun.ttc"), Path("/System/Library/Fonts/PingFang.ttc"), Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), ) for candidate in candidates: if not candidate.exists(): continue font_id = QFontDatabase.addApplicationFont(str(candidate)) families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else [] if families: application.setFont(QFont(families[0], 10)) return application.setFont(QFont("sans-serif", 10)) def main() -> None: application = QApplication.instance() or QApplication([]) application.setStyle("Fusion") _load_cjk_font(application) for size in ((1024, 640), (1440, 900)): path = _render(application, size) print(path.relative_to(ROOT)) for state in ( "empty_doctors", "empty_roster", "loading", "refreshing", "error", "keyboard_focus", ): path = _render(application, (1440, 900), state=state) print(path.relative_to(ROOT)) if __name__ == "__main__": main()