Files
zyt/app/scripts/render_appointment_drawer_visual.py
2026-08-11 09:12:51 +08:00

395 lines
14 KiB
Python

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()