491 lines
17 KiB
Python
491 lines
17 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
|
|
|
|
DENSITY_SIZES = ((1366, 768), (1710, 920))
|
|
|
|
|
|
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 _application() -> QApplication:
|
|
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))
|
|
return app
|
|
|
|
|
|
def render_density() -> list[Path]:
|
|
"""Render only the two desktop-density acceptance sizes."""
|
|
|
|
app = _application()
|
|
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 DENSITY_SIZES:
|
|
page = _new_page(app, repository, width, height)
|
|
path = output / f"diagnosis_{width}x{height}.png"
|
|
paths.append(_save(page, path))
|
|
page.close()
|
|
app.processEvents()
|
|
return paths
|
|
|
|
|
|
def render() -> list[Path]:
|
|
app = _application()
|
|
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), *DENSITY_SIZES, (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)
|