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

466 lines
16 KiB
Python

"""Render deterministic offscreen diagnosis-index reference screenshots."""
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 Qt, QThreadPool, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import ConsultationsPage
class _ScreenshotDiagnosisDialog(QWidget):
"""Invisible list-rendering seam; no detail drawer is exercised in this script."""
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
def open_for(self, *_args: Any, **_kwargs: Any) -> None:
return None
consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog
def _row(identifier: int, variant: int) -> dict[str, Any]:
common: dict[str, Any] = {
"id": identifier,
"diagnosis_id": identifier,
"patient_id": identifier + 1000,
"patient_name": ("林晓岚", "周明远", "许安然", "沈知夏")[variant % 4],
"gender": 2 if variant % 2 else 1,
"age": 28 + variant,
"assistant_id": 8,
"assistant_name": "赵医助",
"assign_read_at": None if variant == 0 else "2026-08-10 08:30",
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_appointment": 1,
"appointment_id": identifier + 2000,
"appointment_status": 1,
"appointment_doctor_id": 18,
"appointments": [
{
"id": identifier + 2000,
"status": 1,
"doctor_id": 18,
"doctor_name": "陈医生",
"time_text": "今天 09:00-09:30",
}
],
"latest_appointment_channel_text": "健康顾问转介",
"has_prescription": 1,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"followup_time_text": "2026-08-17 09:00",
"followup_doctor_name": "陈医生",
"followup_rx_voided": 0,
"unserved_days": (1, 4, 8, 2)[variant % 4],
"last_blood_record_at": "2026-08-09 20:10",
"video_call_hint": "未在通话中",
}
if variant == 1:
common.update(
{
"has_appointment": 0,
"appointment_id": 0,
"appointment_status": None,
"appointments": [],
"DiagnosisViewRecord": [{"is_confirmed": 0}],
"assistant_id": 0,
"assistant_name": "",
"has_prescription": 0,
"followup_time_text": "",
"last_blood_record_at": "",
"unserved_days": None,
}
)
elif variant == 2:
common.update(
{
"appointment_status": 4,
"appointments": [
{
"id": identifier + 2000,
"status": 4,
"doctor_id": 18,
"doctor_name": "陈医生",
"time_text": "昨天 15:00-15:30",
},
{
"id": identifier + 2001,
"status": 3,
"doctor_id": 19,
"doctor_name": "李医生",
"time_text": "08-03 10:30-11:00",
},
],
"followup_rx_voided": 1,
}
)
return common
class ScreenshotRepository:
"""No-network repository used only by the visual renderer."""
def __init__(self) -> None:
self.rows = [_row(501 + index, index % 4) for index in range(15)]
def list_consultations(
self,
*,
page_no: int = 1,
page_size: int = 15,
**_filters: Any,
) -> dict[str, Any]:
start = max(0, page_no - 1) * page_size
return {"lists": self.rows[start : start + page_size], "count": 42}
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
values = {
"diagnosis_type": [("中医", "tcm"), ("中西医结合", "integrated")],
"syndrome_type": [("痰湿", "phlegm_damp"), ("气虚", "qi_deficiency")],
"channels": [("健康顾问", "advisor"), ("门诊", "clinic")],
}
return [{"name": name, "value": value} for name, value in values.get(dictionary_type, [])]
def list_diagnosis_assistants(self) -> list[dict[str, Any]]:
return [{"id": 8, "name": "赵医助"}, {"id": 9, "name": "王医助"}]
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
raise RuntimeError(f"visual fixture does not cancel appointment #{appointment_id}")
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, str]:
raise RuntimeError(
f"visual fixture does not generate video QR: {doctor_id}/{patient_id}/{share_user_id}"
)
def generate_diagnosis_qrcode(
self,
diagnosis_id: int,
doctor_id: int,
patient_id: int,
share_user_id: int,
) -> dict[str, str]:
raise RuntimeError(
"visual fixture does not generate diagnosis QR: "
f"{diagnosis_id}/{doctor_id}/{patient_id}/{share_user_id}"
)
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise RuntimeError(f"visual fixture does not load logs for diagnosis #{diagnosis_id}")
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
raise RuntimeError(
f"visual fixture does not create order: {patient_id}/{order_type}/{amount}/{remark}"
)
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
raise RuntimeError(f"visual fixture does not generate payment QR: {order_no}")
# The list only checks these methods as a fail-closed capability contract;
# rendering never starts a call.
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None:
raise RuntimeError(f"visual fixture does not issue tickets: {patient_id}/{diagnosis_id}")
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None:
raise RuntimeError(
f"visual fixture does not start calls: {diagnosis_id}/{patient_id}/{call_type}"
)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
raise RuntimeError(f"visual fixture does not bind rooms: {diagnosis_id}/{room_id}")
def end_call(self, diagnosis_id: int) -> None:
raise RuntimeError(f"visual fixture does not end calls: {diagnosis_id}")
def _settle(app: QApplication, page: ConsultationsPage) -> None:
QThreadPool.globalInstance().waitForDone(3000)
for _ in range(8):
app.processEvents()
page.poll_timer.stop()
page.loading_overlay.stop()
page.page_scroll.verticalScrollBar().setValue(0)
app.processEvents()
def _new_page(
app: QApplication,
repository: ScreenshotRepository,
width: int,
height: int,
*,
permissions: PermissionSet | None = None,
) -> ConsultationsPage:
page = ConsultationsPage(
repository,
permissions=permissions or PermissionSet(["*"]),
current_user={"id": 1, "name": "陈医生"},
)
page.resize(width, height)
page.show()
_settle(app, page)
return page
def _save(page: ConsultationsPage, path: Path) -> Path:
if not page.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
return path
def _save_with_menu(page: ConsultationsPage, path: Path) -> Path:
"""Composite the real QMenu popup over the real page grab for offscreen determinism."""
index = page.table_host.model.index(0, 11)
cell = page.table_host.fixed.indexWidget(index)
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
menu = more.menu()
menu.ensurePolished()
menu.adjustSize()
menu.resize(menu.sizeHint())
menu.show()
QApplication.processEvents()
page_pixmap = page.grab()
menu_pixmap = menu.grab()
painter = QPainter(page_pixmap)
x = max(16, page.width() - menu_pixmap.width() - 24)
y = min(page.height() - menu_pixmap.height() - 16, 330)
painter.drawPixmap(x, max(16, y), menu_pixmap)
painter.end()
menu.hide()
if not page_pixmap.save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
return path
def _payment_qr_fixture() -> QPixmap:
"""Create only the renderer's stand-in for a server-fetched QR image."""
image = QImage(256, 256, QImage.Format.Format_ARGB32)
image.fill(QColor("#FFFFFF"))
painter = QPainter(image)
painter.setPen(Qt.PenStyle.NoPen)
module = 8
origin = 12
def draw_finder(left: int, top: int) -> None:
painter.setBrush(QColor("#111111"))
painter.drawRect(origin + left * module, origin + top * module, 7 * module, 7 * module)
painter.setBrush(QColor("#FFFFFF"))
painter.drawRect(
origin + (left + 1) * module,
origin + (top + 1) * module,
5 * module,
5 * module,
)
painter.setBrush(QColor("#111111"))
painter.drawRect(
origin + (left + 2) * module,
origin + (top + 2) * module,
3 * module,
3 * module,
)
for y in range(29):
for x in range(29):
inside_finder = (x < 8 and y < 8) or (x > 20 and y < 8) or (x < 8 and y > 20)
if not inside_finder and ((x * 11 + y * 7 + x * y) % 5 in {0, 2}):
painter.setBrush(QColor("#111111"))
painter.drawRect(origin + x * module, origin + y * module, module, module)
draw_finder(0, 0)
draw_finder(22, 0)
draw_finder(0, 22)
painter.end()
return QPixmap.fromImage(image)
def _save_with_payment_qr(
page: ConsultationsPage,
path: Path,
) -> Path:
record = page.table_host.model.rows[0]
dialog = consultations_module._DiagnosisOrderQrDialog(
record,
"ZYT202608100001",
page,
)
dialog.set_result("https://api.zyt.example/payment/ZYT202608100001/qrcode.png")
dialog.preview.show_loading()
dialog.preview.setText("")
dialog.preview.setPixmap(_payment_qr_fixture())
dialog.ensurePolished()
dialog.adjustSize()
dialog.show()
QApplication.processEvents()
page_pixmap = page.grab()
dialog_pixmap = dialog.grab()
painter = QPainter(page_pixmap)
painter.fillRect(page_pixmap.rect(), QColor(0, 0, 0, 52))
x = (page_pixmap.width() - dialog_pixmap.width()) // 2
y = max(18, (page_pixmap.height() - dialog_pixmap.height()) // 2)
painter.fillRect(
x - 8,
y - 8,
dialog_pixmap.width() + 16,
dialog_pixmap.height() + 16,
QColor(0, 0, 0, 34),
)
painter.drawPixmap(x, y, dialog_pixmap)
painter.end()
dialog.close()
if not page_pixmap.save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
return path
def render() -> list[Path]:
app = QApplication.instance() or QApplication([])
# The offscreen Windows plugin does not enumerate system fonts. Register
# the same CJK face used by the production QSS when it is available.
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
repository = ScreenshotRepository()
for width, height in ((1024, 640), (1440, 900)):
page = _new_page(app, repository, width, height)
path = output / f"diagnosis_{width}x{height}.png"
paths.append(_save(page, path))
page.close()
app.processEvents()
scenarios: tuple[tuple[str, int, int, str], ...] = (
("loading", 1280, 800, "loading"),
("empty", 1280, 800, "empty"),
("error", 1280, 800, "error"),
("hover_warning", 1280, 800, "hover"),
("focus", 1280, 800, "focus"),
("permissions_cropped", 1280, 800, "permissions"),
("horizontal_scroll", 1024, 640, "horizontal"),
("pending_assign", 1280, 800, "pending_assign"),
("advanced_filters", 1280, 800, "advanced"),
)
for filename, width, height, state in scenarios:
permissions = (
PermissionSet(["tcm.diagnosis/readonlyDetail"])
if state == "permissions"
else PermissionSet(["*"])
)
page = _new_page(app, repository, width, height, permissions=permissions)
if state == "loading":
page.table_host.begin_loading()
page.loading_overlay.start()
elif state == "empty":
page.table_host.set_rows([])
page.pager.update_state(1, 0)
elif state == "error":
page.table_host.set_rows([])
page.pager.update_state(1, 0)
page.table_host.show_error("列表加载失败,请重试\n网络连接不可用")
elif state == "hover":
page.table_host.model.set_hover_row(1)
page.table_host.main.viewport().update()
page.table_host.fixed.viewport().update()
elif state == "focus":
page.activateWindow()
page.keyword_edit.setFocus(Qt.FocusReason.TabFocusReason)
elif state == "horizontal":
scrollbar = page.table_host.main.horizontalScrollBar()
scrollbar.setValue(scrollbar.maximum())
elif state == "pending_assign":
page._appointment_date = ""
page._pending_assign = "1"
page._update_quick_buttons()
elif state == "advanced":
page.more_filter_button.setChecked(True)
page._toggle_advanced_filters(True)
app.processEvents()
page.page_scroll.verticalScrollBar().setValue(0)
if state != "focus":
app.processEvents()
path = output / f"diagnosis_{filename}_{width}x{height}.png"
paths.append(_save(page, path))
page.loading_overlay.stop()
page.close()
app.processEvents()
page = _new_page(app, repository, 1280, 800)
full_menu = output / "diagnosis_full_menu_1280x800.png"
paths.append(_save_with_menu(page, full_menu))
page.close()
app.processEvents()
page = _new_page(app, repository, 1280, 800)
payment_qr = output / "diagnosis_order_qrcode_1280x800.png"
paths.append(_save_with_payment_qr(page, payment_qr))
page.close()
app.processEvents()
page = _new_page(app, repository, 1280, 800)
double = _row(880, 0)
double.update(
{
"appointment_id": 2880,
"appointment_status": 1,
"appointments": [
{
"id": 2880,
"status": 1,
"doctor_id": 18,
"doctor_name": "陈医生",
"time_text": "今天 09:00-09:30",
},
{
"id": 2881,
"status": 4,
"doctor_id": 19,
"doctor_name": "李医生",
"time_text": "昨天 15:00-15:30",
},
],
}
)
page.table_host.set_rows([double, _row(881, 1), _row(882, 3)])
page.pager.update_state(1, 3)
for _ in range(4):
page.page_scroll.verticalScrollBar().setValue(0)
app.processEvents()
double_cancel = output / "diagnosis_double_appointment_cancel_1280x800.png"
paths.append(_save(page, double_cancel))
page.close()
app.processEvents()
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)