This commit is contained in:
Your Name
2026-09-07 12:30:42 +08:00
parent d5164b7369
commit 9ef6eb8d67
369 changed files with 11733 additions and 0 deletions
+1
View File
@@ -32,3 +32,4 @@ app/.test-tmp-stream/
/.spool /.spool
TUICallKit-Vue3/.env TUICallKit-Vue3/.env
/.codegraph /.codegraph
app/artifacts/
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+29
View File
@@ -0,0 +1,29 @@
# Bundled Noto Sans SC
`NotoSansSC-VF.ttf` is the unmodified Google Fonts distribution of
`NotoSansSC[wght].ttf`, stored under a filename without brackets for simpler
resource lookup. No font bytes were changed or subsetted locally.
- Qt family: `Noto Sans SC`
- Font version: `Version 2.004-H2;hotconv 1.0.118;makeotfexe 2.5.65603`
- Variable axis: `wght`, 100900; named instances at every 100, including 600.
- Size: 17,772,300 bytes.
- SHA-256: `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da`
- Official repository revision: `google/fonts@5e35378e6bda803962ee6fd257e444a7d459660d`.
- [Pinned font source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf).
- [Pinned license source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/OFL.txt).
The font is distributed under the SIL Open Font License 1.1. Retain
`OFL-NotoSansSC.txt`, including its copyright notice, when redistributing the
font with the application. The license applies to the font, independently of
the application's license.
Load this local resource through `QFontDatabase.addApplicationFont` after
creating `QApplication`, then use the returned family name. The application
must not fetch fonts at runtime. The PyInstaller spec already copies the
entire `resources` directory, including this directory and its license.
Google Fonts supplies explicit Regular (400), Medium (500), and SemiBold (600)
instances. The Noto CJK upstream 2.004 file lacks a named 600 instance and Qt
may select Medium for a plain `font-weight: 600` request; this distribution
preserves distinct results with the application's normal QSS font weights.
+93
View File
@@ -0,0 +1,93 @@
"""Render the clinical reading surfaces with demo data and production fonts."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QFontInfo, QGuiApplication, QPalette
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import ShellWindow, apply_theme
def _settle(app: QApplication) -> None:
for _ in range(4):
QThreadPool.globalInstance().waitForDone(3000)
app.processEvents()
QTest.qWait(100)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=Path("artifacts/ui_comfort"))
parser.add_argument("--width", type=int, default=1536)
parser.add_argument("--height", type=int, default=912)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication.instance() or QApplication([])
apply_theme(app)
repo = DemoDoctorRepository()
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
shell = ShellWindow(
repo, {"session": session, "demo_mode": True}, permissions=session.permissions
)
shell.resize(args.width, args.height)
shell.show()
try:
shell.navigate("reception")
_settle(app)
page = shell.pages["reception"]
page._set_queue_filter(None)
_settle(app)
# A synthetic multiline case tests paragraph rhythm without capturing
# a live patient or connecting to a production service.
page.case_labels["present"].setText(
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
)
app.processEvents()
if not shell.grab().save(str(args.output / "reception.png")):
raise RuntimeError("Could not save reception preview")
daily = next(
index
for index in range(page.detail_tabs.count())
if page.detail_tabs.tabText(index) == "日常记录"
)
page.detail_tabs.setCurrentIndex(daily)
_settle(app)
if not shell.grab().save(str(args.output / "daily_records.png")):
raise RuntimeError("Could not save daily-record preview")
metrics = {
"family": QFontInfo(app.font()).family(),
"pixel_size": app.font().pixelSize(),
"font_strategy": app.font().styleStrategy().value,
"font_hinting": app.font().hintingPreference().name,
"text_color": app.palette().color(QPalette.ColorRole.Text).name(),
"device_pixel_ratio": shell.devicePixelRatioF(),
"window": [shell.width(), shell.height()],
"daily_table_font": QFontInfo(page.daily_panel.matrix.font()).family(),
"daily_table_size": page.daily_panel.matrix.font().pixelSize(),
}
(args.output / "render.json").write_text(
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(args.output)
finally:
_settle(app)
shell.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,144 @@
"""Page-scoped palette and compact typography for the approved appointment list."""
from string import Template
from .reception_style import body_family, heading_family
def appointments_stylesheet() -> str:
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
_QSS = """
#AppointmentsPage { background: #F3F7FD; color: #273244; }
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
color: #5D6B80; font-size: 13px; font-weight: 400;
}
#AppointmentsPage QFrame#AppointmentFilterPanel,
#AppointmentsPage QFrame#AppointmentMainCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#AppointmentsPage QPushButton {
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 6px; background: #FFFFFF;
}
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
#AppointmentsPage QPushButton[variant="primary"] {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
#AppointmentsPage QPushButton[variant="danger"] {
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
}
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
#AppointmentsPage QPushButton:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
}
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
#AppointmentsPage QLineEdit QToolButton {
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
}
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
#AppointmentsPage QPushButton#AppointmentSearchButton {
min-height: 39px; max-height: 39px; min-width: 52px;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
border-color: transparent; color: #5D6B80; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
}
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
}
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
background: transparent; border-color: transparent; font-size: 13px;
}
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
}
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
color: #1769E8; border-color: #ADC8F0;
}
#AppointmentsPage QTableWidget#AppointmentTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
}
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
background: transparent; border-left: 3px solid transparent;
}
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#AppointmentsPage QWidget[appointmentInfoHost="true"],
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
background: #EAF2FF; border-radius: 4px; font-size: 13px;
}
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
#AppointmentsPage QPushButton[tableCancelAction="true"] {
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
#AppointmentsPage QWidget#Pager QPushButton {
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
}
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
"""
@@ -0,0 +1,131 @@
"""Scoped colors and compact typography for the approved consultation list."""
from string import Template
from .reception_style import body_family, heading_family
def consultations_stylesheet() -> str:
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
_QSS = """
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
background: #F3F7FD; color: #273244; border: 0;
}
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
color: #5D6B80; font-size: 13px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
}
#DiagnosisIndex QPushButton {
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
border-radius: 6px; background: #FFFFFF;
}
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
font-size: 13px;
}
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
#DiagnosisIndex QLineEdit QToolButton {
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
}
#DiagnosisIndex QComboBox::drop-down, #DiagnosisIndex QDateEdit::drop-down {
width: 22px; border: 0; background: transparent;
}
#DiagnosisIndex QComboBox QAbstractItemView {
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
}
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
#DiagnosisIndex QToolButton[statusTab="true"] {
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
}
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
}
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
#DiagnosisIndex QFrame#DiagnosisDateRange {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
}
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
#DiagnosisIndex QTableView {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
}
#DiagnosisIndex QTableView QHeaderView::section {
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
}
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
color: #5D6B80; font-size: 13px;
}
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
}
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,81 @@
"""Compact, presentation-only disclosure for page search and overview regions."""
from __future__ import annotations
from collections.abc import Sequence
from PySide6.QtCore import QObject, QSize, Qt, Signal
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from . import icons
class FilterDisclosure(QObject):
"""Keep query values and loading state intact while reclaiming list space.
Targets should be region containers, not individual permission-controlled
controls. Showing a container preserves its children's explicit visibility.
"""
expanded_changed = Signal(bool)
def __init__(
self,
parent: QWidget,
targets: Sequence[QWidget],
*,
expanded: bool = False,
) -> None:
super().__init__(parent)
self._targets = tuple(targets)
self._expanded = bool(expanded)
self.button = QPushButton(parent)
self.button.setObjectName("FilterDisclosureButton")
self.button.setCheckable(True)
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
self.button.setFixedHeight(32)
self.button.setIconSize(QSize(14, 14))
self.button.setStyleSheet("""
QPushButton#FilterDisclosureButton {
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
min-width: 92px; font-size: 13px; font-weight: 400;
}
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
""")
self.button.toggled.connect(self.set_expanded)
self._apply()
@property
def expanded(self) -> bool:
return self._expanded
def set_expanded(self, expanded: bool) -> None:
expanded = bool(expanded)
changed = expanded != self._expanded
self._expanded = expanded
self._apply()
if changed:
self.expanded_changed.emit(expanded)
def _apply(self) -> None:
# Keep keyboard focus on a visible control when folding a focused form.
focused = QApplication.focusWidget()
if not self._expanded and focused is not None and any(
target is focused or target.isAncestorOf(focused) for target in self._targets
):
self.button.setFocus(Qt.FocusReason.OtherFocusReason)
for target in self._targets:
target.setVisible(self._expanded)
blocked = self.button.blockSignals(True)
self.button.setChecked(self._expanded)
self.button.blockSignals(blocked)
label = "收起筛选" if self._expanded else "展开筛选"
self.button.setText(label)
self.button.setAccessibleName(label)
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
+794
View File
@@ -0,0 +1,794 @@
"""Single source of truth for every line icon in the workstation.
Before this module the application drew its icons from nine independent
painters (``ui/shell.py`` had two, ``ui/login.py`` two,
``ui/pages/reception.py`` four, and ``ui/pages/prescriptions.py``,
``ui/pages/patients.py`` and ``ui/diagnosis_index_widgets.py`` one each). They disagreed on everything that
makes an icon set read as one family:
* seven stroke weights - 1.4, 1.5, 1.55, 1.6, 1.7, 2.0 and ``size / 11.5`` px;
* four design grids - geometry authored against 14, 16, 18 and 24 px boxes, so
the same glyph asked for at another size came out off-centre or clipped;
* mixed fills and strokes inside one row of icons (a stroked ``search`` beside a
solid ``down`` triangle);
* integer ``QRect`` coordinates in the menu painter, which put a 1.6 px stroke
across a pixel boundary and rendered visibly softer than its neighbours;
* six near-identical indigos and two near-identical reds picked per call site
instead of from the palette.
Everything here is authored once on a 24-unit grid with a 20-unit optical safe
area, stroked with one weight formula, and scaled to the requested size by the
painter transform. Glyphs are pure stroke unless a filled counter is part of
the mark (a list bullet, the dot on an "i"), which keeps the whole set at a
single apparent weight.
Icons are cached as well. List pages build one icon per action button per row,
so the previous code re-ran a ``QPainter`` for every visible row on every
refresh; the cache turns that into one paint per (kind, colour, size).
"""
from __future__ import annotations
import math
from collections.abc import Callable
from functools import lru_cache
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
from PySide6.QtWidgets import QApplication
from .theme import COLORS, crisp_pixmap
#: Every glyph is drawn inside this box. Nothing is authored against the pixel
#: size the caller asks for, which is what keeps a 14 px and a 24 px request
#: optically identical instead of merely proportional.
GRID = 24.0
#: Ideal stroke at the reference grid. ``2 / 24`` is the Feather/Lucide ratio;
#: the clamp keeps the line from vanishing at 12 px or turning into a slab at
#: 36 px, which is the range the shell actually asks for.
_STROKE_RATIO = 2.0 / GRID
_STROKE_MIN_PX = 1.25
_STROKE_MAX_PX = 2.25
def stroke_px(size: float) -> float:
"""Return the on-screen stroke width used for an icon of ``size`` px."""
return max(_STROKE_MIN_PX, min(_STROKE_MAX_PX, size * _STROKE_RATIO))
# --- Semantic colour roles ------------------------------------------------
# Call sites name a role instead of a hex value. The seven painters replaced
# here between them hardcoded #5265F6, #5761F4, #5469F0, #5E69F6, #5365F5,
# #4965F5 and #6675F5 for what was always meant to be one accent.
ROLES = {
"default": COLORS["muted"],
"muted": COLORS["muted"],
"soft": COLORS["text_soft"],
"strong": COLORS["text"],
"accent": COLORS["indigo"],
"on_accent": "#FFFFFF",
"success": COLORS["success"],
"warning": COLORS["warning"],
"danger": COLORS["danger"],
"info": COLORS["info"],
"disabled": COLORS["disabled_text"],
"inverse": "#FFFFFF",
}
def resolve_color(color: str) -> str:
"""Accept either a semantic role name or a literal colour string."""
return ROLES.get(color, color)
_GLYPHS: dict[str, Callable[[QPainter, float], None]] = {}
_Glyph = Callable[[QPainter, float], None]
def _glyph(*names: str) -> Callable[[_Glyph], _Glyph]:
def register(fn: _Glyph) -> _Glyph:
for name in names:
_GLYPHS[name] = fn
return fn
return register
def _line(p: QPainter, x1: float, y1: float, x2: float, y2: float) -> None:
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
def _polyline(p: QPainter, *points: tuple[float, float]) -> None:
path = QPainterPath(QPointF(*points[0]))
for point in points[1:]:
path.lineTo(QPointF(*point))
p.drawPath(path)
def _circle(p: QPainter, cx: float, cy: float, r: float) -> None:
p.drawEllipse(QPointF(cx, cy), r, r)
def _dot(p: QPainter, cx: float, cy: float, r: float) -> None:
"""Filled counter - used only where the mark itself is solid."""
pen = p.pen()
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(pen.color())
p.drawEllipse(QPointF(cx, cy), r, r)
p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(pen)
def _page(p: QPainter, *, fold: bool = True) -> None:
"""Shared document silhouette so every file-like glyph has one outline."""
path = QPainterPath(QPointF(14.0, 2.5))
path.lineTo(QPointF(6.5, 2.5))
path.quadTo(QPointF(5.0, 2.5), QPointF(5.0, 4.0))
path.lineTo(QPointF(5.0, 20.0))
path.quadTo(QPointF(5.0, 21.5), QPointF(6.5, 21.5))
path.lineTo(QPointF(17.5, 21.5))
path.quadTo(QPointF(19.0, 21.5), QPointF(19.0, 20.0))
path.lineTo(QPointF(19.0, 7.5))
path.closeSubpath()
p.drawPath(path)
if fold:
_polyline(p, (14.0, 2.5), (14.0, 7.5), (19.0, 7.5))
def _sparkle(p: QPainter, cx: float, cy: float, r: float) -> None:
"""Four-point concave star - the one AI mark used across the product."""
path = QPainterPath(QPointF(cx, cy - r))
path.quadTo(QPointF(cx, cy), QPointF(cx + r, cy))
path.quadTo(QPointF(cx, cy), QPointF(cx, cy + r))
path.quadTo(QPointF(cx, cy), QPointF(cx - r, cy))
path.quadTo(QPointF(cx, cy), QPointF(cx, cy - r))
path.closeSubpath()
p.drawPath(path)
def _panel(p: QPainter) -> None:
p.drawRoundedRect(QRectF(2.5, 4.0, 19.0, 16.0), 3.0, 3.0)
_line(p, 9.5, 4.0, 9.5, 20.0)
# --- Navigation -----------------------------------------------------------
@_glyph("reception", "workbench", "monitor")
def _reception(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(2.5, 3.5, 19.0, 13.5), 3.0, 3.0)
_polyline(p, (6.0, 10.5), (8.8, 10.5), (10.6, 7.5), (13.4, 13.5), (15.2, 10.5), (18.0, 10.5))
_line(p, 12.0, 17.0, 12.0, 20.5)
_line(p, 8.0, 20.5, 16.0, 20.5)
@_glyph("appointments", "calendar")
def _calendar(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 16.5), 3.0, 3.0)
_line(p, 3.0, 10.0, 21.0, 10.0)
_line(p, 8.0, 2.75, 8.0, 7.0)
_line(p, 16.0, 2.75, 16.0, 7.0)
@_glyph("prescription_library", "library", "layers")
def _layers(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(12.0, 2.5))
path.lineTo(QPointF(21.0, 7.0))
path.lineTo(QPointF(12.0, 11.5))
path.lineTo(QPointF(3.0, 7.0))
path.closeSubpath()
p.drawPath(path)
_polyline(p, (3.0, 12.0), (12.0, 16.5), (21.0, 12.0))
_polyline(p, (3.0, 16.75), (12.0, 21.25), (21.0, 16.75))
@_glyph("prescriptions", "file_check")
def _file_check(p: QPainter, w: float) -> None:
_page(p)
_polyline(p, (8.5, 15.0), (10.9, 17.4), (15.5, 12.0))
@_glyph("patients", "users")
def _users(p: QPainter, w: float) -> None:
_circle(p, 9.0, 8.0, 3.5)
path = QPainterPath(QPointF(2.5, 20.5))
path.quadTo(QPointF(2.5, 14.5), QPointF(9.0, 14.5))
path.quadTo(QPointF(15.5, 14.5), QPointF(15.5, 20.5))
p.drawPath(path)
_circle(p, 17.6, 8.0, 2.8)
tail = QPainterPath(QPointF(17.0, 13.9))
tail.quadTo(QPointF(21.5, 14.6), QPointF(21.5, 20.5))
p.drawPath(tail)
@_glyph("consultations", "consult", "message", "other")
def _message(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(6.5, 3.5))
path.lineTo(QPointF(17.5, 3.5))
path.quadTo(QPointF(20.5, 3.5), QPointF(20.5, 6.5))
path.lineTo(QPointF(20.5, 13.5))
path.quadTo(QPointF(20.5, 16.5), QPointF(17.5, 16.5))
path.lineTo(QPointF(11.5, 16.5))
path.lineTo(QPointF(7.0, 20.5))
path.lineTo(QPointF(7.0, 16.5))
path.quadTo(QPointF(3.5, 16.5), QPointF(3.5, 13.5))
path.lineTo(QPointF(3.5, 6.5))
path.quadTo(QPointF(3.5, 3.5), QPointF(6.5, 3.5))
p.drawPath(path)
_line(p, 7.75, 8.25, 16.25, 8.25)
_line(p, 7.75, 11.75, 13.0, 11.75)
# --- Shell chrome ---------------------------------------------------------
@_glyph("fold", "panel_close")
def _fold(p: QPainter, w: float) -> None:
_panel(p)
_polyline(p, (17.0, 9.0), (14.0, 12.0), (17.0, 15.0))
@_glyph("expand", "panel_open")
def _expand(p: QPainter, w: float) -> None:
_panel(p)
_polyline(p, (14.0, 9.0), (17.0, 12.0), (14.0, 15.0))
@_glyph("search")
def _search(p: QPainter, w: float) -> None:
_circle(p, 10.5, 10.5, 6.25)
_line(p, 15.15, 15.15, 19.75, 19.75)
@_glyph("refresh", "rotate")
def _refresh(p: QPainter, w: float) -> None:
# The arc terminates exactly on the arrow corner so the mark reads as one
# continuous stroke. The painters replaced here left a detached triangle
# (shell) or two stray lines that never formed a head at all (prescriptions).
radius = math.hypot(8.5, 3.5)
start = math.degrees(math.atan2(3.5, 8.5))
p.drawArc(
QRectF(12.0 - radius, 12.0 - radius, radius * 2, radius * 2),
round(start * 16),
round((360.0 - start) * 16),
)
_polyline(p, (20.5, 3.5), (20.5, 8.5), (15.5, 8.5))
@_glyph("ai", "spark", "sparkle", "assistant")
def _ai(p: QPainter, w: float) -> None:
_sparkle(p, 10.2, 11.8, 7.2)
_sparkle(p, 18.0, 6.0, 3.2)
@_glyph("fullscreen", "expand-corners", "maximize")
def _fullscreen(p: QPainter, w: float) -> None:
_polyline(p, (9.0, 3.5), (3.5, 3.5), (3.5, 9.0))
_polyline(p, (15.0, 3.5), (20.5, 3.5), (20.5, 9.0))
_polyline(p, (3.5, 15.0), (3.5, 20.5), (9.0, 20.5))
_polyline(p, (20.5, 15.0), (20.5, 20.5), (15.0, 20.5))
@_glyph("minimize")
def _minimize(p: QPainter, w: float) -> None:
_line(p, 5.0, 12.0, 19.0, 12.0)
@_glyph("close", "cross")
def _close(p: QPainter, w: float) -> None:
_line(p, 5.75, 5.75, 18.25, 18.25)
_line(p, 18.25, 5.75, 5.75, 18.25)
@_glyph("down", "chevron_down")
def _down(p: QPainter, w: float) -> None:
_polyline(p, (5.5, 9.0), (12.0, 15.5), (18.5, 9.0))
@_glyph("up", "chevron_up")
def _up(p: QPainter, w: float) -> None:
_polyline(p, (5.5, 15.0), (12.0, 8.5), (18.5, 15.0))
@_glyph("left", "chevron_left")
def _left(p: QPainter, w: float) -> None:
_polyline(p, (15.0, 5.5), (8.5, 12.0), (15.0, 18.5))
@_glyph("right", "chevron_right")
def _right(p: QPainter, w: float) -> None:
_polyline(p, (9.0, 5.5), (15.5, 12.0), (9.0, 18.5))
@_glyph("notification", "bell")
def _bell(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(6.75, 17.5))
path.lineTo(QPointF(6.75, 10.75))
path.arcTo(QRectF(6.75, 5.0, 10.5, 11.5), 180.0, -180.0)
path.lineTo(QPointF(17.25, 17.5))
p.drawPath(path)
_line(p, 4.5, 17.5, 19.5, 17.5)
p.drawArc(QRectF(10.0, 17.4, 4.0, 3.6), 180 * 16, 180 * 16)
@_glyph("settings", "sliders")
def _settings(p: QPainter, w: float) -> None:
_line(p, 3.5, 8.5, 20.5, 8.5)
_line(p, 3.5, 15.5, 20.5, 15.5)
_circle(p, 9.0, 8.5, 2.4)
_circle(p, 15.0, 15.5, 2.4)
# --- Row and toolbar actions ---------------------------------------------
@_glyph("eye", "view")
def _eye(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(2.5, 12.0))
path.quadTo(QPointF(12.0, 2.5), QPointF(21.5, 12.0))
path.quadTo(QPointF(12.0, 21.5), QPointF(2.5, 12.0))
p.drawPath(path)
_circle(p, 12.0, 12.0, 3.0)
@_glyph("pencil", "edit")
def _pencil(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(16.25, 3.0))
path.lineTo(QPointF(20.75, 7.5))
path.lineTo(QPointF(8.5, 19.75))
path.lineTo(QPointF(3.0, 21.0))
path.lineTo(QPointF(4.25, 15.5))
path.closeSubpath()
p.drawPath(path)
_line(p, 13.0, 6.25, 17.5, 10.75)
@_glyph("trash", "delete")
def _trash(p: QPainter, w: float) -> None:
_line(p, 3.5, 6.25, 20.5, 6.25)
_polyline(p, (9.0, 6.25), (9.0, 3.5), (15.0, 3.5), (15.0, 6.25))
path = QPainterPath(QPointF(5.75, 6.25))
path.lineTo(QPointF(6.6, 19.4))
path.quadTo(QPointF(6.7, 20.5), QPointF(7.8, 20.5))
path.lineTo(QPointF(16.2, 20.5))
path.quadTo(QPointF(17.3, 20.5), QPointF(17.4, 19.4))
path.lineTo(QPointF(18.25, 6.25))
p.drawPath(path)
_line(p, 10.0, 10.0, 10.0, 16.75)
_line(p, 14.0, 10.0, 14.0, 16.75)
@_glyph("plus", "add")
def _plus(p: QPainter, w: float) -> None:
_line(p, 12.0, 4.75, 12.0, 19.25)
_line(p, 4.75, 12.0, 19.25, 12.0)
@_glyph("check")
def _check(p: QPainter, w: float) -> None:
_polyline(p, (4.75, 12.5), (9.75, 17.5), (19.25, 7.0))
@_glyph("check_circle", "health")
def _check_circle(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_polyline(p, (7.75, 12.25), (10.75, 15.25), (16.25, 9.0))
@_glyph("checkbox")
def _checkbox(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.75, 3.75, 16.5, 16.5), 3.5, 3.5)
@_glyph("lock")
def _lock(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(4.5, 10.5, 15.0, 10.0), 2.75, 2.75)
p.drawArc(QRectF(8.0, 3.75, 8.0, 13.5), 0, 180 * 16)
_dot(p, 12.0, 15.5, 1.35)
@_glyph("document", "file")
def _document(p: QPainter, w: float) -> None:
_page(p)
_line(p, 8.5, 12.75, 15.5, 12.75)
_line(p, 8.5, 16.75, 13.25, 16.75)
@_glyph("report")
def _report(p: QPainter, w: float) -> None:
_page(p)
_line(p, 8.75, 17.75, 8.75, 14.25)
_line(p, 12.0, 17.75, 12.0, 10.5)
_line(p, 15.25, 17.75, 15.25, 12.75)
@_glyph("list")
def _list(p: QPainter, w: float) -> None:
for y in (6.5, 12.0, 17.5):
_dot(p, 4.5, y, 1.2)
_line(p, 8.75, y, 19.5, y)
@_glyph("user", "person")
def _user(p: QPainter, w: float) -> None:
_circle(p, 12.0, 8.0, 4.0)
path = QPainterPath(QPointF(4.25, 20.5))
path.quadTo(QPointF(4.25, 14.5), QPointF(12.0, 14.5))
path.quadTo(QPointF(19.75, 14.5), QPointF(19.75, 20.5))
p.drawPath(path)
@_glyph("remove", "minus_circle")
def _remove(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_line(p, 8.0, 12.0, 16.0, 12.0)
@_glyph("stop", "close_circle")
def _stop(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_line(p, 9.0, 9.0, 15.0, 15.0)
_line(p, 15.0, 9.0, 9.0, 15.0)
@_glyph("info")
def _info(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_line(p, 12.0, 11.25, 12.0, 16.5)
_dot(p, 12.0, 7.75, 1.0)
@_glyph("picture", "image")
def _picture(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.0, 4.5, 18.0, 15.0), 3.0, 3.0)
_circle(p, 7.75, 8.75, 1.6)
_polyline(p, (3.5, 17.75), (9.75, 12.25), (13.25, 15.5), (15.75, 13.25), (20.5, 17.75))
@_glyph("meds", "pill")
def _meds(p: QPainter, w: float) -> None:
p.save()
p.translate(12.0, 12.0)
p.rotate(-45.0)
p.drawRoundedRect(QRectF(-9.25, -4.5, 18.5, 9.0), 4.5, 4.5)
_line(p, 0.0, -4.5, 0.0, 4.5)
p.restore()
@_glyph("daily", "clipboard")
def _clipboard(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(8.5, 4.5))
path.lineTo(QPointF(6.75, 4.5))
path.quadTo(QPointF(4.25, 4.5), QPointF(4.25, 7.0))
path.lineTo(QPointF(4.25, 19.0))
path.quadTo(QPointF(4.25, 21.5), QPointF(6.75, 21.5))
path.lineTo(QPointF(17.25, 21.5))
path.quadTo(QPointF(19.75, 21.5), QPointF(19.75, 19.0))
path.lineTo(QPointF(19.75, 7.0))
path.quadTo(QPointF(19.75, 4.5), QPointF(17.25, 4.5))
path.lineTo(QPointF(15.5, 4.5))
p.drawPath(path)
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 4.0), 1.5, 1.5)
_line(p, 8.0, 12.0, 16.0, 12.0)
_line(p, 8.0, 16.25, 13.5, 16.25)
@_glyph("followup", "calendar_clock")
def _followup(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(13.0, 20.0))
path.lineTo(QPointF(5.5, 20.0))
path.quadTo(QPointF(3.0, 20.0), QPointF(3.0, 17.5))
path.lineTo(QPointF(3.0, 7.5))
path.quadTo(QPointF(3.0, 5.0), QPointF(5.5, 5.0))
path.lineTo(QPointF(14.5, 5.0))
path.quadTo(QPointF(17.0, 5.0), QPointF(17.0, 7.5))
path.lineTo(QPointF(17.0, 9.0))
p.drawPath(path)
_line(p, 3.0, 9.5, 17.0, 9.5)
_line(p, 7.0, 2.75, 7.0, 6.75)
_line(p, 13.0, 2.75, 13.0, 6.75)
_circle(p, 16.75, 16.75, 4.5)
_polyline(p, (16.75, 14.25), (16.75, 16.75), (18.9, 16.75))
@_glyph("brand", "logo")
def _brand(p: QPainter, w: float) -> None:
_circle(p, 12.0, 12.0, 8.75)
_polyline(p, (7.0, 12.0), (10.0, 12.0), (11.5, 8.5), (13.5, 15.5), (15.0, 12.0), (17.0, 12.0))
# --- AI consultation ------------------------------------------------------
@_glyph("chart", "analytics")
def _analytics(p: QPainter, w: float) -> None:
_polyline(p, (3.5, 3.0), (3.5, 20.5), (21.0, 20.5))
_polyline(p, (7.0, 16.5), (10.5, 8.5), (14.0, 13.0), (20.0, 5.5))
@_glyph("trend")
def _trend(p: QPainter, w: float) -> None:
_polyline(p, (3.0, 18.0), (8.5, 9.5), (12.5, 13.5), (21.0, 5.5))
_polyline(p, (15.5, 5.5), (21.0, 5.5), (21.0, 11.0))
@_glyph("alert", "warning")
def _alert(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(12.0, 3.0))
path.lineTo(QPointF(21.5, 20.0))
path.lineTo(QPointF(2.5, 20.0))
path.closeSubpath()
p.drawPath(path)
_line(p, 12.0, 9.5, 12.0, 14.5)
_dot(p, 12.0, 17.4, 1.05)
@_glyph("mic", "microphone")
def _mic(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 12.0), 3.5, 3.5)
p.drawArc(QRectF(5.0, 6.0, 14.0, 14.0), 0, -180 * 16)
_line(p, 12.0, 17.5, 12.0, 21.0)
_line(p, 8.25, 21.0, 15.75, 21.0)
@_glyph("send")
def _send(p: QPainter, w: float) -> None:
path = QPainterPath(QPointF(21.0, 3.0))
path.lineTo(QPointF(2.5, 10.5))
path.lineTo(QPointF(10.25, 13.75))
path.lineTo(QPointF(13.5, 21.5))
path.closeSubpath()
p.drawPath(path)
_line(p, 10.25, 13.75, 21.0, 3.0)
@_glyph("qr", "qrcode")
def _qr(p: QPainter, w: float) -> None:
"""Three finder squares plus a few modules - the shape people scan for."""
for x, y in ((3.0, 3.0), (14.0, 3.0), (3.0, 14.0)):
p.drawRoundedRect(QRectF(x, y, 7.0, 7.0), 1.5, 1.5)
_dot(p, x + 3.5, y + 3.5, 1.15)
_line(p, 14.5, 14.5, 14.5, 17.0)
_line(p, 18.0, 14.5, 21.0, 14.5)
_line(p, 17.5, 18.0, 17.5, 21.0)
_dot(p, 20.75, 20.75, 1.15)
@_glyph("video", "call")
def _video(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(2.5, 6.0, 13.5, 12.0), 3.0, 3.0)
path = QPainterPath(QPointF(16.0, 10.5))
path.lineTo(QPointF(21.5, 7.25))
path.lineTo(QPointF(21.5, 16.75))
path.lineTo(QPointF(16.0, 13.5))
path.closeSubpath()
p.drawPath(path)
# --- Clinical measures ----------------------------------------------------
# The vital-sign tiles and the lifestyle row used to carry two more bespoke
# painters (22 px / 1.35 px stroke and 16 px / 1.3 px stroke). Beyond the extra
# weights, two of their glyphs were simply wrong: "weight" read as a padlock and
# "BMI" as the Venus symbol.
@_glyph("height")
def _height(p: QPainter, w: float) -> None:
_line(p, 6.0, 3.5, 18.0, 3.5)
_line(p, 6.0, 20.5, 18.0, 20.5)
_line(p, 12.0, 5.75, 12.0, 18.25)
_polyline(p, (9.5, 8.25), (12.0, 5.75), (14.5, 8.25))
_polyline(p, (9.5, 15.75), (12.0, 18.25), (14.5, 15.75))
@_glyph("weight", "scale")
def _weight(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 14.5), 3.5, 3.5)
p.drawArc(QRectF(7.0, 10.0, 10.0, 10.0), 25 * 16, 130 * 16)
_line(p, 12.0, 15.0, 9.9, 10.9)
@_glyph("bmi", "body")
def _bmi(p: QPainter, w: float) -> None:
_circle(p, 12.0, 5.0, 2.75)
_line(p, 12.0, 7.75, 12.0, 15.0)
_line(p, 7.25, 11.0, 16.75, 11.0)
_polyline(p, (8.5, 20.75), (12.0, 15.0), (15.5, 20.75))
@_glyph("blood_pressure", "gauge", "bp")
def _blood_pressure(p: QPainter, w: float) -> None:
p.drawArc(QRectF(3.0, 5.5, 18.0, 18.0), 0, 180 * 16)
_line(p, 3.0, 14.5, 21.0, 14.5)
_line(p, 12.0, 14.5, 16.4, 9.4)
_dot(p, 12.0, 14.5, 1.15)
@_glyph("pulse", "heart")
def _pulse(p: QPainter, w: float) -> None:
heart = QPainterPath(QPointF(12.0, 20.25))
heart.cubicTo(QPointF(3.2, 13.6), QPointF(2.2, 9.6), QPointF(4.7, 6.7))
heart.cubicTo(QPointF(7.2, 4.0), QPointF(10.5, 4.8), QPointF(12.0, 7.7))
heart.cubicTo(QPointF(13.5, 4.8), QPointF(16.8, 4.0), QPointF(19.3, 6.7))
heart.cubicTo(QPointF(21.8, 9.6), QPointF(20.8, 13.6), QPointF(12.0, 20.25))
heart.closeSubpath()
p.drawPath(heart)
_polyline(
p, (5.6, 12.4), (9.0, 12.4), (10.6, 9.7), (13.2, 15.1), (14.7, 12.4), (18.4, 12.4)
)
@_glyph("smoke", "cigarette")
def _smoke(p: QPainter, w: float) -> None:
p.drawRoundedRect(QRectF(2.5, 14.0, 14.0, 5.0), 1.75, 1.75)
_line(p, 13.0, 14.0, 13.0, 19.0)
curl = QPainterPath(QPointF(19.0, 12.0))
curl.quadTo(QPointF(21.5, 9.5), QPointF(19.0, 7.5))
curl.quadTo(QPointF(16.5, 5.5), QPointF(19.0, 3.5))
p.drawPath(curl)
@_glyph("drink", "glass")
def _drink(p: QPainter, w: float) -> None:
bowl = QPainterPath(QPointF(6.5, 3.5))
bowl.lineTo(QPointF(17.5, 3.5))
bowl.lineTo(QPointF(13.75, 12.5))
bowl.lineTo(QPointF(10.25, 12.5))
bowl.closeSubpath()
p.drawPath(bowl)
_line(p, 7.75, 7.5, 16.25, 7.5)
_line(p, 12.0, 12.5, 12.0, 20.0)
_line(p, 8.0, 20.0, 16.0, 20.0)
@_glyph("exercise", "run")
def _exercise(p: QPainter, w: float) -> None:
_circle(p, 15.75, 4.75, 2.5)
_polyline(p, (14.5, 9.0), (9.75, 12.5), (6.0, 20.5))
_polyline(p, (14.5, 9.0), (18.75, 12.75), (21.0, 10.75))
_polyline(p, (11.75, 11.0), (15.25, 16.0), (13.25, 20.75))
# --- Painting -------------------------------------------------------------
def _device_ratio() -> float:
app = QApplication.instance()
if app is None:
return 1.0
screen = app.primaryScreen()
if screen is None:
return 1.0
return max(1.0, float(screen.devicePixelRatio()))
def _render(kind: str, color: str, size: int) -> QPixmap:
canvas = crisp_pixmap(size)
draw = _GLYPHS.get(kind)
if draw is None:
return canvas
painter = QPainter(canvas)
try:
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
# Inset the grid by half a stroke on every edge. Without it a glyph that
# legitimately reaches grid unit 24 loses the outer half of its line to
# the pixmap boundary at the smaller sizes - which is exactly how the old
# painters lost the shell star's companion dot and flattened the top of
# the calendar. Insetting here means every glyph can use the full grid.
weight = stroke_px(size)
scale = (size - weight) / GRID
painter.translate(weight / 2.0, weight / 2.0)
painter.scale(scale, scale)
# The pen width is expressed on the design grid, so the on-screen weight
# stays the same fraction of the box at every size the shell asks for.
width = weight / scale
pen = QPen(QColor(color), width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
draw(painter, width)
finally:
painter.end()
return canvas
@lru_cache(maxsize=1024)
def _cached_pixmap(kind: str, color: str, size: int, ratio: float) -> QPixmap:
del ratio # part of the cache key only; crisp_pixmap reads it back itself
return _render(kind, color, size)
def pixmap(kind: str, color: str = "default", size: int = 18) -> QPixmap:
"""Return a cached, device-pixel-correct pixmap for ``kind``."""
return _cached_pixmap(kind, resolve_color(color), int(size), _device_ratio())
@lru_cache(maxsize=1024)
def _cached_icon(kind: str, color: str, size: int, ratio: float) -> QIcon:
result = QIcon(_cached_pixmap(kind, color, size, ratio))
result.addPixmap(
_cached_pixmap(kind, ROLES["disabled"], size, ratio),
QIcon.Mode.Disabled,
QIcon.State.Off,
)
return result
def icon(kind: str, color: str = "default", size: int = 18) -> QIcon:
"""Return a cached icon with a matching disabled variant already attached.
``color`` accepts a role name from :data:`ROLES` or a literal colour.
"""
return _cached_icon(kind, resolve_color(color), int(size), _device_ratio())
@lru_cache(maxsize=256)
def _cached_state_icon(
kind: str,
size: int,
normal: str,
active: str,
checked: str,
disabled: str,
ratio: float,
) -> QIcon:
result = QIcon()
result.addPixmap(_cached_pixmap(kind, normal, size, ratio), QIcon.Mode.Normal, QIcon.State.Off)
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Normal, QIcon.State.On)
result.addPixmap(_cached_pixmap(kind, active, size, ratio), QIcon.Mode.Active, QIcon.State.Off)
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Active, QIcon.State.On)
result.addPixmap(
_cached_pixmap(kind, disabled, size, ratio), QIcon.Mode.Disabled, QIcon.State.Off
)
return result
def state_icon(
kind: str,
*,
size: int = 18,
normal: str = "muted",
active: str = "strong",
checked: str = "inverse",
disabled: str = "disabled",
) -> QIcon:
"""Return an icon carrying its own hover / selected / disabled colours.
Qt only tints an icon when a widget asks it to, so a single-pixmap icon on a
selected navigation row keeps its resting grey and reads as switched off.
"""
return _cached_state_icon(
kind,
int(size),
resolve_color(normal),
resolve_color(active),
resolve_color(checked),
resolve_color(disabled),
_device_ratio(),
)
def available_kinds() -> tuple[str, ...]:
"""Every glyph name this module answers to, aliases included."""
return tuple(sorted(_GLYPHS))
def clear_cache() -> None:
"""Drop cached pixmaps - used when the display scale factor changes."""
_cached_pixmap.cache_clear()
_cached_icon.cache_clear()
_cached_state_icon.cache_clear()
@@ -0,0 +1,320 @@
"""Incremental server lists with a compact status footer and stable view state."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from PySide6.QtCore import QEvent, QSignalBlocker, Qt, QTimer
from PySide6.QtWidgets import (
QAbstractScrollArea,
QCheckBox,
QHBoxLayout,
QLabel,
QPushButton,
QTableWidget,
QWidget,
)
from .widgets import first_value, get_value, page_items, page_total, run_async
def record_key(row: Any) -> str:
value = first_value(
row, "id", "prescription_id", "appointment_id", "diagnosis_id", "order_id", "patient_id"
)
return str(value) if value is not None else repr(row)
class ListSnapshot:
"""Keep repository metadata available while replacing only the list payload."""
def __init__(self, rows: list[Any], total: int, source: Any) -> None:
self.items = rows
self.total = total
self.source = source
def __getattr__(self, name: str) -> Any:
return get_value(self.source, name, None)
class InfiniteList(QWidget):
"""Bind to a scrolling view; fetch pages only as the visible list needs them.
Reloads use captured query arguments. Refreshing the same query rebuilds the
loaded prefix atomically, so polling neither drops appended rows nor mixes
an updated first page with an old tail. Failed appends retain the prior page
and can be retried explicitly without an automatic request loop.
"""
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("InfiniteList")
self.setFixedHeight(24)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 0, 12, 0)
self.summary_label = QLabel("", self)
self.summary_label.setStyleSheet(
"color: #5D6B80; font-size: 12px; background: transparent;"
)
layout.addWidget(self.summary_label)
layout.addStretch(1)
self.retry_button = QPushButton("加载失败,点击重试", self)
self.retry_button.setFlat(True)
self.retry_button.setStyleSheet(
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
)
self.retry_button.hide()
self.retry_button.clicked.connect(self.retry)
layout.addWidget(self.retry_button)
self.page_size = page_size
self.page = 0
self.total = 0
self.rows: list[Any] = []
self.loading = False
self.has_more = False
self._generation = 0
self._query_key: Any = object()
self._view: QAbstractScrollArea | None = None
self._views: list[QAbstractScrollArea] = []
self._error = False
self._configured = False
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._maybe_load_more)
def bind(self, view: QAbstractScrollArea) -> None:
if view in self._views:
return
self._views.append(view)
self._view = view
view.verticalScrollBar().valueChanged.connect(self._schedule_check)
view.verticalScrollBar().rangeChanged.connect(self._schedule_check)
view.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: Any) -> bool:
if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
self._schedule_check()
return super().eventFilter(watched, event)
def _schedule_check(self, *_: Any) -> None:
self._timer.start(30)
def _maybe_load_more(self) -> None:
view = next((v for v in self._views if v.isVisible()), None)
if view is None or self.loading or self._error or not self.has_more:
return
bar = view.verticalScrollBar()
# pageStep respects both per-item and per-pixel Qt scrolling modes.
if bar.maximum() - bar.value() <= max(1, bar.pageStep() // 4):
self.load_more()
def invalidate(self) -> None:
"""Disarm callbacks when a reusable dialog switches to another record."""
self._generation += 1
self._configured = False
self.loading = self.has_more = self._error = False
self.rows, self.page, self.total = [], 0, 0
self._timer.stop()
self.retry_button.hide()
self._status()
reset = invalidate
def reload(
self,
fetch: Callable[[int], Any],
apply: Callable[[Any], None],
on_error: Callable[[Exception], None],
*,
runner: Callable[..., Any] = run_async,
query_key: Any = None,
on_finished: Callable[[], None] | None = None,
) -> None:
same_query = self._configured and query_key == self._query_key
if same_query and self.loading:
# Polling must not restart a slow prefix refresh indefinitely. The
# caller may have advanced its own generation, so use its latest
# render/error closures while the captured request finishes.
self._apply, self._on_error = apply, on_error
self._on_finished = on_finished
return
self._generation += 1
self._query_key = query_key
self._configured = True
self._fetch, self._apply, self._on_error = fetch, apply, on_error
self._runner, self._on_finished = runner, on_finished
self._target = max(1, self.page) if same_query else 1
self._reset_view = not same_query
if not same_query:
self.rows, self.page, self.total = [], 0, 0
self.has_more = False
self._render(ListSnapshot([], 0, None), preserve=False)
self._begin(1, [], refresh=True)
def load_more(self) -> None:
if not self._configured or self.loading or self._error or not self.has_more:
return
self._reset_view = False
self._target = self.page + 1
self._begin(self.page + 1, list(self.rows), refresh=False)
def retry(self) -> None:
if self.loading or not self._error:
return
self._begin(self._failed_page, list(self._failed_rows), refresh=self._failed_refresh)
def _begin(self, page: int, rows: list[Any], *, refresh: bool) -> None:
self.loading = True
self._error = False
self.retry_button.hide()
self.summary_label.setText(
f"已加载 {len(self.rows)} 条 · 正在加载…" if self.rows else "正在加载…"
)
generation = self._generation
fetch = self._fetch
self._runner(
lambda: fetch(page),
on_success=lambda result: self._received(result, generation, page, rows, refresh),
on_error=lambda error: self._failed(error, generation, page, rows, refresh),
on_finished=lambda: None,
)
def _received(
self, result: Any, generation: int, page: int, prior: list[Any], refresh: bool
) -> None:
if generation != self._generation:
return
incoming = page_items(result)
merged = {record_key(row): row for row in prior}
before = len(merged)
for row in incoming:
merged[record_key(row)] = row
rows = list(merged.values())
total = page_total(result, -1)
more = (
bool(incoming)
and len(rows) > before
and (len(rows) < total if total >= 0 else len(incoming) >= self.page_size)
)
# Retain first-page metadata (scope, counts, filter choices) on refresh.
if page == 1:
self._refresh_source = result
source = self._refresh_source
if refresh and page < self._target and more:
self._begin(page + 1, rows, refresh=True)
return
self.rows, self.page = rows, page
self.total = max(len(rows), total)
self.has_more = more
self._render(ListSnapshot(rows, self.total, source), preserve=not self._reset_view)
self.loading = False
self._status()
if self._on_finished is not None:
self._on_finished()
self._schedule_check()
def _failed(
self, error: Exception, generation: int, page: int, rows: list[Any], refresh: bool
) -> None:
if generation != self._generation:
return
self.loading = False
self._error = True
self._failed_page, self._failed_rows, self._failed_refresh = page, rows, refresh
self.summary_label.setText(f"已加载 {len(self.rows)}" if self.rows else "暂未加载数据")
self.retry_button.show()
self._on_error(error)
if self._on_finished is not None:
self._on_finished()
def _status(self) -> None:
if self.has_more:
self.summary_label.setText(f"已加载 {len(self.rows)} / {self.total} 条 · 下拉加载更多")
else:
if self.total > len(self.rows):
self.summary_label.setText(
f"已加载 {len(self.rows)} / {self.total} 条 · 暂无更多数据"
)
else:
self.summary_label.setText(
f"{len(self.rows)} 条 · 已全部加载" if self.rows else "暂无数据"
)
def update_state(self, page: int, total: int) -> None:
"""Compatibility for existing render callbacks; requests own the state."""
del page, total
self._status()
def _render(self, snapshot: ListSnapshot, *, preserve: bool) -> None:
view = next((v for v in self._views if v.isVisible()), self._view)
if view is None:
self._apply(snapshot)
return
bar = view.verticalScrollBar()
scroll = bar.value()
horizontal_scroll = view.horizontalScrollBar().value()
selected: set[str] = set()
checks: dict[tuple[str, int], Qt.CheckState] = {}
widget_checks: dict[tuple[str, int, int], bool] = {}
if preserve and isinstance(view, QTableWidget):
for row in range(view.rowCount()):
first = view.item(row, 0)
if first is None:
continue
key = record_key(first.data(Qt.ItemDataRole.UserRole))
if first.isSelected():
selected.add(key)
for column in range(view.columnCount()):
item = view.item(row, column)
if (
item is not None
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable
and item.data(Qt.ItemDataRole.CheckStateRole) is not None
):
checks[key, column] = item.checkState()
widget = view.cellWidget(row, column)
if widget is not None:
boxes = (
[widget]
if isinstance(widget, QCheckBox)
else widget.findChildren(QCheckBox)
)
for index, box in enumerate(boxes):
widget_checks[key, column, index] = box.isChecked()
blocker = QSignalBlocker(view)
try:
self._apply(snapshot)
if preserve and isinstance(view, QTableWidget):
if selected:
view.clearSelection()
for row in range(view.rowCount()):
first = view.item(row, 0)
if first is None:
continue
key = record_key(first.data(Qt.ItemDataRole.UserRole))
if key in selected:
view.selectRow(row)
for column in range(view.columnCount()):
item = view.item(row, column)
if item is not None and (key, column) in checks:
item.setCheckState(checks[key, column])
widget = view.cellWidget(row, column)
if widget is not None:
boxes = (
[widget]
if isinstance(widget, QCheckBox)
else widget.findChildren(QCheckBox)
)
for index, box in enumerate(boxes):
if (key, column, index) in widget_checks:
box.setChecked(widget_checks[key, column, index])
bar.setValue(min(scroll, bar.maximum()) if preserve else bar.minimum())
view.horizontalScrollBar().setValue(horizontal_scroll)
finally:
blocker.unblock()
if isinstance(view, QTableWidget):
view.itemSelectionChanged.emit()
__all__ = ["InfiniteList", "ListSnapshot"]
+364
View File
@@ -0,0 +1,364 @@
"""Motion tokens and helpers.
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
at all, so every state change was an instant cut: pages replaced each other
between one frame and the next, drawers appeared fully formed, toasts blinked
in and out. Nothing was slow - it just gave the eye no continuity to follow,
which is what reads as "not smooth" however fast the code underneath is.
Everything here is short. A workstation is used all day, so transitions are
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
measured in single-digit pixels. Anything longer starts costing the user time.
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
throughout. Two rules keep that safe:
* an animation must be owned, or PySide garbage-collects it mid-flight and the
widget freezes half-faded - :func:`_own` parks it on the target;
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
offscreen render path, which would make a table scroll badly for the rest of
the session - every fade here removes its effect when it finishes.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from typing import Any
from PySide6.QtCore import (
QAbstractAnimation,
QEasingCurve,
QEvent,
QObject,
QPoint,
QPropertyAnimation,
Qt,
QTimer,
)
from PySide6.QtWidgets import (
QAbstractScrollArea,
QGraphicsOpacityEffect,
QStackedWidget,
QWidget,
)
#: Durations in milliseconds.
FAST = 110 # hover-scale feedback, small fades
BASE = 170 # the default: page and panel transitions
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
#: Entering elements decelerate; elements that move between two known places
#: ease in and out; large travel gets a longer tail so it never looks linear.
EASE_ENTER = QEasingCurve.Type.OutCubic
EASE_MOVE = QEasingCurve.Type.InOutCubic
EASE_TRAVEL = QEasingCurve.Type.OutQuint
#: How far an entering surface rises, in device-independent pixels. Kept small
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
RISE = 8
def reduced_motion() -> bool:
"""Whether animation should be skipped entirely.
Off by default under the offscreen platform so widget grabs in tests and in
the packaging smoke checks capture a settled frame rather than a frame from
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
"""
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
if override in {"off", "0", "false", "none", "reduce"}:
return True
if override in {"on", "1", "true", "full"}:
return False
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
previous = running.get(key)
if previous is not None:
previous.stop()
running[key] = animation
target._doctor_motion = running
return animation
def animate(
target: Any,
prop: bytes,
start: Any,
end: Any,
*,
duration: int = BASE,
easing: QEasingCurve.Type = EASE_ENTER,
key: str | None = None,
owner: QWidget | None = None,
on_finished: Callable[[], None] | None = None,
) -> QPropertyAnimation | None:
"""Animate one Qt property, or apply the end value outright if motion is off.
``owner`` keeps the animation alive independently of ``target``. Fades
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
ends, so parenting the animation to the effect would destroy the animation
from inside its own ``finished`` emission.
"""
if reduced_motion():
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
if on_finished is not None:
on_finished()
return None
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
animation.setDuration(duration)
animation.setEasingCurve(easing)
animation.setStartValue(start)
animation.setEndValue(end)
if on_finished is not None:
animation.finished.connect(on_finished)
_own(owner if owner is not None else target, key or prop.decode(), animation)
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
return animation
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
effect.setEnabled(True)
return effect
def _drop_effect(widget: QWidget) -> None:
"""Detach the opacity effect once a fade is done.
Leaving it attached keeps the widget on Qt's offscreen composite path, which
is exactly the sort of quiet, permanent frame-rate tax this module exists to
avoid introducing.
The detach is deferred by one event-loop turn on purpose. ``finished`` is
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
the old effect immediately - tearing down the object graph underneath a
signal that is still being delivered.
"""
def detach() -> None:
try:
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
widget.setGraphicsEffect(None)
except RuntimeError: # the widget went away while the fade was running
pass
QTimer.singleShot(0, detach)
def fade_in(
widget: QWidget,
*,
duration: int = BASE,
start: float = 0.0,
easing: QEasingCurve.Type = EASE_ENTER,
) -> None:
"""Fade a widget up to full opacity, showing it first if needed."""
if reduced_motion():
widget.show()
return
effect = _opacity_effect(widget)
effect.setOpacity(start)
widget.show()
animate(
effect,
b"opacity",
start,
1.0,
duration=duration,
easing=easing,
key="fade",
owner=widget,
on_finished=lambda: _drop_effect(widget),
)
def fade_out(
widget: QWidget,
*,
duration: int = FAST,
hide: bool = True,
on_finished: Callable[[], None] | None = None,
) -> None:
"""Fade a widget down, optionally hiding it when the fade completes."""
if reduced_motion():
if hide:
widget.hide()
if on_finished is not None:
on_finished()
return
effect = _opacity_effect(widget)
def done() -> None:
if hide:
widget.hide()
_drop_effect(widget)
if on_finished is not None:
on_finished()
animate(
effect,
b"opacity",
float(effect.opacity()),
0.0,
duration=duration,
easing=EASE_MOVE,
key="fade",
owner=widget,
on_finished=done,
)
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
"""Fade a surface in while it settles upward by a few pixels.
The rise is what makes a swap read as one surface replacing another rather
than as a repaint; keeping it under ten pixels stops it becoming a gesture
the user has to wait out.
"""
if reduced_motion():
widget.show()
return
fade_in(widget, duration=duration)
if rise:
origin = widget.pos()
widget.move(origin + QPoint(0, rise))
animate(
widget,
b"pos",
widget.pos(),
origin,
duration=duration,
easing=EASE_ENTER,
key="enter",
)
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
"""Change the current page of a stack with a short cross-fade.
``QStackedWidget`` swaps pages between two frames with nothing in between,
which is the single most-seen transition in this product - it happens on
every sidebar click and on every list that toggles to its empty state.
"""
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
stack.setCurrentIndex(index)
return
stack.setCurrentIndex(index)
page = stack.currentWidget()
if page is None or reduced_motion():
return
enter(page, rise=rise)
# --- Smooth scrolling -----------------------------------------------------
#: One wheel notch travels this far, and takes this long to get there. Qt's
#: default is an instant jump of three lines per notch, which on a long clinical
#: record is the single jerkiest thing in the interface.
SCROLL_STEP = 120
SCROLL_MS = 190
class _SmoothScroller(QObject):
"""Animate a scroll area's wheel movement instead of jumping to it."""
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
super().__init__(area)
self._bar = (
area.verticalScrollBar()
if orientation is Qt.Orientation.Vertical
else area.horizontalScrollBar()
)
self._target = self._bar.value()
self._animation = QPropertyAnimation(self._bar, b"value", self)
self._animation.setEasingCurve(EASE_ENTER)
self._animation.setDuration(SCROLL_MS)
# Keyboard, programmatic and drag movements must not be fought over: when
# nothing is animating, the wheel target follows wherever the bar went.
self._bar.valueChanged.connect(self._sync_target)
area.viewport().installEventFilter(self)
def _sync_target(self, value: int) -> None:
if self._animation.state() != QAbstractAnimation.State.Running:
self._target = value
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
del watched
if event.type() is not QEvent.Type.Wheel or reduced_motion():
return False
delta = event.angleDelta().y() or event.angleDelta().x()
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
return False
lower, upper = self._bar.minimum(), self._bar.maximum()
# Re-clamp first: the range can shrink underneath a running animation
# when the content behind it reloads, which would otherwise leave the
# pending target past the end of the new content.
self._target = max(lower, min(upper, self._target))
target = self._target - round(delta / 120.0 * SCROLL_STEP)
target = max(lower, min(upper, target))
# At either end, hand the wheel back so an enclosing scroll area still
# gets it - swallowing it there is what makes nested panes feel stuck.
if target == self._target:
return False
self._target = target
self._animation.stop()
self._animation.setStartValue(self._bar.value())
self._animation.setEndValue(target)
self._animation.start()
return True
def install_smooth_scroll(
area: QAbstractScrollArea,
*,
orientation: Qt.Orientation = Qt.Orientation.Vertical,
) -> None:
"""Give a scroll area eased wheel scrolling."""
if getattr(area, "_doctor_smooth_scroll", None) is not None:
return
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
def press_feedback(widget: QWidget) -> None:
"""Mark a widget so the shared stylesheet can give it a pressed transform.
Qt has no CSS transitions, so the visual step itself lives in the palette's
pressed state; this only tags the widget as one that should get it.
"""
widget.setProperty("motionPress", True)
__all__ = [
"BASE",
"EASE_ENTER",
"EASE_MOVE",
"EASE_TRAVEL",
"FAST",
"RISE",
"SLOW",
"animate",
"enter",
"fade_in",
"fade_out",
"install_smooth_scroll",
"press_feedback",
"reduced_motion",
"switch_stack",
]
@@ -0,0 +1,119 @@
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return Template(_ORDERS).substitute(body=body_family())
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: #273244; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,54 @@
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return Template(_PROGRESS).substitute(body=body_family())
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: #273244; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,141 @@
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
from string import Template
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
def patient_list_stylesheet() -> str:
return Template(_LIST).substitute(body=body_family())
_CHROME = """
#PatientsPage { background: #F3F7FD; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: #273244; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: #5D6B80; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientSummaryStrip,
#PatientListWorkspace QFrame#PatientListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
}
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
}
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF;
}
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientListWorkspace QDateEdit:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
#PatientListWorkspace QTableWidget#PatientTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,359 @@
"""Local technology-blue rendering for the approved prescription library."""
from __future__ import annotations
from collections.abc import Iterable
from string import Template
from typing import Any
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .reception_style import body_family, heading_family
from .widgets import SortableTable, first_value
def library_stylesheet() -> str:
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, "#A4ADBA"),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Repaint existing action widgets and restore selection by template ID."""
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = "#BE4657" if button.property("danger") else "#1769E8"
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor("#5D6B80"))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor("#E6EDF6"))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor("#1769E8"))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor("#273244"))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.drawRoundedRect(pill, 4, 4)
painter.setPen(QColor("#1769E8"))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
#PrescriptionLibraryPage QFrame#MetricCard,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: #273244; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: #1555B6; background: #F2F7FF;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
@@ -0,0 +1,107 @@
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
_PRESCRIPTIONS = """
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: #A4ADBA;
}
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,106 @@
"""Palette and real font families for the approved reception page only."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.resources import resource_path
TECH_BLUE = {
"accent": "#1769E8",
"accent_hover": "#155BCC",
"accent_pressed": "#124EA9",
"selection": "#EAF2FF",
"selected_text": "#1555B6",
"canvas": "#F3F7FD",
"sidebar": "#EDF4FF",
"surface": "#FFFFFF",
"raised": "#F7FAFE",
"line": "#DBE5F2",
"text": "#273244",
"heading": "#202C3F",
"muted": "#5D6B80",
"focus": "#75A5F0",
}
def _families() -> dict[str, str]:
"""Resolve after Qt starts; font registration does not change its theme."""
app = QApplication.instance()
if app is None:
raise RuntimeError("Reception font families require a QApplication")
cached = getattr(app, "_reception_font_families", None)
if cached is not None:
return cached
heading = getattr(app, "_doctor_bundled_font_family", None)
if not heading:
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
if registered:
heading = registered[0]
app._doctor_bundled_font_family = heading
available = set(QFontDatabase.families())
# Qt's offscreen platform does not enumerate Windows fonts automatically.
# Register only files already installed on this machine, never substitutes
# downloaded or installed into the user's Windows font registry.
if sys.platform == "win32":
font_dir = (
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
)
for family, filenames in (
("Microsoft YaHei UI", ("msyh.ttc",)),
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
):
if family in available:
continue
for filename in filenames:
font_path = font_dir / filename
if not font_path.is_file():
continue
font_id = QFontDatabase.addApplicationFont(str(font_path))
if font_id >= 0:
available.update(QFontDatabase.applicationFontFamilies(font_id))
fallback = heading or next(
(
family
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
if family in available
),
app.font().family(),
)
resolved = {
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
"heading": heading or fallback,
"number": "Segoe UI" if "Segoe UI" in available else fallback,
}
app._reception_font_families = resolved
return resolved
def body_family() -> str:
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
return _families()["body"]
def heading_family() -> str:
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
return _families()["heading"]
def number_family() -> str:
"""Segoe UI supplies Regular and Semibold for numeric labels."""
return _families()["number"]
+242
View File
@@ -0,0 +1,242 @@
"""Scrolling, query isolation and refresh contracts for the two clinic queues."""
from __future__ import annotations
import os
from copy import deepcopy
from datetime import date
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt, Signal
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QComboBox, QSpinBox, QWidget
from doctor_workstation.ui.infinite_list import InfiniteList
from doctor_workstation.ui.pages import appointments, consultations
class _DiagnosisDialog(QWidget):
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
class _Repository:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.fail_page: int | None = None
self.revision = 0
def _list(self, **query: Any) -> dict[str, Any]:
self.calls.append(dict(query))
page = query["page_no"]
if page == self.fail_page:
raise RuntimeError("暂时无法加载")
second = bool(query.get("keyword") or query.get("patient_name"))
offset = 1000 if second else 0
total = 4 if second else 34
start = (page - 1) * query["page_size"]
rows = [
{
"id": offset + index,
"diagnosis_id": offset + index,
"source_patient_id": index + 2000,
"patient_id": index + 2000,
"patient_name": f"患者{offset + index} · {self.revision}",
"status": 1,
"status_desc": "待接诊",
"appointment_id": offset + index,
"appointment_status": 1,
"appointment_date": date.today().isoformat(),
"appointment_time": "09:00-09:30",
"patient_phone": "13800001234",
"doctor_name": "测试医生",
"doctor_id": 30,
"diagnosis_confirmed": True,
"appointments": [],
}
for index in range(start + 1, min(total, start + query["page_size"]) + 1)
]
result = {"lists": rows, "count": total}
if page == 1:
result["extend"] = {
"status_count": {"1": total, "3": 7},
"date_counts": {"today": total, "tomorrow": 9},
}
return result
list_appointments = _list
list_consultations = _list
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
try:
result = function()
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
def _settle(application: QApplication) -> None:
for _ in range(3):
application.processEvents()
QTest.qWait(35)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(params=["appointments", "consultations"])
def queue_page(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
module = appointments if request.param == "appointments" else consultations
monkeypatch.setattr(module, "run_async", _inline)
monkeypatch.setattr(consultations, "DiagnosisDialog", _DiagnosisDialog)
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
repository = _Repository()
page_class = appointments.AppointmentsPage if module is appointments else consultations.ConsultationsPage
page = page_class(repository, permissions={"*"}, current_user={"role_id": 1})
page.resize(1280, 800)
page.show()
page.poll_timer.stop()
_settle(application)
yield page, repository, module
page.close()
page.deleteLater()
_settle(application)
def _scroll_bottom(page: Any, application: QApplication) -> None:
scrollbar = page.table.verticalScrollBar()
assert scrollbar.maximum() > 0
scrollbar.setValue(scrollbar.maximum())
_settle(application)
def test_scroll_appends_and_preserves_selection(queue_page: Any, application: QApplication) -> None:
page, repository, _module = queue_page
assert page.table.rowCount() == 15
page.table.selectRow(6)
if hasattr(page, "table_host"):
model = page.table_host.model
model.setData(model.index(6, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
_scroll_bottom(page, application)
assert page.table.rowCount() == 30
assert [call["page_no"] for call in repository.calls] == [1, 2]
assert page.table.current_data()["id"] == 7
assert page.table.verticalScrollBar().value() > 0
if hasattr(page, "table_host"):
assert [row["id"] for row in page.table_host.selected_records()] == [7]
else:
assert page._status_counts[3] == 7
assert page.date_buttons["tomorrow"].text().endswith(" 9")
_scroll_bottom(page, application)
assert page.table.rowCount() == 34
assert len({row["id"] for row in page.pager.rows}) == 34
assert not page.pager.has_more
assert "已全部加载" in page.pager.summary_label.text()
_scroll_bottom(page, application)
assert [call["page_no"] for call in repository.calls] == [1, 2, 3]
def test_silent_refresh_keeps_the_loaded_prefix(queue_page: Any, application: QApplication) -> None:
page, repository, _module = queue_page
_scroll_bottom(page, application)
page.table.selectRow(19)
old_scroll = page.table.verticalScrollBar().value()
repository.calls.clear()
repository.revision = 2
page.refresh(silent=True)
_settle(application)
assert [call["page_no"] for call in repository.calls] == [1, 2]
assert page.table.rowCount() == 30
assert page.table.current_data()["id"] == 20
assert page.table.current_data()["patient_name"].endswith(" · 2")
assert page.table.verticalScrollBar().value() == old_scroll
if isinstance(page, appointments.AppointmentsPage):
assert page._status_counts[3] == 7
assert page.date_buttons["tomorrow"].text().endswith(" 9")
def test_filter_change_supersedes_pending_append(
queue_page: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch
) -> None:
page, _repository, module = queue_page
jobs: list[tuple[Any, dict[str, Any]]] = []
def deferred(function: Any, **callbacks: Any) -> None:
jobs.append((function, callbacks))
monkeypatch.setattr(module, "run_async", deferred)
# Reconfigure the shared controller to use the deferred runner, then finish
# that refresh before simulating a slow next page.
page.refresh(silent=True)
function, callbacks = jobs.pop()
callbacks["on_success"](function())
page.pager.load_more()
append_function, append_callbacks = jobs.pop()
stale_result = deepcopy(append_function())
search = page.patient_input if module is appointments else page.keyword_edit
search.setText("第二组")
page.refresh(silent=True)
assert len(jobs) == 1
function, callbacks = jobs.pop()
assert function()["lists"][0]["id"] == 1001
callbacks["on_success"](function())
append_callbacks["on_success"](stale_result)
_settle(application)
assert page.table.rowCount() == 4
assert [row["id"] for row in page.pager.rows] == [1001, 1002, 1003, 1004]
assert page.pager.page == 1
assert not page.pager.loading
assert page.table.verticalScrollBar().value() == 0
def test_failed_append_retries_without_losing_rows(queue_page: Any, application: QApplication) -> None:
page, repository, _module = queue_page
repository.fail_page = 2
_scroll_bottom(page, application)
assert page.table.rowCount() == 15
assert page.pager.page == 1
assert page.pager.retry_button.isVisible()
calls = len(repository.calls)
_settle(application)
assert len(repository.calls) == calls
repository.fail_page = None
page.pager.retry_button.click()
_settle(application)
assert page.table.rowCount() == 30
assert page.pager.page == 2
assert page.pager.retry_button.isHidden()
def test_list_footer_is_compact_without_page_controls(queue_page: Any, application: QApplication) -> None:
page, _repository, _module = queue_page
for height in (768, 960):
page.resize(1280, height)
_settle(application)
assert isinstance(page.pager, InfiniteList)
assert page.pager.height() == 24
assert not page.pager.findChildren(QComboBox)
assert not page.pager.findChildren(QSpinBox)
content = page.page_scroll.widget() if hasattr(page, "page_scroll") else page
assert content.layout().contentsMargins().bottom() == 8
+326
View File
@@ -0,0 +1,326 @@
"""Native Qt interaction and layout checks for the approved appointment surface."""
from __future__ import annotations
import os
import socket
from copy import deepcopy
from datetime import date
from itertools import combinations
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QLabel,
QLineEdit,
QPushButton,
QTabBar,
QWidget,
)
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.pages import appointments as appointments_module
from doctor_workstation.ui.pages.appointments import AppointmentsPage
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
class _Repository:
def __init__(self) -> None:
self.rows = [
{
"id": identifier,
"patient_name": f"测试患者{identifier}",
"patient_phone": "13800001234",
"gender": 1,
"age": 36,
"status": 1,
"status_desc": "待接诊",
"appointment_date": date.today().isoformat(),
"appointment_time": "09:00-09:30",
"doctor_name": "测试医生",
"doctor_id": 21,
"diagnosis_id": identifier + 100,
"source_patient_id": identifier + 200,
"diagnosis_confirmed": True,
"channel_name": "测试渠道",
}
for identifier in (401, 402, 403)
]
self.queries: list[dict[str, Any]] = []
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
self.queries.append(kwargs)
return {
"lists": deepcopy(self.rows),
"count": len(self.rows),
"extend": {"status_count": {"1": len(self.rows)}},
}
def list_departments(self) -> list[dict[str, Any]]:
return [{"id": 10, "name": "测试部门", "children": []}]
class _QuietPage(QWidget):
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
super().__init__(parent)
def _settle(application: QApplication) -> None:
for _ in range(3):
application.processEvents()
QTest.qWait(5)
@pytest.fixture(scope="module")
def application() -> QApplication:
application = QApplication.instance() or QApplication([])
apply_theme(application)
return application
@pytest.fixture
def window_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
def run_inline(function: Any, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
def reject_network(*_args: Any, **_kwargs: Any):
pytest.fail("The appointment visual tests must stay offline")
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
monkeypatch.setattr(appointments_module, "run_async", run_inline)
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
navigation = [
NavigationItem("appointments", "挂号列表", "", AppointmentsPage, ("doctor.appointment/lists",)),
NavigationItem("reception", "接诊台", "", _QuietPage, ("doctor.appointment/lists",)),
NavigationItem("patients", "我的患者", "", _QuietPage, ("firstvisit.myPatient/lists",)),
NavigationItem("prescriptions", "已开处方", "", _QuietPage, ("tcm.prescription/lists",)),
NavigationItem("legacy_reference", "参考页", "", _QuietPage, ()),
]
monkeypatch.setattr(
shell_module, "_resolve_navigation",
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
)
windows = []
def create(*, admin: bool = False, width: int = 1536, height: int = 960):
repository = _Repository()
shell = ShellWindow(
repository,
{"user": {"name": "测试医生", "role_id": 3 if admin else 1}, "demo_mode": True},
permissions={"*"},
)
windows.append(shell)
shell.resize(width, height)
shell.show()
_settle(application)
page = shell.pages["appointments"]
page.poll_timer.stop()
return shell, page, repository
yield create
for shell in windows:
shell.close()
shell.deleteLater()
_settle(application)
def _selector(page: AppointmentsPage, row: int) -> QCheckBox:
host = page.table.cellWidget(row, 0)
assert host is not None
selector = host.findChild(QCheckBox)
assert selector is not None
return selector
def _assert_selection_matches(page: AppointmentsPage) -> None:
table = page.table
selected = {index.row() for index in table.selectionModel().selectedRows()}
assert selected == {table.currentRow()}
assert sum(_selector(page, row).isChecked() for row in range(table.rowCount())) == 1
for row in range(table.rowCount()):
assert _selector(page, row).isChecked() == (row in selected)
assert table.cellWidget(row, 0).property("selected") == (row in selected)
def test_real_selector_tracks_initial_row_click_repeat_click_and_refresh(
application: QApplication, window_factory
) -> None:
shell, page, repository = window_factory()
assert page.table.rowCount() == 3
_assert_selection_matches(page)
checkbox = _selector(page, 1)
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
_settle(application)
assert page.table.currentRow() == 1
_assert_selection_matches(page)
# Clicking the selected checkbox cannot leave the selected patient unchecked.
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
_settle(application)
_assert_selection_matches(page)
assert page.table.currentRow() == 1
target = page.table.item(2, 2)
QTest.mouseClick(
page.table.viewport(), Qt.MouseButton.LeftButton,
pos=page.table.visualItemRect(target).center(),
)
_settle(application)
assert page.table.currentRow() == 2
_assert_selection_matches(page)
selected_id = page.table.current_data()["id"]
shell.refresh_button.click()
_settle(application)
assert page.table.current_data()["id"] == selected_id
_assert_selection_matches(page)
# Changed server data rebuilds the cells, unlike unchanged polling.
repository.rows[0]["channel_name"] = "更新后的测试渠道"
shell.refresh_button.click()
_settle(application)
assert page.table.current_data()["id"] == selected_id
_assert_selection_matches(page)
def test_selector_selects_its_visible_patient_after_sorting(
application: QApplication, window_factory
) -> None:
_shell, page, _repository = window_factory()
# A user can change sorting after cell widgets have already been installed.
first_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
header = page.table.horizontalHeader()
header_position = QPoint(
header.sectionViewportPosition(1) + header.sectionSize(1) // 2,
header.height() // 2,
)
for _ in range(2):
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=header_position)
_settle(application)
if page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id:
break
expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
assert expected_id != first_id
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
_settle(application)
assert page.table.current_data()["id"] == expected_id
_assert_selection_matches(page)
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
_settle(application)
assert page.table.current_data()["id"] == expected_id
_assert_selection_matches(page)
def _rect(widget: QWidget, ancestor: QWidget) -> QRect:
return QRect(widget.mapTo(ancestor, QPoint()), widget.size())
def _assert_filter_layout(page: AppointmentsPage) -> None:
types = (QPushButton, QComboBox, QLineEdit, QTabBar, QLabel)
controls = [widget for widget in page.filter_panel.findChildren(QWidget)
if isinstance(widget, types) and widget.isVisible()]
panel = page.filter_panel.rect()
for widget in controls:
assert panel.contains(_rect(widget, page.filter_panel)), widget.objectName()
for first, second in combinations(controls, 2):
if first.isAncestorOf(second) or second.isAncestorOf(first):
continue
overlap = _rect(first, page).intersected(_rect(second, page))
assert overlap.isEmpty(), (first.objectName(), second.objectName(), overlap)
assert not _rect(page.filter_panel, page).intersects(_rect(page.table_card, page))
assert page.table.viewport().height() > 80
@pytest.mark.parametrize("admin", [False, True], ids=["doctor", "admin"])
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 640)])
def test_more_filters_fit_exact_window_and_preserve_all_columns(
application: QApplication, window_factory, admin: bool, width: int, height: int
) -> None:
shell, page, _repository = window_factory(admin=admin, width=width, height=height)
page.filter_disclosure.set_expanded(True)
_settle(application)
for expanded in (False, True, False):
if page.more_filters_button.isChecked() != expanded:
QTest.mouseClick(page.more_filters_button, Qt.MouseButton.LeftButton)
_settle(application)
assert shell.size() == QSize(width, height)
assert page.advanced_filters.isVisible() == expanded
assert page.dept_filter.isVisible() == expanded
assert page.doctor_input.isVisible() == (expanded and admin)
assert page.custom_date_button.isVisible() == expanded
assert page.reset_filter_button.isVisible() == expanded
_assert_filter_layout(page)
assert page.table.columnCount() == 11
assert all(not page.table.isColumnHidden(column) for column in range(11))
assert [page.table.horizontalHeaderItem(column).text() for column in range(11)] == [
"", "ID", "患者", "性别 / 年龄", "挂号信息", "确认", "复诊", "助理", "开方", "未服务天数", "IM 问诊",
]
if width == 1024:
assert page.table.horizontalScrollBar().maximum() > 0
page.table.horizontalScrollBar().setValue(page.table.horizontalScrollBar().maximum())
_settle(application)
right = page.table.columnViewportPosition(10) + page.table.columnWidth(10)
assert right <= page.table.viewport().width()
def test_page_typography_remains_compact_and_chrome_restores(
application: QApplication, window_factory
) -> None:
shell, page, _repository = window_factory()
original_qss = application.styleSheet()
assert page.header.title_label.font().pixelSize() == 20
assert page.patient_input.font().pixelSize() == 14
assert page.table.font().pixelSize() == 14
assert page.table.item(0, 2).font().pixelSize() == 14
assert page.header.subtitle_label.font().pixelSize() == 13
assert page.more_filters_button.font().pixelSize() == 13
assert shell.sidebar.width() == 208
assert shell.topbar.height() == 76
assert shell.navigate("reception")
_settle(application)
assert shell.sidebar.width() == 208
assert shell.topbar.height() == 76
assert shell.navigate("patients")
_settle(application)
assert shell.sidebar.width() == 208
assert shell.topbar.height() == 76
assert shell.navigate("prescriptions")
_settle(application)
assert shell.sidebar.width() == 208
assert shell.topbar.height() == 76
assert shell.navigate("legacy_reference")
_settle(application)
assert shell.sidebar.width() == 190
assert shell.topbar.height() == 62
assert shell.workspace.pos() == QPoint(203, 13)
assert shell.fold_button.isVisible()
assert not shell.menu_sidebar_action.isVisible()
for widget, stylesheet in shell._legacy_chrome_styles.items():
assert widget.styleSheet() == stylesheet
assert shell.navigate("appointments")
_settle(application)
assert shell.sidebar.width() == 208
assert shell.topbar.height() == 76
assert page.header.title_label.font().pixelSize() == 20
assert application.styleSheet() == original_qss
+198
View File
@@ -0,0 +1,198 @@
"""Native Qt coverage for presentation-only filter folding on both queues."""
from __future__ import annotations
import json
import os
from datetime import date
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, QRect, Qt, Signal
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.pages import appointments, consultations
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
class _Dialog(QWidget):
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
class _Repository:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def _list(self, **query: Any) -> dict[str, Any]:
self.calls.append(query)
rows = [{
"id": index, "diagnosis_id": index, "patient_id": index + 2000,
"source_patient_id": index + 2000, "patient_name": f"演示患者{index:02d}",
"patient_phone": "13800001234", "gender": 2, "age": 38,
"status": 1, "status_desc": "待接诊", "doctor_name": "演示医生",
"doctor_id": 30, "assistant_name": "演示医助", "has_appointment": True,
"appointment_id": index + 1000, "appointment_status": 1,
"appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30",
"diagnosis_confirmed": True, "has_prescription": False,
"appointments": [{"id": index + 1000, "status": 1,
"doctor_name": "演示医生", "time_text": "09:00-09:30"}],
} for index in range(1, 16)]
return {"lists": rows, "count": 15, "extend": {"status_count": {"1": 15}}}
list_appointments = _list
list_consultations = _list
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
try:
result = function()
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
def _settle(application: QApplication) -> None:
for _ in range(4):
application.processEvents()
QTest.qWait(10)
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def list_window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(appointments, "run_async", _inline)
monkeypatch.setattr(consultations, "run_async", _inline)
monkeypatch.setattr(consultations, "DiagnosisDialog", _Dialog)
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
navigation = [
NavigationItem("appointments", "挂号列表", "", appointments.AppointmentsPage,
("doctor.appointment/lists",)),
NavigationItem("consultations", "问诊列表", "", consultations.ConsultationsPage,
("tcm.diagnosis/lists",)),
]
monkeypatch.setattr(shell_module, "_resolve_navigation",
lambda *_args, **_kwargs: [(item, item.title) for item in navigation])
repository = _Repository()
window = ShellWindow(repository, {"user": {"name": "演示医生", "role_id": 1},
"demo_mode": True}, permissions={"*"})
yield window, repository
window.close()
window.deleteLater()
_settle(application)
@pytest.mark.parametrize("kind", ["appointments", "consultations"])
@pytest.mark.parametrize(("width", "height"), [(1366, 768), (1536, 960)])
def test_folding_reclaims_rows_and_retains_query_refresh_and_navigation_state(
application: QApplication, list_window: Any, kind: str, width: int, height: int,
) -> None:
window, repository = list_window
window.resize(width, height)
window.show()
assert window.navigate(kind)
_settle(application)
page = window.pages[kind]
page.poll_timer.stop()
panel = page.filter_panel if kind == "appointments" else page.filters_card
header = page.header if kind == "appointments" else page.page_header
search = page.patient_input if kind == "appointments" else page.patient_name_edit
nested = page.more_filters_button if kind == "appointments" else page.more_filter_button
action = page.toolbar_edit_button if kind == "appointments" else page.add_button
disclosure = page.filter_disclosure
filters = page._query_filters if kind == "appointments" else page._filters
assert not disclosure.expanded
assert panel.isHidden() and not search.isVisible()
assert header.height() <= 44
assert not header.subtitle_label.isVisible()
assert disclosure.button.isVisible() and action.isVisible()
assert 30 <= disclosure.button.height() <= 34
assert window.refresh_button.isVisible()
assert any(button.isVisible() and button.text() == "刷新"
for button in page.findChildren(QPushButton))
collapsed_height = page.table.viewport().height()
capture_dir = os.environ.get("COLLAPSIBLE_LIST_SCREENSHOTS")
if capture_dir:
Path(capture_dir).mkdir(parents=True, exist_ok=True)
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-collapsed.png"))
calls_before = len(repository.calls)
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
_settle(application)
assert len(repository.calls) == calls_before
assert panel.isVisible() and search.isVisible()
assert collapsed_height >= page.table.viewport().height() + 100
assert panel.rect().contains(QRect(search.mapTo(panel, QPoint()), search.size()))
assert search.parentWidget().rect().contains(search.geometry())
assert not page.advanced_filters.isVisible()
if capture_dir:
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-expanded.png"))
(Path(capture_dir) / f"{kind}-{width}-metrics.json").write_text(
json.dumps({"window": [width, height], "header_height": header.height(),
"toggle_height": disclosure.button.height(),
"collapsed_viewport_height": collapsed_height,
"expanded_viewport_height": page.table.viewport().height(),
"viewport_gain": collapsed_height - page.table.viewport().height()},
indent=2), encoding="utf-8",
)
search.setText("演示患者")
query_button = (page.findChild(QPushButton, "AppointmentSearchButton")
if kind == "appointments" else page.search_button)
QTest.mouseClick(query_button, Qt.MouseButton.LeftButton)
_settle(application)
assert len(repository.calls) > calls_before
assert repository.calls[-1]["patient_name"] == "演示患者"
QTest.mouseClick(nested, Qt.MouseButton.LeftButton)
_settle(application)
assert page.advanced_filters.isVisible()
query = dict(filters())
calls_before = len(repository.calls)
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
_settle(application)
assert len(repository.calls) == calls_before
assert filters() == query
assert search.text() == "演示患者"
assert panel.isHidden()
assert page.table.viewport().height() == collapsed_height
window.resize(width - 50, height - 20)
window.refresh_button.click()
_settle(application)
assert not disclosure.expanded and panel.isHidden()
assert filters() == query
other = "consultations" if kind == "appointments" else "appointments"
assert window.navigate(other)
assert window.navigate(kind)
_settle(application)
assert not disclosure.expanded and panel.isHidden()
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
_settle(application)
assert page.advanced_filters.isVisible() and nested.isChecked()
window.refresh_button.click()
_settle(application)
assert disclosure.expanded and panel.isVisible()
assert filters() == query
+242
View File
@@ -0,0 +1,242 @@
"""Page disclosures reclaim list space without changing queries or tab state."""
from __future__ import annotations
import os
import socket
from copy import deepcopy
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import PatientsPage
from doctor_workstation.ui.theme import apply_theme
class Repository:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]:
self.calls.append((kind, deepcopy(query)))
return {
"lists": [{
"id": 101 + index, "diagnosis_id": 501 + index,
"patient_id": 301 + index, "patient_name": f"患者 {index + 1}",
"order_no": f"TEST-20260907-{index + 1}", "queue_no": index + 1,
"appointment_status": 1, "queue_status": "waiting",
"queue_status_text": "等待中", "doctor_name": "测试医生",
} for index in range(4)],
"count": 4,
"extend": {
"scope": {"label": "本人患者"}, "schedule_mode": "ownership",
"summary": {"waiting": 4, "today": 4, "orders": 4},
},
}
def list_patients(self, **query: Any) -> dict[str, Any]:
return self._result("patients", query)
def patient_orders(self, **query: Any) -> dict[str, Any]:
return self._result("orders", query)
def patient_progress(self, **query: Any) -> dict[str, Any]:
return self._result("progress", query)
def settle(application: QApplication) -> None:
for _ in range(4):
application.processEvents()
@pytest.fixture(scope="module")
def application() -> QApplication:
application = QApplication.instance() or QApplication([])
apply_theme(application)
return application
@pytest.fixture
def page_factory(application, monkeypatch):
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
def reject_network(*_args, **_kwargs):
pytest.fail("Disclosure tests must use local fixture data")
monkeypatch.setattr(patients_module, "run_async", immediate)
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
opened = []
def create(width=1328, height=884):
repository = Repository()
page = PatientsPage(repository, PermissionSet(["*"]))
opened.append(page)
page.resize(width, height)
page.show()
settle(application)
return page, repository
yield create
for page in opened:
for timer in page.findChildren(QTimer):
timer.stop()
page.close()
page.deleteLater()
settle(application)
def regions(page, index):
return (
(page.patient_workspace.search_toolbar, page.patient_workspace.filter_card,
page.patient_workspace.summary_strip),
(page.order_workspace.filter_card, page.order_workspace.summary_strip),
(page.progress_workspace.overview_card, page.progress_workspace.schedule_card),
)[index]
@pytest.mark.parametrize("index", [0, 1, 2], ids=["patients", "orders", "progress"])
def test_tabs_default_collapsed_and_keyboard_expansion_gives_space_to_lists(
application, page_factory, index
):
page, repository = page_factory()
page.tabs.setCurrentIndex(index)
settle(application)
workspace = page.tabs.currentWidget()
table = workspace.queue_table if index == 2 else workspace.table
disclosure = page.filter_disclosure
assert not disclosure.expanded
assert all(widget.isHidden() for widget in regions(page, index))
assert sum(item.button.isVisible() for item in page.filter_disclosures) == 1
assert disclosure.button.text() == disclosure.button.accessibleName() == "展开筛选"
assert disclosure.button.height() == 32
assert page.header.height() <= 48
assert not page.header.subtitle_label.isVisible()
assert page.refresh_button.isVisible() and page.tabs.tabBar().isVisible()
collapsed_height = table.viewport().height()
before = deepcopy(repository.calls)
disclosure.button.setFocus()
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
settle(application)
assert disclosure.expanded and disclosure.button.text() == "收起筛选"
assert all(widget.isVisible() for widget in regions(page, index))
assert collapsed_height >= table.viewport().height() + 100
assert repository.calls == before
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
settle(application)
assert table.viewport().height() == collapsed_height
assert table.isVisible() and workspace.pager.isVisible()
assert repository.calls == before
@pytest.mark.parametrize("index", [0, 1], ids=["patient-filters", "order-filters"])
def test_search_values_survive_collapse_refresh_and_tab_switch(
application, page_factory, index
):
page, repository = page_factory()
page.tabs.setCurrentIndex(index)
page.filter_disclosure.set_expanded(True)
settle(application)
workspace = page.tabs.currentWidget()
workspace.keyword_edit.setText(" 林青 ")
if index == 0:
QTest.mouseClick(workspace.custom_date_button, Qt.MouseButton.LeftButton)
else:
workspace.use_dates.setChecked(True)
workspace.rx_audit.setCurrentIndex(2)
workspace.start_date.setDate(QDate(2026, 9, 1))
workspace.end_date.setDate(QDate(2026, 9, 7))
QTest.mouseClick(workspace.search_button, Qt.MouseButton.LeftButton)
settle(application)
query = deepcopy(repository.calls[-1])
before = deepcopy(repository.calls)
page.filter_disclosure.set_expanded(False)
settle(application)
assert repository.calls == before
assert workspace.keyword_edit.text() == " 林青 "
assert workspace.start_date.date() == QDate(2026, 9, 1)
assert workspace.end_date.date() == QDate(2026, 9, 7)
QTest.mouseClick(page.refresh_button, Qt.MouseButton.LeftButton)
settle(application)
assert repository.calls[-1] == query
assert not page.filter_disclosure.expanded
page.tabs.setCurrentIndex(2)
assert not page.filter_disclosure.expanded
page.filter_disclosure.set_expanded(True)
page.tabs.setCurrentIndex(index)
settle(application)
assert not page.filter_disclosure.expanded
assert repository.calls[-1] == query
page.filter_disclosure.set_expanded(True)
settle(application)
assert workspace.keyword_edit.isVisible() and workspace.start_date.isEnabled()
assert (workspace.custom_date_button.isChecked() if index == 0 else
workspace.rx_audit.currentIndex() == 2 and workspace.use_dates.isChecked())
page.tabs.setCurrentIndex(2)
assert page.filter_disclosure.expanded
@pytest.mark.parametrize(("width", "height"), [(1328, 884), (1158, 692), (816, 564)])
def test_resize_keeps_collapsed_regions_hidden_and_queue_reachable(
application, page_factory, width, height
):
page, _repository = page_factory()
page.resize(width, height)
for index in range(3):
page.tabs.setCurrentIndex(index)
settle(application)
workspace = page.tabs.currentWidget()
assert all(widget.isHidden() for widget in regions(page, index))
for widget in (page.filter_disclosure.button, page.refresh_button):
assert page.rect().contains(QRect(widget.mapTo(page, QPoint()), widget.size()))
assert workspace.scroll.verticalScrollBar().maximum() == 0
assert workspace.pager.isVisibleTo(page)
if index == 2:
assert workspace.splitter.minimumHeight() == 222
assert workspace.queue_card.isVisible()
assert workspace.queue_card.height() == workspace.splitter.height()
page.hide()
page.show()
settle(application)
assert not page.filter_disclosure.expanded
assert all(widget.isHidden() for widget in regions(page, 2))
def test_collapsed_refresh_coalesces_identical_in_flight_queries(
application, page_factory, monkeypatch
):
page, _repository = page_factory()
pending = []
monkeypatch.setattr(
patients_module, "run_async",
lambda function, **callbacks: pending.append((function, callbacks)),
)
page.refresh()
page.refresh()
assert len(pending) == 1
function, callbacks = pending[0]
callbacks["on_success"](function())
settle(application)
assert page.patient_workspace.table.rowCount() == 4
assert not page.filter_disclosure.expanded
assert not page.patient_workspace.search_toolbar.isVisible()
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import ReceptionPage
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(application):
for _ in range(8):
application.processEvents()
@pytest.fixture
def page(application, monkeypatch):
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
widget = ReceptionPage(object(), PermissionSet(["*"]))
widget.resize(1280, 800)
widget.show()
widget.poll_timer.stop()
widget.detail_stack.setCurrentIndex(1)
settle(application)
yield widget
widget.close()
widget.deleteLater()
settle(application)
def test_default_fold_reclaims_queue_space_and_keeps_clinical_workspace(page, application):
assert not page.filter_disclosure.expanded
assert page.filter_disclosure.button.text() == "展开筛选"
assert page.refresh_button.isVisible()
assert page.queue_filter_summary.isVisible()
assert not page.queue_filter_panel.isVisible()
assert not page.queue_date_button.isVisible()
assert not page.search_edit.isVisible()
assert all(not button.isVisible() for button in page._queue_filter_buttons.values())
assert page.detail_tabs.isVisible()
assert page.patient_name_label.isVisible()
assert page.clinical_info_group.isVisible()
clinical_geometry = page.detail_stack.geometry()
collapsed_height = page.queue_stack.height()
QTest.mouseClick(page.filter_disclosure.button, Qt.MouseButton.LeftButton)
settle(application)
assert page.filter_disclosure.expanded
assert page.filter_disclosure.button.text() == "收起筛选"
assert page.queue_date_button.isVisible()
assert page.search_edit.isVisible()
assert all(button.isVisible() for button in page._queue_filter_buttons.values())
assert collapsed_height - page.queue_stack.height() >= 64
assert page.detail_stack.geometry() == clinical_geometry
def test_toggle_keeps_queries_patient_draft_loaded_pages_and_resize_state(page, application, monkeypatch):
requests = []
monkeypatch.setattr(page, "_request_queue_page", lambda query, **options: requests.append((query, options)))
page.filter_disclosure.set_expanded(True)
page.queue_date_button._pick_date(QDate(2026, 8, 3))
page._queue_filter_buttons[None].click()
page.search_edit.setText(" 折叠测试患者 ")
QTest.keyClick(page.search_edit, Qt.Key.Key_Return)
assert requests[-1][0]["patient_name"] == "折叠测试患者"
assert requests[-1][0]["status"] is None
assert requests[-1][0]["start_date"] == requests[-1][0]["end_date"] == "2026-08-03"
assert page.queue_filter_summary.text() == "2026-08-03 · 全部状态 · 姓名已筛选"
assert "折叠测试患者" in page.queue_filter_summary.toolTip()
page._selected_appointment_id = 51
page._queue_records = [{"id": index} for index in range(1, 31)]
page._queue_page = 2
page._queue_total = 40
page.note_edit.setPlainText("尚未保存的接诊备注")
page.detail_tabs.setCurrentIndex(4)
query_snapshot = dict(page._queue_query)
request_count = len(requests)
loading = page._queue_loading
for expanded in (False, True, False):
page.filter_disclosure.set_expanded(expanded)
page.resize(816 if expanded else 1280, 800)
settle(application)
assert page.filter_disclosure.expanded is expanded
assert page.queue_filter_panel.isVisible() is expanded
assert len(requests) == request_count
assert page._selected_appointment_id == 51
assert page._queue_page == 2
assert len(page._queue_records) == 30
assert page._queue_loading is loading
assert page._queue_query == query_snapshot
assert page.search_edit.text() == " 折叠测试患者 "
assert page.note_edit.toPlainText() == "尚未保存的接诊备注"
assert page.detail_tabs.currentIndex() == 4
page.refresh(workspace_refresh=True)
assert not page.filter_disclosure.expanded
assert requests[-1][0]["patient_name"] == "折叠测试患者"
assert requests[-1][0]["status"] is None
assert requests[-1][0]["start_date"] == "2026-08-03"
assert requests[-1][0]["page_size"] == 30
assert page._selected_appointment_id == 51
@pytest.mark.parametrize("width", [816, 1280])
def test_expanded_queue_controls_fit_at_supported_page_widths(page, application, width):
page.filter_disclosure.set_expanded(True)
page.resize(width, 800)
page._update_queue_filter_counts({"extend": {"status_count": {"1": 23, "2": 11, "3": 42}}}, [])
settle(application)
panel = page.queue_panel
controls = [
page.refresh_button,
page.filter_disclosure.button,
page.queue_date_button,
page.search_edit,
*page._queue_filter_buttons.values(),
]
for widget in controls:
position = widget.mapTo(panel, QPoint())
assert position.x() >= 0
assert position.x() + widget.width() <= panel.width()
assert widget.width() >= widget.minimumSizeHint().width()
assert page.queue_date_button.geometry().right() < page.search_edit.geometry().left()
assert page.filter_disclosure.button.height() == page.search_edit.height() == 32
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
import copy
import os
import subprocess
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QFont
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
from doctor_workstation.ui.diagnosis_index_widgets import (
DiagnosisTableHost,
_blue_appointment_layout,
_blue_appointment_text,
_blue_font,
)
from doctor_workstation.ui.reception_style import body_family, heading_family
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _settle(application: QApplication) -> None:
for _ in range(12):
application.processEvents()
def _row(identifier: int = 501) -> dict:
return {
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
"appointments": [
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
],
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
"unserved_days": 1,
}
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
row = _row()
before = copy.deepcopy(row)
assert _blue_appointment_text(row, row["appointments"][0]) == (
"陈医生(演示)", "2026-09-05 09:00-09:30",
)
assert _blue_appointment_text(row, row["appointments"][1]) == (
"", "2026-08-06 时间 —",
)
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
assert _blue_appointment_text(row, {}) == ("", "时间 —")
for missing_id in (0, "", None):
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("", "时间 —")
assert row == before
legacy = DiagnosisTableHost()
blue = DiagnosisTableHost(tech_blue=True)
for host in (legacy, blue):
host.set_rows([row])
assert "陈医生" not in legacy.model.index(0, 4).data()
assert blue.model.index(0, 4).data().splitlines() == [
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
]
assert blue.model.index(0, 9).data() == ""
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
assert blue.model.index(0, 4).data() == "— · 时间 —"
blue.set_rows([{**row, "appointments": []}])
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
legacy.close()
blue.close()
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
blue = DiagnosisTableHost(tech_blue=True)
legacy = DiagnosisTableHost()
row = _row()
single = _row(502)
single["appointments"] = single["appointments"][:1]
long = copy.deepcopy(single)
long["id"] = long["diagnosis_id"] = 503
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
long["has_prescription"] = 1
long["followup_time_text"] = "2026-10-15 09:00-09:30"
long["followup_doctor_name"] = "复诊医生姓名"
long["followup_rx_voided"] = True
blue.set_rows([row, single, long])
legacy.set_rows([row])
assert blue.model.columnCount() == 12
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
assert blue.main.horizontalHeader().height() == 41
assert legacy.main.horizontalHeader().height() == 39
assert blue.main.rowHeight(0) == 108
assert blue.main.rowHeight(1) == 72
assert blue.main.rowHeight(2) > 110
assert legacy.main.rowHeight(0) == 96
for index in range(3):
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
assert _blue_font(14).family() == body_family()
assert _blue_font(14, medium=True).family() == heading_family()
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
assert _blue_font(13).pixelSize() == 13
blue.close()
legacy.close()
@pytest.mark.parametrize("tech_blue", [False, True])
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
application: QApplication, tech_blue: bool,
) -> None:
window = QWidget()
layout = QVBoxLayout(window)
layout.setContentsMargins(7, 7, 7, 7)
host = DiagnosisTableHost(tech_blue=tech_blue)
layout.addWidget(host, 1)
footer = QLabel("始终可达的分页")
footer.setFixedHeight(42)
layout.addWidget(footer)
host.set_rows([_row(i) for i in range(40)])
window.resize(1280, 640)
window.show()
_settle(application)
minimum = window.minimumSizeHint().height()
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
window.resize(width, height)
_settle(application)
# The previous implementation wrote the available height into the
# fixed child's minimum and enlarged every ancestor on each relayout.
assert window.size().height() == height
assert window.minimumSizeHint().height() == minimum
assert host.fixed.minimumHeight() == 0
assert host.main.viewport().height() == host.fixed.viewport().height()
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
assert main_scroll.maximum() == fixed_scroll.maximum()
main_scroll.setValue(main_scroll.maximum() // 2)
_settle(application)
assert main_scroll.value() == fixed_scroll.value()
fixed_scroll.setValue(fixed_scroll.maximum())
_settle(application)
assert main_scroll.value() == fixed_scroll.value()
frozen_x = host.fixed.x()
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
_settle(application)
assert host.fixed.x() == frozen_x
assert host.fixed.geometry().right() <= host.rect().right()
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
window.close()
_settle(application)
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
host = DiagnosisTableHost(tech_blue=True)
host.resize(1300, 420)
host.set_rows([_row(501), _row(502)])
host.show()
_settle(application)
host.main.selectRow(1)
box = host.main.visualRect(host.model.index(1, 0)).center()
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
assert [row["id"] for row in host.selected_records()] == [502]
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
assert host.selected_records() == []
assert host.main.currentRow() == 1
header = host.main.horizontalHeader()
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
assert {row["id"] for row in host.selected_records()} == {501, 502}
assert host.main.currentRow() == 1
host.main.selectRow(0)
assert {row["id"] for row in host.selected_records()} == {501, 502}
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
assert host.selected_records() == []
host.close()
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
host = DiagnosisTableHost(tech_blue=True, action_policy={
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
})
row = _row()
# Make the second entry cancellable and the first doctor wrap: hit regions
# must follow measured content, not a legacy fixed 42-pixel multiplier.
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
row["appointments"][1]["status"] = 4
host.set_rows([row])
host.resize(1300, 500)
host.show()
_settle(application)
received = []
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
overlay = host.main.indexWidget(host.model.index(0, 4))
buttons = overlay.findChildren(QToolButton)
assert len(buttons) == 2
entries, total = _blue_appointment_layout(row, overlay.width())
for position, button in enumerate(buttons):
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
assert button.geometry().bottom() < overlay.height()
button.click()
assert received == [(501, 2501), (501, 2401)]
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
row["video_call_hint"] = {"state": "live"}
host.set_rows([row])
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
assert video is not None and video.isEnabled()
row["patient_id"] = 0
host.set_rows([row])
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
assert video is not None and not video.isEnabled()
host.action_policy["appointment_cancel"] = False
host.set_rows([row])
assert host.main.indexWidget(host.model.index(0, 4)) is None
host.close()
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
script = tmp_path / "shell_navigation.py"
script.write_text('''
import socket
from unittest.mock import patch
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import ShellWindow, apply_theme
app = QApplication([])
apply_theme(app)
def settle():
for _ in range(12):
QThreadPool.globalInstance().waitForDone(1000)
app.processEvents()
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
repo = DemoDoctorRepository()
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
shell.resize(1536, 960)
shell.show()
settle()
for route in ("consultations", "appointments", "consultations"):
shell.navigate(route)
settle()
host = shell.pages["consultations"].table_host
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
shell.resize(width, height)
settle()
assert host.main.viewport().height() == host.fixed.viewport().height()
assert host.fixed.minimumHeight() == 0
assert shell.height() == height
shell.close()
settle()
print("native navigation geometry stable")
''', encoding="utf-8")
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
encoding="utf-8", errors="replace", env=env, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
assert "native navigation geometry stable" in result.stdout
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(application: QApplication) -> None:
for _ in range(10):
application.processEvents()
@pytest.mark.parametrize("width", [600, 760, 860])
def test_prescription_form_keeps_date_gender_and_controls_readable(
application: QApplication, width: int
) -> None:
host = QWidget()
host.resize(width, 640)
host.show()
editor = PrescriptionEditorDialog(
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
parent=host,
)
editor.show()
settle(application)
try:
display = editor.date_edit._display
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
for button in (editor.gender_male, editor.gender_female):
assert button.width() >= button.sizeHint().width()
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
assert len({control.height() for control in controls}) == 1
assert editor.body_content.width() == editor.body_scroll.viewport().width()
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
footer_position = editor.footer.mapToGlobal(QPoint())
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
settle(application)
assert editor.footer.mapToGlobal(QPoint()) == footer_position
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
# Changing prescription type keeps its conditional fields reachable after reflow.
editor.prescription_type.setCurrentText("饮片")
settle(application)
assert not editor._main_decoction_field.isHidden()
assert editor._main_bag_field.isHidden()
if width == 860:
for resized_width in (600, 860):
editor.resize(resized_width, 640)
settle(application)
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
finally:
editor.close()
host.close()
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
tabs = DiagnosisTabWidget()
tabs.setObjectName("DiagnosisDrawerTabs")
tabs.setStyleSheet(DIAGNOSIS_QSS)
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
tabs.addTab(QWidget(), title)
tabs.resize(614, 480)
tabs.show()
settle(application)
try:
selected = tabs.tabBar().tabRect(tabs.currentIndex())
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
assert tabs.tab_scrollbar.isVisible()
assert not selected.intersects(tabs.tab_scrollbar.geometry())
tabs.tab_scrollbar.setValue(tabs.count() - 1)
assert tabs.currentIndex() == tabs.count() - 1
finally:
tabs.close()
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
dialog = QDialog()
dialog.setObjectName("AppointmentDrawerOverlay")
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
layout = QVBoxLayout(dialog)
card = _SlotCard("09:00-09:30", "可约")
card.setProperty("appointmentSlot", True)
card.setProperty("availability", "available")
card.setCheckable(True)
layout.addWidget(card)
dialog.show()
settle(application)
try:
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
card.setChecked(checked)
settle(application)
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
card.setChecked(True)
card.setEnabled(False)
settle(application)
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
finally:
dialog.close()
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
application: QApplication, monkeypatch: pytest.MonkeyPatch
) -> None:
def immediate(function, **kwargs):
kwargs["on_success"](function())
if kwargs.get("on_finished"):
kwargs["on_finished"]()
monkeypatch.setattr(ai_module, "run_async", immediate)
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
dialog.show()
settle(application)
try:
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
# QLabel vertically centers text: compare the centers, not just widget origins.
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
assert abs(centers[0] - centers[1]) <= 1
finally:
dialog.close()
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
try:
result = function()
except Exception as error:
on_error(error)
else:
on_success(result)
if on_finished:
on_finished()
for module in (ai_consult_picker, diagnosis, prescription):
monkeypatch.setattr(module, "run_async", run)
class ListRepository:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.fail_page = 0
def _list(self, **filters: Any) -> dict[str, Any]:
self.calls.append(filters)
page, size = filters["page_no"], filters["page_size"]
if page == self.fail_page:
raise RuntimeError("暂时无法加载")
start = (page - 1) * size + 1
return {
"lists": [
{
"id": number,
"diagnosis_id": number,
"patient_id": 1000 + number,
"patient_name": f"患者{number}",
"prescription_id": 77,
"prescription_name": f"处方{number}",
"herbs": [{"name": "白术", "dosage": 10}],
"order_no": f"ORDER{number}",
}
for number in range(start, min(start + size, 46))
],
"count": 45,
}
list_prescription_templates = _list
list_prescription_orders = _list
list_ai_patient_options = _list
def scroll_to_bottom(table: Any) -> None:
scrollbar = table.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
QTest.qWait(70)
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
def test_scroll_appends_and_retains_selected_business_identity(
application: QApplication, immediate_async: None, kind: str
) -> None:
repository = ListRepository()
if kind == "template":
dialog = prescription.TemplateImportDialog(repository, 42)
controller = dialog.infinite_list
elif kind == "order":
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
controller = dialog.infinite_list
else:
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
controller = dialog.pager
dialog.resize(980, 520)
dialog.show()
application.processEvents()
try:
page_size = controller.page_size
assert dialog.table.rowCount() == page_size
assert not any(
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
)
dialog.table.selectRow(2)
scroll_to_bottom(dialog.table)
assert [call["page_no"] for call in repository.calls] == [1, 2]
assert dialog.table.rowCount() == page_size * 2
assert dialog.table.currentRow() == 2
assert all(
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
for row in range(dialog.table.rowCount())
for column in range(dialog.table.columnCount())
)
repository.fail_page = 3
scroll_to_bottom(dialog.table)
assert controller.retry_button.isVisible()
assert dialog.table.rowCount() == page_size * 2
repository.fail_page = 0
controller.retry_button.click()
assert dialog.table.rowCount() == 45
assert not controller.has_more
assert dialog.table.currentRow() == 2
if kind == "template":
dialog.accept()
assert dialog.selected_template()["id"] == 3
elif kind == "ai":
dialog.accept()
assert dialog.selected_target().diagnosis_id == 3
assert dialog.selected_target().patient_id == 1003
else:
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
finally:
dialog.close()
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
application: QApplication, monkeypatch: pytest.MonkeyPatch
) -> None:
pending: list[tuple[Any, dict[str, Any]]] = []
monkeypatch.setattr(
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
)
repository = ListRepository()
dialog = prescription.TemplateImportDialog(repository, 42)
dialog.show()
application.processEvents()
try:
dialog.name_edit.setText("白术")
dialog.formula_combo.setCurrentIndex(1)
dialog.search()
assert len(pending) == 2
latest_function, latest_callbacks = pending[1]
latest_callbacks["on_success"](latest_function())
old_function, old_callbacks = pending[0]
old_callbacks["on_success"](old_function())
old_callbacks["on_error"](RuntimeError("过期错误"))
assert dialog.table.rowCount() == 15
assert not dialog.banner.isVisible()
assert repository.calls[0] == {
"page_no": 1,
"page_size": 15,
"prescription_name": "白术",
"formula_type": "主方",
"prescribing_creator_id": 42,
}
finally:
dialog.close()
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
application: QApplication, immediate_async: None
) -> None:
class Repository(ListRepository):
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
result = self._list(**filters)
if filters["page_no"] == 1:
for row in result["lists"]:
row["id"] = -row["id"]
row["diagnosis_id"] = row["id"]
return result
repository = Repository()
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
dialog.show()
application.processEvents()
try:
QTest.qWait(100)
assert [call["page_no"] for call in repository.calls] == [1, 2]
assert dialog.table.rowCount() == 20
assert dialog.table.item(0, 3).text() == "21"
assert dialog.table.isVisible()
assert not dialog.empty_state.isVisible()
finally:
dialog.close()
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
application: QApplication, immediate_async: None
) -> None:
repository = ListRepository()
dialog = diagnosis.DiagnosisDialog(
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
)
dialog.open_for(
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
)
application.processEvents()
try:
table = dialog._table_registry["orders"][0]
dialog.readonly_scroll.ensureWidgetVisible(table)
assert table.rowCount() == 10
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
table.selectRow(2)
scroll_to_bottom(table)
assert table.rowCount() == 20
assert table.currentRow() == 2
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
assert repository.calls[-1] == {
"page_no": 2,
"page_size": 10,
"patient_id": 301,
"context_diagnosis_id": 501,
"scene": "diagnosis_edit",
}
dialog.open_for(
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
)
assert table.rowCount() == 10
assert repository.calls[-1]["page_no"] == 1
assert repository.calls[-1]["patient_id"] == 302
assert repository.calls[-1]["context_diagnosis_id"] == 502
finally:
dialog.close()
+126
View File
@@ -0,0 +1,126 @@
"""Search disclosure preserves queries, permissions, focus and usable list space."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLineEdit, QPushButton, QVBoxLayout, QWidget
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.filter_disclosure import FilterDisclosure
from doctor_workstation.ui.pages import prescription_library, prescriptions
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(app):
for _ in range(3):
app.processEvents()
QTest.qWait(35)
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
try:
value = function()
if on_success:
on_success(value)
except Exception as error:
if on_error:
on_error(error)
raise
finally:
if on_finished:
on_finished()
def test_keyboard_fold_preserves_hidden_permission_controls_and_focus(application):
host = QWidget()
layout = QVBoxLayout(host)
panel = QWidget(host)
form = QVBoxLayout(panel)
field = QLineEdit("保留查询", panel)
forbidden = QPushButton("无权限操作", panel)
forbidden.hide()
form.addWidget(field)
form.addWidget(forbidden)
disclosure = FilterDisclosure(host, [panel])
layout.addWidget(disclosure.button)
layout.addWidget(panel)
host.show()
settle(application)
assert not panel.isVisible()
assert disclosure.button.height() == 32
disclosure.button.setFocus()
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
settle(application)
assert panel.isVisible() and not forbidden.isVisible()
field.setFocus()
disclosure.set_expanded(False)
assert QApplication.focusWidget() is disclosure.button
disclosure.set_expanded(True)
assert field.text() == "保留查询" and forbidden.isHidden()
host.close()
host.deleteLater()
@pytest.mark.parametrize("kind", ["issued", "library"])
@pytest.mark.parametrize("size", [(1328, 860), (1158, 690), (816, 620)])
def test_compact_default_expands_for_query_and_retains_values(application, monkeypatch, kind, size):
module = prescriptions if kind == "issued" else prescription_library
monkeypatch.setattr(module, "run_async", immediate)
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page_type = module.PrescriptionsPage if kind == "issued" else module.PrescriptionLibraryPage
page = page_type(repository, {"*"}, session.user)
page.resize(*size)
page.show()
settle(application)
disclosure = page.filter_disclosure
assert not disclosure.expanded and page.filter_card.isHidden()
assert page.header.height() == 44
assert page.header.breadcrumb_label.isHidden() and page.header.subtitle_label.isHidden()
assert disclosure.button.isVisible()
assert disclosure.button.height() == 32
assert page.header.rect().contains(disclosure.button.mapTo(page.header, QPoint(0, 0)))
compact_height = page.table.height()
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
settle(application)
assert disclosure.expanded and page.filter_card.isVisible()
assert compact_height - page.table.height() >= 100
field = page.sn_filter if kind == "issued" else page.name_filter
assert field.isVisible() and page.query_button.isVisible() and page.reset_button.isVisible()
field.setText("不存在的查询")
# Folding must not issue a request or clear an unfinished query.
generation = page._generation
disclosure.set_expanded(False)
settle(application)
assert page._generation == generation
assert field.text() == "不存在的查询"
disclosure.set_expanded(True)
QTest.keyClick(field, Qt.Key.Key_Return)
settle(application)
assert page._generation > generation
assert disclosure.expanded
disclosure.set_expanded(False)
page.refresh()
page.resize(size[0] + 10, size[1])
settle(application)
assert not disclosure.expanded and not page.filter_card.isVisible()
disclosure.set_expanded(True)
page.reset_button.click()
settle(application)
assert field.text() == "" and page.table.rowCount() == 2
for timer in page.findChildren(QTimer):
timer.stop()
page.close()
page.deleteLater()
+176
View File
@@ -0,0 +1,176 @@
"""Contract for the shared icon system.
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
painters that disagreed on stroke weight, design grid and palette. These tests
lock in the properties that keep the set reading as one family, so a new glyph
cannot quietly reintroduce a one-off weight or a clipped mark.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtGui import QColor, QImage
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui import icons
from doctor_workstation.ui.theme import COLORS
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _rendered(kind: str, size: int) -> QImage:
return icons.pixmap(kind, "strong", size).toImage()
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
left, top = image.width(), image.height()
right = bottom = -1
count = 0
for y in range(image.height()):
for x in range(image.width()):
if image.pixelColor(x, y).alpha() > 24:
count += 1
left, top = min(left, x), min(top, y)
right, bottom = max(right, x), max(bottom, y)
return left, top, right, bottom, count
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
application: QApplication,
) -> None:
"""No glyph may touch the edge of its pixmap.
Clipping is what the old painters did whenever a call site asked for a size
other than the one the geometry was authored against - the shell's ``ai``
star lost its companion dot, and the prescription icons were drawn with
16 px geometry inside 14 px and 15 px boxes.
"""
for kind in icons.available_kinds():
for size in (14, 16, 18, 20, 24):
image = _rendered(kind, size)
device = image.width() # honours the device pixel ratio
left, top, right, bottom, count = _ink_bounds(image)
assert count > 0, f"{kind}@{size} painted nothing"
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
assert right < device - 1 and bottom < device - 1, (
f"{kind}@{size} is clipped at the bottom/right"
)
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
application: QApplication,
) -> None:
"""Every mark lives in the same safe area, so none reads over- or undersized.
The set this replaces mixed glyphs that spanned the full 18 px box with a
``close`` cross that spanned only 7 px, which is why the title bar controls
never looked like siblings.
"""
for kind in icons.available_kinds():
image = _rendered(kind, 24)
device = image.width()
left, top, right, bottom, _count = _ink_bounds(image)
extent = max(right - left, bottom - top) / device
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
assert icons.stroke_px(24) == pytest.approx(2.0)
assert icons.stroke_px(18) == pytest.approx(1.5)
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
# Clamped so a small icon stays visible and a large one does not turn slab.
assert icons.stroke_px(8) == pytest.approx(1.25)
assert icons.stroke_px(64) == pytest.approx(2.25)
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
application: QApplication,
) -> None:
"""A list page builds one icon per action button per row on every refresh.
Without the cache that is a fresh ``QPainter`` run per button; the pages in
this product ask for the same handful of (kind, colour, size) triples over
and over, so the cache turns a per-row cost into a per-process one.
"""
icons.clear_cache()
for _ in range(50):
icons.icon("eye", "accent", 15)
icons.icon("trash", "danger", 15)
# Two glyphs, each painted once in its requested colour and once disabled.
assert icons._cached_pixmap.cache_info().misses == 4
# The remaining 98 calls are answered from the icon cache without painting.
assert icons._cached_icon.cache_info().hits == 98
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
assert icons.resolve_color("accent") == COLORS["indigo"]
assert icons.resolve_color("danger") == COLORS["danger"]
assert icons.resolve_color("muted") == COLORS["muted"]
# A literal colour still passes through for the few bespoke tints that remain.
assert icons.resolve_color("#8268E8") == "#8268E8"
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
application: QApplication,
) -> None:
"""Navigation rows invert on selection, so the icon has to invert with them."""
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
from PySide6.QtGui import QIcon
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
assert off != on
assert _dominant_ink(on) == QColor("#FFFFFF")
def _dominant_ink(image: QImage) -> QColor:
for y in range(image.height()):
for x in range(image.width()):
colour = image.pixelColor(x, y)
if colour.alpha() > 240:
colour.setAlpha(255)
return colour
raise AssertionError("no opaque pixel found")
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
"""Six sidebar entries need six silhouettes.
The reception detail tabs previously shared three near-identical
"document with lines" marks, which made them unreadable at 16 px.
"""
renders = {
kind: _rendered(kind, 24).constBits().tobytes()
for kind in (
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
"consultations",
"report",
"meds",
"daily",
"followup",
)
}
assert len(set(renders.values())) == len(renders)
+210
View File
@@ -0,0 +1,210 @@
"""Incremental requests retain business rows and survive query changes/errors."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
from doctor_workstation.ui.infinite_list import InfiniteList
from doctor_workstation.ui.widgets import get_value, page_items
@pytest.fixture(scope="module")
def app():
return QApplication.instance() or QApplication([])
class Requests:
def __init__(self):
self.pending = []
def __call__(self, function, **callbacks):
self.pending.append((function, callbacks))
def finish(self, index=0, error=None):
fn, cb = self.pending.pop(index)
if error:
cb["on_error"](error)
else:
cb["on_success"](fn())
cb["on_finished"]()
def setup(app, size=2):
host = QWidget()
layout = QVBoxLayout(host)
table = QTableWidget(0, 2)
footer = InfiniteList(size)
footer.bind(table)
layout.addWidget(table)
layout.addWidget(footer)
host.resize(420, 220)
seen = []
errors = []
jobs = Requests()
def apply(result):
rows = page_items(result)
seen.append(result)
table.setRowCount(len(rows))
for i, row in enumerate(rows):
for col in range(2):
item = QTableWidgetItem(str(row["id"]))
item.setData(Qt.ItemDataRole.UserRole, row)
if col == 1:
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Unchecked)
table.setItem(i, col, item)
return host, table, footer, seen, errors, jobs, apply
def test_append_deduplicates_and_preserves_selection_checks_metadata(app):
host, table, f, seen, errors, jobs, apply = setup(app)
pages = {
1: {"items": [{"id": 1}, {"id": 2}], "total": 4, "extend": {"scope": "mine"}},
2: {"items": [{"id": 2}, {"id": 3}], "total": 4},
3: {"items": [{"id": 4}], "total": 4},
}
f.reload(lambda p: pages[p], apply, errors.append, runner=jobs, query_key="a")
jobs.finish()
table.selectRow(1)
table.item(1, 1).setCheckState(Qt.CheckState.Checked)
f.load_more()
f.load_more()
assert len(jobs.pending) == 1
jobs.finish()
assert [r["id"] for r in f.rows] == [1, 2, 3]
assert table.currentRow() == 1 and table.item(1, 1).checkState() == Qt.CheckState.Checked
assert get_value(seen[-1], "extend.scope") == "mine"
f.load_more()
jobs.finish()
assert not f.has_more
assert table.rowCount() == 4 and not errors
host.close()
def test_changed_query_discards_old_success_and_error(app):
host, t, f, seen, errors, jobs, apply = setup(app)
f.reload(
lambda p: {"items": [{"id": 1}], "total": 1},
apply,
errors.append,
runner=jobs,
query_key="a",
)
f.reload(
lambda p: {"items": [{"id": 2}], "total": 1},
apply,
errors.append,
runner=jobs,
query_key="b",
)
jobs.finish(1)
jobs.finish(0, error=RuntimeError("stale"))
assert f.rows == [{"id": 2}] and not errors and not f.loading
f.invalidate()
assert not f.has_more
host.close()
def test_append_failure_retry_and_empty_end_are_bounded(app):
host, t, f, seen, errors, jobs, apply = setup(app)
calls = []
def fetch(p):
calls.append(p)
return {"items": [{"id": 1}, {"id": 2}] if p == 1 else [], "total": 8}
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
jobs.finish()
f.load_more()
jobs.finish(error=RuntimeError("offline"))
assert t.rowCount() == 2 and f.page == 1 and not f.retry_button.isHidden()
f.load_more()
assert not jobs.pending
f.retry()
jobs.finish()
assert f.page == 2 and not f.has_more
assert calls == [1, 2] and len(errors) == 1
host.close()
def test_refresh_retains_loaded_prefix_until_complete(app):
host, t, f, seen, errors, jobs, apply = setup(app)
calls = []
def fetch(p):
calls.append(p)
return {"items": [{"id": p * 2 - 1}, {"id": p * 2}], "total": 6}
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
jobs.finish()
f.load_more()
jobs.finish()
assert t.rowCount() == 4
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
jobs.finish()
assert t.rowCount() == 4 and f.loading
jobs.finish()
assert t.rowCount() == 4 and f.page == 2
assert calls == [1, 2, 1, 2]
host.close()
def test_scroll_autofill_then_load_at_bottom(app):
host, t, f, seen, errors, jobs, apply = setup(app, size=12)
def fetch(p):
return {"items": [{"id": i} for i in range((p - 1) * 12, p * 12)], "total": 36}
host.show()
app.processEvents()
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
jobs.finish()
app.processEvents()
QTest.qWait(60)
assert not jobs.pending
t.verticalScrollBar().setValue(t.verticalScrollBar().maximum())
QTest.qWait(60)
assert len(jobs.pending) == 1
old = t.verticalScrollBar().value()
jobs.finish()
assert t.verticalScrollBar().value() == old and t.rowCount() == 24
host.close()
def test_poll_during_slow_request_keeps_work_and_updates_consumer_callbacks(app):
host, table, footer, seen, errors, jobs, apply = setup(app)
completed = []
def fetch(page):
return {"items": [{"id": page * 2 - 1}, {"id": page * 2}], "total": 6}
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
footer.reload(
fetch,
apply,
errors.append,
runner=jobs,
query_key="a",
on_finished=lambda: completed.append("latest"),
)
assert len(jobs.pending) == 1
jobs.finish()
assert completed == ["latest"] and table.rowCount() == 2
footer.load_more()
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
assert len(jobs.pending) == 1
jobs.finish()
assert table.rowCount() == 4 and footer.page == 2
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
jobs.finish() # Prefix page 2 now pending.
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
assert len(jobs.pending) == 1
jobs.finish()
assert footer.page == 2 and not footer.loading and table.rowCount() == 4
host.close()
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, QTimer
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.pages import (
appointments,
consultations,
patients,
prescription_library,
prescriptions,
)
from doctor_workstation.ui.widgets import PageHeader
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def page_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
def immediate(function: Any, *args: Any, on_success=None, on_error=None,
on_finished=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()
modules = (appointments, consultations, patients, prescription_library, prescriptions)
for module in modules:
monkeypatch.setattr(module, "run_async", immediate)
opened = []
def create(kind: str, width: int = 1040):
repo = DemoDoctorRepository()
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
classes = {
"appointments": appointments.AppointmentsPage,
"patients": patients.PatientsPage,
"consultations": consultations.ConsultationsPage,
"prescriptions": prescriptions.PrescriptionsPage,
"prescription_library": prescription_library.PrescriptionLibraryPage,
}
page = classes[kind](repo, permissions=session.permissions, current_user=session.user)
opened.append(page)
page.resize(width, 700)
page.show()
page.refresh()
for _ in range(4):
application.processEvents()
return page
yield create
for page in opened:
for timer in page.findChildren(QTimer):
timer.stop()
page.close()
page.deleteLater()
application.processEvents()
@pytest.mark.parametrize("kind", ["appointments", "patients", "consultations", "prescriptions", "prescription_library"])
def test_list_header_fits_the_real_bundled_font(page_factory, kind: str) -> None:
page = page_factory(kind)
header = page.findChild(PageHeader)
assert header is not None
for label in header.findChildren(QLabel):
if label.isVisible() and label.text() and label.property("role"):
assert label.height() >= label.fontMetrics().height(), (
kind, label.text(), label.height(), label.fontMetrics().height()
)
@pytest.mark.parametrize("kind", ["patients", "prescription_library"])
@pytest.mark.parametrize("width", [1040, 1200])
def test_row_actions_are_fully_reachable_at_each_width(page_factory, kind, width):
page = page_factory(kind, width)
table = page.patient_workspace.table if kind == "patients" else page.table
assert table.rowCount() > 0
assert table.horizontalHeader().visualIndex(9) == 9
actions = table.cellWidget(0, 9)
assert actions is not None
assert table.horizontalScrollBar().value() == 0
# Both approved layouts put actions after the nine data columns. Preserve
# full-size controls and the existing reachability assertion after scrolling.
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
QApplication.processEvents()
assert actions.geometry().left() >= 0
assert actions.geometry().right() < table.viewport().width()
assert actions.height() >= actions.minimumSizeHint().height()
for child in actions.findChildren(QPushButton):
assert actions.rect().contains(child.mapTo(actions, QPoint()))
assert actions.rect().contains(child.mapTo(actions, child.rect().bottomRight()))
def test_appointment_detail_lines_and_cancel_fit_without_overlapping(page_factory) -> None:
page = page_factory("appointments")
table = page.table
assert table.rowCount() > 0
host = table.cellWidget(0, 4)
assert host.width() >= host.minimumSizeHint().width()
assert host.height() >= host.minimumSizeHint().height()
for label in host.findChildren(QLabel):
assert label.height() >= label.fontMetrics().height()
assert host.rect().contains(label.geometry())
cancel = next(button for button in host.findChildren(QPushButton) if button.text() == "取消")
for label in host.findChildren(QLabel):
assert not label.geometry().intersects(cancel.geometry())
# Underlying text is still available to sorting while a delegate paints only
# the selection surface beneath the actual controls.
assert table.item(0, 4).text()
assert isinstance(table.itemDelegateForColumn(4), appointments._AppointmentInfoDelegate)
assert table.horizontalScrollBar().maximum() == 0
search = page.findChild(QPushButton, "AppointmentSearchButton")
assert search.height() == page.patient_input.height()
assert all(button.height() == search.height() for button in page.date_buttons.values() if button.isVisible())
@pytest.mark.parametrize("kind", ["patients", "prescriptions", "prescription_library", "consultations"])
def test_filter_actions_align_with_their_inputs(page_factory, kind: str) -> None:
page = page_factory(kind)
if kind == "patients":
controls = page.patient_workspace
widgets = (controls.keyword_edit, controls.search_button, controls.reset_button)
statuses = tuple(controls.status_buttons.values())
assert len({widget.height() for widget in statuses}) == 1
assert len({widget.mapTo(controls.status_host, QPoint()).y() for widget in statuses}) == 1
for widget in statuses:
assert controls.status_host.rect().contains(widget.geometry())
assert widget.height() >= widget.fontMetrics().height()
elif kind == "prescriptions":
widgets = (page.sn_filter, page.query_button, page.reset_button, page.doctor_filter.button)
elif kind == "prescription_library":
widgets = (page.name_filter, page.query_button, page.reset_button)
else:
widgets = (page.search_button, page.reset_button, page.custom_date_edit)
assert len({widget.height() for widget in widgets}) == 1
+228
View File
@@ -0,0 +1,228 @@
"""Contract for the shared motion system.
The product previously had no `QPropertyAnimation` at all, so these tests exist
to keep the two things that make added motion a liability from creeping back:
an animation that outlives or destroys the object it is animating, and a
graphics effect left attached after a fade, which would quietly move a whole
subtree onto Qt's offscreen composite path for the rest of the session.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# The module disables itself under the offscreen platform so widget grabs in the
# other suites capture settled frames; these tests are about the animation, so
# they opt back in.
os.environ["DOCTOR_MOTION"] = "on"
import pytest
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import (
QApplication,
QLabel,
QScrollArea,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from doctor_workstation.ui import motion
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]:
host = QWidget()
host.resize(320, 120)
layout = QVBoxLayout(host)
stack = QStackedWidget()
first, second = QLabel("A"), QLabel("B")
stack.addWidget(first)
stack.addWidget(second)
layout.addWidget(stack)
host.show()
application.processEvents()
return host, stack, second
def test_page_transition_fades_and_settles_upward(application: QApplication) -> None:
host, stack, incoming = _stack(application)
origin = incoming.pos()
motion.switch_stack(stack, 1)
animations = incoming._doctor_motion
rise, fade = animations["enter"], animations["fade"]
assert rise.duration() == fade.duration() == motion.BASE
samples = []
for at in (0, motion.BASE // 2, motion.BASE - 1):
rise.setCurrentTime(at)
fade.setCurrentTime(at)
samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity()))
offsets = [offset for offset, _ in samples]
opacities = [opacity for _, opacity in samples]
assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up"
assert opacities == sorted(opacities), "opacity must rise monotonically"
assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0)
host.close()
def test_fade_detaches_its_graphics_effect_when_it_finishes(
application: QApplication,
) -> None:
"""A left-behind opacity effect is a permanent frame-rate tax, not a leak."""
host, stack, incoming = _stack(application)
motion.switch_stack(stack, 1)
assert incoming.graphicsEffect() is not None
fade = incoming._doctor_motion["fade"]
fade.setCurrentTime(fade.duration())
# The detach is deferred by one event-loop turn on purpose, so that the
# effect is not destroyed from inside the signal it is emitting.
application.processEvents()
application.processEvents()
assert incoming.graphicsEffect() is None
host.close()
def test_motion_can_be_turned_off_without_leaving_widgets_mid_state(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DOCTOR_MOTION", "off")
assert motion.reduced_motion()
host, stack, incoming = _stack(application)
origin = incoming.pos()
motion.switch_stack(stack, 1)
assert stack.currentIndex() == 1
assert incoming.pos() == origin
assert incoming.graphicsEffect() is None
host.close()
def _scroll_area(application: QApplication) -> QScrollArea:
area = QScrollArea()
area.setWidgetResizable(True)
area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300))))
area.resize(300, 200)
area.show()
application.processEvents()
return area
def _wheel(delta: int) -> QWheelEvent:
return QWheelEvent(
QPointF(50, 50),
QPointF(50, 50),
QPoint(0, 0),
QPoint(0, delta),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
assert scroller.eventFilter(area.viewport(), _wheel(-120))
animation = scroller._animation
assert animation.endValue() == motion.SCROLL_STEP
values = []
for at in (0, 60, 120, animation.duration() - 1):
animation.setCurrentTime(at)
values.append(bar.value())
assert values[0] == 0
assert values == sorted(values)
assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early"
animation.stop()
area.close()
def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area(
application: QApplication,
) -> None:
"""Swallowing the wheel at the extremes is what makes nested panes feel stuck."""
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
bar.setValue(bar.minimum())
application.processEvents()
assert not scroller.eventFilter(area.viewport(), _wheel(120))
assert scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.stop()
bar.setValue(bar.maximum())
application.processEvents()
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
area.close()
def test_zoom_gestures_are_left_alone(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
ctrl_wheel = QWheelEvent(
QPointF(50, 50),
QPointF(50, 50),
QPoint(0, 0),
QPoint(0, -120),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.ControlModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
assert not scroller.eventFilter(area.viewport(), ctrl_wheel)
area.close()
def test_install_is_idempotent(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
first = area._doctor_smooth_scroll
motion.install_smooth_scroll(area)
assert area._doctor_smooth_scroll is first
area.close()
def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
application: QApplication,
) -> None:
"""A QScrollArea should not have to opt in page by page."""
from doctor_workstation.ui.theme import apply_theme
apply_theme(application)
area = QScrollArea()
area.setWidget(QLabel("content"))
area.show()
area.ensurePolished()
application.sendEvent(area, QEvent(QEvent.Type.Polish))
application.processEvents()
assert getattr(area, "_doctor_smooth_scroll", None) is not None
area.close()
@@ -0,0 +1,641 @@
"""Order contracts at risk when filters, summary and table receive the blue layout."""
from __future__ import annotations
import os
import socket
from copy import deepcopy
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import PatientOrdersWorkspace, PatientsPage
from doctor_workstation.ui.theme import apply_theme
class _Repository:
def __init__(self) -> None:
self.queries: list[dict[str, Any]] = []
self.rows = [
{
"id": 901 + index,
"order_no": f"PO2026081000{index + 1}",
"patient_name": name,
"patient_phone_masked": f"186****482{index}",
"recipient_phone": f"1860000482{index}",
"prescription_id": 801 + index,
"diagnosis_id": 501 + index,
"amount": 368 + index,
"effective_amount": 368 + index,
"prescription_audit_status": 1,
"payment_slip_audit_status": 1,
"fulfillment_status": 2,
"assistant_name": "周医助",
"doctor_name": "陈医生(演示)",
"creator_name": "周医助",
# Source-only values must not fill display fields that are absent.
"create_time": "2026-09-05 10:15:00",
"pay_orders": [{"id": 2001 + index, "pay_amount": 368 + index}],
}
for index, name in enumerate(("阿青", "林青", "赵青"))
]
self.total = 47
self.summary = {
"orders": 47,
"amount": 12368.5,
"pending": 8,
"completed": 9,
"rejected": 2,
"rejection_rate": 4.3,
}
def patient_orders(self, **query: Any) -> dict[str, Any]:
self.queries.append(query)
return {
"lists": deepcopy(self.rows),
"count": self.total,
"extend": {
"scope": {"label": "测试部门订单范围"},
"summary": deepcopy(self.summary),
},
}
def list_patients(self, **_query: Any) -> dict[str, Any]:
return {
"lists": [{"id": 501, "diagnosis_id": 501, "patient_name": "阿青"}],
"count": 1,
"extend": {"scope": {"label": "测试患者范围"}},
}
def patient_progress(self, **_query: Any) -> dict[str, Any]:
return {
"lists": [],
"count": 0,
"extend": {"scope": {"label": "测试面诊范围"}},
}
def _settle(application: QApplication) -> None:
for _ in range(3):
application.processEvents()
def _click(widget, application: QApplication) -> None:
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
_settle(application)
def _rect_in(widget, parent) -> QRect:
return QRect(widget.mapTo(parent, QPoint()), widget.size())
def _banner_text(workspace: PatientOrdersWorkspace) -> str:
return " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
@pytest.fixture(scope="module")
def application() -> QApplication:
application = QApplication.instance() or QApplication([])
apply_theme(application)
return application
@pytest.fixture
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
def reject_network(*_args: Any, **_kwargs: Any):
pytest.fail("Order visual tests must use only local fixture data")
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
monkeypatch.setattr(patients_module, "run_async", immediate)
opened = []
def create(*, permissions=("*",), width=1270, height=680, page=False):
repository = _Repository()
widget = (
PatientsPage(repository, permissions=PermissionSet(list(permissions)))
if page
else PatientOrdersWorkspace(repository, PermissionSet(list(permissions)))
)
opened.append(widget)
widget.resize(width, height)
widget.show()
if not page:
widget.refresh()
_settle(application)
return widget, repository
yield create
for widget in opened:
for timer in widget.findChildren(QTimer):
timer.stop()
widget.close()
widget.deleteLater()
_settle(application)
def test_filter_defaults_and_reset_keep_edited_dates_but_remove_query_limit(
application: QApplication, workspace_factory
) -> None:
workspace, repository = workspace_factory()
default_query = {
"keyword": "",
"prescription_audit_status": None,
"payment_slip_audit_status": None,
"fulfillment_status": None,
"start_date": "",
"end_date": "",
"page_no": 1,
"page_size": 15,
}
assert repository.queries[0] == default_query
assert all(query == {**default_query, "page_no": index + 1}
for index, query in enumerate(repository.queries))
assert not workspace.use_dates.isChecked()
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
assert workspace.start_date.date() == QDate.currentDate().addDays(-30)
assert workspace.end_date.date() == QDate.currentDate()
assert all(
combo.currentData() is None
for combo in (workspace.rx_audit, workspace.pay_audit, workspace.fulfillment)
)
before_filters = len(repository.queries)
workspace.keyword_edit.setText(" 林医生 ")
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
assert len(repository.queries) == before_filters # Changing status does not auto-submit.
_click(workspace.use_dates, application)
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
start, end = QDate(2026, 7, 3), QDate(2026, 8, 8)
workspace.start_date.setDate(start)
workspace.end_date.setDate(end)
_click(workspace.search_button, application)
assert repository.queries[before_filters] == {
**default_query,
"keyword": "林医生",
"prescription_audit_status": 2,
"payment_slip_audit_status": 0,
"fulfillment_status": 9,
"start_date": "2026-07-03",
"end_date": "2026-08-08",
}
workspace.pager.load_more()
assert repository.queries[-1]["page_no"] == 2
assert repository.queries[-1]["page_size"] == 15
workspace.keyword_edit.setText("新检索")
QTest.keyClick(workspace.keyword_edit, Qt.Key.Key_Return)
_settle(application)
assert next(query for query in repository.queries if query["keyword"] == "新检索")["page_no"] == 1
workspace.start_date.setDate(end.addDays(1))
before = len(repository.queries)
_click(workspace.search_button, application)
assert len(repository.queries) == before
assert workspace.banner.isVisible()
assert "开始日期不能晚于结束日期" in _banner_text(workspace)
_click(workspace.reset_button, application)
assert repository.queries[-1] == {**default_query, "page_no": repository.queries[-1]["page_no"]}
assert workspace.keyword_edit.text() == ""
assert not workspace.use_dates.isChecked()
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
assert workspace.start_date.date() == end.addDays(1)
assert workspace.end_date.date() == end
assert not workspace.banner.isVisible()
def test_twelve_columns_keep_source_text_distinct_ids_and_missing_display_fields(
application: QApplication, workspace_factory
) -> None:
workspace, repository = workspace_factory()
row = repository.rows[0]
row["order_no"] = "PO-非常长的订单号-20260905-ABCDEFGHIJ"
row["patient_name"] = "用于检查省略与提示的较长患者姓名"
row["doctor_name"] = "用于检查分行显示与完整提示信息的开方医生(演示)"
workspace.refresh()
_settle(application)
table = workspace.table
assert [table.horizontalHeaderItem(column).text() for column in range(table.columnCount())] == [
"订单",
"患者",
"处方 / 诊单",
"有效金额",
"处方审核",
"支付审核",
"履约",
"支付单",
"归属助理",
"开方人",
"创建人",
"创建时间",
]
index = next(
index
for index in range(table.rowCount())
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == 901
)
assert table.item(index, 0).text() == f"{row['order_no']} · #901"
assert table.item(index, 1).text() == f"{row['patient_name']} · 186****4820"
assert table.item(index, 2).text() == "#801 / #501"
assert table.item(index, 9).text() == row["doctor_name"]
assert table.item(index, 0).toolTip() == table.item(index, 0).text()
assert table.item(index, 1).toolTip() == table.item(index, 1).text()
assert table.item(index, 9).toolTip() == row["doctor_name"]
assert table.item(index, 7).text() == ""
assert table.item(index, 11).text() == ""
for column in range(12):
assert not table.isColumnHidden(column)
assert table.item(index, column).data(Qt.ItemDataRole.UserRole) == row
assert row["recipient_phone"] not in table.item(index, column).text()
def test_sorted_selection_buttons_and_menu_emit_current_order_resource_ids(
application: QApplication, workspace_factory
) -> None:
workspace, _repository = workspace_factory()
table = workspace.table
emitted = []
workspace.diagnosis_requested.connect(lambda row: emitted.append(("diagnosis", row)))
workspace.detail_requested.connect(lambda row: emitted.append(("detail", row)))
workspace.action_requested.connect(lambda key, row: emitted.append((key, row)))
table.sortItems(0, Qt.SortOrder.AscendingOrder)
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
table.sortItems(0, Qt.SortOrder.DescendingOrder)
_settle(application)
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id
for index in (0, 2, 1):
table.selectRow(index)
_settle(application)
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
workspace.diagnosis_button.click()
workspace.detail_button.click()
next(
action for action in workspace.action_menu.actions() if action.text() == "确认发货"
).trigger()
assert [kind for kind, _row in emitted[-3:]] == ["diagnosis", "detail", "ship"]
for _kind, row in emitted[-3:]:
assert (row["id"], row["prescription_id"], row["diagnosis_id"]) == (
expected["id"],
expected["prescription_id"],
expected["diagnosis_id"],
)
@pytest.mark.parametrize(
("permissions", "menu", "detail"),
[
(("*",), ["撤回支付审核", "修改快递单号", "确认发货", "上传药房"], True),
(("tcm.prescriptionOrder/detail",), [], True),
(("tcm.prescriptionOrder/ship",), ["确认发货"], False),
(("tcm.prescriptionOrder.detail", "tcm.prescriptionOrder.ship"), [], False),
((), [], False),
],
)
def test_selected_order_menu_requires_canonical_permissions(
application: QApplication, workspace_factory, permissions, menu, detail
) -> None:
workspace, _repository = workspace_factory(permissions=permissions)
assert [action.text() for action in workspace.action_menu.actions()] == menu
assert workspace.action_button.isVisible() is bool(menu)
assert workspace.detail_button.isVisible() is detail
assert workspace.diagnosis_button.isEnabled()
emitted = []
workspace.detail_requested.connect(lambda row: emitted.append(row["id"]))
workspace._request_detail() # The double-click path also checks permission.
assert bool(emitted) is detail
workspace.permissions = PermissionSet([])
workspace._selection_changed()
_settle(application)
assert workspace.action_menu.actions() == []
assert not workspace.action_button.isVisible()
assert not workspace.detail_button.isVisible()
@pytest.mark.parametrize(
("rx", "pay", "fulfillment", "rx_text", "pay_text", "state", "exclusion"),
[
(0, 1, 4, "待审核", "已通过", "已取消", "已取消不计入"),
(1, 2, 9, "已通过", "已驳回", "拒收", "拒收不计入"),
(2, 0, 10, "已驳回", "待审核", "退款", "退款不计入"),
(1, 1, 6, "已通过", "已通过", "已签收", ""),
],
)
def test_amount_exclusion_reasons_and_independent_status_text_survive_rendering(
application: QApplication,
workspace_factory,
rx,
pay,
fulfillment,
rx_text,
pay_text,
state,
exclusion,
) -> None:
workspace, repository = workspace_factory()
repository.rows = [
{
**repository.rows[0],
"prescription_audit_status": rx,
"payment_slip_audit_status": pay,
"fulfillment_status": fulfillment,
"amount_included": not bool(exclusion),
"amount_exclusion_text": exclusion,
"effective_amount": 12368.5,
}
]
workspace.refresh()
_settle(application)
assert workspace.table.item(0, 3).text() == (exclusion or "¥12,368.50")
assert [workspace.table.item(0, column).text() for column in (4, 5, 6)] == [
rx_text,
pay_text,
state,
]
if fulfillment == 6:
refund = next(
action for action in workspace.action_menu.actions() if action.text() == "退款"
)
assert refund.property("danger") is True
def test_summary_uses_response_scope_and_legacy_aliases_without_recomputing_rows(
application: QApplication, workspace_factory
) -> None:
workspace, repository = workspace_factory()
assert {key: label.text() for key, label in workspace.metrics.items()} == {
"orders": "47",
"amount": "¥12,368.50",
"pending": "8",
"completed": "9",
"rejected": "2",
"rejection_rate": "4.3%",
}
assert workspace.scope_label.text() == "测试部门订单范围"
assert workspace.pager.total == 47
assert workspace.pager.page_size == 15
assert workspace.pager.height() == 24
# Preserve the existing ratio compatibility, including its known ambiguity.
repository.summary = {
"order_count": 20,
"effective_amount": 700.25,
"pending_audit": 3,
"completed": 4,
"rejected": 1,
"rejection_rate": 0.05,
}
workspace.refresh()
_settle(application)
assert {key: label.text() for key, label in workspace.metrics.items()} == {
"orders": "20",
"amount": "¥700.25",
"pending": "3",
"completed": "4",
"rejected": "1",
"rejection_rate": "5.0%",
}
before = len(repository.queries)
for metric in workspace.metrics.values():
_click(metric, application)
assert len(repository.queries) == before # The six metrics are read-only.
@pytest.mark.parametrize("silent", [False, True])
def test_loading_freezes_queries_resets_rows_and_ignores_stale_success_and_failure(
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
) -> None:
workspace, repository = workspace_factory()
table = workspace.table
queued = []
monkeypatch.setattr(
patients_module,
"run_async",
lambda function, **callbacks: queued.append((function, callbacks)),
)
workspace.keyword_edit.setText("旧请求")
workspace.refresh(silent=silent)
workspace.keyword_edit.setText("最新请求")
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
workspace.use_dates.setChecked(True)
workspace.start_date.setDate(QDate(2026, 7, 1))
workspace.end_date.setDate(QDate(2026, 8, 1))
workspace.refresh(silent=silent)
_settle(application)
assert table.rowCount() == 0
assert workspace.content_stack.currentIndex() == 1
assert workspace.pager.isVisible()
assert not workspace.banner.isVisible()
workspace.keyword_edit.setText("尚未提交")
workspace.rx_audit.setCurrentIndex(0)
workspace.use_dates.setChecked(False)
newer = queued[1][0]()
assert repository.queries[-1] == {
"keyword": "最新请求",
"prescription_audit_status": 2,
"payment_slip_audit_status": 0,
"fulfillment_status": 9,
"start_date": "2026-07-01",
"end_date": "2026-08-01",
"page_no": 1,
"page_size": 15,
}
newer["extend"]["scope"]["label"] = "最新范围"
queued[1][1]["on_success"](newer)
current_item = table.item(0, 0)
stale = queued[0][0]()
assert repository.queries[-1]["keyword"] == "旧请求"
queued[0][1]["on_success"](stale)
queued[0][1]["on_error"](RuntimeError("过期失败"))
_settle(application)
assert table.item(0, 0) is current_item
assert workspace.scope_label.text() == "最新范围"
assert not workspace.banner.isVisible()
workspace.keyword_edit.setText("最新请求")
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
workspace.use_dates.setChecked(True)
workspace.refresh(silent=silent)
queued[-1][1]["on_error"](RuntimeError("订单查询失败"))
_settle(application)
assert table.item(0, 0) is current_item
assert workspace.metrics["orders"].text() == "47"
assert workspace.banner.isVisible()
assert "订单查询失败" in _banner_text(workspace)
workspace.refresh(silent=silent)
queued[-1][1]["on_success"]({"lists": [], "count": 0})
_settle(application)
assert table.rowCount() == 0
assert workspace.content_stack.currentIndex() == 1
assert "当前范围内暂无订单" in " ".join(
label.text() for label in workspace.content_stack.currentWidget().findChildren(QLabel)
)
assert not workspace.diagnosis_button.isEnabled()
assert not workspace.detail_button.isEnabled()
assert workspace.action_menu.actions() == []
assert workspace.pager.total == 0
assert workspace.metrics["orders"].text() == "0"
assert workspace.metrics["amount"].text() == "¥0.00"
assert not workspace.banner.isVisible()
@pytest.mark.parametrize(("width", "height"), [(1270, 680), (1014, 490), (760, 380)])
def test_layout_keeps_filters_actions_and_load_status_reachable_at_narrow_viewports(
application: QApplication, workspace_factory, width: int, height: int
) -> None:
workspace, _repository = workspace_factory(width=width, height=height)
table = workspace.table
assert workspace.size().width() == width
assert workspace.size().height() == height
assert table.columnCount() == 12
assert table.horizontalHeader().height() == 46
assert all(table.rowHeight(index) == 68 for index in range(table.rowCount()))
assert table.font().pixelSize() == 14
assert workspace.scope_label.font().pixelSize() == 13
assert all(metric.font().pixelSize() == 18 for metric in workspace.metrics.values())
for widget in (
workspace.keyword_edit,
workspace.rx_audit,
workspace.pay_audit,
workspace.fulfillment,
workspace.search_button,
workspace.reset_button,
workspace.use_dates,
workspace.start_date,
workspace.end_date,
):
assert widget.isVisibleTo(workspace)
assert workspace.filter_card.rect().contains(_rect_in(widget, workspace.filter_card))
for metric in workspace.metrics.values():
assert workspace.summary_strip.rect().contains(_rect_in(metric, workspace.summary_strip))
if width == 1270:
assert workspace.filter_card.height() == 132
assert workspace.summary_strip.height() == 96
if width == 760:
assert workspace.filter_card.height() == 184
assert (
len(
{
metric.mapTo(workspace.summary_strip, QPoint()).y()
for metric in workspace.metrics.values()
}
)
== 2
)
assert table.horizontalScrollBar().maximum() > 0
assert workspace.scroll.verticalScrollBar().maximum() > 0
for host in (
workspace.filter_card,
workspace.summary_strip,
workspace.action_bar,
workspace.pager,
):
workspace.scroll.ensureWidgetVisible(host, 0, 0)
_settle(application)
assert (
workspace.scroll.viewport().rect().contains(_rect_in(host, workspace.scroll.viewport()))
), host.objectName()
for button in (workspace.diagnosis_button, workspace.detail_button, workspace.action_button):
assert workspace.action_bar.rect().contains(_rect_in(button, workspace.action_bar))
assert workspace.pager.height() == 24
for widget in (workspace.pager.summary_label,):
assert workspace.pager.rect().contains(_rect_in(widget, workspace.pager))
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
table.scrollToBottom()
_settle(application)
assert table.columnViewportPosition(11) >= 0
assert table.columnViewportPosition(11) + table.columnWidth(11) <= table.viewport().width()
assert table.rowViewportPosition(2) + table.rowHeight(2) <= table.viewport().height()
def test_tab_switches_keep_patient_styles_scope_deduplication_and_progress_timer(
application: QApplication, workspace_factory
) -> None:
page, _repository = workspace_factory(width=1328, height=884, page=True)
patient = page.patient_workspace
progress = page.progress_workspace
patient_style = patient.styleSheet()
assert patient.table.objectName() == "PatientTable"
assert patient.table.columnCount() == 10
assert patient.table.rowHeight(0) == 66
assert patient.table.horizontalHeader().height() == 42
assert patient.table.font().pixelSize() == 14
assert not page.scope_badge.isVisible()
assert not patient.search_toolbar.isVisible()
page.filter_disclosure.set_expanded(True)
_settle(application)
assert patient.search_toolbar.isVisible()
assert not progress.timer.isActive()
page.tabs.setCurrentIndex(2)
_settle(application)
progress_style = progress.styleSheet()
progress_table_font = progress.schedule_table.font()
progress_metric_font = progress.overview["total"][0].font()
assert progress.timer.interval() == 15_000
assert progress.timer.isActive()
assert not page.scope_badge.isVisible()
assert not progress.scope_label.isVisible()
page.filter_disclosure.set_expanded(True)
_settle(application)
assert progress.scope_label.isVisible()
assert progress_table_font.pixelSize() == 14
assert progress_metric_font.pixelSize() == 18
assert progress.schedule_table.rowCount() == 7
assert progress.schedule_table.columnCount() == 7
page.tabs.setCurrentIndex(1)
_settle(application)
assert not progress.timer.isActive()
assert not page.scope_badge.isVisible()
assert not patient.search_toolbar.isVisible()
assert page.order_workspace.scope_label.isVisible()
assert page.order_workspace.scope_label.text() == "测试部门订单范围"
assert patient.styleSheet() == patient_style
assert progress.styleSheet() == progress_style
assert progress.schedule_table.font() == progress_table_font
assert progress.overview["total"][0].font() == progress_metric_font
assert patient.table.rowHeight(0) == 66
assert patient.table.horizontalHeader().height() == 42
page.tabs.setCurrentIndex(0)
_settle(application)
assert patient.search_toolbar.isVisible()
assert not page.scope_badge.isVisible()
assert not progress.timer.isActive()
page.tabs.setCurrentIndex(2)
_settle(application)
assert not page.scope_badge.isVisible()
assert progress.scope_label.isVisible()
assert progress.timer.isActive()
page.hide()
_settle(application)
assert not progress.timer.isActive()
page.show()
_settle(application)
assert progress.timer.isActive()
@@ -0,0 +1,483 @@
"""Progress data/display contracts and native reachability after the blue redesign."""
from __future__ import annotations
import os
import socket
from copy import deepcopy
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import PatientProgressWorkspace, PatientsPage
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
class _Repository:
def __init__(self) -> None:
self.queries: list[dict[str, Any]] = []
self.result = {
"lists": [{"id": 101, "diagnosis_id": 501, "patient_id": 301,
"patient_name": "林晓岚", "doctor_name": "陈医生(演示)",
"appointment_time": "09:00-09:30"}],
"count": 37,
"extend": {"scope": {"label": "本部门及下级"}, "schedule_mode": "self",
"summary": {"waiting": 37, "completed": 2, "missed": 3}},
}
def patient_progress(self, **query: Any) -> dict[str, Any]:
self.queries.append(deepcopy(query))
return deepcopy(self.result)
def list_patients(self, **_query: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def patient_orders(self, **_query: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def _settle(application: QApplication) -> None:
for _ in range(4):
application.processEvents()
def _texts(table) -> list[list[str]]:
return [[table.item(row, column).text() for column in range(table.columnCount())]
for row in range(table.rowCount())]
def _metrics(workspace) -> dict[str, tuple[str, str]]:
return {key: (value.text(), hint.text()) for key, (value, hint) in workspace.overview.items()}
def _rect_in(widget, parent) -> QRect:
return QRect(widget.mapTo(parent, QPoint()), widget.size())
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
def reject_network(*_args, **_kwargs):
pytest.fail("Progress regression tests must use local fixtures or Demo only")
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
monkeypatch.setattr(patients_module, "run_async", immediate)
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
opened = []
def create(*, page=False, shell=False, permissions=("*",), width=1274, height=696,
repository=None, refresh=True):
repository = repository if repository is not None else _Repository()
permission_set = PermissionSet(list(permissions))
if shell:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
# A real shell with only the tested navigation route, not unrelated workspaces.
widget = ShellWindow(repository, {
"user": session.user, "permissions": permission_set,
"menu": [{"perms": "firstvisit.myPatient/lists"}],
}, permissions=permission_set)
elif page:
widget = PatientsPage(repository, permissions=permission_set)
else:
widget = PatientProgressWorkspace(repository)
opened.append(widget)
widget.resize(width, height)
widget.show()
if shell:
widget.navigate("patients")
widget.pages["patients"].tabs.setCurrentIndex(2)
elif page:
widget.tabs.setCurrentIndex(2)
elif refresh:
widget.refresh()
_settle(application)
return widget, repository
yield create
for widget in opened:
for timer in widget.findChildren(QTimer):
timer.stop()
widget.close()
widget.deleteLater()
_settle(application)
@pytest.mark.parametrize(
("extend", "total", "expected_mode", "expected"),
[
({"schedule_mode": "self", "summary": {"waiting": 5, "completed": 2, "missed": 1}},
99, "按本人归属", {"total": ("8", "0 位接诊医生"), "waiting": ("5", "本人归属患者"), "completed": ("2", "已过号 1 人")}),
({"schedule_mode": "ownership", "summary": {"completed": 3, "missed": 2}},
7, "按本人归属", {"total": ("12", "0 位接诊医生"), "waiting": ("7", "本人归属患者"), "completed": ("3", "已过号 2 人")}),
({"schedule_mode": "self", "summary": {"waiting": 8, "completed": 9, "missed": 7},
"today_overview": {"total_visits": 0, "booked": 0, "completed": 0, "missed": 0, "doctor_count": 4}},
99, "按本人归属", {"total": ("0", "4 位接诊医生"), "waiting": ("0", "本人归属患者"), "completed": ("0", "已过号 0 人")}),
({"schedule_mode": "roster", "summary": {"waiting": 30, "completed": 40, "missed": 5},
"today_overview": {"total_visits": 18, "booked": 12, "completed": 4, "empty_slots": 6, "doctor_count": 3, "missed": 2}},
99, "与排班合并", {"total": ("18", "3 位接诊医生"), "waiting": ("12", "有效挂号"), "completed": ("6", "当前空号")}),
({"schedule_mode": "roster", "summary": {"waiting": 6, "completed": 3, "missed": 1}},
99, "与排班合并", {"total": ("10", "0 位接诊医生"), "waiting": ("6", "有效挂号"), "completed": ("0", "当前空号")}),
({}, 4, "与排班合并", {"total": ("4", "0 位接诊医生"), "waiting": ("4", "有效挂号"), "completed": ("0", "当前空号")}),
],
ids=["self-summary", "ownership-total-fallback", "explicit-zero-wins", "roster-empty-slots", "roster-summary-fallback", "missing-extend"],
)
def test_overview_preserves_mode_fallbacks_and_fixed_completed_caption(
workspace_factory, extend, total, expected_mode, expected
) -> None:
workspace, repository = workspace_factory()
repository.result.update(count=total, extend=extend)
workspace.refresh()
assert _metrics(workspace) == expected
assert workspace.mode_label.text() == expected_mode
assert [label.text() for label in workspace.overview_card.findChildren(QLabel)
if label.text() in {"今日面诊总数", "待面诊", "已完成"}] == ["今日面诊总数", "待面诊", "已完成"]
assert workspace.queue_count.text() == f"{total} 人 · 每 15 秒刷新"
assert workspace.queue_table.rowCount() == 1 # Totals are server scope, not visible-row counts.
@pytest.mark.parametrize("week_schedule", [None, [], "invalid"], ids=["missing", "empty", "invalid-type"])
def test_schedule_fallback_is_seven_consecutive_days_with_blank_doctors(
workspace_factory, monkeypatch, week_schedule
) -> None:
# Sunday fixes the distinction between rolling seven days and a calendar week.
class Sunday:
@staticmethod
def currentDate():
return QDate(2026, 9, 6)
monkeypatch.setattr(patients_module, "QDate", Sunday)
workspace, repository = workspace_factory()
repository.result["extend"]["week_schedule"] = week_schedule
workspace.refresh()
rows = _texts(workspace.schedule_table)
assert [row[0] for row in rows] == ["09-06 周日", "09-07 周一", "09-08 周二", "09-09 周三", "09-10 周四", "09-11 周五", "09-12 周六"]
assert all(row[1:6] == ["0"] * 5 and row[6] == "" for row in rows)
def test_schedule_zero_priority_missing_fallback_and_doctor_names(workspace_factory) -> None:
workspace, repository = workspace_factory()
repository.result["extend"]["week_schedule"] = [
{"date_text": "09-05", "weekday": "周六", "doctor_count": 2, "total_appointments": 0,
"waiting_appointments": 0, "completed_appointments": 0, "total_slots": 11,
"booked_slots": 7, "empty_slots": 4, "missed_appointments": 1, "doctors": []},
{"date_text": "09-06", "weekday": "周日", "doctor_count": 2, "total_appointments": None,
"waiting_appointments": "", "total_slots": 11, "booked_slots": 7, "empty_slots": 4,
"missed_appointments": 0, "doctors": [{"doctor_name": "甲医生"}, {"name": "乙医生"}]},
{"date_text": "09-07", "weekday": "周一", "doctors": "invalid"},
]
workspace.refresh()
rows = {row[0]: row for row in _texts(workspace.schedule_table)}
assert rows["09-05 周六"] == ["09-05 周六", "2", "0", "0", "0", "1", ""]
assert rows["09-06 周日"] == ["09-06 周日", "2", "11", "7", "4", "0", "甲医生、乙医生"]
assert rows["09-07 周一"][6] == ""
def test_demo_row_keeps_missing_queue_fields_and_empty_cells_visually_blank(
application, workspace_factory
) -> None:
repository = DemoDoctorRepository()
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
workspace, _ = workspace_factory(repository=repository)
assert _texts(workspace.queue_table) == [["", "林晓岚", "陈医生(演示)", "09:00", "0 位", ""]]
assert _metrics(workspace) == {"total": ("1", "0 位接诊医生"), "waiting": ("1", "本人归属患者"), "completed": ("0", "已过号 0 人")}
assert workspace.scope == "演示医生本人患者"
assert workspace.pager.summary_label.text() == "共 1 条 · 已全部加载"
assert not workspace.pager.has_more
table = workspace.schedule_table
table.scrollToItem(table.item(0, 6))
_settle(application)
rect = table.visualItemRect(table.item(0, 6)).adjusted(3, 3, -3, -3)
image = table.viewport().grab(rect).toImage()
assert not image.isNull()
# Read the actual delegate output, not just the model's DisplayRole: blank is not “—”.
assert all(image.pixelColor(x, y).lightness() > 160
for x in range(image.width()) for y in range(image.height()))
dash_table = workspace.queue_table
dash_image = dash_table.viewport().grab(dash_table.visualItemRect(dash_table.item(0, 0))).toImage()
assert any(dash_image.pixelColor(x, y).lightness() < 100
for x in range(dash_image.width()) for y in range(dash_image.height()))
def test_queue_waiting_statuses_keep_source_counts_and_no_status_desc_fallback(workspace_factory) -> None:
workspace, repository = workspace_factory()
examples = [
({"queue_status": "consulting", "queue_status_text": "就诊中", "ahead_count": 12}, "0(进行中)", "就诊中"),
({"queue_status": "next", "queue_status_text": "待确认", "ahead_count": 12}, "0(待接诊)", "待确认"),
({"queue_status": "waiting", "queue_status_text": "等待中", "ahead_count": "3"}, "3 位 · 约 45 分钟", "等待中"),
({"queue_status": "waiting", "ahead_count": 2, "estimated_wait_minutes": 7}, "2 位 · 约 7 分钟", ""),
({"queue_status": "missed", "queue_status_text": "已过号", "ahead_count": 2, "estimated_wait_minutes": -5}, "2 位 · 约 0 分钟", "已过号"),
({"queue_status": "completed", "queue_status_text": "已完成", "ahead_count": -8}, "0 位", "已完成"),
({"status_desc": "待接诊", "diagnosis_confirmed": 1, "ahead_count": "bad"}, "0 位", ""),
]
base = repository.result["lists"][0]
repository.result["lists"] = [dict(base, **fields, id=100 + index, queue_no=40 + index)
for index, (fields, _, _) in enumerate(examples)]
workspace.refresh()
table = workspace.queue_table
for row_index in range(table.rowCount()):
row = table.item(row_index, 0).data(Qt.ItemDataRole.UserRole)
fields, expected_wait, expected_status = examples[row["id"] - 100]
assert table.item(row_index, 0).text() == str(row["queue_no"])
assert table.item(row_index, 4).text() == expected_wait
assert table.item(row_index, 5).text() == expected_status
assert all(table.item(row_index, col).data(Qt.ItemDataRole.UserRole) == row for col in range(6))
assert table.selectionMode() == QAbstractItemView.SelectionMode.SingleSelection
assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers
assert table.isSortingEnabled()
@pytest.mark.parametrize(
("permissions", "expected"),
[(('tcm.diagnosis/edit', 'tcm.diagnosis/readonlyDetail'), "edit"),
(("tcm.diagnosis/readonlyDetail",), "read"), ((), "denied"),
(("tcm.diagnosis.edit", "tcm.diagnosis.readonlyDetail"), "denied")],
ids=["edit-preferred", "readonly", "no-permission", "noncanonical-permission"],
)
def test_sorted_double_click_uses_selected_diagnosis_and_canonical_permissions(
application, workspace_factory, monkeypatch, permissions, expected
) -> None:
page, repository = workspace_factory(page=True, width=1328, height=884, permissions=permissions)
workspace = page.progress_workspace
repository.result["lists"] = [dict(repository.result["lists"][0], id=101 + index,
diagnosis_id=501 + index, patient_id=301 + index,
patient_name=name, queue_no=index + 1)
for index, name in enumerate(("阿青", "林青", "赵青"))]
workspace.refresh()
calls, toasts = [], []
dialog = SimpleNamespace(
open_for=lambda resource, **kwargs: calls.append(("edit", resource, kwargs)),
open_view_only=lambda resource, **kwargs: calls.append(("read", resource, kwargs)),
)
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: dialog)
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
table = workspace.queue_table
table.sortItems(0, Qt.SortOrder.DescendingOrder)
table.selectRow(0)
table.scrollToItem(table.item(0, 1))
workspace.scroll.ensureWidgetVisible(table, 0, 0)
_settle(application)
row = table.item(0, 0).data(Qt.ItemDataRole.UserRole)
assert row["id"] == 103 # A sorted row must not resolve to the first source row (101).
point = table.visualItemRect(table.item(0, 1)).center()
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
_settle(application)
if expected == "denied":
assert calls == [] and toasts == ["当前账号没有诊单查看权限。"]
else:
assert len(calls) == 1 and calls[0][0:2] == (expected, row["diagnosis_id"])
assert calls[0][1] != row["patient_id"] and calls[0][2]["seed"] == row
assert not toasts
def test_selected_row_id_fallback_and_invalid_diagnosis_never_use_patient_id(
workspace_factory, monkeypatch
) -> None:
page, repository = workspace_factory(page=True, permissions=("tcm.diagnosis/edit",))
workspace = page.progress_workspace
calls, toasts = [], []
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: SimpleNamespace(
open_for=lambda resource, **kwargs: calls.append(resource)))
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
for row, expected in [({"id": 810, "patient_id": 910}, 810),
({"id": 811, "diagnosis_id": 0, "patient_id": 911}, None),
({"patient_id": 912}, None)]:
repository.result["lists"] = [row]
workspace.refresh()
workspace.queue_table.selectRow(0)
before = len(calls)
workspace.queue_table.itemDoubleClicked.emit(workspace.queue_table.item(0, 0))
assert calls[before:] == ([] if expected is None else [expected])
assert toasts == ["患者诊单信息不完整。", "患者诊单信息不完整。"]
def test_query_captures_today_status_and_page_before_worker_runs(
application, workspace_factory, monkeypatch
) -> None:
workspace, repository = workspace_factory()
queued = []
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
today = QDate.currentDate().toString("yyyy-MM-dd")
workspace.refresh(silent=True)
queued.pop()[1]["on_success"](deepcopy(repository.result))
workspace.pager.load_more()
workspace.pager.load_more()
assert len(queued) == 1 # Repeated bottom events share the in-flight request.
assert not workspace.banner.isVisible()
# Later GUI changes must not reach the pending worker's immutable query.
workspace._page = 9
queued[0][0]()
assert repository.queries[-1:] == [
{"status": 1, "start_date": today, "end_date": today, "page_no": 2, "page_size": 15},
]
assert "page" not in repository.queries[-1]
assert workspace.pager.page_size == 15
def test_stale_success_error_and_latest_error_preserve_the_last_good_data(
application, workspace_factory, monkeypatch
) -> None:
workspace, repository = workspace_factory()
pending = []
monkeypatch.setattr(patients_module, "run_async", lambda _function, **callbacks: pending.append(callbacks))
workspace.refresh()
# A date rollover changes the query; repeated same-day polls reuse in-flight work.
tomorrow = patients_module.QDate.currentDate().addDays(1)
monkeypatch.setattr(patients_module, "QDate", SimpleNamespace(currentDate=lambda: tomorrow))
workspace.refresh()
newer = deepcopy(repository.result)
newer["lists"][0].update(id=202, patient_name="最新结果")
newer["count"] = 22
newer["extend"].update(scope={"label": "新范围"}, summary={"waiting": 22, "completed": 4, "missed": 2})
pending[1]["on_success"](newer)
_settle(application)
snapshot = (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
pending[0]["on_success"]({"lists": [], "count": 0})
pending[0]["on_error"](RuntimeError("obsolete failure"))
_settle(application)
assert not workspace.banner.isVisible()
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
workspace.refresh(silent=True)
assert not workspace.banner.isVisible()
pending[2]["on_error"](RuntimeError("连接失败,请稍后刷新重试。"))
_settle(application)
assert workspace.banner.isVisible() and workspace.banner.property("kind") == "danger"
assert "连接失败" in workspace.banner.label.text()
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
def test_empty_list_keeps_total_week_and_compact_status_visible(
application, workspace_factory
) -> None:
workspace, repository = workspace_factory()
repository.result.update(lists=[], count=37)
workspace.refresh()
_settle(application)
assert workspace.queue_stack.currentIndex() == 1
assert not workspace.queue_table.isVisible() and workspace.pager.isVisible()
assert workspace.pager.height() == 24
assert workspace.schedule_table.rowCount() == 7
assert workspace.queue_count.text() == "共 37 人 · 每 15 秒刷新"
labels = [label.text() for label in workspace.queue_stack.currentWidget().findChildren(QLabel)]
assert "今日暂无候诊患者" in labels
assert "当前权限范围内没有有效挂号。" in labels
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 768)])
def test_actual_shell_keeps_all_columns_rows_and_load_status_reachable(
application, workspace_factory, width, height
) -> None:
shell, _ = workspace_factory(shell=True, width=width, height=height)
page = shell.pages["patients"]
page.filter_disclosure.set_expanded(True)
_settle(application)
workspace = page.progress_workspace
assert (shell.width(), shell.height()) == (width, height)
assert not page.scope_badge.isVisible() and workspace.scope_label.isVisible()
schedule, queue = workspace.schedule_table, workspace.queue_table
assert (schedule.rowCount(), schedule.columnCount(), queue.columnCount()) == (7, 7, 6)
if width == 1536:
assert schedule.verticalScrollBar().value() == 0
assert schedule.rowViewportPosition(6) + schedule.rowHeight(6) <= schedule.viewport().height()
assert workspace.scroll.viewport().rect().contains(_rect_in(schedule, workspace.scroll.viewport()))
for table in (schedule, queue):
workspace.scroll.ensureWidgetVisible(table, 0, 0)
_settle(application)
assert workspace.scroll.viewport().rect().contains(_rect_in(table, workspace.scroll.viewport()))
for column in range(table.columnCount()):
item = table.item(table.rowCount() - 1, column)
table.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
_settle(application)
assert not table.isColumnHidden(column)
assert table.viewport().rect().contains(table.visualItemRect(item)), (width, column)
workspace.scroll.ensureWidgetVisible(workspace.pager, 0, 0)
_settle(application)
assert workspace.scroll.viewport().rect().contains(_rect_in(workspace.pager, workspace.scroll.viewport()))
assert workspace.pager.height() == 24
for child in (workspace.pager.summary_label,):
assert workspace.pager.rect().contains(_rect_in(child, workspace.pager))
if width == 1024:
assert schedule.horizontalScrollBar().maximum() > 0
assert queue.horizontalScrollBar().maximum() > 0
assert workspace.scroll.verticalScrollBar().maximum() > 0
def test_splitter_mouse_drag_reallocates_space_but_cannot_collapse_panels(
application, workspace_factory
) -> None:
workspace, _ = workspace_factory()
splitter = workspace.splitter
before = splitter.sizes()
handle = splitter.handle(1)
point = handle.rect().center()
QTest.mousePress(handle, Qt.MouseButton.LeftButton, pos=point)
QTest.mouseMove(handle, point + QPoint(0, -60))
QTest.mouseRelease(handle, Qt.MouseButton.LeftButton, pos=point + QPoint(0, -60))
_settle(application)
assert splitter.sizes()[0] < before[0]
assert not splitter.childrenCollapsible()
for sizes in ([0, 1000], [1000, 0]):
splitter.setSizes(sizes)
_settle(application)
assert splitter.sizes()[0] >= workspace.schedule_card.minimumHeight() > 0
assert splitter.sizes()[1] >= workspace.queue_card.minimumHeight() > 0
def test_typography_and_styles_are_local_to_the_progress_workspace(
application, workspace_factory
) -> None:
app_style = application.styleSheet()
unrelated = QLabel("外部标签")
unrelated.ensurePolished()
original_font = unrelated.font()
workspace, _ = workspace_factory()
assert workspace.schedule_table.font().pixelSize() == 14
assert workspace.queue_table.font().pixelSize() == 14
assert workspace.scope_label.font().pixelSize() == 13
assert workspace.mode_label.font().pixelSize() == 13
assert workspace.queue_count.font().pixelSize() == 13
for value, hint in workspace.overview.values():
assert value.font().pixelSize() == 18 and hint.font().pixelSize() == 13
for label in workspace.findChildren(QLabel):
if label.property("role") == "sectionTitle":
assert label.font().pixelSize() == 14
assert application.styleSheet() == app_style
assert "ProgressWorkspace" not in app_style
assert unrelated.font() == original_font and not unrelated.styleSheet()
unrelated.deleteLater()
+285
View File
@@ -0,0 +1,285 @@
"""Patient workspaces keep resource identity while appending server pages."""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QCheckBox, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import (
PatientListWorkspace,
PatientOrdersWorkspace,
PatientProgressWorkspace,
PatientsPage,
)
class Repository:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.failed_pages: set[int] = set()
self.version = 0
def _list(self, **query: Any) -> dict[str, Any]:
self.calls.append(dict(query))
page = query["page_no"]
if page in self.failed_pages:
raise RuntimeError("连接失败,请重试。")
start = (page - 1) * query["page_size"]
rows = [
{
"id": index + 100,
"diagnosis_id": index + 1000,
"patient_id": index + 2000,
"appointment_id": index + 3000,
"order_id": index + 4000,
"patient_name": f"患者 {index:02d} · {query.get('keyword', '')}{self.version}",
"appointment_status": 1,
"prescription_audit_status": 0,
"payment_slip_audit_status": 0,
"fulfillment_status": 2,
"queue_no": index + 1,
"queue_status": "waiting",
"queue_status_text": "等待中",
}
for index in range(start, min(start + query["page_size"], 37))
]
return {
"lists": rows,
"count": 37,
"extend": {
"scope": {"label": "本人患者"},
"summary": {"orders": 37, "amount": 3700, "today": 37, "waiting": 37},
"schedule_mode": "ownership",
},
}
list_patients = _list
patient_orders = _list
patient_progress = _list
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
result = function()
except Exception as error:
callbacks["on_error"](error)
else:
callbacks["on_success"](result)
finally:
if callbacks.get("on_finished"):
callbacks["on_finished"]()
return object()
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(params=["patients", "orders", "progress"])
def workspace(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(patients_module, "run_async", run_immediately)
repository = Repository()
permissions = PermissionSet(["*"])
constructors = {
"patients": lambda: PatientListWorkspace(repository, permissions),
"orders": lambda: PatientOrdersWorkspace(repository, permissions),
"progress": lambda: PatientProgressWorkspace(repository),
}
widget = constructors[request.param]()
widget.resize(1280, 900)
widget.show()
application.processEvents()
yield request.param, widget, repository
widget.close()
widget.deleteLater()
application.processEvents()
def table_for(workspace: Any) -> Any:
return getattr(workspace, "table", None) or workspace.queue_table
def settle(application: QApplication) -> None:
application.processEvents()
QTest.qWait(60)
application.processEvents()
def scroll_down(workspace: Any, application: QApplication) -> None:
table = table_for(workspace)
table.verticalScrollBar().setValue(table.verticalScrollBar().maximum())
settle(application)
def test_scrolling_appends_without_duplicates_or_selection_loss(workspace, application):
kind, widget, repository = workspace
widget.refresh()
settle(application)
table = table_for(widget)
assert table.rowCount() == 15
assert widget.pager.height() == 24
assert not hasattr(widget.pager, "page_changed")
assert not any(button.text().isdigit() for button in widget.pager.findChildren(QPushButton))
table.sortItems(0, Qt.SortOrder.DescendingOrder)
table.selectRow(5)
chosen = table.current_data()["id"]
if kind == "patients":
table.cellWidget(5, 0).findChild(QCheckBox).setChecked(True)
scroll_down(widget, application)
assert table.rowCount() == 30
assert table.current_data()["id"] == chosen
if kind == "patients":
selected_row = next(index for index in range(table.rowCount())
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == chosen)
assert table.cellWidget(selected_row, 0).findChild(QCheckBox).isChecked()
scroll_down(widget, application)
assert table.rowCount() == 37
assert len({table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"]
for index in range(table.rowCount())}) == 37
assert [query["page_no"] for query in repository.calls] == [1, 2, 3]
assert all(query["page_size"] == 15 and "page" not in query for query in repository.calls)
assert widget.pager.page == widget._page == 3
assert widget.pager.total == 37
assert not widget.pager.has_more
assert widget.scope == "本人患者"
scroll_down(widget, application)
assert len(repository.calls) == 3
def test_refresh_retains_loaded_prefix_scroll_and_distinct_action_ids(workspace, application):
kind, widget, repository = workspace
widget.refresh()
settle(application)
scroll_down(widget, application)
table = table_for(widget)
table.selectRow(20)
chosen = dict(table.current_data())
scroll = table.verticalScrollBar().value()
repository.version = 1
widget.refresh(silent=True)
assert [query["page_no"] for query in repository.calls] == [1, 2, 1, 2]
assert table.rowCount() == 30
assert table.current_data()["id"] == chosen["id"]
assert table.current_data()["patient_name"].endswith("1")
assert table.verticalScrollBar().value() == scroll
selected = []
if kind == "patients":
widget.diagnosis_requested.connect(lambda row, _edit: selected.append(row))
widget._open_selected_diagnosis()
elif kind == "orders":
widget.detail_requested.connect(selected.append)
widget._request_detail()
else:
widget.diagnosis_requested.connect(selected.append)
widget._open_selected()
assert selected[0]["id"] == chosen["id"]
assert selected[0]["diagnosis_id"] == chosen["diagnosis_id"]
assert selected[0]["diagnosis_id"] != selected[0]["patient_id"]
def test_failed_append_keeps_rows_and_retry_continues_same_page(workspace, application):
_kind, widget, repository = workspace
repository.failed_pages.add(2)
widget.refresh()
settle(application)
scroll_down(widget, application)
table = table_for(widget)
assert table.rowCount() == 15
assert widget.pager.page == 1
assert widget.pager.retry_button.isVisible()
settle(application)
assert [query["page_no"] for query in repository.calls] == [1, 2]
repository.failed_pages.clear()
widget.pager.retry_button.click()
assert table.rowCount() == 30
assert [query["page_no"] for query in repository.calls] == [1, 2, 2]
assert not widget.pager.retry_button.isVisible()
def test_refresh_preserves_horizontal_scroll_at_narrow_width(workspace, application):
_kind, widget, _repository = workspace
widget.resize(760, 600)
widget.refresh()
settle(application)
table = table_for(widget)
horizontal = table.horizontalScrollBar()
assert horizontal.maximum() > 0
horizontal.setValue(horizontal.maximum())
offset = horizontal.value()
widget.refresh(silent=True)
assert horizontal.value() == offset
def test_first_page_failure_keeps_compact_retry_accessible(workspace, application):
_kind, widget, repository = workspace
repository.failed_pages.add(1)
widget.refresh()
settle(application)
widget.scroll.ensureWidgetVisible(widget.pager)
settle(application)
assert table_for(widget).rowCount() == 0
assert widget.pager.isVisible()
assert widget.pager.retry_button.isVisible()
assert widget.pager.retry_button.height() <= widget.pager.height()
repository.failed_pages.clear()
widget.pager.retry_button.click()
assert table_for(widget).rowCount() == 15
def test_filter_change_restarts_and_ignores_old_append(workspace, application, monkeypatch):
kind, widget, repository = workspace
widget.refresh()
settle(application)
pending = []
monkeypatch.setattr(patients_module, "run_async",
lambda function, **callbacks: pending.append((function, callbacks)))
# Capture the new runner for the next scroll request without changing query.
widget.refresh(silent=True)
pending.pop()[1]["on_success"](repository._list(page_no=1, page_size=15))
scroll_down(widget, application)
assert len(pending) == 1
if kind == "progress":
class Tomorrow:
@staticmethod
def currentDate():
return QDate.currentDate().addDays(1)
monkeypatch.setattr(patients_module, "QDate", Tomorrow)
else:
widget.keyword_edit.setText("新条件")
widget.refresh()
assert len(pending) == 2
stale_function, stale_callbacks = pending[0]
current_function, current_callbacks = pending[1]
current_callbacks["on_success"](current_function())
stale_callbacks["on_success"](stale_function())
stale_callbacks["on_error"](RuntimeError("旧请求错误"))
assert table_for(widget).rowCount() == 15
assert widget.pager.page == 1
assert not widget.banner.isVisible()
latest = repository.calls[-2]
assert latest["page_no"] == 1
if kind == "progress":
assert latest["start_date"] == QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
else:
assert latest["keyword"] == "新条件"
def test_patient_route_uses_eight_pixel_bottom_margin(application, monkeypatch):
monkeypatch.setattr(patients_module, "run_async", run_immediately)
page = PatientsPage(Repository(), PermissionSet(["*"]))
assert page.layout().contentsMargins().bottom() == 8
assert page.order_workspace.action_bar.height() == 54
page.close()
page.deleteLater()
application.processEvents()
+328
View File
@@ -0,0 +1,328 @@
"""Interaction contracts at risk when the patient list moves to the blue layout."""
from __future__ import annotations
import os
import socket
from copy import deepcopy
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QCheckBox, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import PatientListWorkspace
from doctor_workstation.ui.theme import apply_theme
class _Repository:
def __init__(self) -> None:
self.queries: list[dict[str, Any]] = []
self.rows = [
{
"id": 601 + index,
"diagnosis_id": 601 + index,
"source_patient_id": 301 + index,
"appointment_id": 401 + index if index != 2 else 0,
"patient_name": name,
"gender": 2,
"age": 36,
"assistant_name": "测试医助",
"assistant_id": 81 if index != 2 else 0,
"appointment_doctor_name": "测试医生" if index != 2 else "",
"appointment_status": (1, 4, 0)[index],
"appointment_status_text": "未预约" if index == 2 else "待接诊",
"appointment_time_text": "2026-09-05 09:00" if index != 2 else "",
"has_id_card": index != 1,
"revisit_count": index,
"confirmation_text": "待确认",
"diagnosis_date_text": "初诊",
"phone_masked": f"138****120{index}",
}
for index, name in enumerate(("阿青", "林青", "赵青"))
]
def list_patients(self, **query: Any) -> dict[str, Any]:
self.queries.append(query)
return {
"lists": deepcopy(self.rows),
"count": len(self.rows),
"extend": {
"scope": {"label": "测试部门可见患者"},
"summary": {"today": 2, "tomorrow": 1, "day_after": 0},
"dates": {"today": QDate.currentDate().toString("yyyy-MM-dd")},
},
}
def _settle(application: QApplication) -> None:
for _ in range(3):
application.processEvents()
@pytest.fixture(scope="module")
def application() -> QApplication:
application = QApplication.instance() or QApplication([])
apply_theme(application)
return application
@pytest.fixture
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
def reject_network(*_args: Any, **_kwargs: Any):
pytest.fail("Patient visual tests must use only local fixture data")
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
monkeypatch.setattr(patients_module, "run_async", immediate)
opened = []
def create(*, permissions=("*",), width=1270, height=700):
repository = _Repository()
workspace = PatientListWorkspace(repository, PermissionSet(list(permissions)))
opened.append(workspace)
workspace.resize(width, height)
workspace.show()
workspace.refresh()
_settle(application)
return workspace, repository
yield create
for workspace in opened:
for timer in workspace.findChildren(QTimer):
timer.stop()
workspace.close()
workspace.deleteLater()
_settle(application)
def _click(widget, application: QApplication) -> None:
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
_settle(application)
def _date_selection(workspace: PatientListWorkspace) -> list[str]:
return [key for key, button in workspace.quick_buttons.items() if button.isChecked()] + (
["custom"] if workspace.custom_date_button.isChecked() else []
)
def test_filters_preserve_independent_dimensions_and_reset_to_unlimited_dates(
application: QApplication, workspace_factory
) -> None:
workspace, repository = workspace_factory()
assert repository.queries == [{
"keyword": "", "status": "", "start_date": "", "end_date": "",
"page_no": 1, "page_size": 15,
}]
assert _date_selection(workspace) == ["all"]
assert not workspace.start_date.isEnabled()
assert not workspace.end_date.isEnabled()
assert workspace.start_date.date() == workspace.end_date.date() == QDate.currentDate()
assert workspace.scope_label.text() == "测试部门可见患者"
assert workspace.summary_buttons["today"].text() == f"今日预约 · {QDate.currentDate().toString('yyyy-MM-dd')}\n2 人"
assert workspace.summary_buttons["tomorrow"].text() == "明日预约\n1 人"
assert workspace.summary_buttons["day_after"].text() == "后天预约\n0 人"
workspace.keyword_edit.setText(" 林医生 ")
workspace.pager.load_more()
_click(workspace.status_buttons["pending_interview"], application)
assert repository.queries[-1]["page_no"] == 1
for mode, offset in (("today", 0), ("tomorrow", 1), ("day_after", 2)):
_click(workspace.summary_buttons[mode], application)
expected = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
assert repository.queries[-1] == {
"keyword": "林医生", "status": "pending_interview", "start_date": expected,
"end_date": expected, "page_no": 1, "page_size": 15,
}
assert _date_selection(workspace) == [mode]
assert workspace.status_buttons["pending_interview"].isChecked()
assert workspace.summary_buttons["today"].text().endswith("2 人")
_click(workspace.custom_date_button, application)
assert _date_selection(workspace) == ["custom"]
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
# Clicking the selected custom mode keeps a visible selection.
_click(workspace.custom_date_button, application)
assert _date_selection(workspace) == ["custom"]
workspace.start_date.setDate(QDate.currentDate().addDays(-4))
workspace.end_date.setDate(QDate.currentDate().addDays(-2))
workspace.end_date.editingFinished.emit()
assert repository.queries[-1]["start_date"] == QDate.currentDate().addDays(-4).toString("yyyy-MM-dd")
assert repository.queries[-1]["end_date"] == QDate.currentDate().addDays(-2).toString("yyyy-MM-dd")
assert _date_selection(workspace) == ["custom"]
workspace.start_date.setDate(QDate.currentDate().addDays(3))
before = len(repository.queries)
workspace.start_date.editingFinished.emit()
_click(workspace.search_button, application)
assert len(repository.queries) == before
assert workspace.banner.isVisible()
assert "开始日期不能晚于结束日期" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
_click(workspace.reset_button, application)
assert repository.queries[-1] == {
"keyword": "", "status": "", "start_date": "", "end_date": "",
"page_no": 1, "page_size": 15,
}
assert workspace.keyword_edit.text() == ""
assert [key for key, button in workspace.status_buttons.items() if button.isChecked()] == [""]
assert _date_selection(workspace) == ["all"]
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
assert not workspace.banner.isVisible()
@pytest.mark.parametrize(("permissions", "expected", "editable"), [
(("*",), ["诊单", "AI 分析"], True),
(("tcm.diagnosis/readonlyDetail",), ["查看"], False),
((), [], None),
])
def test_row_actions_obey_edit_readonly_and_no_permission(
application: QApplication, workspace_factory, permissions, expected, editable
) -> None:
workspace, _repository = workspace_factory(permissions=permissions)
opened = []
workspace.diagnosis_requested.connect(lambda row, can_edit: opened.append((row["diagnosis_id"], can_edit)))
for index in range(workspace.table.rowCount()):
row = workspace.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
actions = workspace.table.cellWidget(index, 9)
assert actions.visible_labels == expected
if permissions == ("*",):
overflow = ["预约", "重新指派" if row["assistant_id"] else "指派医助"]
if not row["has_id_card"]:
overflow.append("补全身份证")
overflow.append("关联订单")
if row["appointment_id"] and row["appointment_status"] in (1, 4):
overflow.append("取消")
assert actions.overflow_labels == overflow
assert all(entry.property("danger") is True for entry in actions.menu.actions() if entry.text() == "取消")
else:
assert actions.more_button is None
assert actions.overflow_labels == []
if editable is not None:
_click(actions.buttons[0], application)
assert opened[-1] == (row["diagnosis_id"], editable)
if editable is None:
workspace.table.selectRow(0)
workspace._open_selected_diagnosis()
assert opened == []
assert workspace.bottom_actions.isHidden()
def _selector(workspace: PatientListWorkspace, row: int) -> QCheckBox:
return workspace.table.cellWidget(row, 0).findChild(QCheckBox)
def _assert_single_selection(workspace: PatientListWorkspace) -> None:
checked = [row for row in range(workspace.table.rowCount()) if _selector(workspace, row).isChecked()]
assert len(checked) <= 1
if checked:
assert checked == [workspace.table.currentRow()]
def test_sorted_selectors_and_row_actions_keep_distinct_resource_ids(
application: QApplication, workspace_factory
) -> None:
workspace, _repository = workspace_factory()
table = workspace.table
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"]
table.sortItems(1, Qt.SortOrder.DescendingOrder)
_settle(application)
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"] != first_id
for index in (0, 1):
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
_click(_selector(workspace, index), application)
assert table.current_data()["diagnosis_id"] == expected["diagnosis_id"]
assert _selector(workspace, index).isChecked()
_assert_single_selection(workspace)
# Leave a different row current: every callback must use its own sorted row.
table.selectRow(2)
_settle(application)
_assert_single_selection(workspace)
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
emitted = []
workspace.diagnosis_requested.connect(lambda row, editable: emitted.append(("diagnosis", row, editable)))
workspace.ai_consult_requested.connect(lambda row: emitted.append(("ai", row, None)))
workspace.appointment_requested.connect(lambda row: emitted.append(("appointment", row, None)))
for index in range(table.rowCount()):
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
actions = table.cellWidget(index, 9)
actions.buttons[0].click()
actions.buttons[1].click()
next(action for action in actions.menu.actions() if action.text() == "预约").trigger()
for kind, row, editable in emitted[-3:]:
assert row["diagnosis_id"] == expected["diagnosis_id"]
assert row["source_patient_id"] == expected["source_patient_id"]
assert row["appointment_id"] == expected["appointment_id"]
if kind == "diagnosis":
assert editable is True
@pytest.mark.parametrize("silent", [False, True])
def test_loading_resets_changed_query_and_ignores_stale_success_and_error(
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
) -> None:
workspace, repository = workspace_factory()
table = workspace.table
queued = []
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
workspace.keyword_edit.setText("旧请求")
workspace.refresh(silent=silent)
workspace.keyword_edit.setText("最新请求")
workspace.refresh(silent=silent)
_settle(application)
assert table.rowCount() == 0
assert workspace.content_stack.currentIndex() == 1
assert workspace.pager.isVisible()
assert not workspace.banner.isVisible()
# Workers must keep the UI-thread snapshot even after text changes again.
workspace.keyword_edit.setText("尚未查询")
newer = queued[1][0]()
assert repository.queries[-1]["keyword"] == "最新请求"
newer["lists"][0]["patient_name"] = "最新结果"
newer["extend"]["scope"]["label"] = "最新范围"
queued[1][1]["on_success"](newer)
current_item = table.item(0, 1)
assert current_item.text().startswith("最新结果")
stale = queued[0][0]()
assert repository.queries[-1]["keyword"] == "旧请求"
queued[0][1]["on_success"](stale)
queued[0][1]["on_error"](RuntimeError("过期失败"))
_settle(application)
assert table.item(0, 1) is current_item
assert workspace.scope_label.text() == "最新范围"
assert not workspace.banner.isVisible()
workspace.keyword_edit.setText("最新请求")
workspace.refresh(silent=silent)
queued[-1][1]["on_error"](RuntimeError("患者查询失败"))
_settle(application)
assert table.item(0, 1) is current_item
assert workspace.banner.isVisible()
assert "患者查询失败" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
workspace.refresh(silent=silent)
queued[-1][1]["on_success"](queued[-1][0]())
_settle(application)
assert not workspace.banner.isVisible()
@@ -0,0 +1,236 @@
"""Library display units and query/identity contracts after the approved redesign."""
from __future__ import annotations
import os
import socket
from copy import deepcopy
from datetime import datetime
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.pages import prescription_library as library
from doctor_workstation.ui.theme import apply_theme
class QueryRepository:
def __init__(self):
self.calls = []
self.rows = [
{"id": 41, "prescription_name": "乙方", "formula_type": "aux", "is_public": False,
"creator_id": 7, "creator_name": "本页医生", "efficacy": "益气养阴",
"herbs": [{"name": "白芍", "dosage": "15g"}], "create_time": "2026-07-15 10:30"},
{"id": 42, "prescription_name": "甲方", "formula_type": "main", "is_public": True,
"creator_id": 8, "creator_name": "其他医生", "efficacy": "清热祛湿",
"herbs": [{"name": "茯苓", "dosage": 10}], "create_time": "2026-07-18 16:20"},
]
def list_prescription_templates(self, **query):
self.calls.append(deepcopy(query))
# Server total deliberately differs from the current-page/effect-filter count.
return {"lists": deepcopy(self.rows), "count": 44}
def settle(app):
for _ in range(4):
app.processEvents()
def record_id(record):
return record["id"] if isinstance(record, dict) else record.id
def row_record(table, index):
return table.item(index, 0).data(Qt.ItemDataRole.UserRole)
def row_buttons(table, index):
return {button.accessibleName(): button for button in table.cellWidget(index, 9).findChildren(QPushButton)}
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture(autouse=True)
def local_execution(monkeypatch):
def reject(*_args, **_kwargs):
pytest.fail("Library regression tests must not contact external services")
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error:
on_error(error)
raise
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
monkeypatch.setattr(socket.socket, "connect", reject)
monkeypatch.setattr(socket.socket, "connect_ex", reject)
monkeypatch.setattr(socket, "create_connection", reject)
monkeypatch.setattr(library, "run_async", immediate)
@pytest.fixture
def page_factory(application):
opened = []
def create(repository=None, *, permissions=None, user=None):
repository = repository or QueryRepository()
page = library.PrescriptionLibraryPage(
repository, {"*"} if permissions is None else permissions,
user or SimpleNamespace(id=7, root=0, role_ids=[]),
)
opened.append(page)
page.resize(1328, 884)
page.show()
settle(application)
assert page.table.rowCount() == 2
return page, repository
yield create
for page in opened:
for timer in page.findChildren(QTimer):
timer.stop()
page.close()
page.deleteLater()
settle(application)
@pytest.mark.parametrize("dose, expected", [(10, "10g"), ("10", "10g"), ("10g", "10g"), ("15G", "15G"), (0, "0g")])
def test_display_unit_is_added_once_without_mutating_dose(dose, expected):
row = {"herbs": [{"medicine_id": 11, "name": "白芍", "dosage": dose}]}
before = deepcopy(row)
assert library._herbs_detail(None, row) == f"白芍 {expected}"
assert row == before
assert library._herbs_detail(None, {"herbs": [{"medicine_name": "茯苓", "amount": dose}]}) == f"茯苓 {expected}"
def test_demo_raw_doses_and_default_unselected_state_are_preserved(page_factory, monkeypatch):
monkeypatch.setattr(library, "datetime", SimpleNamespace(now=lambda: datetime(2026, 9, 5)))
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
originals = {identifier: repository.get_prescription_template(identifier) for identifier in (701, 702)}
snapshots = {identifier: deepcopy((row.herbs, row.raw)) for identifier, row in originals.items()}
assert library._herbs_detail(None, originals[701]) == "柴胡 10g、白芍 15g、茯苓 15g"
assert library._herbs_detail(None, originals[702]) == "酸枣仁 20g、夜交藤 30g"
assert {identifier: (row.herbs, row.raw) for identifier, row in originals.items()} == snapshots
page, _ = page_factory(repository, permissions=session.permissions, user=session.user)
assert page.table.columnCount() == 10
assert [page.table.horizontalHeader().logicalIndex(index) for index in range(10)] == list(range(10))
assert page.table.currentRow() == -1
assert all(not button.isEnabled() for button in (page.view_button, page.ai_button, page.edit_button, page.delete_button))
assert [page.metric_cards[key].value_label.text() for key in ("total", "private", "public", "month")] == ["2", "1", "1", "0"]
for index in range(2):
assert all(button.isEnabled() for button in row_buttons(page.table, index).values())
assert len(row_buttons(page.table, index)) == 4
assert page.table.item(index, 5).text() == ""
identifier = record_id(row_record(page.table, index))
assert page.table.item(index, 4).text() == library._herbs_detail(None, originals[identifier])
def test_query_return_reset_favorite_tab_and_page_send_original_dto(page_factory, application):
page, repository = page_factory()
repository.calls.clear()
page.name_filter.setText(" 测试名称 ")
page.filter_disclosure.set_expanded(True)
settle(application)
page.formula_filter.setCurrentIndex(page.formula_filter.findData("主方"))
page.visibility_filter.setCurrentIndex(page.visibility_filter.findData(1))
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
assert repository.calls == [] # Combo selection still requires query or Return.
QTest.keyClick(page.name_filter, Qt.Key.Key_Return)
settle(application)
filtered = {"prescription_name": "测试名称", "formula_type": "主方", "is_public": 1, "page_no": 1, "page_size": 15}
assert repository.calls == [filtered]
assert page.table.rowCount() == 1
assert record_id(row_record(page.table, 0)) == 42
assert page.metric_cards["total"].value_label.text() == "44"
assert page.metric_cards["private"].value_label.text() == "0"
assert page.metric_cards["public"].value_label.text() == "1"
page.pager.load_more()
assert repository.calls[-1] == dict(filtered, page_no=2)
assert page.pager.page == 2
page.favorite_tab.click()
assert repository.calls[-2:] == [filtered, dict(filtered, page_no=2)] # Same query refreshes its loaded prefix.
assert page.favorite_tab.isChecked() and not page.all_tab.isChecked()
page.reset_button.click()
assert repository.calls[-1] == {"prescription_name": "", "formula_type": "", "is_public": "", "page_no": 1, "page_size": 15}
assert page.effect_filter.currentData() == ""
assert page.favorite_tab.isChecked()
assert page.table.rowCount() == 2
@pytest.mark.parametrize("column, order", [(0, Qt.SortOrder.DescendingOrder), (2, Qt.SortOrder.DescendingOrder)])
def test_sort_then_refresh_keeps_selection_tags_and_each_action_bound_to_visible_id(
page_factory, application, monkeypatch, column, order
):
page, repository = page_factory()
original_rows = deepcopy(repository.rows)
table = page.table
visited = []
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(record_id(table.current_data())))
table.sortItems(column, order)
settle(application)
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
def verify_visible_actions():
for index in range(2):
record = row_record(table, index)
assert table.item(index, 2).text() == ("主方" if record["formula_type"] == "main" else "辅方")
assert table.item(index, 6).text() == ("所有人可见" if record["is_public"] else "仅自己可见")
buttons = row_buttons(table, index)
assert buttons["编辑处方模板"].isEnabled() == (record["creator_id"] == 7)
table.selectRow(1 - index)
button = buttons["查看处方模板"]
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
assert visited[-1] == record["id"]
assert record_id(table.current_data()) == record["id"]
verify_visible_actions()
table.selectRow(next(index for index in range(2) if record_id(row_record(table, index)) == 41))
page.refresh()
settle(application)
assert record_id(table.current_data()) == 41
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
verify_visible_actions()
assert visited == [42, 41, 42, 41]
assert repository.rows == original_rows
@pytest.mark.parametrize(
"permissions, user, expected_actions, can_edit_foreign",
[({"wcf.prescription/read"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释"}, False),
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, False),
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[3]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, True)],
)
def test_row_action_permissions_and_foreign_public_management_are_preserved(
page_factory, permissions, user, expected_actions, can_edit_foreign
):
page, _ = page_factory(permissions=permissions, user=user)
index = next(index for index in range(2) if record_id(row_record(page.table, index)) == 42)
buttons = row_buttons(page.table, index)
assert set(buttons) == expected_actions
assert buttons["查看处方模板"].isEnabled()
assert buttons["AI 解释"].isEnabled()
if "编辑处方模板" in buttons:
assert buttons["编辑处方模板"].isEnabled() == can_edit_foreign
assert buttons["删除处方模板"].isEnabled() == can_edit_foreign
else:
assert page.edit_button.isHidden() and page.delete_button.isHidden()
+156
View File
@@ -0,0 +1,156 @@
"""Real prescription pages append server pages without losing row actions."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from types import SimpleNamespace
import pytest
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.pages import prescription_library, prescriptions
@pytest.fixture(scope="module")
def app():
return QApplication.instance() or QApplication([])
def inline(function, *, on_success, on_error, on_finished):
try:
result = function()
except Exception as error:
on_error(error)
else:
on_success(result)
finally:
on_finished()
class Repository:
def __init__(self):
self.calls = []
def get(self, **q):
self.calls.append(dict(q))
start = (q["page_no"] - 1) * q["page_size"]
return {
"lists": [
{
"id": i,
"prescription_name": f"Template {i}",
"sn": f"RX{i}",
"patient_name": f"Patient {i}",
"creator_id": 1,
"is_public": False,
"efficacy": "清热祛湿" if i > 15 else "益气养阴",
"herbs": [],
"create_time": "2026-09-01 10:00",
}
for i in range(start + 1, min(start + q["page_size"], 37) + 1)
],
"count": 37,
"extend": {"doctors": [{"id": 1, "name": "Doctor"}]},
}
list_prescriptions = get
list_prescription_templates = get
@pytest.mark.parametrize(
"module,cls",
[
(prescriptions, prescriptions.PrescriptionsPage),
(prescription_library, prescription_library.PrescriptionLibraryPage),
],
)
def test_scroll_append_refresh_and_filter_reset(app, monkeypatch, module, cls):
monkeypatch.setattr(module, "run_async", inline)
repo = Repository()
page = cls(repo, {"*"}, SimpleNamespace(id=1, root=1, role_ids=[]))
page.resize(1250, 760)
page.show()
page.refresh()
# Let deferred filter/table geometry settle before scrolling its real viewport.
for _ in range(3):
app.processEvents()
QTest.qWait(35)
assert page.table.rowCount() == 15
page.table.selectRow(4)
before_id = page.table.current_data()["id"]
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
for _ in range(50):
if page.table.rowCount() == 30:
break
QTest.qWait(20)
assert page.table.rowCount() == 30 and page.table.current_data()["id"] == before_id
assert (
len({page.table.item(r, 0).data(Qt.ItemDataRole.UserRole)["id"] for r in range(30)}) == 30
)
page.refresh()
assert page.table.rowCount() == 30
assert [q["page_no"] for q in repo.calls[-2:]] == [1, 2]
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
QTest.qWait(90)
assert page.table.rowCount() == 37 and not page.pager.has_more
edit = page.name_filter if module is prescription_library else page.patient_filter
edit.setText("changed")
page._search()
assert page.table.rowCount() == 15 and repo.calls[-1]["page_no"] == 1
assert page.pager.height() == 24 and page.layout().contentsMargins().bottom() == 8
page.close()
def test_local_effect_filter_continues_until_matching_rows_are_visible(app, monkeypatch):
monkeypatch.setattr(prescription_library, "run_async", inline)
page = prescription_library.PrescriptionLibraryPage(
Repository(), {"*"}, SimpleNamespace(id=1, root=1, role_ids=[])
)
page.resize(1250, 760)
page.show()
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
page._search()
QTest.qWait(180)
assert page.table.rowCount() >= 15
assert page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] > 15
page.close()
def test_audit_status_sort_keeps_actions_on_the_visible_prescription(app, monkeypatch):
from PySide6.QtWidgets import QPushButton
monkeypatch.setattr(prescriptions, "run_async", inline)
class AuditRepository(Repository):
def get(self, **query):
result = super().get(**query)
for row in result["lists"]:
identifier = row["id"]
row.update(
audit_status=identifier % 3,
audit_remark="rejected reason" if identifier % 3 == 2 else "",
business_prescription_audit_rejected=identifier % 2 == 0,
business_prescription_audit_remark="business reason"
if identifier % 2 == 0
else "",
)
return result
list_prescriptions = get
page = prescriptions.PrescriptionsPage(AuditRepository(), {"*"}, SimpleNamespace(id=1))
page.refresh()
opened = []
page._view_selected = lambda: opened.append(page.table.current_data()["id"])
page.table.sortItems(6, Qt.SortOrder.DescendingOrder)
page.pager.load_more()
page.refresh()
assert page.table.rowCount() == 30
for row in range(page.table.rowCount()):
expected = page.table.item(row, 0).data(Qt.ItemDataRole.UserRole)["id"]
buttons = page.table.cellWidget(row, 2).findChildren(QPushButton)
next(button for button in buttons if button.accessibleName() == "查看处方").click()
assert opened[-1] == expected
page.close()
+254
View File
@@ -0,0 +1,254 @@
"""Native interactions and reachability unique to the issued-prescription blue UI."""
from __future__ import annotations
import os
import socket
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import icons
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def _settle(application: QApplication) -> None:
for _ in range(5):
application.processEvents()
def _rect_in(widget, parent) -> QRect:
return QRect(widget.mapTo(parent, QPoint()), widget.size())
def _assert_visible_within(widget, viewport) -> None:
assert widget.isVisible()
rect = _rect_in(widget, viewport)
assert viewport.rect().contains(rect), (widget.objectName(), rect, viewport.rect())
def _record_id(table, row: int) -> int:
return int(table.item(row, 0).data(Qt.ItemDataRole.UserRole).id)
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture(autouse=True)
def shell_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
network_attempts = []
def reject_network(*_args, **_kwargs):
network_attempts.append("socket")
pytest.fail("Prescription blue interaction tests must use local Demo data only")
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
try:
result = function()
except Exception as error:
if on_error is not None:
on_error(error)
raise
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
monkeypatch.setattr(socket.socket, "connect", reject_network)
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
monkeypatch.setattr(socket, "create_connection", reject_network)
monkeypatch.setattr(prescriptions_module, "run_async", immediate)
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
opened = []
def create(width=1536, height=960):
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
permissions = PermissionSet(["*"])
shell = ShellWindow(repository, {
"user": session.user,
"permissions": permissions,
"demo_mode": True,
"menu": [{"perms": "tcm.prescription/lists"}],
}, permissions=permissions)
opened.append(shell)
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
shell.resize(width, height)
shell.show()
assert shell.navigate("prescriptions")
_settle(application)
page = shell.pages["prescriptions"]
assert {_record_id(page.table, row) for row in range(page.table.rowCount())} == {801, 802}
assert (shell.width(), shell.height()) == (width, height)
return shell, page
yield create
for shell in opened:
for timer in shell.findChildren(QTimer):
timer.stop()
shell.close()
shell.deleteLater()
_settle(application)
assert network_attempts == []
@pytest.mark.parametrize(
("width", "height", "filter_height"),
[(1536, 960, 144), (1366, 768, 144), (1024, 768, 200), (1024, 640, 200)],
ids=["full-desktop", "compact-desktop", "small-window", "minimum-window"],
)
def test_shell_preserves_all_columns_filters_and_reachable_pager(
shell_factory, application, width, height, filter_height
) -> None:
shell, page = shell_factory(width, height)
page.filter_disclosure.set_expanded(True)
_settle(application)
table = page.table
assert [column.key for column in table.columns] == [
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
]
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
]
assert table.horizontalHeaderItem(2).text() == "操作"
assert page.filter_card.height() == filter_height
for name in ("quick_date", "start_time", "end_time", "audit_filter", "source_filter",
"sn_filter", "patient_filter", "doctor_filter", "query_button", "reset_button"):
_assert_visible_within(getattr(page, name), page.filter_card)
outer_scroll = page.scroll.verticalScrollBar()
if width >= 1366:
assert outer_scroll.maximum() == 0
_assert_visible_within(page.filter_card, page.scroll.viewport())
_assert_visible_within(page.pager, page.scroll.viewport())
else:
# The compact footer allows 1024x768 to fit without outer scrolling.
if height <= 640:
assert outer_scroll.maximum() > 0
assert table.horizontalScrollBar().maximum() > 0
if width == 1536:
assert table.horizontalScrollBar().maximum() == 0
for row in range(table.rowCount()):
for column in range(11):
assert table.viewport().rect().contains(table.visualItemRect(table.item(row, column))), (row, column)
_assert_visible_within(page.pager, shell)
# Every logical column remains reachable in the native table at every width.
outer_scroll.setValue(outer_scroll.maximum())
for column in range(11):
table.scrollToItem(table.item(0, column), QAbstractItemView.ScrollHint.EnsureVisible)
_settle(application)
assert table.viewport().rect().contains(table.visualItemRect(table.item(0, column))), column
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
_settle(application)
action_host = table.cellWidget(0, 2)
assert action_host is not None
_assert_visible_within(action_host, table.viewport())
_assert_visible_within(action_host, page.scroll.viewport())
_assert_visible_within(page.pager, page.scroll.viewport())
_assert_visible_within(page.pager.summary_label, shell)
assert page.pager.height() == 24
assert not hasattr(page.pager, "next")
for button in page.pager.findChildren(QPushButton):
if button.isVisible():
_assert_visible_within(button, page.scroll.viewport())
for button in action_host.findChildren(QPushButton):
_assert_visible_within(button, table.viewport())
def test_native_checkbox_center_clicks_and_space_keep_row_binding(shell_factory, application) -> None:
_shell, page = shell_factory()
table = page.table
row = next(index for index in range(table.rowCount()) if _record_id(table, index) == 802)
selector = table.item(row, 0)
table.setCurrentItem(selector)
table.setFocus()
_settle(application)
center = table.visualItemRect(selector).center()
assert table.viewport().rect().contains(center)
assert selector.checkState() == Qt.CheckState.Unchecked
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
assert selector.checkState() == Qt.CheckState.Checked
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
assert selector.checkState() == Qt.CheckState.Unchecked
QTest.keyClick(table, Qt.Key.Key_Space)
assert selector.checkState() == Qt.CheckState.Checked
assert int(table.current_data().id) == 802
assert selector.data(Qt.ItemDataRole.UserRole).id == 802
@pytest.mark.parametrize("order", [Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder])
def test_sorted_native_row_view_button_targets_visible_record(
shell_factory, application, monkeypatch, order
) -> None:
_shell, page = shell_factory()
table = page.table
visited = []
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(int(table.current_data().id)))
table.sortItems(1, order)
_settle(application)
expected_order = [801, 802] if order == Qt.SortOrder.AscendingOrder else [802, 801]
assert [_record_id(table, index) for index in range(2)] == expected_order
for row in range(2):
# Start on the other record, so a stale action that only uses selection fails.
table.selectRow(1 - row)
host = table.cellWidget(row, 2)
button = next(button for button in host.findChildren(QPushButton)
if button.accessibleName() == "查看处方")
_assert_visible_within(button, table.viewport())
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
assert visited[-1] == expected_order[row]
assert int(table.current_data().id) == expected_order[row]
assert visited == expected_order
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
kind, color, size = "pencil", "#1769E8", 19
shared = icons.icon(kind, color, size)
original_key = shared.cacheKey()
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
original_disabled = shared.pixmap(size, size, QIcon.Mode.Disabled).toImage()
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
assert icons.icon(kind, color, size) is shared
assert shared.cacheKey() == original_key
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
_shell, page = shell_factory(1024, 768)
page.filter_disclosure.set_expanded(True)
_settle(application)
button = page.doctor_filter.button
full_text = "联合会诊专家门诊陈医生、疑难病联合门诊林医生 等3人"
button.setText(full_text)
_settle(application)
assert button.fontMetrics().horizontalAdvance(full_text) > button.width() - 48
assert button.text() == full_text
assert button.toolTip() == full_text
assert not button.grab().isNull() # Execute the actual compact paint path.
assert button.text() == full_text
assert page.doctor_filter.values() == []
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import pytest
from PySide6.QtCore import Qt
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import ReceptionPage
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(app):
for _ in range(8):
app.processEvents()
@pytest.fixture
def page(application, monkeypatch):
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
widget = ReceptionPage(object(), PermissionSet(["*"]))
widget.resize(1340, 900)
widget.show()
widget.poll_timer.stop()
widget.detail_stack.setCurrentIndex(1)
widget.patient_name_label.setText("林晓岚")
widget.patient_meta_label.setText("女 · 46岁 · 138****1203 | 就诊号:101")
settle(application)
yield widget
widget.close()
widget.deleteLater()
settle(application)
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
page.video_button.setEnabled(True)
settle(application)
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
page.video_button.setEnabled(False)
settle(application)
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
def test_typography_preserves_regular_body_and_distinct_values(page):
body = page.case_labels["present"]
assert body.font().pixelSize() == 14
assert body.font().weight() == 400
assert page.patient_name_label.font().pixelSize() == 22
assert page.patient_name_label.font().weight() == 600
assert page.vital_labels["height"].font().pixelSize() == 18
@pytest.mark.parametrize("width", [1340, 1080, 816])
def test_identity_and_actions_stay_inside_hero_at_both_layouts(page, application, width):
page.resize(width, 900)
settle(application)
hero = page.findChild(QFrame, "ReceptionHero")
for button in (page.notify_button, page.history_button, page.video_button, page.more_button):
assert hero.rect().contains(button.geometry())
assert button.width() >= button.minimumSizeHint().width()
if width == 1340:
assert abs(page.video_button.geometry().center().y() - page.patient_avatar_label.geometry().center().y()) < 20
else:
assert page.video_button.geometry().top() >= page.patient_meta_label.geometry().bottom()
def test_body_growth_stays_readable_and_does_not_cover_actions(page, application):
body = page.case_labels["present"]
body.setText("患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n" * 12)
settle(application)
assert body.height() >= body.heightForWidth(body.width())
assert page.detail_scroll.verticalScrollBar().maximum() > 0
assert body.textFormat() == Qt.TextFormat.PlainText
@pytest.mark.parametrize("value", ["6.8 mmol/L", "1234.56 mg/g", "≤0.25 mg/L", ""])
def test_lab_value_keeps_full_plain_text_and_wraps_long_units(page, application, value):
label = page.lab_labels["fasting"]
label.setText(value)
settle(application)
assert label.text() == value
assert label.textFormat() == Qt.TextFormat.PlainText
for width in (55, 120):
layout, size = label._value_layout(width)
assert sum(layout.lineAt(i).textLength() for i in range(layout.lineCount())) == len(value)
assert size.height() == label.heightForWidth(width)
for i in range(layout.lineCount()):
line = layout.lineAt(i)
assert line.naturalTextWidth() <= width + 1
assert line.y() + line.height() <= size.height()
def test_queue_summary_and_navigation_tabs_remain_available(page):
summary = page.findChild(QLabel, "ReceptionQueueSummary")
assert summary.isVisible()
assert [page.detail_tabs.tabText(i) for i in range(page.detail_tabs.count())] == [
"问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"
]
def test_risks_remain_in_full_report_while_preview_matches_approved_image(page, application):
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
settle(application)
page._render_ai_risk_chips([
{"label": "血糖波动风险", "level": "medium"},
{"label": "睡眠质量下降", "level": "low"},
])
settle(application)
assert not page.ai_risk_chip_host.isVisible()
dialog = reception_module._ReceptionAiAnalysisDialog({"qwen": {
"diagnosis_advice": "需要结合病史复核。",
"risk_assessment": [{"label": "血糖波动风险", "level": "medium"}],
"treatment_advice": "完整治疗建议仍可阅读。",
}})
dialog.show()
settle(application)
try:
risk = next(label for label in dialog.findChildren(QLabel) if label.text() == "血糖波动风险")
assert risk.isVisible()
assert risk.width() >= risk.fontMetrics().horizontalAdvance(risk.text())
assert any(label.text() == "完整治疗建议仍可阅读。" for label in dialog.findChildren(QLabel))
finally:
dialog.close()
def test_compact_ai_keeps_full_treatment_data_without_pushing_controls_out_of_view(page, application):
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
page.ai_summary_label.setText("需要结合完整病史与复诊记录核对。" * 25)
page.ai_treatment_label.setText("请结合患者实际情况复核完整分析。" * 25)
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
settle(application)
assert page.ai_treatment_label.text() == "请结合患者实际情况复核完整分析。" * 25
assert not page.ai_treatment_label.isVisible()
assert not page.ai_question_edit.isVisible()
assert not page.ai_analysis_history_button.isVisible()
actions = [action.text() for action in page.more_button.menu().actions()]
assert "向 AI 提问" in actions
assert "历史 AI 报告" in actions
assert "重新分析 AI 报告" in actions
assert page.ai_analysis_card.height() < 320
def test_patient_switch_clears_previous_condition_tooltip(page):
page.patient_meta_label.setToolTip("上一位患者的病情提示")
page._reset_detail_content({"id": 202, "patient_name": "新患者"})
assert page.patient_meta_label.toolTip() == ""
page.patient_meta_label.setToolTip("已失效的病情提示")
page._render_detail_load_failure("详情暂不可用")
assert page.patient_meta_label.toolTip() == ""
def test_moved_ai_controls_keep_actions_and_enabled_state(application, monkeypatch):
calls = []
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
monkeypatch.setattr(ReceptionPage, "_open_ai_analysis_dialog", lambda self: calls.append("history"))
monkeypatch.setattr(ReceptionPage, "_regenerate_patient_ai_reports", lambda self: calls.append("regenerate"))
monkeypatch.setattr(ReceptionPage, "_open_ai_assistant", lambda self, prompt: calls.append(("ask", prompt)))
widget = ReceptionPage(object(), PermissionSet(["*"]))
try:
widget.poll_timer.stop()
assert not widget.ai_history_action.isEnabled()
assert not widget.ai_regenerate_action.isEnabled()
widget._ai_analysis_histories["qwen"] = [{"diagnosis_advice": "示例分析"}]
widget._ai_analysis_patient_flow = True
widget._ai_analysis_patient_id = 101
widget._sync_ai_analysis_view()
assert widget.ai_history_action.isEnabled()
assert widget.ai_regenerate_action.isEnabled()
widget.ai_history_action.trigger()
widget.ai_regenerate_action.trigger()
next(action for action in widget.more_button.menu().actions() if action.text() == "向 AI 提问").trigger()
widget.ai_assistant_card.findChild(QPushButton, "ReceptionAiTitle").click()
assert calls == ["history", "regenerate", ("ask", ""), ("ask", "")]
widget._ai_analysis_regenerating = True
widget._sync_ai_analysis_view()
assert not widget.ai_regenerate_action.isEnabled()
finally:
widget.close()
widget.deleteLater()
settle(application)
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, QSize
from PySide6.QtGui import QFont, QFontInfo, QIcon
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.reception_style import (
TECH_BLUE,
body_family,
heading_family,
number_family,
)
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import COLORS, apply_theme
class _Page(QWidget):
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
self.label = QLabel("原有页面正文", self)
layout.addWidget(self.label)
@pytest.fixture(scope="module")
def application() -> QApplication:
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
navigation = [
NavigationItem(key, title, "", _Page, ("doctor.appointment/lists",))
for key, title in (
("appointments", "挂号列表"),
("consultations", "问诊列表"),
("reception", "接诊台"),
("patients", "我的患者"),
("prescriptions", "已开处方"),
("prescription_library", "我的处方库"),
("legacy_reference", "原版框架参照"),
)
]
monkeypatch.setattr(
shell_module,
"_resolve_navigation",
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
)
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
shell = ShellWindow(
object(),
{"user": {"name": "陈医生"}, "demo_mode": True},
permissions={"doctor.appointment/lists", "tcm.diagnosis/aiAssistant"},
)
shell.resize(1536, 960)
shell.show()
application.processEvents()
yield shell
shell.close()
application.processEvents()
def _pixel(widget: QWidget, x: int, y: int) -> str:
image = widget.grab().toImage()
ratio = image.devicePixelRatio()
return image.pixelColor(round(x * ratio), round(y * ratio)).name().upper()
def test_approved_chrome_restores_styles_fonts_icons_and_geometry(
application: QApplication, window: ShellWindow
) -> None:
assert window.navigate("legacy_reference")
application.processEvents()
original_app_qss = application.styleSheet()
original_app_font = application.font().toString()
original_colors = dict(COLORS)
original_page_fonts = {key: page.label.font().toString() for key, page in window.pages.items()}
original_styles = dict(window._legacy_chrome_styles)
original_avatar = window.user_menu_button.icon().pixmap(34, 34).toImage()
original_ai = window.ai_top_button.icon().pixmap(18, 18).toImage()
original_nav = (
window.nav_buttons["reception"]
.icon()
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
.toImage()
)
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library", "consultations", "appointments"):
assert window.navigate(key)
application.processEvents()
assert window.sidebar.width() == 208
assert _pixel(window, 6, 300) == "#F3F7FD"
assert _pixel(window.sidebar, 5, 300) == "#F3F7FD"
assert _pixel(window.workspace, 600, 10) == TECH_BLUE["surface"]
assert window.nav_buttons[key].font().family() == body_family()
assert window.nav_buttons[key].font().weight() == QFont.Weight.Normal
assert window.brand_name.font().family() == heading_family()
assert window.brand_name.font().weight() == QFont.Weight.DemiBold
assert window.assistant_glyph.property("receptionTechBlue") is True
assert window.assistant_status.property("receptionTechBlue") is True
assert original_avatar != window.user_menu_button.icon().pixmap(34, 34).toImage()
assert original_ai != window.ai_top_button.icon().pixmap(18, 18).toImage()
assert (
original_nav
!= window.nav_buttons["reception"]
.icon()
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
.toImage()
)
assert {
key: page.label.font().toString() for key, page in window.pages.items()
} == original_page_fonts
assert window.navigate("legacy_reference")
application.processEvents()
assert window.sidebar.width() == 190
assert _pixel(window, 6, 300) == COLORS["canvas"].upper()
assert _pixel(window, 18, 313) == COLORS["canvas"].upper()
assert {widget: widget.styleSheet() for widget in original_styles} == original_styles
assert window.user_menu_button.icon().pixmap(34, 34).toImage() == original_avatar
assert window.ai_top_button.icon().pixmap(18, 18).toImage() == original_ai
assert (
window.nav_buttons["reception"]
.icon()
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
.toImage()
== original_nav
)
assert window.assistant_glyph.property("receptionTechBlue") is False
assert window.assistant_status.property("receptionTechBlue") is False
assert application.styleSheet() == original_app_qss
assert application.font().toString() == original_app_font
assert original_colors == COLORS
assert {
key: page.label.font().toString() for key, page in window.pages.items()
} == original_page_fonts
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
def test_selected_navigation_and_ai_tile_use_approved_blue(
application: QApplication, window: ShellWindow, key: str
) -> None:
window.navigate(key)
application.processEvents()
button = window.nav_buttons[key]
button.clearFocus()
application.processEvents()
assert _pixel(button, 1, button.height() // 2) == TECH_BLUE["accent"]
assert _pixel(button, button.width() - 12, button.height() // 2) == TECH_BLUE["selection"]
assert _pixel(window.assistant_glyph, 10, 25) == TECH_BLUE["accent"]
assert _pixel(window.assistant_status, 3, 9) == TECH_BLUE["accent"]
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
def test_approved_brand_mark_uses_blue_medical_cross_and_restores_original(
application: QApplication, window: ShellWindow, key: str
) -> None:
assert window.navigate("legacy_reference")
application.processEvents()
mark = window.brand_mark
original = mark.grab().toImage()
assert window.navigate(key)
application.processEvents()
assert mark.property("receptionTechBlue") is True
assert mark.width() == mark.height() == 38
assert _pixel(mark, 5, 18) == TECH_BLUE["accent"]
assert _pixel(mark, 19, 19) == TECH_BLUE["accent"]
assert _pixel(mark, 18, 9) == TECH_BLUE["surface"]
assert original != mark.grab().toImage()
assert window.navigate("legacy_reference")
application.processEvents()
assert mark.property("receptionTechBlue") is False
assert mark.grab().toImage() == original
def test_collapsed_rail_keeps_state_across_approved_and_original_pages(
application: QApplication, window: ShellWindow
) -> None:
window.toggle_sidebar()
assert window.sidebar.width() == 68
window.navigate("reception")
application.processEvents()
assert window.sidebar.width() == 68
assert window.nav_buttons["reception"].isChecked()
assert not window.assistant_card.isVisible()
assert window.nav_buttons["reception"].toolTip() == "接诊台"
window.toggle_sidebar()
application.processEvents()
assert window.sidebar.width() == 208
assert window.assistant_card.isVisible()
window.toggle_sidebar()
window.navigate("appointments")
application.processEvents()
assert window.sidebar.width() == 68
assert window.nav_buttons["appointments"].isChecked()
assert window.nav_buttons["appointments"].toolTip() == "挂号列表"
assert not window.assistant_card.isVisible()
window.navigate("consultations")
application.processEvents()
assert window.sidebar.width() == 68
assert window.nav_buttons["consultations"].isChecked()
assert window.nav_buttons["consultations"].toolTip() == "问诊列表"
assert not window.assistant_card.isVisible()
window.navigate("patients")
application.processEvents()
assert window.sidebar.width() == 68
assert window.nav_buttons["patients"].isChecked()
window.toggle_sidebar()
application.processEvents()
assert window.sidebar.width() == 208
window.toggle_sidebar()
window.navigate("prescriptions")
application.processEvents()
assert window.sidebar.width() == 68
assert window.nav_buttons["prescriptions"].isChecked()
assert window.nav_buttons["prescriptions"].toolTip() == "已开处方"
assert not window.assistant_card.isVisible()
window.toggle_sidebar()
application.processEvents()
assert window.sidebar.width() == 208
window.toggle_sidebar()
window.navigate("legacy_reference")
application.processEvents()
assert window.sidebar.width() == 68
window.toggle_sidebar()
application.processEvents()
assert window.sidebar.width() == 190
assert window.nav_buttons["legacy_reference"].text() == "原版框架参照"
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
def test_approved_shell_geometry_and_function_entries_restore(
application: QApplication, window: ShellWindow, key: str
) -> None:
assert window.navigate("legacy_reference")
application.processEvents()
original_geometry = {
widget: (widget.size(), widget.iconSize() if hasattr(widget, "iconSize") else None)
for widget in (
window.sidebar, window.topbar, window.search_host, window.brand_mark,
window.assistant_card, window.assistant_button, window.assistant_status,
window.upload_settings_button, window.user_menu_button,
window.refresh_button, window.notification_button, window.settings_button,
*window.nav_buttons.values(),
)
}
assert window.navigate(key)
application.processEvents()
assert window.workspace.pos() == QPoint(208, 0)
assert window.topbar.height() == 76
assert window.centralWidget().layout().contentsMargins().isNull()
assert window.search_host.mapTo(window, QPoint()) == QPoint(234, 15)
assert window.search_host.size() == QSize(384, 44)
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 695)
assert window.assistant_card.size() == QSize(168, 171)
assert window.assistant_button.height() == 46
assert window.brand_name.font().pixelSize() == 18
assert window.brand_subtitle.font().pixelSize() == 13
for index, button in enumerate(window.nav_buttons.values()):
assert button.mapTo(window, QPoint()).y() == 99 + 58 * index
assert button.height() == 52
assert button.font().pixelSize() == 15
assert button.iconSize() == QSize(20, 20)
assert not window.fold_button.isVisible()
assert not window.ai_top_button.isVisible()
assert window.menu_sidebar_action.isVisible()
assert window.menu_ai_action.isVisible()
window.menu_sidebar_action.trigger()
application.processEvents()
assert window.sidebar.width() == 68
assert window.menu_sidebar_action.isVisible()
window.menu_sidebar_action.trigger()
application.processEvents()
assert window.sidebar.width() == 208
assert window.navigate("legacy_reference")
application.processEvents()
assert window.workspace.pos() == QPoint(203, 13)
assert window.fold_button.isVisible()
assert window.ai_top_button.isVisible()
assert not window.menu_sidebar_action.isVisible()
assert not window.menu_ai_action.isVisible()
assert not window.user_separator.isVisible()
for widget, (size, icon_size) in original_geometry.items():
assert widget.size() == size
if icon_size is not None:
assert widget.iconSize() == icon_size
def test_reception_fonts_resolve_real_requested_weights(application: QApplication) -> None:
for family, weights in (
(body_family(), (QFont.Weight.Normal,)),
(heading_family(), (QFont.Weight.Medium, QFont.Weight.DemiBold)),
(number_family(), (QFont.Weight.Normal, QFont.Weight.DemiBold)),
):
for weight in weights:
font = QFont(family)
font.setPixelSize(15)
font.setWeight(weight)
resolved = QFontInfo(font)
assert resolved.family() == family
assert resolved.weight() == weight
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
def test_minimum_window_keeps_search_and_all_chrome_actions_separate(
application: QApplication, window: ShellWindow, key: str
) -> None:
window.navigate(key)
window.resize(1024, 640)
application.processEvents()
assert window.width() == 1024
assert 220 <= window.search_host.width() < 384
controls = (
window.search_host, window.refresh_button, window.notification_button,
window.settings_button, window.user_menu_button, window.minimize_button,
window.fullscreen_button, window.close_button,
)
for left, right in zip(controls, controls[1:], strict=False):
assert left.x() + left.width() <= right.x()
assert right.x() + right.width() <= window.topbar.width()
window.resize(1536, 960)
application.processEvents()
assert window.search_host.size() == QSize(384, 44)
def test_refresh_animation_keeps_running_and_restores_palette_on_page_switch(
application: QApplication, window: ShellWindow
) -> None:
window.navigate("legacy_reference")
application.processEvents()
button = window.refresh_button
original = button.icon().pixmap(18, 18).toImage()
button.start_spin()
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"):
window.navigate(key)
application.processEvents()
assert button._timer.isActive()
assert button.property("receptionTechBlue") is True
window.navigate("legacy_reference")
application.processEvents()
assert button._timer.isActive()
assert button.property("receptionTechBlue") is False
assert button._resting.pixmap(18, 18).toImage() == original
button._timer.stop()
def test_navigation_between_approved_pages_keeps_existing_chrome_assets(
application: QApplication, window: ShellWindow
) -> None:
assert window.navigate("appointments")
application.processEvents()
styles = {widget: widget.styleSheet() for widget in window._legacy_chrome_styles}
icons = {
widget: widget.icon().cacheKey()
for widget in (
window.user_menu_button, window.refresh_button, window.ai_top_button,
*window.nav_buttons.values(),
)
}
window.global_search.setText("患者查询")
for key in ("consultations", "reception", "patients", "prescriptions", "prescription_library", "appointments", "consultations"):
assert window.navigate(key)
application.processEvents()
assert window.nav_buttons[key].isChecked()
assert window.global_search.text() == "患者查询"
assert window._reception_chrome_active
assert {widget: widget.styleSheet() for widget in styles} == styles
assert {widget: widget.icon().cacheKey() for widget in icons} == icons
+145
View File
@@ -0,0 +1,145 @@
"""List pages must load without a banner that reflows the page.
Every list refresh used to raise an info banner ("正在加载…") into the page
layout and drop it again on completion. That is two relayouts per refresh - the
filter bar and the whole table jump down and back - and the appointment and
consultation pages poll every five seconds, so the jump repeated on its own with
nobody touching the keyboard.
Errors still surface on the banner; only the routine load is silent.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.pages import appointments as appointments_module
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.appointments import AppointmentsPage
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
"""Run the worker inline so the banner state can be inspected mid-load."""
def run_immediately(
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: # noqa: BLE001 - mirrors the real worker
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
for module in (prescriptions_module, library_module, appointments_module):
monkeypatch.setattr(module, "run_async", run_immediately)
def _pages(application: QApplication) -> list[Any]:
del application
repository = DemoDoctorRepository()
return [
PrescriptionsPage(repository, ["*"]),
PrescriptionLibraryPage(
repository, ["*"], SimpleNamespace(id=1, root=1, role_ids=[])
),
AppointmentsPage(repository, PermissionSet(["*"])),
]
def test_a_list_refresh_never_raises_a_loading_banner(
application: QApplication,
) -> None:
seen: list[tuple[str, str]] = []
for page in _pages(application):
banner = page.banner
original = banner.show_message
def record(text: str, kind: str = "info", _page: Any = page) -> None:
seen.append((type(_page).__name__, text))
banner.show_message = record # type: ignore[method-assign]
page.refresh()
application.processEvents()
banner.show_message = original # type: ignore[method-assign]
page.close()
assert seen == [], f"a list refresh still announced itself: {seen}"
def test_a_refresh_does_not_move_anything_on_the_page(
application: QApplication,
) -> None:
"""The real symptom was reflow, so assert on geometry rather than on a flag.
A hidden widget keeps whatever size it was last given, so checking the
banner's own height proves nothing; what matters is that the table below it
does not shift when a refresh runs.
"""
for page in _pages(application):
page.resize(1200, 700)
page.show()
application.processEvents()
table = page.table
settled = (table.mapTo(page, table.rect().topLeft()), table.size())
for _ in range(3):
page.refresh()
application.processEvents()
assert not page.banner.isVisible()
assert (table.mapTo(page, table.rect().topLeft()), table.size()) == settled
page.close()
application.processEvents()
def test_a_failed_load_still_reports_on_the_banner(
application: QApplication,
) -> None:
"""Silence is only for the happy path; failures must stay visible."""
class Failing(DemoDoctorRepository):
def list_prescriptions(self, **_filters: Any) -> Any:
raise RuntimeError("上游不可用")
page = PrescriptionsPage(Failing(), ["*"])
page.resize(1200, 700)
page.show()
page.refresh()
application.processEvents()
assert page.banner.isVisible()
assert page.banner.label.text()
page.close()
application.processEvents()
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import pytest
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import ReceptionDailyRecordsPanel, ReceptionPage
from doctor_workstation.ui.theme import COLORS, apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(app):
for _ in range(6):
app.processEvents()
@pytest.fixture
def page(application, monkeypatch):
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
widget = ReceptionPage(object(), PermissionSet([]))
widget.resize(1080, 760)
widget.show()
widget.poll_timer.stop()
widget.detail_stack.setCurrentIndex(1)
settle(application)
yield widget
widget.close()
widget.deleteLater()
settle(application)
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
settle(application)
for button in page.ai_prompt_buttons:
assert button.width() >= button.minimumSizeHint().width()
assert button.parentWidget().rect().contains(button.geometry())
analysis = page.ai_analysis_card.geometry()
assistant = page.ai_assistant_card.geometry()
assert assistant.top() > analysis.bottom()
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
page.detail_tabs.setCurrentIndex(0)
settle(application)
consultation_height = page.clinical_pages.height()
page.detail_tabs.setCurrentIndex(3)
settle(application)
assert page.clinical_pages.height() < consultation_height - 80
def test_short_risk_labels_stay_on_one_line(page, application):
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
settle(application)
chip = page.ai_risk_chip_layout.itemAt(0).widget()
assert chip.width() >= chip.fontMetrics().horizontalAdvance(chip.text()) + 18
def test_seven_day_matrix_fits_readable_columns_without_unnecessary_horizontal_scroll(application):
panel = ReceptionDailyRecordsPanel()
panel.resize(900, 580)
panel.set_data({}, [])
panel.show()
settle(application)
try:
assert panel.matrix.columnCount() == 8
assert panel.matrix.horizontalScrollBar().maximum() == 0
assert all(panel.matrix.columnWidth(i) >= 96 for i in range(1, 8))
panel.range_buttons["30"].click()
panel.set_data({}, [])
settle(application)
assert panel.matrix.columnCount() == 31
assert panel.matrix.horizontalScrollBar().maximum() > 0
assert panel.matrix.columnWidth(1) >= 96
finally:
panel.close()
@pytest.mark.parametrize("variant", ["secondary", "success", "warning", "danger", "link"])
def test_disabled_semantic_buttons_do_not_look_actionable(application, variant):
button = QPushButton("查看记录")
button.setProperty("variant", variant)
button.setEnabled(False)
button.ensurePolished()
assert button.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText).name() == COLORS["disabled_text"].lower()
button.close()
@@ -0,0 +1,26 @@
# Reception shell typography reduction
Changed only reception-specific text sizing in `app/src/doctor_workstation/ui/shell.py`, plus the existing matching size assertions in `app/tests/test_reception_chrome.py`.
| Text | Previous | Updated |
| --- | --- | --- |
| Brand name | 20 px | 18 px |
| Brand subtitle | 14 px | 13 px |
| Navigation labels | 17 px | 15 px |
| AI assistant title | 18 px | 16 px |
| AI assistant CTA | 17 px | 15 px |
| Settings label | 17 px | 15 px |
| Assistant status metadata | 14 px | 13 px |
| Global search text | 17 px | 15 px |
| User menu name | 14 px | 13 px |
The 17 px custom-painted text near line 694 is the avatar initial, not navigation. It remains 17 px. AI tile lettering, shortcut hint, icons, colors, font families, font weights, margins, padding, borders, and every explicit geometry value are unchanged.
Validation:
- `QT_QPA_PLATFORM=offscreen .venv/Scripts/python.exe -m pytest tests/test_reception_chrome.py tests/test_shell_contract.py --basetemp artifacts/test-tmp/reception-smaller-chrome-20260905` — 36 passed. Pytest emitted one cache-write permission warning for its pre-existing `.pytest_cache`; test temporary files used the project-local path above.
- `.venv/Scripts/python.exe -m ruff check src/doctor_workstation/ui/shell.py tests/test_reception_chrome.py` — passed.
- Existing geometry assertions passed unchanged: sidebar 208 px, top bar 76 px, search at (234, 15) sized 384 × 44 px, AI card at (20, 695) sized 168 × 171 px, AI CTA height 46 px, navigation rows height 52 px and 58 px vertical stride, icons 20 × 20 px.
- Existing restoration tests verify original page fonts, application stylesheet and font, colors, chrome styles, icons, and effective widget sizes return when navigating to other pages. Minimum-window controls and sidebar collapse behavior also pass.
No files under packaging, global theme, reception page implementation, or distribution output were modified by this subtask.
@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use think\facade\Db;
use think\facade\Log;
/**
* 企业微信新增事件的标签快照。
*
* qywx_external_contact_tag 是当前状态投影,客户改标签或删除后会被覆盖/清理;
* 本服务只在 add_external_contact 发生时写入,之后永不更新或删除。
*/
class QywxExternalContactEventTagSnapshotService
{
public const SOURCE_CONTACT_DETAIL = 1;
public const SOURCE_PROMOTION_TASK = 2;
private static ?bool $installed = null;
/**
* 灰度发布保护:代码先于迁移生效时,读取侧可以显式降级而不是返回 500。
*/
public static function installed(): bool
{
if (self::$installed !== null) {
return self::$installed;
}
try {
self::$installed = Db::name('qywx_external_contact_event_tag')->getFields() !== [];
return self::$installed;
} catch (\Throwable $e) {
$message = $e->getMessage();
if (str_contains($message, '42S02')
|| str_contains($message, '1146')
|| str_contains($message, 'no such table')) {
self::$installed = false;
return self::$installed;
}
throw $e;
}
}
/**
* 从 /externalcontact/get 的 follow_user[] 中,只截取产生事件的员工标签。
* 找不到该员工时不写空快照,避免把一次不完整同步误判为“当时无标签”。
*
* @param array<int, mixed> $followUsers
*/
public static function captureFromFollowUsers(int $eventId, string $followUserId, array $followUsers): void
{
$followUserId = trim($followUserId);
if ($eventId <= 0 || $followUserId === '') {
return;
}
foreach ($followUsers as $followUser) {
if (!is_array($followUser)
|| trim((string) ($followUser['userid'] ?? '')) !== $followUserId) {
continue;
}
self::capture(
$eventId,
$followUserId,
is_array($followUser['tags'] ?? null) ? $followUser['tags'] : [],
self::SOURCE_CONTACT_DETAIL
);
return;
}
}
/**
* 回调入库后,用同一推广任务的不可变 config_json 补写快照。
*/
public static function captureForPromotionEvent(int $eventId): void
{
if ($eventId <= 0) {
return;
}
try {
$event = Db::name('qywx_external_contact_event')->where('id', $eventId)->find();
if (!$event || (string) ($event['change_type'] ?? '') !== 'add_external_contact') {
return;
}
$task = Db::name('qywx_promotion_automation_task')
->where('change_type', (string) $event['change_type'])
->where('userid', (string) $event['user_id'])
->where('external_userid', (string) $event['external_userid'])
->where('event_time', (int) $event['event_time'])
->find();
if ($task) {
self::captureFromPromotionTask($task, $eventId);
}
} catch (\Throwable $e) {
self::logFailure($e, $eventId, 'promotion_event');
}
}
/**
* 标签动作成功后追加推广配置中的确定标签,但不写完成标记。
* 推广配置只描述自动添加的标签,不能证明客户当时没有其他标签;完整快照由后续客户详情同步完成。
*
* @param array<string, mixed> $task
*/
public static function captureFromPromotionTask(array $task, int $knownEventId = 0): void
{
if ((string) ($task['change_type'] ?? '') !== 'add_external_contact') {
return;
}
$actions = json_decode((string) ($task['actions_json'] ?? ''), true);
$tagStatus = is_array($actions)
? (string) ($actions['tags']['status'] ?? '')
: '';
if ($tagStatus !== 'success') {
return;
}
try {
$eventId = $knownEventId;
if ($eventId <= 0) {
$eventId = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('user_id', (string) ($task['userid'] ?? ''))
->where('external_userid', (string) ($task['external_userid'] ?? ''))
->where('event_time', (int) ($task['event_time'] ?? 0))
->value('id');
}
if ($eventId <= 0) {
return;
}
$tags = [];
$config = json_decode((string) ($task['config_json'] ?? ''), true);
foreach ((array) ($config['tag_ids'] ?? []) as $tagId) {
$tagId = trim((string) $tagId);
if ($tagId !== '') {
$tags[] = ['tag_id' => $tagId];
}
}
if ($tags === []) {
return;
}
self::appendTags(
$eventId,
(string) ($task['userid'] ?? ''),
$tags,
self::SOURCE_PROMOTION_TASK
);
} catch (\Throwable $e) {
self::logFailure($e, $knownEventId, 'promotion_task');
}
}
/**
* 追加推广任务能够证明的标签,不写 tag_id='' 完成标记。
*
* @param array<int, mixed> $tags
*/
private static function appendTags(int $eventId, string $followUserId, array $tags, int $source): void
{
$followUserId = mb_substr(trim($followUserId), 0, 64);
if ($eventId <= 0 || $followUserId === '') {
return;
}
try {
foreach (self::normalizeTags($tags) as $tag) {
self::insertIgnore([
'event_id' => $eventId,
'follow_user_id' => $followUserId,
'tag_id' => $tag['tag_id'],
'tag_name' => $tag['tag_name'],
'group_name' => $tag['group_name'],
'snapshot_source' => $source,
'create_time' => time(),
]);
}
} catch (\Throwable $e) {
self::logFailure($e, $eventId, 'append_tags');
}
}
/**
* 先写 tag_id='' 完成标记,再写真实标签;同一事务保证不会留下半份快照。
* 完成标记已存在时直接返回,使重复回调不会把后来新增的标签补进历史事件。
*
* @param array<int, mixed> $tags
*/
private static function capture(int $eventId, string $followUserId, array $tags, int $source): void
{
$followUserId = mb_substr(trim($followUserId), 0, 64);
if ($eventId <= 0 || $followUserId === '') {
return;
}
$normalized = self::normalizeTags($tags);
try {
Db::transaction(static function () use ($eventId, $followUserId, $normalized, $source): void {
$inserted = self::insertIgnore([
'event_id' => $eventId,
'follow_user_id' => $followUserId,
'tag_id' => '',
'tag_name' => '',
'group_name' => '',
'snapshot_source' => $source,
'create_time' => time(),
]);
if ($inserted === 0) {
return;
}
foreach ($normalized as $tag) {
self::insertIgnore([
'event_id' => $eventId,
'follow_user_id' => $followUserId,
'tag_id' => $tag['tag_id'],
'tag_name' => $tag['tag_name'],
'group_name' => $tag['group_name'],
'snapshot_source' => $source,
'create_time' => time(),
]);
}
});
} catch (\Throwable $e) {
// 快照是统计增强,迁移未执行或短时 DB 异常不能阻断企微回调主链路。
self::logFailure($e, $eventId, 'capture');
}
}
/**
* @param array<int, mixed> $tags
* @return array<string, array{tag_id:string,tag_name:string,group_name:string}>
*/
private static function normalizeTags(array $tags): array
{
$normalized = [];
foreach ($tags as $tag) {
if (!is_array($tag)) {
continue;
}
$tagId = mb_substr(trim((string) ($tag['tag_id'] ?? $tag['id'] ?? '')), 0, 64);
if ($tagId === '') {
continue;
}
$normalized[$tagId] = [
'tag_id' => $tagId,
'tag_name' => mb_substr((string) ($tag['tag_name'] ?? $tag['name'] ?? ''), 0, 128),
'group_name' => mb_substr((string) ($tag['group_name'] ?? ''), 0, 128),
];
}
return $normalized;
}
/** @param array<string, int|string> $row */
private static function insertIgnore(array $row): int
{
$table = (string) config('database.connections.mysql.prefix')
. 'qywx_external_contact_event_tag';
$columns = array_keys($row);
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $columns) . '`) VALUES ('
. implode(',', array_fill(0, count($columns), '?')) . ')';
return Db::execute($sql, array_values($row));
}
private static function logFailure(\Throwable $e, int $eventId, string $stage): void
{
Log::warning('qywx external contact event tag snapshot failed: ' . $e->getMessage(), [
'event_id' => $eventId,
'stage' => $stage,
]);
}
}
@@ -0,0 +1,93 @@
-- 企业微信新增事件标签快照:历史渠道归属只追加,不随当前标签的修改/删除而回落。
-- 上线顺序:先执行本迁移,再发布读取 qywx_external_contact_event_tag 的代码。
CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event_tag` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`event_id` bigint unsigned NOT NULL COMMENT 'qywx_external_contact_event.id',
`follow_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '产生新增事件的企微员工userid',
`tag_id` varchar(64) NOT NULL DEFAULT '' COMMENT '事件发生时标签ID;空串为快照完成标记',
`tag_name` varchar(128) NOT NULL DEFAULT '' COMMENT '事件发生时标签名',
`group_name` varchar(128) NOT NULL DEFAULT '' COMMENT '事件发生时标签组名',
`snapshot_source` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '1客户详情 2推广任务 3上线时当前关系回填',
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_user_tag` (`event_id`, `follow_user_id`, `tag_id`),
KEY `idx_tag_event_user` (`tag_id`, `event_id`, `follow_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微新增事件标签快照(append-only';
-- 优先追加推广自动化任务中已确认成功的标签证据;这里不写完成标记,
-- 因为任务配置不能证明客户当时没有其他企微标签。
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
SELECT
e.`id`,
e.`user_id`,
LEFT(
COALESCE(
JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(t.`config_json`), t.`config_json`, '{}'), '$.tag_ids[0]')),
''
),
64
),
'',
'',
2,
UNIX_TIMESTAMP()
FROM `zyt_qywx_promotion_automation_task` t
INNER JOIN `zyt_qywx_external_contact_event` e
ON e.`change_type` = t.`change_type`
AND e.`user_id` = t.`userid`
AND e.`external_userid` = t.`external_userid`
AND e.`event_time` = t.`event_time`
WHERE t.`change_type` = 'add_external_contact'
AND JSON_UNQUOTE(
JSON_EXTRACT(IF(JSON_VALID(t.`actions_json`), t.`actions_json`, '{}'), '$.tags.status')
) = 'success'
AND COALESCE(
JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(t.`config_json`), t.`config_json`, '{}'), '$.tag_ids[0]')),
''
) <> '';
-- 尚未完成快照的老事件,仅按同一员工的当前标签尽力冻结;不做跨员工补偿。
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
SELECT
e.`id`,
e.`user_id`,
current_tag.`tag_id`,
current_tag.`tag_name`,
current_tag.`group_name`,
3,
UNIX_TIMESTAMP()
FROM `zyt_qywx_external_contact_event` e
INNER JOIN `zyt_qywx_external_contact_tag` current_tag
ON current_tag.`external_userid` = e.`external_userid`
AND current_tag.`follow_user_id` = e.`user_id`
WHERE e.`change_type` = 'add_external_contact'
AND EXISTS (
SELECT 1
FROM `zyt_qywx_external_contact` active_contact
WHERE active_contact.`external_userid` = e.`external_userid`
AND active_contact.`delete_time` IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM `zyt_qywx_external_contact_event_tag` existing_snapshot
WHERE existing_snapshot.`event_id` = e.`id`
AND existing_snapshot.`follow_user_id` = e.`user_id`
AND existing_snapshot.`tag_id` = ''
);
-- 给“当前关系回填”补完成标记,确保后续改标签不会再追加到同一历史事件。
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
SELECT DISTINCT
snapshot_row.`event_id`,
snapshot_row.`follow_user_id`,
'',
'',
'',
3,
UNIX_TIMESTAMP()
FROM `zyt_qywx_external_contact_event_tag` snapshot_row
WHERE snapshot_row.`snapshot_source` = 3
AND snapshot_row.`tag_id` <> '';
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import t from"./error-ypE3v-gF.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CeZEm9rq.js";import"./index-vflU_jNS.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
@@ -0,0 +1 @@
import e from"./error-ypE3v-gF.js";import{o,q as r,r as t,v as s}from"./.pnpm-CeZEm9rq.js";import"./index-vflU_jNS.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-CeZEm9rq.js";import{a as V}from"./doctor-BR8GzTlW.js";import{m as A,_ as M}from"./index-vflU_jNS.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}${c}`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
@@ -0,0 +1 @@
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-CeZEm9rq.js";import{af as V}from"./tcm-BmNVoRI3.js";import{_ as q}from"./index-vflU_jNS.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
@@ -0,0 +1 @@
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,di as c}from"./.pnpm-CeZEm9rq.js";import{ag as Y}from"./tcm-BmNVoRI3.js";import{_ as q}from"./index-vflU_jNS.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{o as N,dk as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-CeZEm9rq.js";import j from"./RecordingPlaybackBlock-DGBQb6Ox.js";import{U as k}from"./index-BP4odEtD.js";import{i as c,_ as q}from"./index-vflU_jNS.js";import{ak as K,al as x,am as A}from"./tcm-BmNVoRI3.js";import"./RecordingVideoPlayer-jwST1qx7.js";import"./file-BXLECkux.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
@@ -0,0 +1 @@
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-CeZEm9rq.js";import{an as q}from"./tcm-BmNVoRI3.js";import{_ as H}from"./index-vflU_jNS.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-C6AeSFwh.js";import"./.pnpm-CeZEm9rq.js";import"./tcm-BmNVoRI3.js";import"./index-vflU_jNS.js";export{o as default};
@@ -0,0 +1 @@
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-CeZEm9rq.js";import{p as j}from"./tcm-BmNVoRI3.js";import{i as C}from"./index-vflU_jNS.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
@@ -0,0 +1 @@
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cX as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as X,M as m,p as Q,ae as U,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-CeZEm9rq.js";import{d as te}from"./dayjs-CyERfvvz.js";import{as as ne,at as oe}from"./tcm-BmNVoRI3.js";import{p as re}from"./im-business-message-parse-Bzp_WHkq.js";import{_ as le}from"./index-vflU_jNS.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=Q(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name}`:"医生/员工":g.value?`患者(${g.value}`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=se,z=W,A=X;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:U(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(Y,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(V,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
@@ -0,0 +1 @@
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-CF1cFioe.js";import"./.pnpm-CeZEm9rq.js";export{m as default};
@@ -0,0 +1 @@
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-CeZEm9rq.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
@@ -0,0 +1 @@
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-CeZEm9rq.js";import{t as j,_ as J}from"./index-vflU_jNS.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
@@ -0,0 +1,2 @@
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d9 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CeZEm9rq.js";import{_ as fe}from"./picker-DeM3E1-N.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-vflU_jNS.js";import{a as T,d as he}from"./patient-DPw8Zw1m.js";import{h as ke}from"./perm-BCohqYdQ.js";import"./index-CxXw87QU.js";import"./index-mw3GnHGJ.js";import"./index.vue_vue_type_script_setup_true_lang-7atNyADr.js";import"./index-IdU3279O.js";import"./index-BP4odEtD.js";import"./file-BXLECkux.js";import"./index.vue_vue_type_script_setup_true_lang-DMxuZ4Hl.js";import"./usePaging-DUs81Q_K.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-CeZEm9rq.js";import{_ as V}from"./index-vflU_jNS.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-CeZEm9rq.js";import H from"./RecordingVideoPlayer-jwST1qx7.js";import{e as I,_ as P}from"./index-vflU_jNS.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-CeZEm9rq.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-CeZEm9rq.js";import{e as ae,_ as ne}from"./index-vflU_jNS.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-CeZEm9rq.js").then(M=>M.dP),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-CeZEm9rq.js";import{a5 as L}from"./tcm-BmNVoRI3.js";import{i as M,_ as S}from"./index-vflU_jNS.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-CWIJgHhb.js";import"./.pnpm-CeZEm9rq.js";import"./index-CxXw87QU.js";import"./index-vflU_jNS.js";export{o as default};
@@ -0,0 +1 @@
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-CeZEm9rq.js";import{_ as L}from"./index-CxXw87QU.js";import{i as V}from"./index-vflU_jNS.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-CWQaoFnC.js";import"./.pnpm-CeZEm9rq.js";import"./index-IdU3279O.js";import"./index-vflU_jNS.js";import"./picker-DP1TJoYI.js";import"./index-CxXw87QU.js";import"./index.vue_vue_type_script_setup_true_lang-7atNyADr.js";import"./article-BlHEzL88.js";import"./usePaging-DUs81Q_K.js";import"./picker-DeM3E1-N.js";import"./index-mw3GnHGJ.js";import"./index-BP4odEtD.js";import"./file-BXLECkux.js";import"./index.vue_vue_type_script_setup_true_lang-DMxuZ4Hl.js";export{o as default};
@@ -0,0 +1 @@
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-CeZEm9rq.js";import{_ as q}from"./index-IdU3279O.js";import{_ as F}from"./picker-DP1TJoYI.js";import{_ as K}from"./picker-DeM3E1-N.js";import{c as O,i as r}from"./index-vflU_jNS.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
@@ -0,0 +1 @@
import{r as n}from"./index-vflU_jNS.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{r as e}from"./index-vflU_jNS.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
@@ -0,0 +1 @@
import{r as e}from"./index-vflU_jNS.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
@@ -0,0 +1 @@
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-CI9raI6A.js";import"./.pnpm-CeZEm9rq.js";export{m as default};
@@ -0,0 +1 @@
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BgsLhGXs.js";import"./.pnpm-CeZEm9rq.js";import"./picker-DeM3E1-N.js";import"./index-CxXw87QU.js";import"./index-vflU_jNS.js";import"./index-mw3GnHGJ.js";import"./index.vue_vue_type_script_setup_true_lang-7atNyADr.js";import"./index-IdU3279O.js";import"./index-BP4odEtD.js";import"./file-BXLECkux.js";import"./index.vue_vue_type_script_setup_true_lang-DMxuZ4Hl.js";import"./usePaging-DUs81Q_K.js";export{o as default};
@@ -0,0 +1 @@
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-DZzEU85p.js";import"./.pnpm-CeZEm9rq.js";export{m as default};
@@ -0,0 +1 @@
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-CvZNeTt0.js";import"./.pnpm-CeZEm9rq.js";import"./add-nav.vue_vue_type_script_setup_true_lang-CWQaoFnC.js";import"./index-IdU3279O.js";import"./index-vflU_jNS.js";import"./picker-DP1TJoYI.js";import"./index-CxXw87QU.js";import"./index.vue_vue_type_script_setup_true_lang-7atNyADr.js";import"./article-BlHEzL88.js";import"./usePaging-DUs81Q_K.js";import"./picker-DeM3E1-N.js";import"./index-mw3GnHGJ.js";import"./index-BP4odEtD.js";import"./file-BXLECkux.js";import"./index.vue_vue_type_script_setup_true_lang-DMxuZ4Hl.js";export{o as default};
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More