395 lines
14 KiB
Python
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()
|