更新
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user