From 21790e35f477323c5aa36a0b03f765820213c4ad Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 13 Aug 2026 16:37:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/api/first_visit.ts | 4 +- .../first_visit/doctor_dashboard/index.vue | 1 + .../views/first_visit/my_patients/index.vue | 6 +- .../first_visit/registration_stats/index.vue | 1 + .../ui/dialogs/prescription.py | 1871 +++++++++++++---- .../ui/pages/prescriptions.py | 39 +- .../test_prescription_editor_drawer_visual.py | 36 +- app/tests/test_prescription_security_ui.py | 2 + app/tests/test_prescription_ui.py | 164 +- .../FirstVisitDoctorDashboardLogic.php | 7 +- .../FirstVisitRegistrationStatsLogic.php | 11 + 11 files changed, 1708 insertions(+), 434 deletions(-) diff --git a/admin/src/api/first_visit.ts b/admin/src/api/first_visit.ts index 5caff26ea..3f3148074 100644 --- a/admin/src/api/first_visit.ts +++ b/admin/src/api/first_visit.ts @@ -155,7 +155,7 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams) } export interface FirstVisitRegistrationStatsParams { - time_type: 'today' | 'week' | 'month' + time_type: 'today' | 'yesterday' | 'week' | 'month' dept_id?: number assistant_id?: number } @@ -169,7 +169,7 @@ export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrati } export interface FirstVisitDoctorDashboardParams { - time_type: 'today' | 'week' | 'month' | 'custom' + time_type: 'today' | 'yesterday' | 'week' | 'month' | 'custom' start_date?: string end_date?: string dept_id?: number diff --git a/admin/src/views/first_visit/doctor_dashboard/index.vue b/admin/src/views/first_visit/doctor_dashboard/index.vue index 081cbd01e..2167d4972 100644 --- a/admin/src/views/first_visit/doctor_dashboard/index.vue +++ b/admin/src/views/first_visit/doctor_dashboard/index.vue @@ -321,6 +321,7 @@ const query = reactive({ const customDateRange = ref([]) const timeOptions = [ { label: '今日', value: 'today' }, + { label: '昨天', value: 'yesterday' }, { label: '本周', value: 'week' }, { label: '本月', value: 'month' }, { label: '自定义', value: 'custom' } diff --git a/admin/src/views/first_visit/my_patients/index.vue b/admin/src/views/first_visit/my_patients/index.vue index 9af6d3b30..6d163b980 100644 --- a/admin/src/views/first_visit/my_patients/index.vue +++ b/admin/src/views/first_visit/my_patients/index.vue @@ -350,7 +350,7 @@ const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPa const PaibanPanel = defineAsyncComponent(() => import('./components/PaibanPanel.vue')) type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed' -type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom' +type DateType = 'all' | 'yesterday' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom' const router = useRouter() const userStore = useUserStore() @@ -401,6 +401,7 @@ const statusOptions: Array<{ label: string; value: StatusFilter }> = [ const dateOptions: Array<{ label: string; value: DateType }> = [ { label: '全部时间', value: 'all' }, + { label: '昨天', value: 'yesterday' }, { label: '今日预约', value: 'today' }, { label: '明日', value: 'tomorrow' }, { label: '后天', value: 'day_after' }, @@ -466,6 +467,9 @@ function selectDateType(type: DateType) { if (type === 'all') { formData.start_date = '' formData.end_date = '' + } else if (type === 'yesterday') { + formData.start_date = today.subtract(1, 'day').format('YYYY-MM-DD') + formData.end_date = formData.start_date } else if (type === 'today') { formData.start_date = today.format('YYYY-MM-DD') formData.end_date = formData.start_date diff --git a/admin/src/views/first_visit/registration_stats/index.vue b/admin/src/views/first_visit/registration_stats/index.vue index 6b36c8d29..65ee346eb 100644 --- a/admin/src/views/first_visit/registration_stats/index.vue +++ b/admin/src/views/first_visit/registration_stats/index.vue @@ -325,6 +325,7 @@ const dashboard = reactive(emptyDashboard()) const query = reactive({ time_type: 'today' }) const timeOptions = [ { label: '今日', value: 'today' }, + { label: '昨天', value: 'yesterday' }, { label: '本周', value: 'week' }, { label: '本月', value: 'month' } ] diff --git a/app/src/doctor_workstation/ui/dialogs/prescription.py b/app/src/doctor_workstation/ui/dialogs/prescription.py index fa7a19b9f..5cd1129e1 100644 --- a/app/src/doctor_workstation/ui/dialogs/prescription.py +++ b/app/src/doctor_workstation/ui/dialogs/prescription.py @@ -14,43 +14,53 @@ import math import os import re import sys +import tempfile from collections.abc import Iterable, Mapping, Sequence from datetime import date, datetime from pathlib import Path +from time import monotonic from typing import Any from PySide6.QtCore import ( QBuffer, QByteArray, QDate, + QEvent, QIODevice, QMarginsF, + QObject, QPoint, QRectF, + QSize, Qt, QTimer, Signal, ) from PySide6.QtGui import ( QColor, + QDoubleValidator, QFont, QFontDatabase, + QIcon, QImage, + QKeyEvent, QMouseEvent, QPageLayout, QPageSize, QPainter, QPdfWriter, QPen, + QPolygon, QTextDocument, ) from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QButtonGroup, + QCalendarWidget, QCheckBox, QComboBox, - QDateEdit, QDialog, QDialogButtonBox, QDoubleSpinBox, @@ -66,6 +76,7 @@ from PySide6.QtWidgets import ( QListWidgetItem, QMessageBox, QPushButton, + QRadioButton, QScrollArea, QSizePolicy, QSpinBox, @@ -89,6 +100,7 @@ from ..widgets import ( page_items, page_total, run_async, + show_toast, ) PRESCRIPTION_DRAWER_QSS = r""" @@ -105,9 +117,43 @@ QFrame#PrescriptionDrawerHeader { border-bottom: 1px solid #E5E7EB; } QLabel#PrescriptionDrawerTitle { - color: #1F2937; - font-size: 20px; - font-weight: 700; + color: #303133; + font-size: 16px; + font-weight: 600; +} +QLabel#PrescriptionFormLabel { + color: #606266; + font-size: 14px; +} +QLabel#PrescriptionFormHint { + color: #909399; + font-size: 12px; +} +QLabel#PrescriptionLockTag { + color: #F56C6C; + background-color: #FEF0F0; + border: 1px solid #FDE2E2; + border-radius: 4px; + padding: 2px 8px; + font-size: 12px; +} +QWidget#PrescriptionHerbTableHeader { + background-color: #F5F7FA; + border: 1px solid #EBEEF5; + border-bottom: 0; +} +QLabel#PrescriptionHerbTableHead { + color: #909399; + font-size: 12px; + font-weight: 600; +} +QFrame#PrescriptionHerbTableRow { + background-color: #FFFFFF; + border: 1px solid #EBEEF5; + border-top: 0; +} +QFrame#PrescriptionHerbTableRow[duplicate="true"] { + background-color: #FDF6EC; } QLabel#PrescriptionDrawerSubtitle { color: #5B7A76; @@ -170,14 +216,16 @@ QLabel#PrescriptionFieldLabel { font-weight: 500; } QLabel#PrescriptionUsageMain { - color: #0891B2; + color: #409EFF; font-size: 13px; font-weight: 600; + padding: 8px 0 4px 0; } QLabel#PrescriptionUsageAux { - color: #D58B1A; + color: #E6A23C; font-size: 13px; font-weight: 600; + padding: 12px 0 4px 0; } QFrame#PrescriptionRpToolbar { background-color: #FFFFFF; @@ -223,8 +271,12 @@ QLabel#PrescriptionFormulaTag[formula="aux"] { border: 1px solid #F3D19E; } QLabel#PrescriptionFormulaEmpty { - color: #A8ABB2; - padding: 10px 2px; + color: #909399; + background-color: #FFFFFF; + border: 1px solid #EBEEF5; + border-top: 0; + padding: 16px 8px; + font-size: 13px; } QFrame#PrescriptionHerbCard { background-color: #FFFFFF; @@ -258,16 +310,24 @@ QFrame#PrescriptionDrawerFooter { } QFrame#PrescriptionDrawerSurface QLineEdit, QFrame#PrescriptionDrawerSurface QTextEdit, -QFrame#PrescriptionDrawerSurface QComboBox, -QFrame#PrescriptionDrawerSurface QDateEdit, QFrame#PrescriptionDrawerSurface QSpinBox, QFrame#PrescriptionDrawerSurface QDoubleSpinBox { - min-height: 34px; - color: #134E4A; + min-height: 32px; + color: #303133; background-color: #FFFFFF; - border: 1px solid #D7DCE3; - border-radius: 6px; - padding: 0 9px; + border: 1px solid #DCDFE6; + border-radius: 4px; + padding: 0 11px; + selection-background-color: #D9ECFF; +} +QFrame#PrescriptionDrawerSurface QComboBox { + min-height: 32px; + color: #303133; + background-color: #FFFFFF; + border: 1px solid #DCDFE6; + border-radius: 4px; + padding-left: 11px; + padding-right: 28px; selection-background-color: #D9ECFF; } QFrame#PrescriptionDrawerSurface QTextEdit { @@ -276,33 +336,97 @@ QFrame#PrescriptionDrawerSurface QTextEdit { QFrame#PrescriptionDrawerSurface QLineEdit:focus, QFrame#PrescriptionDrawerSurface QTextEdit:focus, QFrame#PrescriptionDrawerSurface QComboBox:focus, -QFrame#PrescriptionDrawerSurface QDateEdit:focus, QFrame#PrescriptionDrawerSurface QSpinBox:focus, QFrame#PrescriptionDrawerSurface QDoubleSpinBox:focus { - border: 1px solid #0891B2; + border: 1px solid #409EFF; } QFrame#PrescriptionDrawerSurface QLineEdit:read-only { color: #5B7A76; background-color: #F0F2F5; } -QFrame#PrescriptionDrawerSurface QComboBox::drop-down, -QFrame#PrescriptionDrawerSurface QDateEdit::drop-down { +QFrame#PrescriptionDatePicker { + min-height: 32px; + background-color: #FFFFFF; + border: 1px solid #DCDFE6; + border-radius: 4px; +} +QFrame#PrescriptionDatePicker:hover { + border-color: #C0C4CC; +} +QFrame#PrescriptionDatePicker[active="true"] { + border: 1px solid #409EFF; +} +QFrame#PrescriptionDrawerSurface QFrame#PrescriptionDatePicker QLineEdit, +QFrame#PrescriptionDrawerSurface QFrame#PrescriptionDatePicker QLineEdit:read-only { + min-height: 30px; + color: #303133; + background-color: transparent; border: 0; - width: 24px; + padding: 0 4px 0 11px; +} +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDateButton { + min-width: 32px; + max-width: 32px; + min-height: 30px; + max-height: 30px; + padding: 0; + color: #909399; + background-color: transparent; + border: 0; + border-radius: 0; +} +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDateButton:hover, +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDateButton:pressed { + color: #409EFF; + background-color: transparent; + border: 0; +} +QFrame#PrescriptionDrawerSurface QComboBox::drop-down { + subcontrol-origin: border; + subcontrol-position: center right; + width: 28px; + border: 0; + background: transparent; +} +QFrame#PrescriptionDrawerSurface QComboBox::down-arrow { + image: url("__CHEVRON_URL__"); + width: 12px; + height: 8px; +} +QFrame#PrescriptionDrawerSurface QComboBox:hover { + border-color: #C0C4CC; +} +QFrame#PrescriptionDrawerSurface QComboBox QAbstractItemView { + outline: 0; + color: #303133; + background-color: #FFFFFF; + border: 1px solid #DCDFE6; + selection-background-color: #ECF5FF; + selection-color: #409EFF; +} +QFrame#PrescriptionDrawerSurface QRadioButton { + min-height: 32px; + spacing: 6px; + color: #606266; } QFrame#PrescriptionDrawerSurface QPushButton { - min-height: 34px; - padding: 0 13px; - color: #0E7490; + min-height: 32px; + padding: 0 15px; + color: #606266; background-color: #FFFFFF; - border: 1px solid #A0CFFF; - border-radius: 6px; - font-weight: 600; + border: 1px solid #DCDFE6; + border-radius: 4px; + font-weight: 500; } QFrame#PrescriptionDrawerSurface QPushButton:hover { - color: #FFFFFF; - background-color: #0891B2; - border-color: #0891B2; + color: #409EFF; + background-color: #ECF5FF; + border-color: #C6E2FF; +} +QFrame#PrescriptionDrawerSurface QPushButton[size="small"] { + min-height: 24px; + padding: 0 11px; + font-size: 12px; } QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose { min-width: 34px; @@ -333,12 +457,41 @@ QFrame#PrescriptionDrawerSurface QPushButton:disabled { } QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"] { color: #FFFFFF; - background-color: #0891B2; - border-color: #0891B2; + background-color: #409EFF; + border-color: #409EFF; } QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"]:hover { - background-color: #0E7490; - border-color: #0E7490; + background-color: #66B1FF; + border-color: #66B1FF; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="success"] { + color: #FFFFFF; + background-color: #67C23A; + border-color: #67C23A; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="success"]:hover { + background-color: #85CE61; + border-color: #85CE61; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"] { + color: #E6A23C; + background-color: #FFFFFF; + border-color: #E6A23C; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"]:hover { + color: #FFFFFF; + background-color: #E6A23C; + border-color: #E6A23C; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="secondary"] { + color: #409EFF; + background-color: #FFFFFF; + border-color: #B3D8FF; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="secondary"]:hover { + color: #FFFFFF; + background-color: #409EFF; + border-color: #409EFF; } QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"] { color: #5B7A76; @@ -363,13 +516,154 @@ QFrame#PrescriptionDrawerSurface QScrollBar::add-line:vertical, QFrame#PrescriptionDrawerSurface QScrollBar::sub-line:vertical { height: 0; } +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionHerbDelete { + min-height: 24px; + padding: 0 6px; + color: #F56C6C; + background-color: transparent; + border: 0; + font-weight: 400; +} +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionHerbDelete:hover { + color: #F56C6C; + background-color: #FEF0F0; + border: 0; +} QWidget#PrescriptionSignaturePad { background-color: #FFFFFF; - border: 1px solid #D7DCE3; - border-radius: 5px; + border: 1px solid #DCDFE6; + border-radius: 4px; +} +QLabel#PrescriptionSignatureHint { + color: #E6A23C; + font-size: 12px; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner { + min-height: 32px; + border: 1px solid #EBEEF5; + border-radius: 4px; + background-color: #F4F4F5; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner QLabel { + background-color: transparent; + border: 0; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner QLabel#MessageBannerIcon { + font-size: 16px; + font-weight: 700; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="info"] { + color: #909399; + background-color: #F4F4F5; + border: 1px solid #E9E9EB; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="info"] QLabel { + color: #909399; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="success"] { + color: #67C23A; + background-color: #F0F9EB; + border: 1px solid #E1F3D8; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="success"] QLabel { + color: #67C23A; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="warning"] { + color: #E6A23C; + background-color: #FDF6EC; + border: 1px solid #F5DAB1; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="warning"] QLabel { + color: #E6A23C; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="danger"] { + color: #F56C6C; + background-color: #FEF0F0; + border: 1px solid #FDE2E2; +} +QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="danger"] QLabel { + color: #F56C6C; } """ +_CHEVRON_PATH: Path | None = None + + +def _select_chevron_url() -> str: + global _CHEVRON_PATH + if _CHEVRON_PATH is not None and _CHEVRON_PATH.exists(): + return _CHEVRON_PATH.as_posix() + path = Path(tempfile.gettempdir()) / "zyt-prescription-select-chevron.png" + image = QImage(24, 16, QImage.Format.Format_ARGB32) + image.fill(Qt.GlobalColor.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(QColor("#606266"), 2.2) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.drawPolyline(QPolygon([QPoint(5, 4), QPoint(12, 11), QPoint(19, 4)])) + painter.end() + image.save(str(path), "PNG") + _CHEVRON_PATH = path + return path.as_posix() + + +def _calendar_icon_url() -> str: + path = Path(tempfile.gettempdir()) / "zyt-prescription-calendar.png" + if path.exists() and path.stat().st_size > 0: + return path.as_posix() + image = QImage(32, 32, QImage.Format.Format_ARGB32) + image.fill(Qt.GlobalColor.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor("#909399"), 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(6, 9, 20, 17, 2, 2) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#909399")) + painter.drawRoundedRect(6, 9, 20, 6, 2, 2) + painter.setPen(QPen(QColor("#909399"), 2.2)) + painter.drawLine(11, 6, 11, 12) + painter.drawLine(21, 6, 21, 12) + painter.end() + image.save(str(path), "PNG") + return path.as_posix() + + +def _prescription_drawer_qss() -> str: + return PRESCRIPTION_DRAWER_QSS.replace("__CHEVRON_URL__", _select_chevron_url()) + + +def _prescription_calendar_qss() -> str: + return """ +QCalendarWidget { + background-color: #FFFFFF; + border: 1px solid #DCDFE6; +} +QCalendarWidget QWidget#qt_calendar_navigationbar { + background-color: #FFFFFF; + border: 0; +} +QCalendarWidget QToolButton { + color: #303133; + background-color: transparent; + border: 0; + padding: 4px 8px; + font-size: 13px; +} +QCalendarWidget QToolButton:hover { + color: #409EFF; +} +QCalendarWidget QAbstractItemView:enabled { + color: #303133; + selection-background-color: #409EFF; + selection-color: #FFFFFF; + outline: 0; +} +""" + + _PRESCRIPTION_DARK_REPLACEMENTS = ( ("color: #FFFFFF", "color: #EEF2FF"), ("selection-background-color: #D9ECFF", "selection-background-color: #6675F5"), @@ -433,6 +727,13 @@ def _bool(value: Any) -> bool: return bool(value) +def _alert_html(title: str, body_html: str) -> str: + return ( + f'
{html.escape(title)}
' + f'
{body_html}
' + ) + + def _mapping(value: Any) -> dict[str, Any]: if isinstance(value, Mapping): return dict(value) @@ -539,6 +840,39 @@ def _set_combo_data(combo: QComboBox, value: Any, fallback: int = 0) -> None: combo.setCurrentIndex(index if index >= 0 else fallback) +class BoolRadioRow(QWidget): + """Two-option radio row matching Element Plus boolean radios.""" + + def __init__( + self, + true_label: str, + false_label: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(16) + self.true_button = QRadioButton(true_label, self) + self.false_button = QRadioButton(false_label, self) + group = QButtonGroup(self) + group.addButton(self.true_button, 1) + group.addButton(self.false_button, 0) + self.false_button.setChecked(True) + layout.addWidget(self.true_button) + layout.addWidget(self.false_button) + layout.addStretch(1) + self.setMinimumHeight(32) + + def isChecked(self) -> bool: # noqa: N802 - Qt compatibility API + return self.true_button.isChecked() + + def setChecked(self, checked: bool) -> None: # noqa: N802 - Qt compatibility API + checked = bool(checked) + self.true_button.setChecked(checked) + self.false_button.setChecked(not checked) + + def _duplicate_herb_names(herbs: Sequence[Mapping[str, Any]]) -> list[str]: seen: set[str] = set() duplicates: list[str] = [] @@ -551,15 +885,307 @@ def _duplicate_herb_names(herbs: Sequence[Mapping[str, Any]]) -> list[str]: return duplicates +class SelectComboBox(QComboBox): + """Non-editable dropdown that keeps its popup above a frameless drawer.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setEditable(False) + self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.setMaxVisibleItems(12) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setPlaceholderText("请选择") + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def showPopup(self) -> None: # noqa: N802 - Qt virtual + super().showPopup() + popup = self.view().window() + popup.raise_() + popup.activateWindow() + + +class LabeledIntCombo(SelectComboBox): + """Integer choices rendered as ``1袋`` / ``1包`` like the admin selects.""" + + def __init__( + self, + values: Sequence[int], + suffix: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._suffix = suffix + numbers = tuple(int(value) for value in values) + self.setRange(min(numbers), max(numbers)) + + def setRange(self, minimum: int, maximum: int) -> None: # noqa: N802 - Qt compatibility + current = self.value() if self.count() else int(minimum) + self.blockSignals(True) + self.clear() + for value in range(int(minimum), int(maximum) + 1): + self.addItem(f"{value}{self._suffix}", value) + self.blockSignals(False) + self.setValue(current) + + def value(self) -> int: + data = self.currentData() + return int(data) if data is not None else self.minimum() + + def setValue(self, value: Any) -> None: # noqa: N802 - Qt compatibility + _set_combo_data(self, _int(value, self.minimum()), 0) + + def minimum(self) -> int: + return int(self.itemData(0) or 1) if self.count() else 1 + + def maximum(self) -> int: + return int(self.itemData(self.count() - 1) or 1) if self.count() else 1 + + +class PrescriptionDateEdit(QFrame): + """Date picker matching admin ``el-date-picker``: text + calendar icon + popup.""" + + dateChanged = Signal(QDate) + + def __init__(self, value: QDate | None = None, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("PrescriptionDatePicker") + self.setProperty("active", False) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setMinimumHeight(32) + self._ignore_open_until = 0.0 + initial = value or QDate.currentDate() + self._date = initial if initial.isValid() else QDate.currentDate() + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 2, 0) + layout.setSpacing(0) + + self._display = QLineEdit(self) + self._display.setReadOnly(True) + self._display.setFrame(False) + self._display.setPlaceholderText("选择日期") + self._display.setCursor(Qt.CursorShape.PointingHandCursor) + self._display.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._display.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self._button = QPushButton(self) + self._button.setObjectName("PrescriptionDateButton") + self._button.setCursor(Qt.CursorShape.PointingHandCursor) + self._button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._button.setFlat(True) + self._button.setIcon(QIcon(_calendar_icon_url())) + self._button.setIconSize(QSize(16, 16)) + self._button.clicked.connect(self.open_calendar) + + layout.addWidget(self._display, 1) + layout.addWidget(self._button, 0) + + self._calendar = QCalendarWidget(self) + self._calendar.setWindowFlags(Qt.WindowType.Popup) + self._calendar.setGridVisible(True) + self._calendar.setVerticalHeaderFormat( + QCalendarWidget.VerticalHeaderFormat.NoVerticalHeader + ) + self._calendar.setHorizontalHeaderFormat( + QCalendarWidget.HorizontalHeaderFormat.ShortDayNames + ) + self._calendar.setFixedSize(292, 280) + self._calendar.setStyleSheet(_prescription_calendar_qss()) + self._calendar.hide() + self._calendar.clicked.connect(self._pick_date) + self._calendar.activated.connect(self._pick_date) + self._calendar.installEventFilter(self) + self._sync_display() + + def date(self) -> QDate: + return QDate(self._date) + + def setDate(self, value: QDate) -> None: # noqa: N802 - Qt compatibility + next_date = value if value is not None and value.isValid() else QDate.currentDate() + changed = next_date != self._date + self._date = next_date + self._sync_display() + if changed: + self.dateChanged.emit(self._date) + + def calendarPopup(self) -> bool: # noqa: N802 - Qt compatibility + return True + + def calendarWidget(self) -> QCalendarWidget: # noqa: N802 - Qt compatibility + return self._calendar + + def open_calendar(self) -> None: + now = monotonic() + if self._calendar.isVisible(): + self._calendar.hide() + self._ignore_open_until = now + 0.25 + self._set_active(False) + return + if now < self._ignore_open_until: + return + self._calendar.setSelectedDate(self.date()) + self._calendar.move(self.mapToGlobal(QPoint(0, self.height() - 1))) + self._set_active(True) + self._calendar.show() + self._calendar.raise_() + self._calendar.setFocus(Qt.FocusReason.PopupFocusReason) + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 + if watched is self._calendar and event.type() == QEvent.Type.Hide: + self._ignore_open_until = monotonic() + 0.25 + self._set_active(False) + return super().eventFilter(watched, event) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt virtual + if event.button() == Qt.MouseButton.LeftButton: + child = self.childAt(event.position().toPoint()) + if child is not self._button: + self.open_calendar() + event.accept() + return + super().mousePressEvent(event) + + def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 - Qt virtual + if event.key() in ( + Qt.Key.Key_Space, + Qt.Key.Key_Return, + Qt.Key.Key_Enter, + Qt.Key.Key_Down, + ): + self.open_calendar() + event.accept() + return + super().keyPressEvent(event) + + def _pick_date(self, value: QDate) -> None: + if value.isValid(): + self.setDate(value) + self._calendar.hide() + self._set_active(False) + + def _sync_display(self) -> None: + self._display.setText(self._date.toString("yyyy-MM-dd")) + + def _set_active(self, active: bool) -> None: + self.setProperty("active", "true" if active else "false") + style = self.style() + if style is not None: + style.unpolish(self) + style.polish(self) + self.update() + + +class AdminDosageInput(SelectComboBox): + """Dosage control with the same selectable/free-input modes as the admin form.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self._allowed_values: tuple[float, ...] | None = None + self._minimum = 0.0 + self._maximum = 1e100 + self._decimals = 2 + self.configure_free(default=None) + + @staticmethod + def _number_text(value: float, decimals: int = 2) -> str: + if float(value).is_integer(): + return str(int(value)) + return f"{value:.{decimals}f}".rstrip("0").rstrip(".") + + def configure_discrete( + self, + values: Iterable[float], + *, + suffix: str, + default: float | None, + ) -> None: + allowed = tuple(float(value) for value in values) + self.blockSignals(True) + self.clear() + self.setEditable(False) + self.setPlaceholderText("请选择用量") + self._allowed_values = allowed + self._minimum = min(allowed) if allowed else 0.0 + self._maximum = max(allowed) if allowed else 0.0 + self._decimals = 0 + for value in allowed: + self.addItem(f"{self._number_text(value, 0)}{suffix}", value) + self.setValue(default) + self.blockSignals(False) + + def configure_free(self, *, default: float | None) -> None: + self.blockSignals(True) + self.clear() + self.setEditable(True) + self._allowed_values = None + self._minimum = 0.0 + self._maximum = 1e100 + self._decimals = 2 + line_edit = self.lineEdit() + if line_edit is not None: + validator = QDoubleValidator(self._minimum, self._maximum, self._decimals, line_edit) + validator.setNotation(QDoubleValidator.Notation.StandardNotation) + line_edit.setValidator(validator) + self.setCurrentIndex(-1) + self.setValue(default) + self.blockSignals(False) + + def optional_value(self) -> float | None: + data = self.currentData() + if isinstance(data, (int, float)): + return float(data) + text = self.currentText().strip() + if not text: + return None + try: + return float(text) + except ValueError: + return None + + def value(self) -> float: + value = self.optional_value() + return 0.0 if value is None else value + + def setValue(self, value: Any) -> None: # noqa: N802 - Qt compatibility API + if value in (None, ""): + self.setCurrentIndex(-1) + if self.isEditable(): + self.setEditText("") + return + numeric = _float(value) + if self._allowed_values is not None: + for index, allowed in enumerate(self._allowed_values): + if math.isclose(numeric, allowed): + self.setCurrentIndex(index) + return + # Preserve an out-of-date stored value exactly as Element Plus does: + # it may be submitted unchanged, but users can only choose canonical options. + self.insertItem(0, f"{self._number_text(numeric)}(历史值)", numeric) + self.setCurrentIndex(0) + return + self.setCurrentIndex(-1) + self.setEditText(self._number_text(numeric, self._decimals)) + + def minimum(self) -> float: + return self._minimum + + def maximum(self) -> float: + return self._maximum + + class MultiSelectComboBox(QComboBox): """Compact fixed-option multi-select matching Element Plus ``multiple`` selects.""" def __init__(self, options: Sequence[str], parent: QWidget | None = None) -> None: super().__init__(parent) self.setEditable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) if self.lineEdit() is not None: self.lineEdit().setReadOnly(True) self.lineEdit().setPlaceholderText("请选择忌口内容(可多选)") + self.lineEdit().setCursor(Qt.CursorShape.PointingHandCursor) for option in options: self.addItem(option, option) self.setItemData( @@ -747,15 +1373,28 @@ class HerbRowWidget(QFrame): *, formula_type: str = "主方", locked: bool = False, + source_locked: bool | None = None, show_formula: bool = False, card_mode: bool = False, + table_mode: bool = False, parent: QWidget | None = None, ) -> None: super().__init__(parent) self._card_mode = card_mode - self.setObjectName("PrescriptionHerbCard" if card_mode else "SubtleCard") + self._table_mode = table_mode + if table_mode: + self.setObjectName("PrescriptionHerbTableRow") + elif card_mode: + self.setObjectName("PrescriptionHerbCard") + else: + self.setObjectName("SubtleCard") self.setProperty("duplicate", False) self._locked = locked + self._source_locked = ( + _bool(first_value(herb, "locked", default=False)) + if source_locked is None + else bool(source_locked) + ) self.formula_combo = QComboBox(self) self.formula_combo.addItem("主方", "主方") self.formula_combo.addItem("辅方", "辅方") @@ -763,7 +1402,7 @@ class HerbRowWidget(QFrame): self.formula_combo, _formula(first_value(herb, "formula_type", default=formula_type)), ) - self.formula_combo.setVisible(show_formula and not card_mode) + self.formula_combo.setVisible(show_formula and not card_mode and not table_mode) self.formula_combo.setEnabled(not locked) self.medicine = RemoteMedicineComboBox( repository, @@ -773,10 +1412,10 @@ class HerbRowWidget(QFrame): ) self.medicine.setEnabled(not locked) self.dosage = QDoubleSpinBox(self) - self.dosage.setRange(0, 99999) + self.dosage.setRange(0, 1e100) self.dosage.setDecimals(1) self.dosage.setSingleStep(0.5) - self.dosage.setSuffix(" g") + self.dosage.setSuffix("" if table_mode else " g") self.dosage.setValue(_float(first_value(herb, "dosage", "amount", default=0))) self.dosage.setEnabled(not locked) self.dosage.setMaximumWidth(130) @@ -784,7 +1423,17 @@ class HerbRowWidget(QFrame): self.remove_button.setProperty("variant", "ghost") self.remove_button.setVisible(not locked) self.remove_button.clicked.connect(lambda: self.remove_requested.emit(self)) - if card_mode: + self.locked_label = QLabel("已锁定", self) + self.locked_label.setObjectName("PrescriptionFormHint") + self.locked_label.setVisible(locked and table_mode) + self.index_label = QLabel("1", self) + self.index_label.setObjectName("PrescriptionHerbTableHead") + self.index_label.setFixedWidth(48) + self.index_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.index_label.setVisible(table_mode) + if table_mode: + self._build_table_layout() + elif card_mode: self._build_card_layout() else: layout = QHBoxLayout(self) @@ -795,6 +1444,20 @@ class HerbRowWidget(QFrame): layout.addWidget(self.dosage) layout.addWidget(self.remove_button) + def _build_table_layout(self) -> None: + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(8) + layout.addWidget(self.index_label) + self.medicine.setMinimumWidth(0) + self.medicine.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + layout.addWidget(self.medicine, 1) + layout.addWidget(self.dosage) + self.remove_button.setObjectName("PrescriptionHerbDelete") + layout.addWidget(self.remove_button) + layout.addWidget(self.locked_label) + def _build_card_layout(self) -> None: self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) layout = QVBoxLayout(self) @@ -817,6 +1480,9 @@ class HerbRowWidget(QFrame): layout.addLayout(dose_row) def set_card_position(self, position: int) -> None: + if self._table_mode: + self.index_label.setText(str(position)) + return if not self._card_mode: return formula = _formula(self.formula_combo.currentData()) @@ -841,7 +1507,7 @@ class HerbRowWidget(QFrame): } if self.medicine.medicine_id: result["medicine_id"] = self.medicine.medicine_id - if self._locked: + if self._source_locked: result["locked"] = True return result @@ -857,6 +1523,7 @@ class HerbEditor(QWidget): *, show_formula: bool = False, grid_mode: bool = False, + table_mode: bool = False, show_actions: bool = True, parent: QWidget | None = None, ) -> None: @@ -864,6 +1531,7 @@ class HerbEditor(QWidget): self.repository = repository self.show_formula = show_formula self.grid_mode = grid_mode + self.table_mode = table_mode self._show_actions = show_actions self.rows: list[HerbRowWidget] = [] self._locked = False @@ -875,21 +1543,23 @@ class HerbEditor(QWidget): self.actions_host = QWidget(self) actions = QHBoxLayout(self.actions_host) actions.setContentsMargins(0, 0, 0, 0) - self.main_button = QPushButton("+ 添加主方药材", self.actions_host) + self.main_button = QPushButton("添加主方药材", self.actions_host) self.main_button.setProperty("variant", "secondary") self.main_button.clicked.connect(lambda: self.add_row(formula_type="主方")) actions.addWidget(self.main_button) - self.aux_button = QPushButton("+ 添加辅方药材", self.actions_host) + self.aux_button = QPushButton("添加辅方药材", self.actions_host) self.aux_button.setProperty("variant", "secondary") self.aux_button.clicked.connect(lambda: self.add_row(formula_type="辅方")) self.aux_button.setVisible(show_formula) actions.addWidget(self.aux_button) actions.addStretch(1) - self.actions_host.setVisible(show_actions) + self.actions_host.setVisible(show_actions and not table_mode) root.addWidget(self.actions_host) self.lock_banner = MessageBanner() root.addWidget(self.lock_banner) - if grid_mode: + if table_mode: + self._build_table_editor(root) + elif grid_mode: self._build_grid_editor(root) else: scroll = QScrollArea() @@ -903,6 +1573,80 @@ class HerbEditor(QWidget): scroll.setWidget(host) root.addWidget(scroll, 1) + @staticmethod + def _herb_table_header() -> QWidget: + header = QWidget() + header.setObjectName("PrescriptionHerbTableHeader") + layout = QHBoxLayout(header) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(8) + index = QLabel("序号") + index.setObjectName("PrescriptionHerbTableHead") + index.setFixedWidth(48) + index.setAlignment(Qt.AlignmentFlag.AlignCenter) + name = QLabel("药材名称") + name.setObjectName("PrescriptionHerbTableHead") + name.setMinimumWidth(0) + name.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + dose = QLabel("剂量(克)") + dose.setObjectName("PrescriptionHerbTableHead") + dose.setFixedWidth(130) + action = QLabel("操作") + action.setObjectName("PrescriptionHerbTableHead") + action.setFixedWidth(72) + layout.addWidget(index) + layout.addWidget(name, 1) + layout.addWidget(dose) + layout.addWidget(action) + return header + + def _build_table_editor(self, root: QVBoxLayout) -> None: + main_head = QHBoxLayout() + main_tag = QLabel("主方") + main_tag.setObjectName("PrescriptionFormulaTag") + main_tag.setProperty("formula", "main") + main_head.addWidget(main_tag, 0, Qt.AlignmentFlag.AlignLeft) + self.main_button.setProperty("variant", "primary") + self.main_button.setProperty("size", "small") + main_head.addWidget(self.main_button) + main_head.addStretch(1) + root.addLayout(main_head) + root.addWidget(self._herb_table_header()) + self.main_rows_host = QWidget(self) + self.main_rows_layout = QVBoxLayout(self.main_rows_host) + self.main_rows_layout.setContentsMargins(0, 0, 0, 0) + self.main_rows_layout.setSpacing(0) + root.addWidget(self.main_rows_host) + self.main_empty = QLabel("暂无主方药材") + self.main_empty.setObjectName("PrescriptionFormulaEmpty") + self.main_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.main_empty.setMinimumHeight(44) + root.addWidget(self.main_empty) + + aux_head = QHBoxLayout() + aux_head.setContentsMargins(0, 10, 0, 0) + aux_tag = QLabel("辅方") + aux_tag.setObjectName("PrescriptionFormulaTag") + aux_tag.setProperty("formula", "aux") + aux_head.addWidget(aux_tag, 0, Qt.AlignmentFlag.AlignLeft) + self.aux_button.setVisible(True) + self.aux_button.setProperty("variant", "secondary") + self.aux_button.setProperty("size", "small") + aux_head.addWidget(self.aux_button) + aux_head.addStretch(1) + root.addLayout(aux_head) + root.addWidget(self._herb_table_header()) + self.aux_rows_host = QWidget(self) + self.aux_rows_layout = QVBoxLayout(self.aux_rows_host) + self.aux_rows_layout.setContentsMargins(0, 0, 0, 0) + self.aux_rows_layout.setSpacing(0) + root.addWidget(self.aux_rows_host) + self.aux_empty = QLabel("暂无辅方药材") + self.aux_empty.setObjectName("PrescriptionFormulaEmpty") + self.aux_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.aux_empty.setMinimumHeight(44) + root.addWidget(self.aux_empty) + def _build_grid_editor(self, root: QVBoxLayout) -> None: main_head = QHBoxLayout() main_tag = QLabel("主方") @@ -946,9 +1690,12 @@ class HerbEditor(QWidget): if self.grid_mode: self.main_grid.removeWidget(row) self.aux_grid.removeWidget(row) + elif self.table_mode: + self.main_rows_layout.removeWidget(row) + self.aux_rows_layout.removeWidget(row) row.deleteLater() self.rows.clear() - if self.grid_mode: + if self.grid_mode or self.table_mode: self._reflow_rows() if not self._suspend_changes: self.rows_changed.emit() @@ -964,20 +1711,24 @@ class HerbEditor(QWidget): self.main_button.setEnabled(not locked) self.aux_button.setEnabled(not locked) if locked: - if self.grid_mode: + if self.grid_mode or self.table_mode: self.lock_banner.clear() else: self.lock_banner.show_message( - "该处方含“禁用修改”模板,药材已锁定;可重新导入模板覆盖。", + "含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖", "warning", ) else: self.lock_banner.clear() for herb in herb_rows: - row_locked = locked or _bool(first_value(herb, "locked", default=False)) - self.add_row(herb, locked=row_locked) + source_locked = _bool(first_value(herb, "locked", default=False)) + self.add_row( + herb, + locked=locked or source_locked, + source_locked=source_locked, + ) self._suspend_changes = False - if self.grid_mode: + if self.grid_mode or self.table_mode: self._reflow_rows() self._refresh_duplicate_state() self.rows_changed.emit() @@ -988,20 +1739,23 @@ class HerbEditor(QWidget): *, formula_type: str = "主方", locked: bool | None = None, + source_locked: bool | None = None, ) -> HerbRowWidget: row = HerbRowWidget( self.repository, herb, formula_type=formula_type, locked=self._locked if locked is None else locked, + source_locked=source_locked, show_formula=self.show_formula, card_mode=self.grid_mode, + table_mode=self.table_mode, ) row.remove_requested.connect(self.remove_row) row.medicine.editTextChanged.connect(self._row_value_changed) row.formula_combo.currentIndexChanged.connect(self._row_formula_changed) self.rows.append(row) - if self.grid_mode: + if self.grid_mode or self.table_mode: self._reflow_rows() else: self.rows_layout.insertWidget(max(0, self.rows_layout.count() - 1), row) @@ -1018,15 +1772,18 @@ class HerbEditor(QWidget): if self.grid_mode: self.main_grid.removeWidget(row) self.aux_grid.removeWidget(row) + elif self.table_mode: + self.main_rows_layout.removeWidget(row) + self.aux_rows_layout.removeWidget(row) row.deleteLater() - if self.grid_mode: + if self.grid_mode or self.table_mode: self._reflow_rows() self._refresh_duplicate_state() self.rows_changed.emit() - def values(self) -> list[dict[str, Any]]: + def values(self, *, include_blank: bool = False) -> list[dict[str, Any]]: values = [row.value() for row in self.rows] - return [value for value in values if value["name"]] + return values if include_blank else [value for value in values if value["name"]] def _row_value_changed(self, _value: Any = None) -> None: self._refresh_duplicate_state() @@ -1034,7 +1791,7 @@ class HerbEditor(QWidget): self.rows_changed.emit() def _row_formula_changed(self, _index: int) -> None: - if self.grid_mode: + if self.grid_mode or self.table_mode: self._reflow_rows() self._row_value_changed() @@ -1049,15 +1806,28 @@ class HerbEditor(QWidget): row.set_duplicate(bool(key and names.get(key, 0) > 1)) def _reflow_rows(self) -> None: - if not self.grid_mode: + if not self.grid_mode and not self.table_mode: return - for layout in (self.main_grid, self.aux_grid): - while layout.count(): - layout.takeAt(0) main_rows = [ row for row in self.rows if _formula(row.formula_combo.currentData()) == "主方" ] aux_rows = [row for row in self.rows if _formula(row.formula_combo.currentData()) == "辅方"] + if self.table_mode: + for layout in (self.main_rows_layout, self.aux_rows_layout): + while layout.count(): + layout.takeAt(0) + for index, row in enumerate(main_rows): + row.set_card_position(index + 1) + self.main_rows_layout.addWidget(row) + for index, row in enumerate(aux_rows): + row.set_card_position(index + 1) + self.aux_rows_layout.addWidget(row) + self.main_empty.setVisible(not main_rows) + self.aux_empty.setVisible(not aux_rows) + return + for layout in (self.main_grid, self.aux_grid): + while layout.count(): + layout.takeAt(0) for layout, rows in ((self.main_grid, main_rows), (self.aux_grid, aux_rows)): for index, row in enumerate(rows): row.set_card_position(index + 1) @@ -1069,7 +1839,7 @@ class HerbEditor(QWidget): def resizeEvent(self, event: Any) -> None: super().resizeEvent(event) - if not self.grid_mode: + if not self.grid_mode or self.table_mode: return width = max(1, event.size().width()) columns = 4 if width >= 880 else 3 if width >= 650 else 2 if width >= 420 else 1 @@ -1338,11 +2108,11 @@ class TemplateImportDialog(QDialog): root = QVBoxLayout(self) filters = QHBoxLayout() self.name_edit = QLineEdit() - self.name_edit.setPlaceholderText("处方名称") + self.name_edit.setPlaceholderText("请输入处方名称搜索") self.name_edit.returnPressed.connect(self.search) filters.addWidget(self.name_edit, 1) self.formula_combo = QComboBox() - self.formula_combo.addItem("全部类型", "") + self.formula_combo.addItem("全部", "") self.formula_combo.addItem("主方", "主方") self.formula_combo.addItem("辅方", "辅方") filters.addWidget(self.formula_combo) @@ -1353,7 +2123,7 @@ class TemplateImportDialog(QDialog): mode_layout = QHBoxLayout() mode_layout.addWidget(QLabel("导入方式")) self.mode_combo = QComboBox() - self.mode_combo.addItem("覆盖同方型药材", "replace") + self.mode_combo.addItem("覆盖现有药材", "replace") self.mode_combo.addItem("追加到末尾", "append") mode_layout.addWidget(self.mode_combo) mode_layout.addStretch(1) @@ -1362,19 +2132,25 @@ class TemplateImportDialog(QDialog): root.addWidget(self.banner) self.table = QTableWidget(0, 6) self.table.setHorizontalHeaderLabels( - ["处方名称", "类型", "药材数", "药材明细", "公开范围", "创建人"] + ["处方名称", "处方类型", "药材数量", "药材明细", "是否公开", "创建人"] ) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.verticalHeader().hide() self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - self.table.itemDoubleClicked.connect(lambda _item: self.accept()) + self.table.itemClicked.connect(lambda _item: self.accept()) root.addWidget(self.table, 1) pager = QHBoxLayout() pager.addStretch(1) self.total_label = QLabel("共 0 条") pager.addWidget(self.total_label) + self.page_size_combo = QComboBox() + for size in (10, 15, 20, 50): + self.page_size_combo.addItem(f"{size} 条/页", size) + self.page_size_combo.setCurrentIndex(1) + self.page_size_combo.currentIndexChanged.connect(self._change_page_size) + pager.addWidget(self.page_size_combo) self.previous = QPushButton("上一页") self.previous.clicked.connect(lambda: self._change_page(self._page - 1)) pager.addWidget(self.previous) @@ -1463,6 +2239,11 @@ class TemplateImportDialog(QDialog): if generation == self._generation: self.banner.show_message(friendly_error(error), "danger") + def _change_page_size(self) -> None: + self._page_size = _int(self.page_size_combo.currentData(), 15) or 15 + self._page = 1 + self.load() + def _change_page(self, page: int) -> None: if page >= 1: self._page = page @@ -1475,6 +2256,11 @@ class TemplateImportDialog(QDialog): if self._selected is None: self.banner.show_message("请选择一条处方模板。", "warning") return + herbs = get_value(self._selected, "herbs", None) or [] + if not herbs: + self.banner.show_message("该处方没有药材信息", "warning") + self._selected = None + return super().accept() @@ -1491,7 +2277,12 @@ def parse_pasted_herbs(text: str) -> list[dict[str, Any]]: .replace("\r", "\n") .translate(str.maketrans("0123456789", "0123456789")) ) - normalized = re.sub(r"^\s*(?:Rp[::]*|处方[::]?|中药处方[::]?)\s*", "", normalized) + normalized = re.sub( + r"^\s*(?:Rp[::**\s]*|处方[::\s]*|中药处方[::\s]*)", + "", + normalized, + flags=re.IGNORECASE, + ) pieces: list[str] = [] for line in normalized.splitlines(): for piece in re.split(r"[、,,;;]", line): @@ -1504,7 +2295,7 @@ def parse_pasted_herbs(text: str) -> list[dict[str, Any]]: ) result: list[dict[str, Any]] = [] for piece in pieces: - if _PASTE_SKIP.search(piece) or re.match(r"^\d", piece): + if _PASTE_SKIP.search(piece) or re.match(r"^\d", piece) or re.match(r"^\d+\s*剂$", piece): continue equal = re.match( r"^(.+?)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$", @@ -1512,8 +2303,17 @@ def parse_pasted_herbs(text: str) -> list[dict[str, Any]]: ) if equal: dosage = float(equal.group(2)) + if dosage <= 0: + continue names = [name for name in re.split(r"[、,,\s]+", equal.group(1)) if name] - result.extend({"name": name.strip(), "dosage": dosage} for name in names) + result.extend( + { + "name": re.sub(r"[((][^))]*[))]$", "", name).strip(), + "dosage": dosage, + } + for name in names + if re.sub(r"[((][^))]*[))]$", "", name).strip() + ) continue matched = re.match(r"^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$", piece) if matched: @@ -1535,17 +2335,30 @@ class PasteHerbsDialog(QDialog): super().__init__(parent) self.repository = repository self.resolved: list[dict[str, Any]] = [] + self.skipped: list[str] = [] self.setWindowTitle("导入药方(识别药材)") self.resize(600, 470) root = QVBoxLayout(self) - root.addWidget(QLabel("粘贴药名与剂量;仅药品库完全同名且唯一的药材会被录入主方。")) + root.addWidget( + QLabel( + "粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。" + "支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g 白术10g」);" + "「黄芪、党参各10克」等同剂量写法亦可识别。" + ) + ) self.text_edit = QTextEdit() - self.text_edit.setPlaceholderText("例如:黄芪15 党参12 茯苓10\n柴胡10g、白术12g") + self.text_edit.setPlaceholderText( + "示例:\n黄芪15 党参12 茯苓10\n柴胡10g、黄芩10、半夏12克\n黄芪、白术、茯苓各15克" + ) root.addWidget(self.text_edit, 1) + mode_row = QHBoxLayout() + mode_row.addWidget(QLabel("导入方式")) self.mode_combo = QComboBox() - self.mode_combo.addItem("覆盖现有主方", "replace") + self.mode_combo.addItem("覆盖现有药材", "replace") self.mode_combo.addItem("追加到末尾", "append") - root.addWidget(self.mode_combo) + mode_row.addWidget(self.mode_combo) + mode_row.addStretch(1) + root.addLayout(mode_row) self.banner = MessageBanner() root.addWidget(self.banner) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel) @@ -1561,7 +2374,10 @@ class PasteHerbsDialog(QDialog): def accept(self) -> None: parsed = parse_pasted_herbs(self.text_edit.toPlainText()) if not parsed: - self.banner.show_message("未能解析出药材,请检查格式。", "warning") + self.banner.show_message( + "未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)", + "warning", + ) return self.import_button.setEnabled(False) self.banner.show_message("正在核对药品库…", "info") @@ -1607,17 +2423,16 @@ class PasteHerbsDialog(QDialog): accepted, skipped = result if not accepted: self.banner.show_message( - "药品库没有唯一同名匹配:" + "、".join(dict.fromkeys(skipped)), + ( + f"药品库中无以下完全同名药材,未录入:{'、'.join(dict.fromkeys(skipped))}" + if skipped + else "未能匹配到药品库中的药材" + ), "warning", ) return self.resolved = accepted - if skipped: - QMessageBox.information( - self, - "部分导入", - "已跳过非唯一同名药材:" + "、".join(dict.fromkeys(skipped)), - ) + self.skipped = skipped super().accept() @@ -1662,7 +2477,7 @@ class PrescriptionEditorDialog(QDialog): "烟草", "萝卜", ) - DRAWER_WIDTH = 1200 + DRAWER_WIDTH = 860 def __init__( self, @@ -1687,6 +2502,7 @@ class PrescriptionEditorDialog(QDialog): else None ) self._source = _mapping(prescription) + self._loading_data = False self._linked_order_generation = 0 self._prescribing_creator_id = _int( first_value( @@ -1706,7 +2522,7 @@ class PrescriptionEditorDialog(QDialog): self.drawer_surface = QFrame() self.drawer_surface.setObjectName("PrescriptionDrawerSurface") - self.drawer_surface.setStyleSheet(PRESCRIPTION_DRAWER_QSS) + self.drawer_surface.setStyleSheet(_prescription_drawer_qss()) surface_layout = QVBoxLayout(self.drawer_surface) surface_layout.setContentsMargins(0, 0, 0, 0) surface_layout.setSpacing(0) @@ -1720,13 +2536,23 @@ class PrescriptionEditorDialog(QDialog): self.body_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.body_content = QWidget() self.body_content.setObjectName("PrescriptionDrawerContent") + self.body_content.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) self.body_layout = QVBoxLayout(self.body_content) - self.body_layout.setContentsMargins(24, 20, 24, 28) - self.body_layout.setSpacing(18) + self.body_layout.setContentsMargins(20, 16, 20, 24) + self.body_layout.setSpacing(12) self.context_banner = MessageBanner() - self.context_banner.setObjectName("PrescriptionEditorContextBanner") self.body_layout.addWidget(self.context_banner) + self.system_auto_banner = MessageBanner() + self.body_layout.addWidget(self.system_auto_banner) + self.reaudit_banner = MessageBanner() + self.body_layout.addWidget(self.reaudit_banner) + self.consumer_reject_banner = MessageBanner() + self.body_layout.addWidget(self.consumer_reject_banner) + self.business_reject_banner = MessageBanner() + self.body_layout.addWidget(self.business_reject_banner) + self.revive_banner = MessageBanner() + self.body_layout.addWidget(self.revive_banner) self.patient_section = self._build_patient_section() self.diagnosis_section = self._build_diagnosis_section() @@ -1753,17 +2579,20 @@ class PrescriptionEditorDialog(QDialog): footer_layout.setSpacing(8) self.validation = MessageBanner() footer_layout.addWidget(self.validation) - self.buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Save - ) - self.cancel_button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel) - self.save_button = self.buttons.button(QDialogButtonBox.StandardButton.Save) - self.cancel_button.setText("取消") - self.save_button.setText("保存处方") + button_row = QWidget() + button_layout = QHBoxLayout(button_row) + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(12) + button_layout.addStretch(1) + self.cancel_button = QPushButton("取消", button_row) + self.cancel_button.clicked.connect(self.reject) + self.save_button = QPushButton("确定", button_row) self.save_button.setProperty("variant", "primary") - self.buttons.rejected.connect(self.reject) - self.buttons.accepted.connect(self.accept) - footer_layout.addWidget(self.buttons) + self.save_button.clicked.connect(self.accept) + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.save_button) + self.buttons = button_row + footer_layout.addWidget(button_row) surface_layout.addWidget(self.footer) self.tabs = _PrescriptionSectionNavigator(self) @@ -1773,31 +2602,14 @@ class PrescriptionEditorDialog(QDialog): def _build_header(self) -> QFrame: header = QFrame() header.setObjectName("PrescriptionDrawerHeader") - header.setFixedHeight(76) + header.setFixedHeight(56) layout = QHBoxLayout(header) - layout.setContentsMargins(24, 12, 18, 12) + layout.setContentsMargins(20, 10, 16, 10) layout.setSpacing(12) - copy = QVBoxLayout() - copy.setSpacing(2) - title = QLabel("中医处方单") + title = QLabel("新增处方" if self.mode == "add" else "编辑处方") title.setObjectName("PrescriptionDrawerTitle") - copy.addWidget(title) - subtitle = QLabel("患者信息、诊断、药材与用法将在同一处方工作流中提交") - subtitle.setObjectName("PrescriptionDrawerSubtitle") - copy.addWidget(subtitle) - layout.addLayout(copy, 1) - diagnosis_id = _int(self._source.get("diagnosis_id"), 0) - self.diagnosis_button = QPushButton("查看患者诊单详情", header) - self.diagnosis_button.setObjectName("PrescriptionDrawerDiagnosisButton") - self.diagnosis_button.setVisible(diagnosis_id > 0) - self.diagnosis_button.clicked.connect( - lambda _checked=False: self.diagnosis_requested.emit(diagnosis_id) - ) - layout.addWidget(self.diagnosis_button, 0, Qt.AlignmentFlag.AlignVCenter) - mode_badge = QLabel("新增" if self.mode == "add" else "编辑") - mode_badge.setObjectName("PrescriptionDrawerMode") - layout.addWidget(mode_badge, 0, Qt.AlignmentFlag.AlignVCenter) - close_button = QPushButton("×") + layout.addWidget(title, 1) + close_button = QPushButton("×", header) close_button.setObjectName("PrescriptionDrawerClose") close_button.setToolTip("关闭") close_button.clicked.connect(self.reject) @@ -1807,10 +2619,10 @@ class PrescriptionEditorDialog(QDialog): def _section(self, title: str, hint: str = "") -> tuple[QFrame, QVBoxLayout]: section = QFrame() - section.setObjectName("PrescriptionSection") + section.setObjectName("PrescriptionFormSection") layout = QVBoxLayout(section) - layout.setContentsMargins(16, 15, 16, 17) - layout.setSpacing(12) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) if title: heading_row = QHBoxLayout() heading_row.setContentsMargins(0, 0, 0, 0) @@ -1829,14 +2641,30 @@ class PrescriptionEditorDialog(QDialog): def _field(self, label: str, widget: QWidget, *, required: bool = False) -> QWidget: field = QWidget() field.setObjectName("PrescriptionField") - layout = QVBoxLayout(field) + layout = QHBoxLayout(field) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - caption = QLabel(label + (" *" if required else "")) - caption.setObjectName("PrescriptionFieldLabel") - layout.addWidget(caption) + layout.setSpacing(12) + blank_label = not str(label).strip() + caption = QLabel() + caption.setObjectName("PrescriptionFormLabel") + caption.setTextFormat(Qt.TextFormat.RichText) + if required: + caption.setText(f'* {label}') + else: + caption.setText(label) + caption.setFixedWidth(12 if blank_label else 120) + caption.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + tall = isinstance(widget, (QTextEdit, SignaturePad)) + layout.addWidget( + caption, + 0, + Qt.AlignmentFlag.AlignTop if tall else Qt.AlignmentFlag.AlignVCenter, + ) + widget.setMinimumWidth(0) widget.setSizePolicy(QSizePolicy.Policy.Expanding, widget.sizePolicy().verticalPolicy()) - layout.addWidget(widget) + if isinstance(widget, (QComboBox, PrescriptionDateEdit)): + widget.setCursor(Qt.CursorShape.PointingHandCursor) + layout.addWidget(widget, 1) return field def _place_field( @@ -1862,35 +2690,97 @@ class PrescriptionEditorDialog(QDialog): for column in range(columns): grid.setColumnStretch(column, 1) + @staticmethod + def _reflow_wrap_grid( + grid: QGridLayout, + fields: Sequence[QWidget], + columns: int = 3, + ) -> None: + host = grid.parentWidget() + while grid.count(): + item = grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(host) + index = 0 + for field in fields: + if field.isHidden(): + continue + grid.addWidget(field, index // columns, index % columns) + index += 1 + for column in range(columns): + grid.setColumnStretch(column, 1) + def _build_patient_section(self) -> QFrame: - section, layout = self._section("患者信息", "处方身份信息") - grid = QGridLayout() - self._prepare_grid(grid) + section, layout = self._section("") + diagnosis_id = _int(self._source.get("diagnosis_id"), 0) + diagnosis_row = QWidget(section) + diagnosis_layout = QHBoxLayout(diagnosis_row) + diagnosis_layout.setContentsMargins(120, 0, 0, 0) + diagnosis_layout.setSpacing(8) + self.diagnosis_button = QPushButton("查看患者诊单详情", diagnosis_row) + self.diagnosis_button.setProperty("variant", "primary") + self.diagnosis_button.setProperty("size", "small") + self.diagnosis_button.setVisible(diagnosis_id > 0) + self.diagnosis_button.clicked.connect( + lambda _checked=False, value=diagnosis_id: self.diagnosis_requested.emit(value) + ) + diagnosis_layout.addWidget(self.diagnosis_button) + self.diagnosis_id_hint = QLabel(f"关联诊单 #{diagnosis_id}" if diagnosis_id > 0 else "") + self.diagnosis_id_hint.setObjectName("PrescriptionFormHint") + diagnosis_layout.addWidget(self.diagnosis_id_hint) + diagnosis_layout.addStretch(1) + layout.addWidget(diagnosis_row) + diagnosis_row.setVisible(diagnosis_id > 0) + self.patient_name = QLineEdit() + self.patient_name.setPlaceholderText("请输入患者姓名") self.patient_name.setMaxLength(50) - self.gender = QComboBox() - self.gender.addItem("男", 1) - self.gender.addItem("女", 0) + gender_host = QWidget() + self.gender_male = QRadioButton("男", gender_host) + self.gender_female = QRadioButton("女", gender_host) + gender_group = QButtonGroup(self) + gender_group.addButton(self.gender_male, 1) + gender_group.addButton(self.gender_female, 0) + self.gender_male.setChecked(True) + gender_layout = QHBoxLayout(gender_host) + gender_layout.setContentsMargins(0, 0, 0, 0) + gender_layout.setSpacing(16) + gender_layout.addWidget(self.gender_male) + gender_layout.addWidget(self.gender_female) + gender_layout.addStretch(1) + self.gender = gender_host self.age = QSpinBox() self.age.setRange(0, 150) - self.phone = QLineEdit() - self.phone.setReadOnly(True) - self.phone.setPlaceholderText("未提供") self.visit_no = QLineEdit() - self.date_edit = QDateEdit(QDate.currentDate()) - self.date_edit.setDisplayFormat("yyyy-MM-dd") - self.date_edit.setCalendarPopup(True) - self._place_field(grid, 0, 0, "姓名", self.patient_name, required=True) - self._place_field(grid, 0, 1, "性别", self.gender, required=True) - self._place_field(grid, 0, 2, "年龄", self.age) - self._place_field(grid, 1, 0, "电话", self.phone) - self._place_field(grid, 1, 1, "门诊号", self.visit_no) - self._place_field(grid, 1, 2, "处方日期", self.date_edit, required=True) + self.visit_no.setPlaceholderText("自动生成或手动输入") + self.date_edit = PrescriptionDateEdit(QDate.currentDate()) + + name_grid = QGridLayout() + self._prepare_grid(name_grid, 2) + self._place_field(name_grid, 0, 0, "患者姓名", self.patient_name, required=True) + self._place_field(name_grid, 0, 1, "门诊号", self.visit_no) + layout.addLayout(name_grid) + + grid = QGridLayout() + self._prepare_grid(grid, 3) + self._place_field(grid, 0, 0, "性别", self.gender, required=True) + self._place_field(grid, 0, 1, "年龄", self.age) + self._place_field(grid, 0, 2, "处方日期", self.date_edit, required=True) layout.addLayout(grid) return section + def _gender_value(self) -> int: + return 0 if self.gender_female.isChecked() else 1 + + def _set_gender(self, value: Any) -> None: + if value in (0, "0"): + self.gender_female.setChecked(True) + else: + self.gender_male.setChecked(True) + def _build_diagnosis_section(self) -> QFrame: - section, layout = self._section("诊断信息", "辨证信息随处方一并留档") + section, layout = self._section("") grid = QGridLayout() self._prepare_grid(grid, 2) self.tongue = QLineEdit() @@ -1922,47 +2812,13 @@ class PrescriptionEditorDialog(QDialog): def _build_herbs_section(self) -> QFrame: section, layout = self._section("") - toolbar = QFrame() - toolbar.setObjectName("PrescriptionRpToolbar") + toolbar = QWidget() toolbar_layout = QHBoxLayout(toolbar) - toolbar_layout.setContentsMargins(14, 12, 14, 12) - toolbar_layout.setSpacing(12) - marker = QFrame() - marker.setObjectName("PrescriptionRpMarker") - marker.setFixedWidth(4) - marker.setMinimumHeight(48) - toolbar_layout.addWidget(marker) - lead_widget = QWidget() - lead_widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - lead = QVBoxLayout(lead_widget) - lead.setContentsMargins(0, 0, 0, 0) - lead.setSpacing(4) - heading = QLabel("中药处方 (RP)") - heading.setObjectName("PrescriptionRpHeading") - lead.addWidget(heading) - self.herb_summary = QLabel() - self.herb_summary.setObjectName("PrescriptionRpMeta") - self.herb_summary.setWordWrap(True) - self.herb_summary.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - lead.addWidget(self.herb_summary) - self.rp_lock_label = QLabel("含“禁用修改”处方库模板,药材已锁定;可重新从处方库导入覆盖。") - self.rp_lock_label.setObjectName("PrescriptionRpLock") - self.rp_lock_label.setWordWrap(True) - self.rp_lock_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - lead.addWidget(self.rp_lock_label) - toolbar_layout.addWidget(lead_widget, 1) - actions_widget = QWidget() - actions = QHBoxLayout(actions_widget) - actions.setContentsMargins(0, 0, 0, 0) - actions.setSpacing(7) - self.add_main_button = QPushButton("添加主方") - self.add_main_button.setProperty("variant", "primary") - self.add_main_button.clicked.connect(lambda: self.herbs.add_row(formula_type="主方")) - actions.addWidget(self.add_main_button) - self.add_aux_button = QPushButton("添加辅方") - self.add_aux_button.clicked.connect(lambda: self.herbs.add_row(formula_type="辅方")) - actions.addWidget(self.add_aux_button) - self.import_library_button = QPushButton("从处方库导入", actions_widget) + toolbar_layout.setContentsMargins(0, 0, 0, 0) + toolbar_layout.setSpacing(8) + self.import_library_button = QPushButton("从处方库导入", toolbar) + self.import_library_button.setProperty("variant", "success") + self.import_library_button.setProperty("size", "small") self.import_library_button.clicked.connect(self._import_library) library_default = self.permissions is None self.import_library_button.setVisible( @@ -1975,121 +2831,143 @@ class PrescriptionEditorDialog(QDialog): ) ) ) - actions.addWidget(self.import_library_button) - self.paste_button = QPushButton("导入药方") + toolbar_layout.addWidget(self.import_library_button) + self.paste_button = QPushButton("导入药方", toolbar) + self.paste_button.setProperty("variant", "warning") + self.paste_button.setProperty("size", "small") self.paste_button.clicked.connect(self._paste_herbs) - actions.addWidget(self.paste_button) - toolbar_layout.addWidget(actions_widget) - layout.addWidget(toolbar) + toolbar_layout.addWidget(self.paste_button) + hint = QLabel("粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)") + hint.setObjectName("PrescriptionFormHint") + hint.setWordWrap(True) + hint.setMinimumWidth(0) + hint.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + toolbar_layout.addWidget(hint, 1) + layout.addWidget(self._field("药材配方", toolbar, required=True)) + + self.lock_row = QWidget() + lock_layout = QHBoxLayout(self.lock_row) + lock_layout.setContentsMargins(120, 0, 0, 0) + lock_layout.setSpacing(8) + lock_tag = QLabel("禁用修改") + lock_tag.setObjectName("PrescriptionLockTag") + lock_layout.addWidget(lock_tag) + self.rp_lock_label = QLabel( + "含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖" + ) + self.rp_lock_label.setObjectName("PrescriptionRpLock") + self.rp_lock_label.setWordWrap(True) + self.rp_lock_label.setMinimumWidth(0) + self.rp_lock_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + lock_layout.addWidget(self.rp_lock_label, 1) + self.lock_row.hide() + layout.addWidget(self.lock_row) + self.herb_summary = QLabel() + self.herb_summary.setObjectName("PrescriptionFormHint") + self.herb_summary.setWordWrap(True) + self.herb_summary.hide() + layout.addWidget(self.herb_summary) + self.herbs = HerbEditor( self.repository, show_formula=True, - grid_mode=True, + table_mode=True, show_actions=False, ) + self.herbs.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.add_main_button = self.herbs.main_button + self.add_aux_button = self.herbs.aux_button self.herbs.rows_changed.connect(self._update_herb_summary) layout.addWidget(self.herbs) self.linked_order_banner = MessageBanner() - self.linked_order_banner.setObjectName("PrescriptionLinkedOrderBanner") + self.linked_order_banner.label.setTextFormat(Qt.TextFormat.RichText) + banner_layout = self.linked_order_banner.layout() + if banner_layout is not None: + banner_layout.setAlignment( + self.linked_order_banner.icon, Qt.AlignmentFlag.AlignTop + ) self.linked_order_banner.hide() layout.addWidget(self.linked_order_banner) return section def _build_usage_section(self) -> QFrame: - section, layout = self._section("剂型与用法", "按主方、辅方分别配置") - grid = QGridLayout() - self._prepare_grid(grid) - self.prescription_type = QComboBox() + section, layout = self._section("") + type_grid = QGridLayout() + self._prepare_grid(type_grid, 3) + self.prescription_type = SelectComboBox() + self.prescription_type.setPlaceholderText("请选择处方类型") for value in self.PRESCRIPTION_TYPES: self.prescription_type.addItem(value, value) self.prescription_type.currentIndexChanged.connect(self._type_changed) self.dose_count = QSpinBox() - self.dose_count.setRange(1, 999) - self.dose_unit = QComboBox() + self.dose_count.setRange(1, 2_147_483_647) + self.dose_unit = SelectComboBox() self.dose_unit.addItems(["剂", "丸", "袋", "盒", "瓶", "膏", "贴"]) - self._place_field(grid, 0, 0, "处方类型", self.prescription_type, required=True) - self._place_field(grid, 0, 1, "剂数", self.dose_count) - self._place_field(grid, 0, 2, "剂量单位", self.dose_unit) - main_heading = QLabel("主方用法") - main_heading.setObjectName("PrescriptionUsageMain") - grid.addWidget(main_heading, 1, 0, 1, 3) - self.dosage_amount = QDoubleSpinBox() - self.dosage_amount.setRange(0, 100000) - self.dosage_amount.setDecimals(2) - self.dosage_unit = QComboBox() + self._place_field(type_grid, 0, 0, "剂数", self.dose_count, required=True) + self._place_field(type_grid, 0, 1, "剂量单位", self.dose_unit) + self._place_field(type_grid, 0, 2, "处方类型", self.prescription_type) + layout.addLayout(type_grid) + + self.usage_main_heading = QLabel("主方用法") + self.usage_main_heading.setObjectName("PrescriptionUsageMain") + layout.addWidget(self.usage_main_heading) + self._main_usage_host = QWidget() + self._main_usage_grid = QGridLayout(self._main_usage_host) + self._prepare_grid(self._main_usage_grid, 3) + self.dosage_amount = AdminDosageInput() + self.dosage_amount.setPlaceholderText("请选择用量") + self.dosage_unit = SelectComboBox() self.dosage_unit.addItems(["g", "ml"]) - self.dosage_bag_count = QSpinBox() - self.dosage_bag_count.setRange(1, 99) - self.need_decoction = QCheckBox("需要代煎") - self.bags_per_dose = QSpinBox() - self.bags_per_dose.setRange(1, 99) + self.dosage_bag_count = LabeledIntCombo(range(1, 6), "袋") + self.need_decoction = BoolRadioRow("代煎", "不代煎") + self.bags_per_dose = LabeledIntCombo(range(1, 10), "包") self.times_per_day = QSpinBox() self.times_per_day.setRange(1, 6) self.usage_days = QSpinBox() self.usage_days.setRange(1, 365) - self._place_field(grid, 2, 0, "单次用量", self.dosage_amount) - self._main_dosage_unit_field = self._place_field(grid, 2, 1, "用量单位", self.dosage_unit) + self._main_dosage_field = self._field("用量", self.dosage_amount) + self._main_dosage_unit_field = self._field("用量单位", self.dosage_unit) + self._main_bag_field = self._field(" ", self.dosage_bag_count) + self._main_decoction_field = self._field("是否代煎", self.need_decoction) + self._main_bags_field = self._field("每贴出包数", self.bags_per_dose) + self._main_times_field = self._field("每天几次", self.times_per_day) + self._main_days_field = self._field("服用天数", self.usage_days) + for field in ( + self._main_dosage_field, + self._main_dosage_unit_field, + self._main_bag_field, + self._main_decoction_field, + self._main_bags_field, + self._main_times_field, + self._main_days_field, + ): + field.setParent(self._main_usage_host) self._main_dosage_unit_field.hide() - self._main_bag_field = self._place_field(grid, 2, 2, "每次袋数", self.dosage_bag_count) - self._main_decoction_field = self._place_field(grid, 3, 0, "代煎", self.need_decoction) - self._main_bags_field = self._place_field(grid, 3, 1, "每贴出包数", self.bags_per_dose) - self._place_field(grid, 3, 2, "服用天数", self.usage_days) - self._place_field(grid, 4, 0, "每天几次", self.times_per_day) - self.usage_instruction = QLineEdit() - self.usage_instruction.setMaxLength(200) - self._place_field(grid, 4, 1, "用法", self.usage_instruction, column_span=2) - self.usage_time = QComboBox() - self.usage_time.addItems(["饭前", "饭后", "饭中", "空腹", "睡前", "晨起", "随时"]) - self.usage_way = QComboBox() - self.usage_way.addItems( - ["温水送服", "开水冲服", "黄酒送服", "淡盐水送服", "米汤送服", "嚼服", "含化"] - ) - self.dietary_taboo = MultiSelectComboBox(self.DIETARY_OPTIONS) - self._place_field(grid, 5, 0, "服用时间", self.usage_time) - self._place_field(grid, 5, 1, "服用方式", self.usage_way) - self._place_field(grid, 5, 2, "忌口", self.dietary_taboo) - self.usage_notes = QTextEdit() - self.usage_notes.setMaximumHeight(72) - self._place_field(grid, 6, 0, "其他说明", self.usage_notes, column_span=3) + layout.addWidget(self._main_usage_host) + self.aux_usage_heading = QLabel("辅方用法") self.aux_usage_heading.setObjectName("PrescriptionUsageAux") - grid.addWidget(self.aux_usage_heading, 7, 0, 1, 3) - self.aux_dosage_amount = QDoubleSpinBox() - self.aux_dosage_amount.setRange(0, 100000) - self.aux_dosage_amount.setDecimals(2) - self.aux_dosage_bag_count = QSpinBox() - self.aux_dosage_bag_count.setRange(1, 99) - self.aux_need_decoction = QCheckBox("辅方需要代煎") - self.aux_bags_per_dose = QSpinBox() - self.aux_bags_per_dose.setRange(1, 99) + layout.addWidget(self.aux_usage_heading) + self._aux_usage_host = QWidget() + self._aux_usage_grid = QGridLayout(self._aux_usage_host) + self._prepare_grid(self._aux_usage_grid, 3) + self.aux_dosage_amount = AdminDosageInput() + self.aux_dosage_amount.setPlaceholderText("请选择用量") + self.aux_dosage_bag_count = LabeledIntCombo(range(1, 6), "袋") + self.aux_need_decoction = BoolRadioRow("代煎", "不代煎") + self.aux_bags_per_dose = LabeledIntCombo(range(1, 10), "包") self.aux_times_per_day = QSpinBox() self.aux_times_per_day.setRange(1, 6) self.aux_usage_days = QSpinBox() self.aux_usage_days.setRange(1, 365) self.aux_prescription_name = QLineEdit() - self._aux_dosage_field = self._place_field( - grid, 8, 0, "辅方单次用量", self.aux_dosage_amount - ) - self._aux_bag_field = self._place_field( - grid, 8, 1, "辅方每次袋数", self.aux_dosage_bag_count - ) - self._aux_name_field = self._place_field( - grid, 8, 2, "辅方模板名", self.aux_prescription_name - ) - # The admin editor keeps this value as import metadata; it is not a - # user-editable usage field. Preserve it in the DTO without exposing - # an extra workstation-only control. - self._aux_name_field.hide() - self._aux_decoction_field = self._place_field( - grid, 9, 0, "辅方代煎", self.aux_need_decoction - ) - self._aux_bags_field = self._place_field( - grid, 9, 1, "辅方每贴出包数", self.aux_bags_per_dose - ) - self._aux_times_field = self._place_field( - grid, 9, 2, "辅方每天次数", self.aux_times_per_day - ) - self._aux_days_field = self._place_field(grid, 10, 0, "辅方服用天数", self.aux_usage_days) + self._aux_dosage_field = self._field("用量", self.aux_dosage_amount) + self._aux_bag_field = self._field(" ", self.aux_dosage_bag_count) + self._aux_name_field = self._field("辅方模板名", self.aux_prescription_name) + self._aux_decoction_field = self._field("是否代煎", self.aux_need_decoction) + self._aux_bags_field = self._field("每贴出包数", self.aux_bags_per_dose) + self._aux_times_field = self._field("每天几次", self.aux_times_per_day) + self._aux_days_field = self._field("服用天数", self.aux_usage_days) self._aux_usage_fields = ( self._aux_dosage_field, self._aux_bag_field, @@ -2098,29 +2976,58 @@ class PrescriptionEditorDialog(QDialog): self._aux_times_field, self._aux_days_field, ) - layout.addLayout(grid) + for field in (*self._aux_usage_fields, self._aux_name_field): + field.setParent(self._aux_usage_host) + self._aux_name_field.hide() + layout.addWidget(self._aux_usage_host) + + self.usage_instruction = QLineEdit() + self.usage_instruction.setMaxLength(200) + self.usage_instruction.setPlaceholderText("例如:水煎服,一日二次") + layout.addWidget(self._field("用法", self.usage_instruction)) + self.usage_time = SelectComboBox() + self.usage_time.setPlaceholderText("请选择服用时间") + self.usage_time.addItems(["饭前", "饭后", "饭中", "空腹", "睡前", "晨起", "随时"]) + self.usage_way = SelectComboBox() + self.usage_way.setPlaceholderText("请选择服用方式") + self.usage_way.addItems( + ["温水送服", "开水冲服", "黄酒送服", "淡盐水送服", "米汤送服", "嚼服", "含化"] + ) + time_grid = QGridLayout() + self._prepare_grid(time_grid, 2) + self._place_field(time_grid, 0, 0, "服用时间", self.usage_time) + self._place_field(time_grid, 0, 1, "服用方式", self.usage_way) + layout.addLayout(time_grid) + self.dietary_taboo = MultiSelectComboBox(self.DIETARY_OPTIONS) + layout.addWidget(self._field("忌口", self.dietary_taboo)) + self.usage_notes = QTextEdit() + self.usage_notes.setPlaceholderText("其他服用注意事项") + self.usage_notes.setMaximumHeight(72) + layout.addWidget(self._field("其他说明", self.usage_notes)) return section def _build_doctor_section(self) -> QFrame: - section, layout = self._section("医生签名", "保存前需完成手写签名") + section, layout = self._section("") self.doctor_name = QLineEdit() - doctor_field = self._field("医生姓名", self.doctor_name, required=True) - doctor_field.setMaximumWidth(360) - layout.addWidget(doctor_field) - signature_head = QHBoxLayout() - signature_label = QLabel("医生手写签名 *") - signature_label.setObjectName("PrescriptionFieldLabel") - signature_head.addWidget(signature_label) - signature_head.addStretch(1) - clear = QPushButton("清空签名") - clear.setProperty("variant", "ghost") - signature_head.addWidget(clear) - layout.addLayout(signature_head) + self.doctor_name.setPlaceholderText("请输入医师姓名") + layout.addWidget(self._field("医师姓名", self.doctor_name, required=True)) + signature_host = QWidget() + signature_layout = QVBoxLayout(signature_host) + signature_layout.setContentsMargins(0, 0, 0, 0) + signature_layout.setSpacing(8) self.signature = SignaturePad() - self.signature.setMaximumWidth(620) + self.signature.setMinimumHeight(168) self.signature.setMaximumHeight(180) - layout.addWidget(self.signature) + self.signature.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + signature_layout.addWidget(self.signature) + clear = QPushButton("清空签名", signature_host) + clear.setProperty("variant", "ghost") clear.clicked.connect(self.signature.clear) + signature_layout.addWidget(clear, 0, Qt.AlignmentFlag.AlignLeft) + hint = QLabel("* 必填,请在框内手写医师签名") + hint.setObjectName("PrescriptionSignatureHint") + signature_layout.addWidget(hint) + layout.addWidget(self._field("医师签名", signature_host, required=True)) return section def scroll_to_section(self, index: int) -> None: @@ -2150,41 +3057,39 @@ class PrescriptionEditorDialog(QDialog): def _update_herb_summary(self) -> None: if not hasattr(self, "herbs"): return - main_count = sum( - _formula(row.formula_combo.currentData()) == "主方" for row in self.herbs.rows - ) - aux_count = len(self.herbs.rows) - main_count - self.herb_summary.setText( - f"共 {len(self.herbs.rows)} 味(主方 {main_count} / 辅方 {aux_count})" - " · 可手动添加、从处方库或粘贴药方批量录入" - + ( - " · 存在重复药名,请核对是否需要合并剂量" - if _duplicate_herb_names(self.herbs.values()) - else "" - ) + aux_count = sum( + _formula(row.formula_combo.currentData()) == "辅方" for row in self.herbs.rows ) locked = self.herbs.locked - self.rp_lock_label.setVisible(locked) + if hasattr(self, "lock_row"): + self.lock_row.setVisible(locked) self.add_main_button.setEnabled(not locked) self.add_aux_button.setEnabled(not locked) - if hasattr(self, "_aux_usage_fields"): - self.aux_usage_heading.setVisible(aux_count > 0) - for field in self._aux_usage_fields: - field.setVisible(aux_count > 0) + duplicates = _duplicate_herb_names(self.herbs.values()) + if duplicates: + self.herb_summary.setText("存在重复药名,请合并剂量或删除多余行") + self.herb_summary.show() + else: + self.herb_summary.hide() + self.herb_summary.clear() + if hasattr(self, "_aux_usage_host"): + has_aux = aux_count > 0 + self.aux_usage_heading.setVisible(has_aux) + self._aux_usage_host.setVisible(has_aux) self._apply_type_visibility() def _load_values(self) -> None: source = self._source + self._loading_data = True self.patient_name.setText(str(source.get("patient_name") or "")) - self.phone.setText(str(source.get("phone") or "")) visit_no = str(source.get("visit_no") or "").strip() - if not visit_no: + if self.mode == "add" and not visit_no: visit_no = build_prescription_visit_no( diagnosis_id=_int(source.get("diagnosis_id"), 0), appointment_id=_int(source.get("appointment_id"), 0), ) self.visit_no.setText(visit_no) - _set_combo_data(self.gender, source.get("gender", 1)) + self._set_gender(source.get("gender", 1)) self.age.setValue(_int(source.get("age"), 0)) date_value = QDate.fromString( str(source.get("prescription_date") or date.today().isoformat()), @@ -2196,7 +3101,7 @@ class PrescriptionEditorDialog(QDialog): self.pulse.setText(str(source.get("pulse") or "")) self.pulse_condition.setText(str(source.get("pulse_condition") or "")) clinical = str(source.get("clinical_diagnosis") or "").strip() - if self.mode == "add" or clinical in _DIAGNOSIS_TYPE_LABELS or not clinical: + if self.mode == "add" and (clinical in _DIAGNOSIS_TYPE_LABELS or not clinical): clinical = build_prescription_clinical_diagnosis(source) or clinical if clinical in _DIAGNOSIS_TYPE_LABELS: clinical = _DIAGNOSIS_TYPE_LABELS[clinical] @@ -2206,7 +3111,11 @@ class PrescriptionEditorDialog(QDialog): source.get("prescription_type") or "浓缩水丸", ) self._type_changed() - self.dosage_amount.setValue(_float(source.get("dosage_amount"), 1)) + raw_dosage_amount = source.get("dosage_amount") + if raw_dosage_amount not in (None, ""): + self.dosage_amount.setValue(raw_dosage_amount) + elif self.mode == "edit": + self.dosage_amount.setValue(None) self.dosage_unit.setCurrentText( str( source.get("dosage_unit") @@ -2233,7 +3142,8 @@ class PrescriptionEditorDialog(QDialog): self.usage_notes.setPlainText(str(source.get("usage_notes") or "")) aux = source.get("aux_usage") aux = dict(aux) if isinstance(aux, Mapping) else {} - self.aux_dosage_amount.setValue(_float(aux.get("dosage_amount"), 5)) + if aux.get("dosage_amount") not in (None, ""): + self.aux_dosage_amount.setValue(aux.get("dosage_amount")) self.aux_dosage_bag_count.setValue(max(1, _int(aux.get("dosage_bag_count"), 1))) self.aux_need_decoction.setChecked(_bool(aux.get("need_decoction"))) self.aux_bags_per_dose.setValue(max(1, _int(aux.get("bags_per_dose"), 1))) @@ -2250,41 +3160,72 @@ class PrescriptionEditorDialog(QDialog): raw_herbs = source.get("herbs") or [] locked = any(_bool(first_value(item, "locked", default=False)) for item in raw_herbs) self.herbs.set_rows(raw_herbs, locked=locked) - if not raw_herbs: - self.herbs.add_row(formula_type="主方") if self.mode == "edit": self.patient_name.setReadOnly(True) self.visit_no.setReadOnly(True) + self._loading_data = False self._update_herb_summary() def _load_editor_context(self) -> None: """Show the same edit/review context exposed by the admin drawer.""" + for banner in ( + self.system_auto_banner, + self.reaudit_banner, + self.consumer_reject_banner, + self.business_reject_banner, + self.revive_banner, + ): + banner.clear() messages: list[str] = [] if self.mode == "edit": - messages.append("保存修改后,处方将重新进入待审核。") + self.reaudit_banner.show_message( + "保存修改后,该处方将重新进入「待审核」,需审核人员再次通过后方可生效。", + "info", + ) + messages.append(self.reaudit_banner.label.text()) if _bool(self._source.get("is_system_auto")): - messages.append("当前为空白处方;保存后将转为手工处方。") + self.system_auto_banner.show_message( + "本处方为系统自动生成(如挂号完成),默认无药材配方,请补充药材与诊断等信息后再保存。", + "warning", + ) + messages.append(self.system_auto_banner.label.text()) voided = _bool(self._source.get("void_status")) rejected = _int(self._source.get("audit_status"), -1) == 2 - if voided and rejected: - messages.append( - "当前处方已作废且已驳回;保存后将取消作废、清除驳回并重新进入待审核。" - ) - elif voided: - messages.append("当前处方已作废;保存后将取消作废并重新进入待审核。") - elif rejected: - messages.append("当前处方已驳回;保存后将清除驳回并重新进入待审核。") - if _bool(self._source.get("business_prescription_audit_rejected")): + if rejected: + remark = str(self._source.get("audit_remark") or "").strip() + text = "当前消费者处方为「已驳回」\n保存修改后将清除驳回并变为「待审核」。" + if remark: + text += f"上次意见:{remark}" + self.consumer_reject_banner.show_message(text, "danger") + messages.append(self.consumer_reject_banner.label.text()) + if _bool(self._source.get("business_prescription_audit_rejected")) and _int( + self._source.get("audit_status"), -1 + ) == 1: remark = str(self._source.get("business_prescription_audit_remark") or "").strip() - message = ( - "业务订单侧处方审核已驳回;保存后关联业务订单的处方和支付单审核会重置为待审。" + text = ( + "业务订单侧「处方审核」已驳回\n" + "消费者处方仍为「已通过」;您修改并保存后,将重置关联业务订单的处方/支付单审核为「待审」," + "请在「处方业务订单」中继续处理。" ) if remark: - message += f" 驳回意见:{remark}" - messages.append(message) + text += f"\n驳回意见:{remark}" + self.business_reject_banner.show_message(text, "warning") + messages.append(self.business_reject_banner.label.text()) + if voided and rejected: + revive = "当前处方已作废且已驳回。保存后将取消作废、清除驳回,并重新进入待审核。" + elif voided: + revive = "当前为已作废处方。保存后将取消作废,并重新进入待审核。" + elif rejected: + revive = "当前为已驳回处方。保存后将清除驳回,并重新进入待审核。" + else: + revive = "" + if revive: + self.revive_banner.show_message(revive, "warning") + messages.append(revive) if messages: self.context_banner.show_message("\n".join(messages), "warning") + self.context_banner.hide() else: self.context_banner.hide() @@ -2306,10 +3247,7 @@ class PrescriptionEditorDialog(QDialog): return rows = page_items(result) if not rows: - self.linked_order_banner.show_message( - "当前处方暂无关联业务订单,或当前账号无订单列表权限。", - "info", - ) + self._show_linked_order_empty() return row = rows[0] order_no = str(first_value(row, "order_no", default="") or "").strip() or "—" @@ -2318,85 +3256,104 @@ class PrescriptionEditorDialog(QDialog): assistant_remark = ( str(first_value(row, "remark_assistant", default="") or "").strip() or "— 无" ) + body = ( + f"

最近关联订单(按创建倒序):" + f"" + f"{html.escape(order_no)}

" + f"

服用天数(订单):{html.escape(days_text)}

" + f"

医助备注(订单):{html.escape(assistant_remark)}

" + ) self.linked_order_banner.show_message( - "业务订单要求(请与本处方保持一致)\n" - f"最近关联订单:{order_no} 服用天数:{days_text}\n" - f"医助备注:{assistant_remark}", + _alert_html("业务订单要求(请与本处方保持一致)", body), "warning", ) def _linked_order_failed(self, generation: int) -> None: if generation == self._linked_order_generation: - self.linked_order_banner.show_message( - "当前处方暂无关联业务订单,或当前账号无订单列表权限。", - "info", - ) + self._show_linked_order_empty() + + def _show_linked_order_empty(self) -> None: + self.linked_order_banner.show_message( + _alert_html( + "业务订单要求(请与本处方保持一致)", + "当前处方暂无关联业务订单,或您无订单列表权限;若有订单,将在此处显示订单内" + "服用天数医助备注。", + ), + "warning", + ) def _type_changed(self, _index: Any = None) -> None: value = self.prescription_type.currentData() + loading = bool(getattr(self, "_loading_data", False)) if value == "饮片": self.dosage_unit.setCurrentText("ml") - self.dosage_amount.setDecimals(0) - self.dosage_amount.setRange(50, 250) - self.dosage_amount.setSingleStep(50) - self.dosage_amount.setValue(50) + drink_values = (50, 100, 120, 150, 180, 200, 250) + self.dosage_amount.configure_discrete( + drink_values, + suffix="ml", + default=50 if not loading else self.dosage_amount.optional_value(), + ) self.dosage_bag_count.setRange(1, 5) - self.dosage_bag_count.setValue(1) - self.need_decoction.setChecked(False) self.bags_per_dose.setRange(1, 9) - self.bags_per_dose.setValue(1) - self.aux_dosage_amount.setDecimals(0) - self.aux_dosage_amount.setRange(50, 250) - self.aux_dosage_amount.setSingleStep(50) - self.aux_dosage_amount.setValue(50) + self.aux_dosage_amount.configure_discrete( + drink_values, + suffix="ml", + default=50 if not loading else self.aux_dosage_amount.optional_value(), + ) self.aux_dosage_bag_count.setRange(1, 5) - self.aux_dosage_bag_count.setValue(1) - self.aux_need_decoction.setChecked(False) self.aux_bags_per_dose.setRange(1, 9) - self.aux_bags_per_dose.setValue(1) + if not loading: + self.dosage_bag_count.setValue(1) + self.bags_per_dose.setValue(1) + self.aux_dosage_bag_count.setValue(1) + self.aux_need_decoction.setChecked(False) + self.aux_bags_per_dose.setValue(1) elif value == "浓缩水丸": self.dosage_unit.setCurrentText("g") - self.dosage_amount.setDecimals(0) - self.dosage_amount.setRange(1, 10) - self.dosage_amount.setSingleStep(1) - self.dosage_amount.setValue(1) + pill_values = tuple(range(1, 11)) + self.dosage_amount.configure_discrete( + pill_values, + suffix="g", + default=1 if not loading else self.dosage_amount.optional_value(), + ) self.dosage_bag_count.setRange(1, 5) - self.dosage_bag_count.setValue(1) - self.need_decoction.setChecked(False) self.bags_per_dose.setRange(1, 9) - self.bags_per_dose.setValue(1) - self.aux_dosage_amount.setDecimals(0) - self.aux_dosage_amount.setRange(1, 10) - self.aux_dosage_amount.setSingleStep(1) - self.aux_dosage_amount.setValue(5) + self.aux_dosage_amount.configure_discrete( + pill_values, + suffix="g", + default=5 if not loading else self.aux_dosage_amount.optional_value(), + ) self.aux_dosage_bag_count.setRange(1, 5) - self.aux_dosage_bag_count.setValue(1) - self.aux_need_decoction.setChecked(False) self.aux_bags_per_dose.setRange(1, 9) - self.aux_bags_per_dose.setValue(1) + if not loading: + self.dosage_bag_count.setValue(1) + self.need_decoction.setChecked(False) + self.bags_per_dose.setValue(1) + self.aux_dosage_bag_count.setValue(1) + self.aux_need_decoction.setChecked(False) + self.aux_bags_per_dose.setValue(1) else: self.dosage_unit.setCurrentText("g") - self.dosage_amount.setDecimals(2) - self.dosage_amount.setRange(0, 100000) - self.dosage_amount.setSingleStep(1) - self.dosage_amount.setValue(0) + self.dosage_amount.configure_free( + default=None if not loading else self.dosage_amount.optional_value() + ) self.dosage_bag_count.setRange(1, 5) - self.dosage_bag_count.setValue(1) - self.need_decoction.setChecked(False) self.bags_per_dose.setRange(1, 9) - self.bags_per_dose.setValue(1) - self.aux_dosage_amount.setDecimals(2) - self.aux_dosage_amount.setRange(0, 100000) - self.aux_dosage_amount.setSingleStep(1) - self.aux_dosage_amount.setValue(1) + self.aux_dosage_amount.configure_free( + default=1 if not loading else self.aux_dosage_amount.optional_value() + ) self.aux_dosage_bag_count.setRange(1, 5) - self.aux_dosage_bag_count.setValue(1) - self.aux_need_decoction.setChecked(False) self.aux_bags_per_dose.setRange(1, 9) - self.aux_bags_per_dose.setValue(1) - self.aux_times_per_day.setValue(3) - self.aux_usage_days.setValue(7) - self.aux_prescription_name.clear() + if not loading: + self.dosage_bag_count.setValue(1) + self.need_decoction.setChecked(False) + self.bags_per_dose.setValue(1) + self.aux_dosage_bag_count.setValue(1) + self.aux_need_decoction.setChecked(False) + self.aux_bags_per_dose.setValue(1) + if not loading: + self.aux_times_per_day.setValue(3) + self.aux_usage_days.setValue(7) self._apply_type_visibility() def _apply_type_visibility(self) -> None: @@ -2405,19 +3362,49 @@ class PrescriptionEditorDialog(QDialog): value = self.prescription_type.currentData() concentrated = value == "浓缩水丸" decoction = value == "饮片" + self._main_dosage_field.show() self._main_bag_field.setVisible(concentrated) self._main_decoction_field.setVisible(decoction) self._main_bags_field.setVisible(decoction) + self._main_times_field.show() + self._main_days_field.show() + self._reflow_wrap_grid( + self._main_usage_grid, + ( + self._main_dosage_field, + self._main_bag_field, + self._main_decoction_field, + self._main_bags_field, + self._main_times_field, + self._main_days_field, + ), + ) has_aux = any( _formula(row.formula_combo.currentData()) == "辅方" for row in self.herbs.rows ) + self.aux_usage_heading.setVisible(has_aux) + self._aux_usage_host.setVisible(has_aux) + self._aux_dosage_field.setVisible(has_aux) self._aux_bag_field.setVisible(has_aux and concentrated) self._aux_decoction_field.setVisible(has_aux and decoction) self._aux_bags_field.setVisible(has_aux and decoction) + self._aux_times_field.setVisible(has_aux) + self._aux_days_field.setVisible(has_aux) + self._reflow_wrap_grid( + self._aux_usage_grid, + ( + self._aux_dosage_field, + self._aux_bag_field, + self._aux_decoction_field, + self._aux_bags_field, + self._aux_times_field, + self._aux_days_field, + ), + ) def _import_library(self) -> None: if not self._prescribing_creator_id: - self.validation.show_message("无法确定开方医生,不能加载处方库。", "warning") + self.validation.show_message("无法确定开方医师,无法从处方库导入", "warning") return dialog = TemplateImportDialog( self.repository, @@ -2428,6 +3415,9 @@ class PrescriptionEditorDialog(QDialog): return template = dialog.selected_template() herbs = get_value(template, "herbs", None) or [] + if not herbs: + self.validation.show_message("该处方没有药材信息", "warning") + return formula_type = _formula(first_value(template, "formula_type", default="主方")) imported = [] locked = _bool(first_value(template, "disable_edit", default=False)) @@ -2448,6 +3438,23 @@ class PrescriptionEditorDialog(QDialog): self.aux_prescription_name.setText( str(first_value(template, "prescription_name", "name", default="")) ) + name = str(first_value(template, "prescription_name", "name", default="")).strip() + mode_hint = "(已追加)" if dialog.import_mode == "append" else "" + duplicates = _duplicate_herb_names(self.herbs.values()) + if duplicates: + show_toast( + self, + f"已导入处方「{name}」{mode_hint},共{len(imported)}味药材。存在重复药名,请合并剂量或删除多余行", + "warning", + 4200, + ) + else: + show_toast( + self, + f"已导入处方「{name}」{mode_hint},共{len(imported)}味药材", + "success", + 3600, + ) def _paste_herbs(self) -> None: dialog = PasteHerbsDialog(self.repository, self) @@ -2457,6 +3464,34 @@ class PrescriptionEditorDialog(QDialog): if dialog.import_mode == "replace": current = [row for row in current if _formula(row.get("formula_type")) == "辅方"] self.herbs.set_rows([*current, *dialog.resolved], locked=False) + skipped = [name for name in getattr(dialog, "skipped", []) if str(name).strip()] + skipped_unique = list(dict.fromkeys(skipped)) + skip_hint = ( + f";药品库未收录已跳过:{'、'.join(skipped_unique)}" if skipped_unique else "" + ) + duplicates = _duplicate_herb_names(self.herbs.values()) + if duplicates: + show_toast( + self, + f"已导入 {len(dialog.resolved)} 味药材{skip_hint}。存在重复药名:" + f"{'、'.join(duplicates)},请合并剂量或删除多余行", + "warning", + 4800, + ) + elif skipped_unique: + show_toast( + self, + f"已导入 {len(dialog.resolved)} 味药材{skip_hint},请核对剂量", + "success", + 3600, + ) + else: + show_toast( + self, + f"已导入 {len(dialog.resolved)} 味药材,请核对药名与剂量", + "success", + 3600, + ) def payload(self) -> dict[str, Any]: hidden_keys = ( @@ -2482,13 +3517,12 @@ class PrescriptionEditorDialog(QDialog): result.update( { "prescription_type": self.prescription_type.currentData(), - "dosage_amount": self.dosage_amount.value(), "dosage_unit": self.dosage_unit.currentText().strip(), "dosage_bag_count": self.dosage_bag_count.value(), "need_decoction": int(self.need_decoction.isChecked()), "bags_per_dose": self.bags_per_dose.value(), "patient_name": self.patient_name.text().strip(), - "gender": self.gender.currentData(), + "gender": self._gender_value(), "age": self.age.value(), "visit_no": self.visit_no.text().strip(), "prescription_date": self.date_edit.date().toString("yyyy-MM-dd"), @@ -2497,7 +3531,7 @@ class PrescriptionEditorDialog(QDialog): "pulse": self.pulse.text().strip(), "pulse_condition": self.pulse_condition.text().strip(), "clinical_diagnosis": self.clinical_diagnosis.toPlainText().strip(), - "herbs": self.herbs.values(), + "herbs": self.herbs.values(include_blank=True), "dose_count": self.dose_count.value(), "dose_unit": self.dose_unit.currentText().strip(), "usage_days": self.usage_days.value(), @@ -2520,23 +3554,39 @@ class PrescriptionEditorDialog(QDialog): }, } ) + dosage_amount = self.dosage_amount.optional_value() + if dosage_amount is not None: + result["dosage_amount"] = dosage_amount + else: + result.pop("dosage_amount", None) return result + def _herb_validation_label(self, global_index: int) -> str: + if global_index >= len(self.herbs.rows): + return f"第{global_index + 1}味" + formula = _formula(self.herbs.rows[global_index].formula_combo.currentData()) + count = 0 + for index in range(global_index + 1): + if _formula(self.herbs.rows[index].formula_combo.currentData()) == formula: + count += 1 + return f"{formula}第{count}味" + def accept(self) -> None: payload = self.payload() checks = ( - (payload["patient_name"], "请输入患者姓名。", self.patient_name, 0), - (payload["prescription_date"], "请输入处方日期。", self.date_edit, 0), + (payload["patient_name"], "请输入患者姓名", self.patient_name, 0), + (payload["prescription_date"], "请选择处方日期", self.date_edit, 0), ( payload["clinical_diagnosis"], - "请输入临床诊断。", + "请输入临床诊断", self.clinical_diagnosis, 0, ), - (payload["doctor_name"], "请输入医生姓名。", self.doctor_name, 3), + (payload.get("dose_count"), "请输入剂数", self.dose_count, 2), + (payload["doctor_name"], "请输入医师姓名", self.doctor_name, 3), ( payload["doctor_signature"], - "请在签名板手写医生签名。", + "请使用电子签名板手写医师签名", self.signature, 3, ), @@ -2551,56 +3601,35 @@ class PrescriptionEditorDialog(QDialog): ( payload["clinical_diagnosis"], 500, - "临床诊断最多 500 个字符。", + "临床诊断最多 500 个字符", self.clinical_diagnosis, ), - (payload["usage_instruction"], 200, "用法最多 200 个字符。", self.usage_instruction), - (payload["usage_notes"], 200, "其他说明最多 200 个字符。", self.usage_notes), + (payload["usage_instruction"], 200, "用法最多 200 个字符", self.usage_instruction), + (payload["usage_notes"], 200, "其他说明最多 200 个字符", self.usage_notes), ) for value, limit, message, widget in length_checks: if len(str(value)) > limit: self.validation.show_message(message, "warning") widget.setFocus() return - prescription_type = str(payload["prescription_type"] or "") - has_aux = any(_formula(herb.get("formula_type")) == "辅方" for herb in payload["herbs"]) - dosage_values = [float(payload["dosage_amount"])] - if has_aux: - dosage_values.append(float(payload["aux_usage"]["dosage_amount"])) - if prescription_type == "浓缩水丸" and any( - value not in {float(item) for item in range(1, 11)} for value in dosage_values - ): - self.validation.show_message("浓缩水丸单次用量只能选择 1–10g。", "warning") - self.dosage_amount.setFocus() - return - if prescription_type == "饮片" and any( - value not in {50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0} for value in dosage_values - ): - self.validation.show_message( - "饮片单次用量只能选择 50、100、120、150、180、200 或 250ml。", - "warning", - ) - self.dosage_amount.setFocus() - return herbs = payload["herbs"] if not herbs: self.tabs.setCurrentIndex(1) - self.validation.show_message("请至少添加一味药材。", "warning") + self.validation.show_message("请至少添加一味药材", "warning") return - for index, herb in enumerate(herbs, 1): + for index, herb in enumerate(herbs): + label = self._herb_validation_label(index) if not str(herb.get("name") or "").strip(): self.tabs.setCurrentIndex(1) - self.validation.show_message(f"第 {index} 味药材名称不能为空。", "warning") + self.validation.show_message(f"{label}药材名称不能为空", "warning") return - if not self.herbs.rows[index - 1].medicine.has_valid_selection: + if not self.herbs.rows[index].medicine.has_valid_selection: self.tabs.setCurrentIndex(1) - self.validation.show_message( - f"第 {index} 味药材必须从药材主数据中选择。", "warning" - ) + self.validation.show_message(f"{label}必须从药材主数据中选择", "warning") return if _float(herb.get("dosage"), 0) <= 0: self.tabs.setCurrentIndex(1) - self.validation.show_message(f"第 {index} 味药材剂量必须大于 0。", "warning") + self.validation.show_message(f"{label}药材剂量必须大于0", "warning") return self.validation.clear() super().accept() diff --git a/app/src/doctor_workstation/ui/pages/prescriptions.py b/app/src/doctor_workstation/ui/pages/prescriptions.py index e5bda7388..65abaa38f 100644 --- a/app/src/doctor_workstation/ui/pages/prescriptions.py +++ b/app/src/doctor_workstation/ui/pages/prescriptions.py @@ -68,6 +68,39 @@ def _truthy(value: Any) -> bool: return bool(value) +def _row_mapping(value: Any) -> dict[str, Any]: + """Return only fields actually supplied by an API row when possible.""" + + if isinstance(value, Mapping): + return dict(value) + raw = getattr(value, "raw", None) + if isinstance(raw, Mapping) and raw: + return dict(raw) + fields = getattr(value, "__dataclass_fields__", {}) + return { + name: getattr(value, name, None) + for name in fields + if name != "raw" + } + + +def merge_prescription_detail(row: Any, detail: Any) -> dict[str, Any]: + """Match the admin editor's ``{...listRow, ...detail}`` state merge.""" + + merged = _row_mapping(row) + merged.update(_row_mapping(detail)) + # Repository models retain the original API row in ``raw`` and expose + # canonical alias fields (for example ``patient_phone`` -> ``phone``) on + # the dataclass. Add those aliases without letting an omitted detail field + # overwrite a list-only audit/void value with its model default. + for source in (detail, row): + fields = getattr(source, "__dataclass_fields__", {}) + for name in fields: + if name != "raw" and name not in merged: + merged[name] = getattr(source, name, None) + return merged + + def prescription_status(row: Any) -> tuple[str, str]: """Return the admin audit-column status; void state has its own column.""" @@ -730,7 +763,11 @@ class PrescriptionsPage(QWidget): or not can_edit_or_delete(row) ): return - self._load_detail(row, self._open_editor, message="正在准备编辑处方…") + self._load_detail( + row, + lambda detail: self._open_editor(merge_prescription_detail(row, detail)), + message="正在准备编辑处方…", + ) def _open_editor(self, detail: Any) -> None: dialog = PrescriptionEditorDialog( diff --git a/app/tests/test_prescription_editor_drawer_visual.py b/app/tests/test_prescription_editor_drawer_visual.py index 320d5363c..17dca06ed 100644 --- a/app/tests/test_prescription_editor_drawer_visual.py +++ b/app/tests/test_prescription_editor_drawer_visual.py @@ -69,13 +69,13 @@ def test_editor_is_a_fixed_header_body_footer_right_drawer( origin = host.mapToGlobal(QPoint(0, 0)) assert editor.windowFlags() & Qt.WindowType.FramelessWindowHint - assert editor.width() == 1200 + assert editor.width() == 860 assert editor.height() == host.height() assert editor.x() + editor.width() == origin.x() + host.width() assert editor.y() == origin.y() + assert editor.save_button.text() == "确定" + assert editor.windowTitle() == "新增处方" assert not editor.findChildren(QTabWidget) - assert editor.tabs.count() == 4 - assert editor.tabs.tabText(2) == "剂型与用法" assert editor.body_scroll.verticalScrollBar().maximum() > 0 assert editor.body_scroll.horizontalScrollBar().maximum() == 0 @@ -110,7 +110,35 @@ def test_drawer_uses_full_width_on_a_narrow_host(application: QApplication) -> N assert editor.width() == host.width() assert editor.x() == origin.x() assert editor.body_scroll.horizontalScrollBar().maximum() == 0 - assert editor.herbs._grid_columns == 3 + assert editor.herbs.table_mode + assert editor.add_main_button.text() == "添加主方药材" + + editor.close() + host.close() + application.processEvents() + + +def test_decoction_usage_fields_pack_without_a_hidden_column_gap( + application: QApplication, +) -> None: + host = QWidget() + host.resize(1440, 900) + host.show() + source = _seed() + source["prescription_type"] = "饮片" + editor = PrescriptionEditorDialog(_repository(), source, parent=host) + _show(editor, application) + + assert editor._main_decoction_field.isVisible() + assert editor._main_bags_field.isVisible() + assert editor._main_bag_field.isHidden() + assert editor._main_dosage_field.y() == editor._main_decoction_field.y() + gap = editor._main_decoction_field.x() - ( + editor._main_dosage_field.x() + editor._main_dosage_field.width() + ) + assert 0 <= gap <= 24 + assert abs(editor._main_dosage_field.width() - editor._main_decoction_field.width()) < 40 + assert editor.cancel_button.x() < editor.save_button.x() editor.close() host.close() diff --git a/app/tests/test_prescription_security_ui.py b/app/tests/test_prescription_security_ui.py index 6403684d5..3b256c98b 100644 --- a/app/tests/test_prescription_security_ui.py +++ b/app/tests/test_prescription_security_ui.py @@ -74,6 +74,8 @@ class _DiagnosisRepository: "height": 162.5, "weight": 52.0, "fasting_blood_sugar": "6.2", + "diagnosis_type": "first_visit", + "local_hospital_name": "杭州市第一人民医院", "chief_complaint": "乏力", "symptoms": "口干", "appetite": ["一般", "少食"], diff --git a/app/tests/test_prescription_ui.py b/app/tests/test_prescription_ui.py index a24123293..ceb8c387c 100644 --- a/app/tests/test_prescription_ui.py +++ b/app/tests/test_prescription_ui.py @@ -7,13 +7,13 @@ from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import QEvent, QObject, QSize, Qt +from PySide6.QtCore import QDate, QEvent, QObject, QSize, Qt from PySide6.QtGui import QColor, QImage, QPainter from PySide6.QtPdf import QPdfDocument -from PySide6.QtWidgets import QApplication, QDialog, QWidget +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QPushButton, QWidget from doctor_workstation import app as app_module -from doctor_workstation.core import PermissionSet +from doctor_workstation.core import PermissionSet, Prescription from doctor_workstation.services import DemoDoctorRepository from doctor_workstation.ui.dialogs import prescription as dialog_module from doctor_workstation.ui.dialogs.prescription import ( @@ -325,6 +325,8 @@ def test_editor_builds_complete_add_payload( ) user = SimpleNamespace(id=7, name="周医生") editor = PrescriptionEditorDialog(repository, mode="add", current_user=user) + # The admin editor starts with an empty table; adding a herb is explicit. + editor.herbs.add_row(formula_type="主方") editor.patient_name.setText("林晓岚") editor.clinical_diagnosis.setPlainText("脾气虚") editor.herbs.rows[0].medicine.set_value(31, "黄芪") @@ -465,6 +467,7 @@ def test_editor_matches_admin_four_observation_fields_and_edit_context( "clinical_diagnosis": "气阴两虚", "doctor_name": "周医生", "audit_status": 2, + "audit_remark": "请补充辨证依据", "void_status": 1, "is_system_auto": 1, "business_prescription_audit_rejected": 1, @@ -473,6 +476,12 @@ def test_editor_matches_admin_four_observation_fields_and_edit_context( } editor = PrescriptionEditorDialog(Repository(), source, mode="edit") + assert editor.windowTitle() == "编辑处方" + assert editor.patient_name.isReadOnly() + assert editor.visit_no.isReadOnly() + assert not hasattr(editor, "phone") + assert editor.gender_male.isChecked() + assert editor.payload()["gender"] == 1 assert editor.tongue.text() == "面象哨兵" assert editor.tongue_image.text() == "舌象哨兵" assert editor.pulse.text() == "脉象哨兵" @@ -483,11 +492,17 @@ def test_editor_matches_admin_four_observation_fields_and_edit_context( assert payload["pulse"] == "脉象哨兵" assert payload["pulse_condition"] == "脉象详情哨兵" assert "取消作废、清除驳回" in editor.context_banner.label.text() - assert "空白处方" in editor.context_banner.label.text() + assert "系统自动生成" in editor.context_banner.label.text() + assert "请补充辨证依据" in editor.context_banner.label.text() + assert "业务订单侧" not in editor.context_banner.label.text() assert calls == [{"page_no": 1, "page_size": 5, "prescription_id": 91}] assert "PO-901" in editor.linked_order_banner.label.text() assert "14 天" in editor.linked_order_banner.label.text() assert "按两周疗程复核" in editor.linked_order_banner.label.text() + assert editor.linked_order_banner.objectName() == "MessageBanner" + assert editor.linked_order_banner.property("kind") == "warning" + assert "#FDF6EC" in editor.drawer_surface.styleSheet() + assert 'QFrame#MessageBanner[kind="warning"]' in editor.drawer_surface.styleSheet() editor.close() application.processEvents() @@ -516,6 +531,9 @@ def test_editor_preserves_global_lock_and_admin_type_constraints( ) assert editor.herbs.locked assert all(row.locked for row in editor.herbs.rows) + locked_payload = editor.payload()["herbs"] + assert locked_payload[0]["locked"] is True + assert "locked" not in locked_payload[1] assert not editor.add_main_button.isEnabled() assert editor.import_library_button.isHidden() assert editor._aux_name_field.isHidden() @@ -537,6 +555,144 @@ def test_editor_preserves_global_lock_and_admin_type_constraints( application.processEvents() +def test_editor_usage_fields_are_dropdowns_and_date_opens_calendar( + application: QApplication, +) -> None: + editor = PrescriptionEditorDialog( + SimpleNamespace(), + { + "prescription_type": "饮片", + "prescription_date": "2026-08-01", + "herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}], + }, + mode="edit", + ) + + assert isinstance(editor.dosage_amount, QComboBox) + assert not editor.dosage_amount.isEditable() + assert editor.dosage_amount.itemText(0).endswith("ml") + assert isinstance(editor.bags_per_dose, QComboBox) + assert editor.bags_per_dose.itemText(0) == "1包" + assert editor.bags_per_dose.maximum() == 9 + assert isinstance(editor.dosage_bag_count, QComboBox) + assert editor.dosage_bag_count.itemText(0) == "1袋" + assert isinstance(editor.prescription_type, QComboBox) + assert isinstance(editor.usage_time, QComboBox) + assert isinstance(editor.usage_way, QComboBox) + assert "down-arrow" in editor.drawer_surface.styleSheet() + assert "__CHEVRON_URL__" not in editor.drawer_surface.styleSheet() + assert editor.prescription_type.cursor().shape() == Qt.CursorShape.PointingHandCursor + assert editor.date_edit.cursor().shape() == Qt.CursorShape.PointingHandCursor + assert editor.date_edit.calendarPopup() + calendar_button = editor.date_edit.findChild(QPushButton, "PrescriptionDateButton") + assert calendar_button is not None + assert not calendar_button.icon().isNull() + assert editor.date_edit.date() == QDate(2026, 8, 1) + editor.show() + application.processEvents() + editor.date_edit.open_calendar() + application.processEvents() + assert editor.date_edit.calendarWidget().isVisible() + editor.date_edit.calendarWidget().clicked.emit(QDate(2026, 8, 13)) + application.processEvents() + assert not editor.date_edit.calendarWidget().isVisible() + assert editor.payload()["prescription_date"] == "2026-08-13" + editor.bags_per_dose.setValue(4) + assert editor.payload()["bags_per_dose"] == 4 + + editor.close() + application.processEvents() + + +def test_editor_matches_admin_empty_rows_dosage_choices_and_detail_merge( + application: QApplication, +) -> None: + editor = PrescriptionEditorDialog( + SimpleNamespace(), + mode="add", + current_user=SimpleNamespace(id=7, name="周医生"), + ) + + assert editor.herbs.rows == [] + editor.herbs.add_row(formula_type="主方") + assert editor.payload()["herbs"] == [ + {"name": "", "dosage": 0.0, "formula_type": "主方"} + ] + + editor.need_decoction.setChecked(True) + editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("饮片")) + assert editor.need_decoction.isChecked() + assert [ + editor.dosage_amount.itemData(index) + for index in range(editor.dosage_amount.count()) + ] == [50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0] + + editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("颗粒")) + assert editor.dosage_amount.optional_value() is None + assert "dosage_amount" not in editor.payload() + + merged = prescription_module.merge_prescription_detail( + { + "id": 91, + "audit_status": 1, + "void_status": 1, + "business_prescription_audit_rejected": 1, + "business_prescription_audit_remark": "订单驳回", + }, + {"id": 91, "patient_name": "林晓岚", "herbs": []}, + ) + assert merged["patient_name"] == "林晓岚" + assert merged["void_status"] == 1 + assert merged["business_prescription_audit_rejected"] == 1 + assert merged["business_prescription_audit_remark"] == "订单驳回" + + model_detail = Prescription.from_dict( + {"id": 91, "patient_phone": "13800138000", "patient_name": "林晓岚"} + ) + model_merged = prescription_module.merge_prescription_detail( + {"id": 91, "void_status": 1}, + model_detail, + ) + assert model_merged["phone"] == "13800138000" + assert model_merged["void_status"] == 1 + + editor.close() + application.processEvents() + + +def test_paste_parser_matches_admin_zero_and_note_rules() -> None: + assert parse_pasted_herbs("黄芪0g") == [] + assert parse_pasted_herbs("黄芪(炙)、党参各6g") == [ + {"name": "黄芪", "dosage": 6.0}, + {"name": "党参", "dosage": 6.0}, + ] + + +def test_editor_only_shows_business_rejection_for_consumer_approved_rows( + application: QApplication, +) -> None: + editor = PrescriptionEditorDialog( + SimpleNamespace(), + { + "id": 92, + "audit_status": 1, + "business_prescription_audit_rejected": 1, + "business_prescription_audit_remark": "重新核对剂量", + "patient_name": "林晓岚", + "clinical_diagnosis": "气阴两虚", + "doctor_name": "周医生", + "herbs": [{"name": "黄芪", "dosage": 15, "formula_type": "主方"}], + }, + mode="edit", + ) + + message = editor.context_banner.label.text() + assert "业务订单侧「处方审核」已驳回" in message + assert "重新核对剂量" in message + editor.close() + application.processEvents() + + def test_audit_reject_requires_remark( application: QApplication, ) -> None: diff --git a/server/app/adminapi/logic/firstvisit/FirstVisitDoctorDashboardLogic.php b/server/app/adminapi/logic/firstvisit/FirstVisitDoctorDashboardLogic.php index aa8496919..5f777a0db 100644 --- a/server/app/adminapi/logic/firstvisit/FirstVisitDoctorDashboardLogic.php +++ b/server/app/adminapi/logic/firstvisit/FirstVisitDoctorDashboardLogic.php @@ -150,7 +150,7 @@ class FirstVisitDoctorDashboardLogic { $today = date('Y-m-d'); $type = (string) ($params['time_type'] ?? 'month'); - if (!in_array($type, ['today', 'week', 'month', 'custom'], true)) { + if (!in_array($type, ['today', 'yesterday', 'week', 'month', 'custom'], true)) { $type = 'month'; } @@ -175,6 +175,11 @@ class FirstVisitDoctorDashboardLogic if ($type === 'today') { return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today]; } + if ($type === 'yesterday') { + $yesterday = date('Y-m-d', strtotime('-1 day')); + + return ['type' => 'yesterday', 'label' => '昨天', 'start' => $yesterday, 'end' => $yesterday]; + } if ($type === 'week') { return [ 'type' => 'week', 'label' => '本周', diff --git a/server/app/adminapi/logic/firstvisit/FirstVisitRegistrationStatsLogic.php b/server/app/adminapi/logic/firstvisit/FirstVisitRegistrationStatsLogic.php index fecef007a..061b5f295 100644 --- a/server/app/adminapi/logic/firstvisit/FirstVisitRegistrationStatsLogic.php +++ b/server/app/adminapi/logic/firstvisit/FirstVisitRegistrationStatsLogic.php @@ -146,6 +146,17 @@ class FirstVisitRegistrationStatsLogic $today = date('Y-m-d'); $tomorrow = date('Y-m-d', strtotime('+1 day')); $dayAfterTomorrow = date('Y-m-d', strtotime('+2 days')); + if ($type === 'yesterday') { + $yesterday = date('Y-m-d', strtotime('-1 day')); + $dayBefore = date('Y-m-d', strtotime('-2 days')); + + return [ + 'type' => 'yesterday', 'label' => '昨天', 'start' => $yesterday, 'end' => $yesterday, + 'compare_start' => $dayBefore, + 'compare_end' => $dayBefore, + 'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow, + ]; + } if ($type === 'week') { $start = date('Y-m-d', strtotime('monday this week'));