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