3458 lines
127 KiB
Python
3458 lines
127 KiB
Python
"""Scoped visual primitives for diagnosis readonly pages and edit drawers.
|
||
|
||
The diagnosis workspace uses the same cool white, indigo and blue-gray visual
|
||
language as the desktop shell. Every rule remains isolated behind diagnosis
|
||
object names and dynamic properties so adjacent pages keep their own styling.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import weakref
|
||
from collections.abc import Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from math import ceil
|
||
from typing import Any
|
||
|
||
from PySide6.QtCore import (
|
||
QDate,
|
||
QEvent,
|
||
QPointF,
|
||
QRect,
|
||
QRectF,
|
||
QSize,
|
||
Qt,
|
||
QThread,
|
||
QTimer,
|
||
QUrl,
|
||
Signal,
|
||
)
|
||
from PySide6.QtGui import (
|
||
QColor,
|
||
QFont,
|
||
QHideEvent,
|
||
QIcon,
|
||
QPainter,
|
||
QPen,
|
||
QPixmap,
|
||
QResizeEvent,
|
||
QShowEvent,
|
||
)
|
||
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
|
||
from PySide6.QtWidgets import (
|
||
QAbstractButton,
|
||
QAbstractItemView,
|
||
QAbstractSpinBox,
|
||
QButtonGroup,
|
||
QComboBox,
|
||
QDateEdit,
|
||
QDoubleSpinBox,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QLineEdit,
|
||
QPlainTextEdit,
|
||
QPushButton,
|
||
QScrollBar,
|
||
QSizePolicy,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QTabWidget,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from .diagnosis_editors import FlowLayout
|
||
from .diagnosis_media import shared_image_manager
|
||
from .diagnosis_terms import FIELD_DICTIONARIES, format_timestamp, shared_terms
|
||
|
||
DIAGNOSIS_QSS = r"""
|
||
QDialog#DiagnosisDialogRoot {
|
||
background: transparent;
|
||
}
|
||
QFrame#DiagnosisDrawerOverlay {
|
||
background-color: rgba(26, 28, 31, 112);
|
||
}
|
||
QFrame#DiagnosisDrawer {
|
||
background-color: #FFFFFF;
|
||
border-left: 1px solid #EDEDEE;
|
||
}
|
||
QFrame#DiagnosisDrawerHeader {
|
||
background-color: qlineargradient(
|
||
x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #FFFFFF, stop:1 #F7F7F7
|
||
);
|
||
border-bottom: 1px solid #EDEDEE;
|
||
}
|
||
QFrame#DiagnosisDrawerFooter {
|
||
background-color: #FFFFFF;
|
||
border-top: 1px solid #EDEDEE;
|
||
border-top-left-radius: 1px;
|
||
border-top-right-radius: 1px;
|
||
}
|
||
QLabel#DiagnosisDrawerTitle {
|
||
color: #1A1C1F;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
QLabel#DiagnosisDrawerMeta {
|
||
color: #606163;
|
||
font-size: 13px;
|
||
}
|
||
QLabel#DiagnosisDrawerModeBadge,
|
||
QLabel#DiagnosisReadonlyStatus,
|
||
QLabel[diagnosisTag="true"] {
|
||
padding: 4px 9px;
|
||
border-radius: 9px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisDrawerModeBadge[kind="info"] {
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border: 1px solid #E4E4E5;
|
||
}
|
||
QLabel#DiagnosisDrawerModeBadge[kind="warning"] {
|
||
color: #A9691D;
|
||
background-color: #FFFBEB;
|
||
border: 1px solid #FCD34D;
|
||
}
|
||
QLabel#DiagnosisDrawerModeBadge[kind="success"] {
|
||
color: #287B65;
|
||
background-color: #F0FDF4;
|
||
border: 1px solid #BBF7D0;
|
||
}
|
||
QLabel[diagnosisTag="true"][kind="success"] {
|
||
color: #287B65; background-color: #F0FDF4; border: 1px solid #BBF7D0;
|
||
}
|
||
QLabel[diagnosisTag="true"][kind="warning"] {
|
||
color: #A9691D; background-color: #FFFBEB; border: 1px solid #FCD34D;
|
||
}
|
||
QLabel[diagnosisTag="true"][kind="danger"] {
|
||
color: #BE4B58; background-color: #FEF2F2; border: 1px solid #FECACA;
|
||
}
|
||
QLabel[diagnosisTag="true"][kind="info"] {
|
||
color: #4F63D9; background-color: #EEF1FA; border: 1px solid #EEF1FA;
|
||
}
|
||
QLabel[diagnosisTag="true"][kind="neutral"] {
|
||
color: #606163; background-color: #F7F7F7; border: 1px solid #E4E4E5;
|
||
}
|
||
QDialog QPushButton {
|
||
min-height: 32px;
|
||
padding: 0 14px;
|
||
color: #606163;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
}
|
||
QDialog QPushButton:hover,
|
||
QDialog QPushButton:focus {
|
||
color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
border-color: #8B9AD9;
|
||
}
|
||
QDialog QPushButton:pressed {
|
||
color: #354BB4;
|
||
border-color: #354BB4;
|
||
}
|
||
QDialog QPushButton:disabled {
|
||
color: #E4E4E5;
|
||
background-color: #FFFFFF;
|
||
border-color: #EDEDEE;
|
||
}
|
||
QDialog QPushButton[variant="primary"] {
|
||
color: #FFFFFF;
|
||
background-color: #4F63D9;
|
||
border-color: #4F63D9;
|
||
font-weight: 500;
|
||
}
|
||
QDialog QPushButton[variant="primary"]:hover,
|
||
QDialog QPushButton[variant="primary"]:focus {
|
||
color: #FFFFFF;
|
||
background-color: #4156C4;
|
||
border-color: #4156C4;
|
||
}
|
||
QDialog QPushButton[variant="primary"]:disabled {
|
||
color: #FFFFFF;
|
||
background-color: #F0F0F0;
|
||
border-color: #F0F0F0;
|
||
}
|
||
QDialog QPushButton[variant="secondary"] {
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border-color: #EEF1FA;
|
||
}
|
||
QDialog QPushButton[variant="secondary"]:hover,
|
||
QDialog QPushButton[variant="secondary"]:focus {
|
||
color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
border-color: #8B9AD9;
|
||
}
|
||
QDialog QPushButton[variant="ghost"] {
|
||
color: #606163;
|
||
background-color: transparent;
|
||
border-color: transparent;
|
||
}
|
||
QDialog QPushButton[variant="ghost"]:hover,
|
||
QDialog QPushButton[variant="ghost"]:focus {
|
||
color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
border-color: #8B9AD9;
|
||
}
|
||
QDialog QPushButton[variant="danger"],
|
||
QDialog QPushButton[variant="dangerGhost"] {
|
||
color: #BE4B58;
|
||
background-color: #FEF2F2;
|
||
border-color: #FECACA;
|
||
}
|
||
QDialog QPushButton[variant="danger"]:hover,
|
||
QDialog QPushButton[variant="danger"]:focus,
|
||
QDialog QPushButton[variant="dangerGhost"]:hover,
|
||
QDialog QPushButton[variant="dangerGhost"]:focus {
|
||
color: #BE4B58;
|
||
background-color: #FEE2E2;
|
||
border-color: #FCA5A5;
|
||
}
|
||
QDialog QPushButton[variant="link"] {
|
||
min-height: 24px;
|
||
padding: 0 4px;
|
||
color: #4F63D9;
|
||
background-color: transparent;
|
||
border-color: transparent;
|
||
}
|
||
QDialog QPushButton[variant="link"]:hover,
|
||
QDialog QPushButton[variant="link"]:focus {
|
||
color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
border-color: transparent;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisCloseButton {
|
||
min-width: 32px;
|
||
max-width: 32px;
|
||
min-height: 32px;
|
||
max-height: 32px;
|
||
padding: 0;
|
||
color: #606163;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: 8px;
|
||
font-size: 18px;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisCloseButton:hover,
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisCloseButton:focus {
|
||
color: #1A1C1F;
|
||
background-color: #F0F0F0;
|
||
border-color: #8B9AD9;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton,
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton {
|
||
min-height: 40px;
|
||
max-height: 40px;
|
||
padding: 0 20px;
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton {
|
||
color: #FFFFFF;
|
||
background-color: #4F63D9;
|
||
border: 1px solid #4F63D9;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:hover,
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:focus {
|
||
background-color: #4156C4;
|
||
border-color: #4156C4;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:disabled {
|
||
color: #FFFFFF;
|
||
background-color: #F0F0F0;
|
||
border-color: #F0F0F0;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton {
|
||
color: #606163;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E4E4E5;
|
||
}
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton:hover,
|
||
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton:focus {
|
||
color: #1A1C1F;
|
||
border-color: #8B9AD9;
|
||
background-color: #F0F0F0;
|
||
}
|
||
QTabWidget#DiagnosisDrawerTabs::pane {
|
||
border: 0;
|
||
background-color: #F7F7F7;
|
||
}
|
||
QTabWidget#DiagnosisDrawerTabs QTabBar::tab {
|
||
min-height: 42px;
|
||
padding: 0 14px;
|
||
margin: 0;
|
||
color: #606163;
|
||
background: #FFFFFF;
|
||
border: 0;
|
||
border-bottom: 2px solid transparent;
|
||
border-radius: 0;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QTabWidget#DiagnosisDrawerTabs QTabBar::tab:hover {
|
||
color: #4156C4;
|
||
background-color: #F7F7F7;
|
||
}
|
||
QTabWidget#DiagnosisDrawerTabs QTabBar::tab:selected {
|
||
color: #4F63D9;
|
||
background-color: #FFFFFF;
|
||
border-bottom-color: #4F63D9;
|
||
font-weight: 600;
|
||
}
|
||
QTabWidget#DiagnosisDrawerTabs QTabBar QToolButton {
|
||
width: 0;
|
||
min-width: 0;
|
||
max-width: 0;
|
||
padding: 0;
|
||
margin: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
}
|
||
QScrollBar#DiagnosisTabOverflow:horizontal {
|
||
height: 4px;
|
||
margin: 0;
|
||
border: 0;
|
||
background: #EDEDEE;
|
||
}
|
||
QScrollBar#DiagnosisTabOverflow::handle:horizontal {
|
||
min-width: 36px;
|
||
border-radius: 2px;
|
||
background: #2E3033;
|
||
}
|
||
QScrollBar#DiagnosisTabOverflow::add-line:horizontal,
|
||
QScrollBar#DiagnosisTabOverflow::sub-line:horizontal,
|
||
QScrollBar#DiagnosisTabOverflow::add-page:horizontal,
|
||
QScrollBar#DiagnosisTabOverflow::sub-page:horizontal {
|
||
width: 0;
|
||
background: transparent;
|
||
}
|
||
QAbstractButton#DiagnosisSwitch {
|
||
min-width: 44px;
|
||
max-width: 44px;
|
||
min-height: 24px;
|
||
max-height: 24px;
|
||
border: 0;
|
||
background: transparent;
|
||
}
|
||
QScrollArea#DiagnosisDrawerBody,
|
||
QScrollArea#DiagnosisReadonlyScroll {
|
||
border: 0;
|
||
background-color: #F7F7F7;
|
||
}
|
||
QScrollArea#DiagnosisDrawerBody > QWidget > QWidget {
|
||
background-color: #F7F7F7;
|
||
}
|
||
QScrollArea#DiagnosisReadonlyScroll > QWidget > QWidget {
|
||
background-color: #F7F7F7;
|
||
}
|
||
QFrame#DiagnosisPrivacyBanner,
|
||
QFrame#DiagnosisMessageBanner {
|
||
background-color: #FFFBEB;
|
||
border: 1px solid #FDE68A;
|
||
border-radius: 10px;
|
||
}
|
||
QFrame#DiagnosisMessageBanner[kind="info"] {
|
||
background-color: #EEF1FA;
|
||
border-color: #EEF1FA;
|
||
}
|
||
QFrame#DiagnosisMessageBanner[kind="success"] {
|
||
background-color: #ECFDF5;
|
||
border-color: #A7F3D0;
|
||
}
|
||
QFrame#DiagnosisMessageBanner[kind="danger"] {
|
||
background-color: #FEF2F2;
|
||
border-color: #FECACA;
|
||
}
|
||
QFrame#DiagnosisMessageBanner[kind="warning"] {
|
||
background-color: #FFFBEB;
|
||
border-color: #FDE68A;
|
||
}
|
||
QFrame#DiagnosisFormHost {
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QFrame[diagnosisSection="true"] {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 12px;
|
||
}
|
||
QLabel[diagnosisFieldLabel="true"] {
|
||
color: #1A1C1F;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisUnitLabel {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
padding: 0 2px;
|
||
}
|
||
QLineEdit[diagnosisField="true"],
|
||
QComboBox[diagnosisField="true"],
|
||
QPlainTextEdit[diagnosisField="true"],
|
||
QDateEdit[diagnosisField="true"],
|
||
QDoubleSpinBox[diagnosisField="true"],
|
||
QSpinBox[diagnosisField="true"],
|
||
QTimeEdit[diagnosisField="true"] {
|
||
color: #1A1C1F;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 10px;
|
||
padding: 0 10px;
|
||
font-size: 13px;
|
||
selection-background-color: #EEF1FA;
|
||
min-height: 34px;
|
||
}
|
||
QDoubleSpinBox[diagnosisField="true"] {
|
||
padding-right: 4px;
|
||
}
|
||
QPlainTextEdit[diagnosisField="true"] {
|
||
padding: 8px 12px;
|
||
}
|
||
QLineEdit[diagnosisField="true"]:focus,
|
||
QComboBox[diagnosisField="true"]:focus,
|
||
QPlainTextEdit[diagnosisField="true"]:focus,
|
||
QDateEdit[diagnosisField="true"]:focus,
|
||
QDoubleSpinBox[diagnosisField="true"]:focus,
|
||
QSpinBox[diagnosisField="true"]:focus,
|
||
QTimeEdit[diagnosisField="true"]:focus {
|
||
border: 1px solid #8B9AD9;
|
||
background-color: #F7F7F7;
|
||
}
|
||
QLineEdit[diagnosisField="true"][invalid="true"],
|
||
QComboBox[diagnosisField="true"][invalid="true"],
|
||
QPlainTextEdit[diagnosisField="true"][invalid="true"] {
|
||
border-color: #BE4B58;
|
||
background-color: #FFF7F7;
|
||
}
|
||
QLineEdit[diagnosisField="true"]:read-only,
|
||
QPlainTextEdit[diagnosisField="true"]:read-only,
|
||
QLineEdit[diagnosisField="true"]:disabled,
|
||
QComboBox[diagnosisField="true"]:disabled,
|
||
QPlainTextEdit[diagnosisField="true"]:disabled,
|
||
QDateEdit[diagnosisField="true"]:disabled,
|
||
QDoubleSpinBox[diagnosisField="true"]:disabled,
|
||
QSpinBox[diagnosisField="true"]:disabled,
|
||
QTimeEdit[diagnosisField="true"]:disabled {
|
||
color: #606163;
|
||
background-color: #F7F7F7;
|
||
border-color: #EDEDEE;
|
||
}
|
||
QLabel#DiagnosisSectionTitle {
|
||
color: #1A1C1F;
|
||
background-color: transparent;
|
||
border: 0;
|
||
padding: 0;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
QFrame#DiagnosisSectionAccent {
|
||
background-color: #4F63D9;
|
||
border: 0;
|
||
border-radius: 2px;
|
||
}
|
||
QFrame#DiagnosisSectionLine {
|
||
min-height: 1px;
|
||
max-height: 1px;
|
||
background-color: #EDEDEE;
|
||
}
|
||
QFrame#DiagnosisReadonlyPage {
|
||
background-color: #F6F6F6;
|
||
}
|
||
QFrame#DiagnosisReadonlyHero {
|
||
background-color: #F7F7F7;
|
||
border: 1px solid #F0F0F0;
|
||
border-radius: 12px;
|
||
}
|
||
QLabel#DiagnosisReadonlyTitle {
|
||
color: #1A1C1F;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisReadonlyPatientName {
|
||
color: #1A1C1F;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
QLabel#DiagnosisReadonlyHeroMeta,
|
||
QLabel#DiagnosisPrivacyText,
|
||
QLabel#DiagnosisOrderOffsetPreview,
|
||
QLabel#DiagnosisOrdersSummary {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
}
|
||
QLabel#DiagnosisOrderOffsetLabel {
|
||
color: #1A1C1F;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton#DiagnosisOrderOffsetHelp {
|
||
min-width: 22px;
|
||
max-width: 22px;
|
||
min-height: 22px;
|
||
max-height: 22px;
|
||
padding: 0;
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border: 1px solid #EEF1FA;
|
||
border-radius: 11px;
|
||
font-weight: 700;
|
||
}
|
||
QPushButton#DiagnosisOrderOffsetHelp:hover,
|
||
QPushButton#DiagnosisOrderOffsetHelp:focus {
|
||
background-color: #EEF1FA;
|
||
border-color: #8B9AD9;
|
||
}
|
||
QLabel#DiagnosisReadonlyStatus[severity="neutral"] {
|
||
color: #8E8F90;
|
||
background-color: rgba(255, 255, 255, 190);
|
||
border: 1px solid #F0F0F0;
|
||
}
|
||
QLabel#DiagnosisReadonlyStatus[severity="success"] {
|
||
color: #287B65;
|
||
background-color: #F0FDF4;
|
||
border: 1px solid #BBF7D0;
|
||
}
|
||
QLabel#DiagnosisReadonlyStatus[severity="warning"] {
|
||
color: #A9691D;
|
||
background-color: #FFF7ED;
|
||
border: 1px solid #FED7AA;
|
||
}
|
||
QLabel#DiagnosisReadonlyStatus[severity="danger"] {
|
||
color: #BE4B58;
|
||
background-color: #FEF2F2;
|
||
border: 1px solid #FECACA;
|
||
}
|
||
QFrame#DiagnosisReadonlyPatientHero {
|
||
background-color: #F7F7F7;
|
||
border: 1px solid #F0F0F0;
|
||
border-radius: 12px;
|
||
}
|
||
QFrame#DiagnosisReadonlyCaseCard,
|
||
QFrame[diagnosisReadonlyCard="true"] {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 14px;
|
||
}
|
||
QLabel#DiagnosisReadonlyCardTitle {
|
||
color: #1A1C1F;
|
||
border-left: 3px solid #4F63D9;
|
||
padding-left: 9px;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisReadonlyPatientTitle {
|
||
color: #1A1C1F;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
}
|
||
QLabel[diagnosisPatientMeta="true"] {
|
||
color: #606163;
|
||
font-size: 13px;
|
||
}
|
||
QLabel[diagnosisCaseGroup="true"] {
|
||
color: #1A1C1F;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel[diagnosisCaseLabel="true"] {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
}
|
||
QLabel[diagnosisCaseValue="true"] {
|
||
color: #1A1C1F;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QLabel[diagnosisCaseValue="true"][abnormal="true"] {
|
||
color: #BE4B58;
|
||
font-weight: 700;
|
||
}
|
||
QTableWidget[diagnosisTable="true"] {
|
||
color: #1A1C1F;
|
||
background-color: #FFFFFF;
|
||
alternate-background-color: #FAFAFA;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 6px;
|
||
gridline-color: #EDEDEE;
|
||
selection-background-color: #EEF1FA;
|
||
selection-color: #1A1C1F;
|
||
font-size: 13px;
|
||
}
|
||
QTableWidget[diagnosisTable="true"]::item {
|
||
padding: 7px 8px;
|
||
border-bottom: 1px solid #EDEDEE;
|
||
}
|
||
QTableWidget[diagnosisTable="true"]::item:hover {
|
||
background-color: #F7F7F7;
|
||
}
|
||
QTableWidget[diagnosisTable="true"] QHeaderView::section {
|
||
color: #6A6B6D;
|
||
background-color: #F7F7F7;
|
||
border: 0;
|
||
border-right: 1px solid #EDEDEE;
|
||
border-bottom: 1px solid #EDEDEE;
|
||
padding: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisEmptyState {
|
||
color: #8E8F90;
|
||
background-color: rgba(255, 255, 255, 218);
|
||
font-size: 13px;
|
||
}
|
||
QFrame#DiagnosisTimelineNode {
|
||
background-color: #FFFFFF;
|
||
border-left: 2px solid #EDEDEE;
|
||
}
|
||
QLabel#DiagnosisTimelineDot {
|
||
min-width: 10px;
|
||
max-width: 10px;
|
||
min-height: 10px;
|
||
max-height: 10px;
|
||
background-color: #4F63D9;
|
||
border: 3px solid #EEF1FA;
|
||
border-radius: 8px;
|
||
}
|
||
QLabel#DiagnosisTimelineDate {
|
||
color: #1A1C1F;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisTimelineContent {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
}
|
||
QPushButton#DiagnosisAttachmentChip {
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border: 1px solid #EEF1FA;
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
}
|
||
QPushButton#DiagnosisTongueThumb {
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border: 1px solid #EEF1FA;
|
||
border-radius: 6px;
|
||
padding: 0;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton#DiagnosisTongueThumb[loadState="loading"],
|
||
QPushButton#DiagnosisChatImage[loadState="loading"] {
|
||
color: transparent;
|
||
background-color: #F7F7F7;
|
||
border-color: #EDEDEE;
|
||
}
|
||
QPushButton#DiagnosisTongueThumb[loadState="ready"],
|
||
QPushButton#DiagnosisChatImage[loadState="ready"] {
|
||
color: transparent;
|
||
background-color: #FFFFFF;
|
||
border-color: #EDEDEE;
|
||
}
|
||
QPushButton#DiagnosisChatImage {
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border: 1px solid #EEF1FA;
|
||
border-radius: 4px;
|
||
padding: 0;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton#DiagnosisAttachmentChip:hover,
|
||
QPushButton#DiagnosisTongueThumb:hover,
|
||
QPushButton#DiagnosisChatImage:hover {
|
||
color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
border-color: #4156C4;
|
||
}
|
||
QPushButton#DiagnosisAttachmentRemove {
|
||
min-width: 20px;
|
||
max-width: 20px;
|
||
min-height: 20px;
|
||
max-height: 20px;
|
||
padding: 0;
|
||
color: #BE4B58;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #FECACA;
|
||
border-radius: 11px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
QPushButton#DiagnosisAttachmentRemove:hover,
|
||
QPushButton#DiagnosisAttachmentRemove:focus {
|
||
color: #FFFFFF;
|
||
background-color: #BE4B58;
|
||
border-color: #BE4B58;
|
||
}
|
||
QFrame#DiagnosisChatInfo {
|
||
background-color: #F0F0F0;
|
||
border: 1px solid #F0F0F0;
|
||
border-radius: 8px;
|
||
}
|
||
QLabel#DiagnosisChatInfoIcon {
|
||
color: #4F63D9;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
QLabel#DiagnosisChatInfoText {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
}
|
||
QFrame#DiagnosisChatBubble[side="patient"] {
|
||
background-color: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 8px;
|
||
}
|
||
QFrame#DiagnosisChatBubble[side="doctor"] {
|
||
background-color: #F0F0F0;
|
||
border: 1px solid #F0F0F0;
|
||
border-radius: 8px;
|
||
}
|
||
QLabel#DiagnosisChatMeta {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
}
|
||
QLabel#DiagnosisChatText {
|
||
color: #1A1C1F;
|
||
font-size: 14px;
|
||
}
|
||
QFrame#DiagnosisLoadingOverlay {
|
||
background-color: rgba(255, 255, 255, 210);
|
||
border-radius: 10px;
|
||
}
|
||
QLabel#DiagnosisLoadingGlyph {
|
||
color: #4F63D9;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
}
|
||
QLabel#DiagnosisChoiceEmpty {
|
||
color: #606163;
|
||
background-color: #F7F7F7;
|
||
border: 1px dashed #E4E4E5;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
}
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton {
|
||
min-height: 32px;
|
||
padding: 0 14px;
|
||
color: #1A1C1F;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
}
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:hover,
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:focus {
|
||
color: #4156C4;
|
||
border-color: #8B9AD9;
|
||
background-color: #F7F7F7;
|
||
}
|
||
/* Match admin el-checkbox-button / el-radio-button selected look. */
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:checked {
|
||
color: #4F63D9;
|
||
border-color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
font-weight: 500;
|
||
}
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:checked:hover,
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:checked:focus {
|
||
color: #4156C4;
|
||
border-color: #8B9AD9;
|
||
background-color: #EEF1FA;
|
||
font-weight: 500;
|
||
}
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:disabled {
|
||
color: #8E8F90;
|
||
background-color: #F7F7F7;
|
||
border-color: #EDEDEE;
|
||
}
|
||
QWidget[diagnosisChoiceHost="true"] QPushButton:checked:disabled {
|
||
color: #1A1C1F;
|
||
border-color: #8B9AD9;
|
||
background-color: #F0F0F0;
|
||
font-weight: 500;
|
||
}
|
||
QWidget[diagnosisChoiceHost="true"][diagnosisChoiceLocked="true"] QPushButton {
|
||
/* View-only keeps selected fill; clicks are blocked on the host. */
|
||
}
|
||
QFrame#DiagnosisDailyPanel,
|
||
QFrame#DiagnosisTrendCard,
|
||
QFrame#DiagnosisTodoCard {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 10px;
|
||
}
|
||
QLabel#DiagnosisDailySectionTitle {
|
||
color: #1A1C1F;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
QTableWidget#DiagnosisDailyMatrix,
|
||
QTableWidget#DiagnosisTodoTable {
|
||
color: #1A1C1F;
|
||
background-color: #FFFFFF;
|
||
alternate-background-color: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
gridline-color: #EDEDEE;
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
}
|
||
QTableWidget#DiagnosisDailyMatrix::item,
|
||
QTableWidget#DiagnosisTodoTable::item {
|
||
padding: 7px 8px;
|
||
border: 0;
|
||
}
|
||
QTableWidget#DiagnosisDailyMatrix QHeaderView::section,
|
||
QTableWidget#DiagnosisTodoTable QHeaderView::section {
|
||
color: #6A6B6D;
|
||
background-color: #F7F7F7;
|
||
border: 0;
|
||
border-right: 1px solid #EDEDEE;
|
||
border-bottom: 1px solid #EDEDEE;
|
||
padding: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QPushButton[diagnosisRange="true"] {
|
||
min-height: 32px;
|
||
padding: 0 12px;
|
||
color: #606163;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 7px;
|
||
font-size: 13px;
|
||
}
|
||
QPushButton[diagnosisRange="true"]:checked {
|
||
color: #4F63D9;
|
||
background-color: #EEF1FA;
|
||
border-color: #E4E4E5;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisUnsupportedState {
|
||
color: #606163;
|
||
background-color: #F7F7F7;
|
||
border: 1px dashed #E4E4E5;
|
||
border-radius: 10px;
|
||
padding: 22px;
|
||
font-size: 13px;
|
||
}
|
||
QDialog#DiagnosisDailyEditor,
|
||
QDialog#DiagnosisOrderDetailDialog,
|
||
QDialog#DiagnosisRecordingPlayer {
|
||
background-color: #FFFFFF;
|
||
}
|
||
QDialog#DiagnosisDailyEditor {
|
||
background-color: #F7F7F7;
|
||
}
|
||
QFrame#DiagnosisEditorHeader,
|
||
QFrame#DiagnosisEditorFooter {
|
||
background-color: #FFFFFF;
|
||
border: 0;
|
||
}
|
||
QFrame#DiagnosisEditorHeader {
|
||
border-bottom: 1px solid #EDEDEE;
|
||
}
|
||
QFrame#DiagnosisEditorFooter {
|
||
border-top: 1px solid #EDEDEE;
|
||
}
|
||
QWidget#DiagnosisEditorContent {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 12px;
|
||
}
|
||
QWidget#DiagnosisEditorContent QLabel {
|
||
color: #1A1C1F;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QLabel#DiagnosisDialogHeading {
|
||
color: #1A1C1F;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
QLabel#DiagnosisDialogGuidance,
|
||
QLabel#DiagnosisRecordingTime {
|
||
color: #606163;
|
||
font-size: 12px;
|
||
}
|
||
QLabel#DiagnosisEditorError {
|
||
color: #BE4B58;
|
||
background-color: #FEF2F2;
|
||
border: 1px solid #FECACA;
|
||
border-radius: 7px;
|
||
padding: 8px 10px;
|
||
}
|
||
QScrollArea#DiagnosisEditorScroll,
|
||
QScrollArea#DiagnosisOrderDetailScroll {
|
||
background-color: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 10px;
|
||
}
|
||
QScrollArea#DiagnosisEditorScroll,
|
||
QScrollArea#DiagnosisEditorScroll > QWidget > QWidget {
|
||
background-color: #F7F7F7;
|
||
border: 0;
|
||
}
|
||
QFrame#DiagnosisOrderOffsetBar,
|
||
QFrame#DiagnosisOrderDetailHero {
|
||
background-color: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 9px;
|
||
}
|
||
QFrame#DiagnosisRecordingSurface,
|
||
QLabel#DiagnosisRecordingPlaceholder {
|
||
background-color: #1A1C1F;
|
||
border: 1px solid #1A1C1F;
|
||
border-radius: 8px;
|
||
}
|
||
QLabel#DiagnosisRecordingPlaceholder {
|
||
color: #E4E4E5;
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor {
|
||
background-color: #FFFFFF;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QTabWidget::pane {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 8px;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QTabBar::tab {
|
||
min-height: 36px;
|
||
padding: 0 14px;
|
||
color: #606163;
|
||
background-color: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
border-bottom: 0;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QTabBar::tab:selected {
|
||
color: #4F63D9;
|
||
background-color: #FFFFFF;
|
||
font-weight: 600;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QScrollArea,
|
||
QDialog#DiagnosisPrescriptionEditor QScrollArea > QWidget > QWidget {
|
||
background-color: #FFFFFF;
|
||
border: 0;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QLineEdit,
|
||
QDialog#DiagnosisPrescriptionEditor QTextEdit,
|
||
QDialog#DiagnosisPrescriptionEditor QPlainTextEdit,
|
||
QDialog#DiagnosisPrescriptionEditor QComboBox,
|
||
QDialog#DiagnosisPrescriptionEditor QDateEdit,
|
||
QDialog#DiagnosisPrescriptionEditor QSpinBox,
|
||
QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox {
|
||
color: #1A1C1F;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E4E4E5;
|
||
border-radius: 7px;
|
||
padding: 0 9px;
|
||
selection-background-color: #EEF1FA;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QTextEdit,
|
||
QDialog#DiagnosisPrescriptionEditor QPlainTextEdit {
|
||
padding: 7px 9px;
|
||
}
|
||
QDialog#DiagnosisPrescriptionEditor QLineEdit:focus,
|
||
QDialog#DiagnosisPrescriptionEditor QTextEdit:focus,
|
||
QDialog#DiagnosisPrescriptionEditor QPlainTextEdit:focus,
|
||
QDialog#DiagnosisPrescriptionEditor QComboBox:focus,
|
||
QDialog#DiagnosisPrescriptionEditor QDateEdit:focus,
|
||
QDialog#DiagnosisPrescriptionEditor QSpinBox:focus,
|
||
QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox:focus {
|
||
border-color: #8B9AD9;
|
||
}
|
||
"""
|
||
|
||
# Normalize the legacy component palette in one place. Keeping the selectors
|
||
# untouched protects the mature drawer behavior while aligning every nested
|
||
# editor, table and state with the shell's current blue-white theme.
|
||
_DIAGNOSIS_BLUE_REPLACEMENTS = (
|
||
("#0891B2", "#4F63D9"),
|
||
("#0E7490", "#4156C4"),
|
||
("#22D3EE", "#8B9AD9"),
|
||
("#67E8F9", "#EEF1FA"),
|
||
("#A5F3FC", "#EEF1FA"),
|
||
("#CFFAFE", "#EEF1FA"),
|
||
("#ECFEFF", "#F7F7F7"),
|
||
("#D9ECFF", "#EEF1FA"),
|
||
("#134E4A", "#1A1C1F"),
|
||
("#2A6B64", "#1A1C1F"),
|
||
("#5B7A76", "#606163"),
|
||
("#66736D", "#606163"),
|
||
("#94A8A4", "#8E8F90"),
|
||
("#67B8C9", "#8B9AD9"),
|
||
("#D5E5E2", "#EDEDEE"),
|
||
("#E2EBE8", "#EDEDEE"),
|
||
("#D9DEDA", "#EDEDEE"),
|
||
("#CBD3CE", "#EDEDEE"),
|
||
("#F5F8F7", "#FFFFFF"),
|
||
("#F0F2EF", "#F7F7F7"),
|
||
("#F0F5F3", "#F7F7F7"),
|
||
("#F0FDFA", "#F7F7F7"),
|
||
("#0F172A", "#1A1C1F"),
|
||
("#1F2937", "#1A1C1F"),
|
||
("#333333", "#1A1C1F"),
|
||
("#475569", "#1A1C1F"),
|
||
("#64748B", "#606163"),
|
||
("#6B7280", "#606163"),
|
||
("#999999", "#8E8F90"),
|
||
("#9AA39E", "#8E8F90"),
|
||
("#C0C4CC", "#8E8F90"),
|
||
("#F1F5F9", "#F7F7F7"),
|
||
("#E2E8F0", "#EDEDEE"),
|
||
("#DCE3EC", "#EDEDEE"),
|
||
("#CBD5E1", "#EDEDEE"),
|
||
("#E5E7EB", "#EDEDEE"),
|
||
("#E6EBF2", "#EDEDEE"),
|
||
("#F8FAFC", "#FFFFFF"),
|
||
("#F6F6F6", "#F6F6F6"),
|
||
("#FAFAFA", "#FFFFFF"),
|
||
("#F8F8F8", "#F7F7F7"),
|
||
("#16A34A", "#287B65"),
|
||
("#15803D", "#287B65"),
|
||
("#F0FDF4", "#EAF9F3"),
|
||
("#ECFDF5", "#EAF9F3"),
|
||
("#BBF7D0", "#BFE9DC"),
|
||
("#A7F3D0", "#BFE9DC"),
|
||
("#B45309", "#A9691D"),
|
||
("#EA580C", "#A9691D"),
|
||
("#F97316", "#A9691D"),
|
||
("#FFFBEB", "#FFF5E6"),
|
||
("#FFF7ED", "#FFF5E6"),
|
||
("#FCD34D", "#F3D6AC"),
|
||
("#FDE68A", "#F3D6AC"),
|
||
("#FED7AA", "#F3D6AC"),
|
||
("#DC2626", "#BE4B58"),
|
||
("#B91C1C", "#BE4B58"),
|
||
("#F56C6C", "#BE4B58"),
|
||
("#FEF2F2", "#FFF1F3"),
|
||
("#FFF7F7", "#FFF1F3"),
|
||
("#FEE2E2", "#FFE4E8"),
|
||
("#FECACA", "#F7C8CD"),
|
||
("#FCA5A5", "#F19BA4"),
|
||
)
|
||
for _source_color, _theme_color in _DIAGNOSIS_BLUE_REPLACEMENTS:
|
||
DIAGNOSIS_QSS = DIAGNOSIS_QSS.replace(_source_color, _theme_color)
|
||
|
||
|
||
def _text(value: Any, default: str = "—") -> str:
|
||
if value is None or value == "":
|
||
return default
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
rendered = "、".join(str(item) for item in value if item not in (None, ""))
|
||
return rendered or default
|
||
return str(value)
|
||
|
||
|
||
def _pick(source: Any, *keys: str, default: Any = "") -> Any:
|
||
if isinstance(source, Mapping):
|
||
for key in keys:
|
||
if key in source and source[key] not in (None, ""):
|
||
return source[key]
|
||
else:
|
||
for key in keys:
|
||
value = getattr(source, key, None)
|
||
if value not in (None, ""):
|
||
return value
|
||
return default
|
||
|
||
|
||
def repolish(widget: QWidget) -> None:
|
||
widget.style().unpolish(widget)
|
||
widget.style().polish(widget)
|
||
widget.update()
|
||
|
||
|
||
class StatusTag(QLabel):
|
||
"""Small semantic tag with dynamic-property styling."""
|
||
|
||
def __init__(self, text: str = "", kind: str = "info", parent: QWidget | None = None) -> None:
|
||
super().__init__(text, parent)
|
||
self.setProperty("diagnosisTag", True)
|
||
self.set_kind(kind)
|
||
|
||
def set_kind(self, kind: str) -> None:
|
||
self.setProperty("kind", kind)
|
||
repolish(self)
|
||
|
||
|
||
class DiagnosisSwitch(QAbstractButton):
|
||
"""Native two-state switch retaining the form editor text contract."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisSwitch")
|
||
self.setCheckable(True)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setAccessibleName("统计端就诊卡")
|
||
# Custom-painted switches do not inherit stylesheet size hints; pin geometry
|
||
# so the form row never collapses to a floating label with an invisible control.
|
||
self.setFixedSize(44, 24)
|
||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(44, 24)
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(44, 24)
|
||
|
||
def toPlainText(self) -> str: # noqa: N802 - editor compatibility
|
||
return "1" if self.isChecked() else "0"
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802
|
||
self.setChecked(str(text).strip().lower() in {"1", "true", "yes", "显示", "启用"})
|
||
|
||
def setReadOnly(self, readonly: bool) -> None: # noqa: N802
|
||
self.setEnabled(not readonly)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
track = QRectF(1, 3, self.width() - 2, self.height() - 6)
|
||
checked = self.isChecked()
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor("#4F63D9" if checked else "#E4E4E5"))
|
||
painter.drawRoundedRect(track, track.height() / 2, track.height() / 2)
|
||
diameter = track.height() - 4
|
||
x = track.right() - diameter - 2 if checked else track.left() + 2
|
||
painter.setBrush(QColor("#FFFFFF"))
|
||
painter.drawEllipse(QRectF(x, track.top() + 2, diameter, diameter))
|
||
painter.end()
|
||
|
||
|
||
class SaveStateButton(QPushButton):
|
||
"""Self-painted save button with spinner and terminal status glyphs."""
|
||
|
||
def __init__(self, text: str = "保存病历", parent: QWidget | None = None) -> None:
|
||
super().__init__(text, parent)
|
||
self._state = "idle"
|
||
self._angle = 0
|
||
self._spinner = QTimer(self)
|
||
self._spinner.setInterval(70)
|
||
self._spinner.timeout.connect(self._advance_spinner)
|
||
|
||
@property
|
||
def state(self) -> str:
|
||
return self._state
|
||
|
||
def set_state(self, state: str) -> None:
|
||
if state not in {"idle", "loading", "success", "error"}:
|
||
raise ValueError(f"unsupported save state: {state}")
|
||
self._state = state
|
||
if state == "loading":
|
||
self._spinner.start()
|
||
else:
|
||
self._spinner.stop()
|
||
self.update()
|
||
|
||
def _advance_spinner(self) -> None:
|
||
self._angle = (self._angle + 30) % 360
|
||
self.update()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
enabled = self.isEnabled()
|
||
color = {
|
||
"success": "#287B65",
|
||
"error": "#BE4B58",
|
||
}.get(self._state, "#4F63D9" if enabled else "#8E8F90")
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor(color))
|
||
painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 10, 10)
|
||
label = {
|
||
"loading": "正在保存",
|
||
"success": "保存成功",
|
||
"error": "保存失败",
|
||
}.get(self._state, self.text())
|
||
metrics = painter.fontMetrics()
|
||
icon_width = 18 if self._state != "idle" else 0
|
||
total_width = metrics.horizontalAdvance(label) + icon_width
|
||
x = (self.width() - total_width) // 2
|
||
center_y = self.height() // 2
|
||
if self._state == "loading":
|
||
painter.setPen(QPen(QColor("#FFFFFF"), 2))
|
||
painter.drawArc(QRect(x, center_y - 7, 14, 14), self._angle * 16, 250 * 16)
|
||
elif self._state in {"success", "error"}:
|
||
painter.setPen(
|
||
QPen(QColor("#FFFFFF"), 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
|
||
)
|
||
if self._state == "success":
|
||
painter.drawLine(x + 2, center_y, x + 6, center_y + 4)
|
||
painter.drawLine(x + 6, center_y + 4, x + 13, center_y - 4)
|
||
else:
|
||
painter.drawLine(x + 3, center_y - 5, x + 12, center_y + 5)
|
||
painter.drawLine(x + 12, center_y - 5, x + 3, center_y + 5)
|
||
painter.setPen(QColor("#FFFFFF"))
|
||
painter.drawText(
|
||
QRect(x + icon_width, 0, metrics.horizontalAdvance(label), self.height()),
|
||
Qt.AlignmentFlag.AlignCenter,
|
||
label,
|
||
)
|
||
painter.end()
|
||
|
||
|
||
class DiagnosisTabWidget(QTabWidget):
|
||
"""Scrollable diagnosis tabs without boxed previous/next arrow controls."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.tabBar().setUsesScrollButtons(True)
|
||
self.tabBar().setExpanding(False)
|
||
self.tabBar().setElideMode(Qt.TextElideMode.ElideNone)
|
||
self.tab_scrollbar = QScrollBar(Qt.Orientation.Horizontal, self)
|
||
self.tab_scrollbar.setObjectName("DiagnosisTabOverflow")
|
||
self.tab_scrollbar.setFixedHeight(4)
|
||
self.tab_scrollbar.setSingleStep(1)
|
||
self.tab_scrollbar.valueChanged.connect(self._scroll_to_tab)
|
||
self.currentChanged.connect(self._sync_scroll_value)
|
||
self.tab_scrollbar.hide()
|
||
self._overflow_sync = QTimer(self)
|
||
self._overflow_sync.setSingleShot(True)
|
||
self._overflow_sync.setInterval(0)
|
||
self._overflow_sync.timeout.connect(self._sync_overflow)
|
||
|
||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||
super().resizeEvent(event)
|
||
self._overflow_sync.start()
|
||
|
||
def tabInserted(self, index: int) -> None: # noqa: N802 - Qt API
|
||
super().tabInserted(index)
|
||
self._overflow_sync.start()
|
||
|
||
def tabRemoved(self, index: int) -> None: # noqa: N802 - Qt API
|
||
super().tabRemoved(index)
|
||
self._overflow_sync.start()
|
||
|
||
def _sync_overflow(self) -> None:
|
||
bar_height = self.tabBar().height()
|
||
# Keep overflow navigation below the selected-tab underline.
|
||
self.tab_scrollbar.setGeometry(0, bar_height, self.width(), 4)
|
||
overflow = self.tabBar().sizeHint().width() > self.width()
|
||
self.tab_scrollbar.setRange(0, max(0, self.count() - 1))
|
||
self.tab_scrollbar.setPageStep(max(1, self.width() // 92))
|
||
self.tab_scrollbar.setVisible(overflow and self.count() > 1)
|
||
self.tab_scrollbar.raise_()
|
||
|
||
def _scroll_to_tab(self, index: int) -> None:
|
||
if 0 <= index < self.count() and index != self.currentIndex():
|
||
self.setCurrentIndex(index)
|
||
|
||
def _sync_scroll_value(self, index: int) -> None:
|
||
self.tab_scrollbar.blockSignals(True)
|
||
self.tab_scrollbar.setValue(max(0, index))
|
||
self.tab_scrollbar.blockSignals(False)
|
||
|
||
|
||
class DiagnosisLineEdit(QLineEdit):
|
||
"""Line editor retaining the legacy plain-text editor contract."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setProperty("diagnosisField", True)
|
||
self.setFixedHeight(32)
|
||
self.setMinimumWidth(0)
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
# Default QLineEdit hints are too wide for a 60% drawer column.
|
||
return QSize(48, 32)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(160, 32)
|
||
|
||
def toPlainText(self) -> str: # noqa: N802 - compatibility with QPlainTextEdit
|
||
return self.text()
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802
|
||
self.setText(text)
|
||
|
||
|
||
class DiagnosisTextEdit(QPlainTextEdit):
|
||
"""Multiline editor using the same scoped field state as line inputs."""
|
||
|
||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setProperty("diagnosisField", True)
|
||
self.setMinimumWidth(0)
|
||
height = 54 if rows <= 2 else 72 if rows == 3 else 92
|
||
self._hint_height = height
|
||
self.setMinimumHeight(height)
|
||
self.setMaximumHeight(height)
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
# QPlainTextEdit defaults to ~80 columns; that blows past the drawer width.
|
||
return QSize(48, self._hint_height)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(160, self._hint_height)
|
||
|
||
|
||
class ExpandableDiagnosisTextEdit(DiagnosisTextEdit):
|
||
"""An inline long-text editor without an inner scrollbar when expanded."""
|
||
|
||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||
super().__init__(rows, parent)
|
||
self._collapsed_height = self._hint_height
|
||
self._caption = "内容"
|
||
self._expand_host: QWidget | None = None
|
||
self.expand_button = QPushButton("展开全部", self)
|
||
self.expand_button.setObjectName("DiagnosisTextExpandButton")
|
||
self.expand_button.setProperty("variant", "link")
|
||
self.expand_button.setCheckable(True)
|
||
self.expand_button.setAutoDefault(False)
|
||
self.expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.expand_button.setToolTip("展开文本框,查看和编辑全部内容")
|
||
self.expand_button.hide()
|
||
self.expand_button.toggled.connect(self._toggle_expanded)
|
||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self._height_sync = QTimer(self)
|
||
self._height_sync.setSingleShot(True)
|
||
self._height_sync.setInterval(0)
|
||
self._height_sync.timeout.connect(self._sync_content_height)
|
||
self.textChanged.connect(self._height_sync.start)
|
||
self.document().documentLayout().documentSizeChanged.connect(
|
||
lambda _size: self._height_sync.start()
|
||
)
|
||
|
||
def wrap_with_expander(self, caption: str) -> QWidget:
|
||
"""Keep the original plain-text editor as the form's save/readonly target."""
|
||
|
||
if self._expand_host is None:
|
||
self._caption = caption
|
||
self.setAccessibleName(caption)
|
||
self.expand_button.setAccessibleName(f"展开{caption}全部内容")
|
||
self._expand_host = QWidget()
|
||
self._expand_host.setMinimumWidth(0)
|
||
self._expand_host.setFocusProxy(self)
|
||
layout = QVBoxLayout(self._expand_host)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(2)
|
||
layout.addWidget(self)
|
||
layout.addWidget(self.expand_button, 0, Qt.AlignmentFlag.AlignRight)
|
||
return self._expand_host
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802 - Qt API
|
||
# A newly loaded record starts compact; typing/paste/undo keep its state.
|
||
self.expand_button.setChecked(False)
|
||
super().setPlainText(text)
|
||
|
||
def _toggle_expanded(self, expanded: bool) -> None:
|
||
self.expand_button.setText("收起" if expanded else "展开全部")
|
||
self.expand_button.setAccessibleName(
|
||
f"收起{self._caption}" if expanded else f"展开{self._caption}全部内容"
|
||
)
|
||
self.expand_button.setToolTip(
|
||
"收起为紧凑文本框,内容不会丢失" if expanded else "展开文本框,查看和编辑全部内容"
|
||
)
|
||
self.setVerticalScrollBarPolicy(
|
||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||
if expanded
|
||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||
)
|
||
self.verticalScrollBar().setValue(0)
|
||
self._height_sync.start()
|
||
|
||
def _sync_content_height(self) -> None:
|
||
expanded = self.expand_button.isChecked()
|
||
document = self.document()
|
||
document_layout = document.documentLayout()
|
||
# QPlainTextDocumentLayout.documentSize().height() counts lines, not
|
||
# pixels. Measure the actual wrapped blocks, including offscreen ones.
|
||
height = self.height() - self.viewport().height() + 2 * document.documentMargin()
|
||
block = document.begin()
|
||
while block.isValid():
|
||
height += document_layout.blockBoundingRect(block).height()
|
||
if not expanded and height > self._collapsed_height:
|
||
break
|
||
block = block.next()
|
||
self.expand_button.setVisible(expanded or height > self._collapsed_height)
|
||
target = max(self._collapsed_height, ceil(height)) if expanded else self._collapsed_height
|
||
if self._hint_height != target:
|
||
self._hint_height = target
|
||
self.setFixedHeight(target)
|
||
self.updateGeometry()
|
||
|
||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||
super().resizeEvent(event)
|
||
if hasattr(self, "_height_sync"):
|
||
self._height_sync.start()
|
||
|
||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API
|
||
super().showEvent(event)
|
||
self._height_sync.start()
|
||
|
||
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||
super().changeEvent(event)
|
||
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(
|
||
self, "_height_sync"
|
||
):
|
||
self._height_sync.start()
|
||
|
||
|
||
class DiagnosisComboBox(QComboBox):
|
||
"""Choice editor with the legacy text API used by diagnosis saving."""
|
||
|
||
def __init__(
|
||
self,
|
||
choices: Sequence[tuple[str, Any]],
|
||
parent: QWidget | None = None,
|
||
*,
|
||
allow_custom: bool = True,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setProperty("diagnosisField", True)
|
||
self.setFixedHeight(32)
|
||
self.setMinimumWidth(0)
|
||
self._read_only = False
|
||
self._allow_custom = bool(allow_custom)
|
||
self.set_choices(choices, preserve=False)
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(72, 32)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(160, 32)
|
||
|
||
def set_choices(
|
||
self,
|
||
choices: Sequence[tuple[str, Any]],
|
||
*,
|
||
preserve: bool = True,
|
||
) -> None:
|
||
"""Replace options while retaining the selected backend value when possible."""
|
||
|
||
selected = self.toPlainText() if preserve and self.count() else ""
|
||
self.clear()
|
||
self.addItem("请选择", "")
|
||
seen: set[str] = set()
|
||
for label, value in choices:
|
||
token = "" if value is None else str(value)
|
||
if token in seen:
|
||
continue
|
||
seen.add(token)
|
||
self.addItem(str(label), value if value is not None else "")
|
||
if selected:
|
||
self.setPlainText(selected)
|
||
|
||
def toPlainText(self) -> str: # noqa: N802
|
||
value = self.currentData()
|
||
return "" if value is None else str(value)
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802
|
||
index = self.findData(text)
|
||
if index < 0:
|
||
try:
|
||
index = self.findData(int(text))
|
||
except (TypeError, ValueError):
|
||
index = -1
|
||
if index < 0:
|
||
index = self.findText(text)
|
||
if index < 0 and text and self._allow_custom:
|
||
self.addItem(text, text)
|
||
index = self.count() - 1
|
||
self.setCurrentIndex(max(0, index))
|
||
|
||
def setReadOnly(self, read_only: bool) -> None: # noqa: N802
|
||
self._read_only = bool(read_only)
|
||
self.setEnabled(not self._read_only)
|
||
|
||
def isReadOnly(self) -> bool: # noqa: N802
|
||
return self._read_only
|
||
|
||
|
||
class DiagnosisChoiceButtons(QWidget):
|
||
"""Chip field matching admin checkbox-button / radio-button groups."""
|
||
|
||
def __init__(
|
||
self,
|
||
choices: Sequence[tuple[str, Any]] = (),
|
||
*,
|
||
multiple: bool = False,
|
||
radio_buttons: bool = False,
|
||
columns: int = 4,
|
||
parent: QWidget | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisChoiceButtons")
|
||
self.setProperty("diagnosisChoiceHost", True)
|
||
self.setProperty("diagnosisField", True)
|
||
self.setMinimumWidth(0)
|
||
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
policy.setHeightForWidth(True)
|
||
self.setSizePolicy(policy)
|
||
self._multiple = bool(multiple)
|
||
# Kept for call-site compatibility; admin always uses button chips.
|
||
self._radio_buttons = bool(radio_buttons)
|
||
self._columns = max(1, int(columns))
|
||
self._read_only = False
|
||
self._choices: list[tuple[str, Any]] = []
|
||
self._buttons: list[QPushButton] = []
|
||
self._group = QButtonGroup(self)
|
||
self._group.setExclusive(not self._multiple)
|
||
self._layout = FlowLayout(self, horizontal_spacing=8, vertical_spacing=8)
|
||
self._empty = QLabel("选项加载中…")
|
||
self._empty.setObjectName("DiagnosisChoiceEmpty")
|
||
self._empty.setWordWrap(True)
|
||
self._empty.setMinimumWidth(0)
|
||
self._empty.setMinimumHeight(32)
|
||
self._layout.addWidget(self._empty)
|
||
self.set_choices(choices)
|
||
|
||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt virtual
|
||
return True
|
||
|
||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual
|
||
# Avoid width=0 (every chip on its own line → absurd height / blank gaps).
|
||
usable = max(160, int(width)) if int(width) <= 1 else max(48, int(width))
|
||
return max(32, self._layout.heightForWidth(usable))
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(48, self.heightForWidth(160))
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
width = max(160, self.width() if self.width() > 1 else 160)
|
||
return QSize(width, self.heightForWidth(width))
|
||
|
||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||
super().resizeEvent(event)
|
||
self._sync_flow_height()
|
||
|
||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API
|
||
super().showEvent(event)
|
||
self._sync_flow_height()
|
||
|
||
def _sync_flow_height(self) -> None:
|
||
"""Lock vertical space to the wrapped chip rows for the current width.
|
||
|
||
Horizontal form rows do not reliably honor height-for-width, so without
|
||
this the option chips are clipped and appear as blank white space.
|
||
"""
|
||
|
||
width = self.width() if self.width() > 1 else 480
|
||
needed = self.heightForWidth(width)
|
||
if self.minimumHeight() != needed:
|
||
self.setMinimumHeight(needed)
|
||
self.updateGeometry()
|
||
parent = self.parentWidget()
|
||
if parent is not None:
|
||
parent.updateGeometry()
|
||
|
||
def set_choices(self, choices: Sequence[tuple[str, Any]], *, preserve: bool = True) -> None:
|
||
selected = self.toPlainText() if preserve else ""
|
||
while self._layout.count():
|
||
item = self._layout.takeAt(0)
|
||
widget = item.widget() if item is not None else None
|
||
if widget is None:
|
||
continue
|
||
if widget is self._empty:
|
||
widget.hide()
|
||
continue
|
||
self._group.removeButton(widget)
|
||
widget.deleteLater()
|
||
self._choices = []
|
||
self._buttons = []
|
||
seen: set[str] = set()
|
||
for label, value in choices:
|
||
token = str(value)
|
||
if token in seen:
|
||
continue
|
||
seen.add(token)
|
||
button = QPushButton(str(label))
|
||
button.setCheckable(True)
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
button.toggled.connect(lambda _checked=False, target=button: repolish(target))
|
||
button.setProperty("diagnosisValue", value)
|
||
button.setProperty("diagnosisLabel", str(label))
|
||
button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||
self._group.addButton(button, len(self._buttons))
|
||
self._layout.addWidget(button)
|
||
self._buttons.append(button)
|
||
self._choices.append((str(label), value))
|
||
if not self._buttons:
|
||
self._empty.setText("暂无字典选项,请检查数据字典配置")
|
||
self._empty.show()
|
||
self._layout.addWidget(self._empty)
|
||
else:
|
||
self._empty.hide()
|
||
self._layout.invalidate()
|
||
self._sync_flow_height()
|
||
self.updateGeometry()
|
||
if selected:
|
||
self.setPlainText(selected)
|
||
if self._read_only:
|
||
self.setReadOnly(True)
|
||
|
||
def _selected_tokens(self) -> list[str]:
|
||
return [
|
||
str(button.property("diagnosisValue")) for button in self._buttons if button.isChecked()
|
||
]
|
||
|
||
def toPlainText(self) -> str: # noqa: N802
|
||
return "、".join(self._selected_tokens())
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802
|
||
raw = str(text or "")
|
||
tokens = [
|
||
part.strip()
|
||
for part in raw.replace(",", "、").replace(",", "、").split("、")
|
||
if part.strip()
|
||
]
|
||
token_set = set(tokens)
|
||
existing = {str(value) for _label, value in self._choices}
|
||
existing_labels = {str(label) for label, _value in self._choices}
|
||
if not token_set and not self._multiple and "" in existing:
|
||
token_set.add("")
|
||
unknown = [
|
||
token
|
||
for token in tokens
|
||
if token not in existing and token not in existing_labels
|
||
]
|
||
if unknown:
|
||
self.set_choices(
|
||
[*self._choices, *((token, token) for token in unknown)],
|
||
preserve=False,
|
||
)
|
||
was_exclusive = self._group.exclusive()
|
||
if was_exclusive:
|
||
self._group.setExclusive(False)
|
||
for button in self._buttons:
|
||
value = str(button.property("diagnosisValue"))
|
||
label = str(button.property("diagnosisLabel") or button.text())
|
||
button.setChecked(value in token_set or label in token_set)
|
||
repolish(button)
|
||
if was_exclusive:
|
||
self._group.setExclusive(True)
|
||
|
||
def setReadOnly(self, read_only: bool) -> None: # noqa: N802
|
||
# Match admin fieldset[disabled]: keep selected chip paint, block interaction.
|
||
self._read_only = bool(read_only)
|
||
self.setProperty("diagnosisChoiceLocked", self._read_only)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, self._read_only)
|
||
self.setCursor(
|
||
Qt.CursorShape.ArrowCursor if self._read_only else Qt.CursorShape.ArrowCursor
|
||
)
|
||
for button in self._buttons:
|
||
button.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, self._read_only)
|
||
if self._read_only:
|
||
button.setCursor(Qt.CursorShape.ArrowCursor)
|
||
else:
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
repolish(button)
|
||
repolish(self)
|
||
|
||
def isReadOnly(self) -> bool: # noqa: N802
|
||
return self._read_only
|
||
|
||
|
||
class DiagnosisNumberEdit(QDoubleSpinBox):
|
||
"""Bounded numeric field that can still represent an empty server value."""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
maximum: float,
|
||
decimals: int = 0,
|
||
step: float = 1.0,
|
||
suffix: str = "",
|
||
parent: QWidget | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setProperty("diagnosisField", True)
|
||
self._sentinel = -1.0
|
||
self._read_only = False
|
||
self._unit = str(suffix or "").strip()
|
||
self.setRange(self._sentinel, maximum)
|
||
self.setDecimals(decimals)
|
||
self.setSingleStep(step)
|
||
self.setSpecialValueText("请输入")
|
||
# Keep units outside the spin box (see wrap_with_unit) so half-column
|
||
# drawers never clip the value against stepper buttons.
|
||
self.setSuffix("")
|
||
self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.UpDownArrows)
|
||
self.setFixedHeight(34)
|
||
self.setMinimumWidth(96)
|
||
self.setValue(self._sentinel)
|
||
|
||
@property
|
||
def unit_text(self) -> str:
|
||
return self._unit
|
||
|
||
def wrap_with_unit(self) -> QWidget:
|
||
"""Host the spin box with an external unit label when a unit is configured."""
|
||
|
||
if not self._unit:
|
||
return self
|
||
host = QWidget()
|
||
host.setMinimumWidth(0)
|
||
host.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
layout = QHBoxLayout(host)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(6)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
layout.addWidget(self, 1)
|
||
unit = QLabel(self._unit)
|
||
unit.setObjectName("DiagnosisUnitLabel")
|
||
unit.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addWidget(unit, 0)
|
||
return host
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(96, 34)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(140, 34)
|
||
|
||
def toPlainText(self) -> str: # noqa: N802
|
||
if self.value() <= self._sentinel:
|
||
return ""
|
||
if self.decimals() == 0:
|
||
return str(int(round(self.value())))
|
||
return f"{self.value():.{self.decimals()}f}".rstrip("0").rstrip(".")
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802
|
||
try:
|
||
self.setValue(float(str(text).strip()))
|
||
except (TypeError, ValueError):
|
||
self.setValue(self._sentinel)
|
||
|
||
def setReadOnly(self, read_only: bool) -> None: # noqa: N802
|
||
self._read_only = bool(read_only)
|
||
self.setEnabled(not self._read_only)
|
||
|
||
def isReadOnly(self) -> bool: # noqa: N802
|
||
return self._read_only
|
||
|
||
|
||
class DiagnosisDateEdit(QDateEdit):
|
||
"""Calendar-backed diagnosis date field with an explicit empty state."""
|
||
|
||
_EMPTY_DATE = QDate(1900, 1, 1)
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setProperty("diagnosisField", True)
|
||
self._read_only = False
|
||
self.setCalendarPopup(True)
|
||
self.setDisplayFormat("yyyy-MM-dd")
|
||
self.setMinimumDate(self._EMPTY_DATE)
|
||
self.setMaximumDate(QDate.currentDate())
|
||
self.setSpecialValueText("请选择日期")
|
||
self.setDate(self._EMPTY_DATE)
|
||
self.setFixedHeight(32)
|
||
self.setMinimumWidth(0)
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(96, 32)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(160, 32)
|
||
|
||
def toPlainText(self) -> str: # noqa: N802
|
||
return "" if self.date() == self._EMPTY_DATE else self.date().toString("yyyy-MM-dd")
|
||
|
||
def setPlainText(self, text: str) -> None: # noqa: N802
|
||
value = QDate.fromString(str(text or ""), "yyyy-MM-dd")
|
||
self.setDate(value if value.isValid() else self._EMPTY_DATE)
|
||
|
||
def setReadOnly(self, read_only: bool) -> None: # noqa: N802
|
||
self._read_only = bool(read_only)
|
||
self.setEnabled(not self._read_only)
|
||
|
||
def isReadOnly(self) -> bool: # noqa: N802
|
||
return self._read_only
|
||
|
||
|
||
class MessageStrip(QFrame):
|
||
"""Compact inline loading/error/warning strip."""
|
||
|
||
action_requested = Signal()
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisMessageBanner")
|
||
self.setProperty("kind", "info")
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(12, 9, 12, 9)
|
||
layout.setSpacing(8)
|
||
self.glyph = QLabel("i")
|
||
self.glyph.setFixedWidth(18)
|
||
self.glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.glyph.setStyleSheet("font-weight:700; color:#4F63D9;")
|
||
self.label = QLabel()
|
||
self.label.setWordWrap(True)
|
||
self.label.setStyleSheet("color:#1A1C1F; font-size:13px;")
|
||
layout.addWidget(self.glyph)
|
||
layout.addWidget(self.label, 1)
|
||
self.action_button = QPushButton()
|
||
self.action_button.setObjectName("DiagnosisMessageAction")
|
||
self.action_button.setStyleSheet(
|
||
"QPushButton{min-height:28px;padding:0 10px;color:#1A1C1F;background:#F0F0F0;"
|
||
"border:1px solid #E4E4E5;border-radius:7px;font-weight:500;}"
|
||
"QPushButton:hover,QPushButton:focus{background:#EEF1FA;border-color:#8B9AD9;}"
|
||
)
|
||
self.action_button.clicked.connect(self.action_requested)
|
||
self.action_button.hide()
|
||
layout.addWidget(self.action_button)
|
||
self.hide()
|
||
|
||
def show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
||
glyphs = {"info": "i", "success": "✓", "warning": "!", "danger": "!"}
|
||
self.label.setText(text)
|
||
self.glyph.setText(glyphs.get(kind, "i"))
|
||
self.action_button.setText(action_text)
|
||
self.action_button.setVisible(bool(action_text))
|
||
self.setProperty("kind", kind)
|
||
repolish(self)
|
||
self.setVisible(bool(text))
|
||
|
||
def clear(self) -> None:
|
||
self.label.clear()
|
||
self.action_button.hide()
|
||
self.hide()
|
||
|
||
|
||
class _LoadingSpinner(QWidget):
|
||
"""Font-independent Element-style indeterminate spinner."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisLoadingGlyph")
|
||
self.setFixedSize(34, 34)
|
||
self._angle = 0
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(80)
|
||
self._timer.timeout.connect(self._advance)
|
||
|
||
def _advance(self) -> None:
|
||
self._angle = (self._angle - 30) % 360
|
||
self.update()
|
||
|
||
def start(self) -> None:
|
||
self._timer.start()
|
||
self.update()
|
||
|
||
def stop(self) -> None:
|
||
self._timer.stop()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
|
||
super().paintEvent(event)
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor("#4F63D9"), 3)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
painter.setPen(pen)
|
||
painter.drawArc(QRectF(5, 5, 24, 24), self._angle * 16, 285 * 16)
|
||
painter.end()
|
||
|
||
|
||
class LoadingOverlay(QFrame):
|
||
"""Content-preserving overlay used while the authoritative bundle loads."""
|
||
|
||
def __init__(self, parent: QWidget, text: str = "正在加载诊单…") -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisLoadingOverlay")
|
||
layout = QVBoxLayout(self)
|
||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
layout.setSpacing(8)
|
||
self.glyph = _LoadingSpinner(self)
|
||
label = QLabel(text)
|
||
label.setStyleSheet("color:#606163; font-size:13px;")
|
||
layout.addWidget(self.glyph)
|
||
layout.addWidget(label)
|
||
self.hide()
|
||
|
||
def sync_geometry(self) -> None:
|
||
parent = self.parentWidget()
|
||
if parent is not None:
|
||
self.setGeometry(parent.rect())
|
||
self.raise_()
|
||
|
||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API
|
||
self.sync_geometry()
|
||
self.glyph.start()
|
||
super().showEvent(event)
|
||
|
||
def hideEvent(self, event: QHideEvent) -> None: # noqa: N802 - Qt API
|
||
self.glyph.stop()
|
||
super().hideEvent(event)
|
||
|
||
|
||
class RecordTable(QTableWidget):
|
||
"""Diagnosis-scoped table with a compact in-viewport empty state."""
|
||
|
||
def __init__(
|
||
self,
|
||
object_name: str,
|
||
columns: Sequence[tuple[str, int]],
|
||
parent: QWidget | None = None,
|
||
) -> None:
|
||
super().__init__(0, len(columns), parent)
|
||
self.setObjectName(object_name)
|
||
self.setProperty("diagnosisTable", True)
|
||
self.setHorizontalHeaderLabels([label for label, _width in columns])
|
||
self.setAlternatingRowColors(True)
|
||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||
self.verticalHeader().setVisible(False)
|
||
self.horizontalHeader().setStretchLastSection(False)
|
||
self.horizontalHeader().setMinimumSectionSize(54)
|
||
for index, (_label, width) in enumerate(columns):
|
||
self.setColumnWidth(index, width)
|
||
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||
self.setMinimumHeight(180)
|
||
self.empty_label = QLabel("暂无数据", self.viewport())
|
||
self.empty_label.setObjectName("DiagnosisEmptyState")
|
||
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.empty_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
self.empty_label.show()
|
||
|
||
def set_empty_text(self, text: str) -> None:
|
||
self.empty_label.setText(text)
|
||
self.empty_label.setToolTip(text if len(text) > 36 else "")
|
||
self.empty_label.setVisible(self.rowCount() == 0)
|
||
|
||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||
super().resizeEvent(event)
|
||
header_height = self.horizontalHeader().height()
|
||
self.empty_label.setGeometry(
|
||
QRect(
|
||
0,
|
||
header_height,
|
||
self.viewport().width(),
|
||
max(60, self.viewport().height() - header_height),
|
||
)
|
||
)
|
||
|
||
def set_matrix(
|
||
self,
|
||
rows: Sequence[Sequence[Any]],
|
||
*,
|
||
tag_columns: Mapping[int, str] | None = None,
|
||
danger_columns: Sequence[int] = (),
|
||
right_columns: Sequence[int] = (),
|
||
) -> None:
|
||
self.clearContents()
|
||
self.setRowCount(len(rows))
|
||
semantic = dict(tag_columns or {})
|
||
for row_index, values in enumerate(rows):
|
||
for column_index, value in enumerate(values):
|
||
item = QTableWidgetItem(_text(value))
|
||
if column_index in right_columns:
|
||
item.setTextAlignment(
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
else:
|
||
item.setTextAlignment(
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
if column_index in danger_columns:
|
||
item.setForeground(QColor("#BE4B58"))
|
||
kind = semantic.get(column_index)
|
||
if kind:
|
||
palette = {
|
||
"success": ("#287B65", "#EAF9F3"),
|
||
"warning": ("#A9691D", "#FFF5E6"),
|
||
"danger": ("#BE4B58", "#FFF1F3"),
|
||
"info": ("#4F63D9", "#EEF1FA"),
|
||
"neutral": ("#6A6B6D", "#F7F7F7"),
|
||
}
|
||
foreground, background = palette.get(kind, palette["neutral"])
|
||
item.setForeground(QColor(foreground))
|
||
item.setBackground(QColor(background))
|
||
item.setToolTip(item.text() if len(item.text()) > 18 else "")
|
||
self.setItem(row_index, column_index, item)
|
||
self.resizeRowsToContents()
|
||
self.empty_label.setVisible(not rows)
|
||
self.viewport().update()
|
||
|
||
|
||
class BloodTrendChart(QWidget):
|
||
"""Small native trend chart; keeps the daily-record view dependency free."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setMinimumHeight(220)
|
||
self._dates: list[str] = []
|
||
self._fasting: list[float | None] = []
|
||
self._postprandial: list[float | None] = []
|
||
|
||
def set_series(
|
||
self,
|
||
dates: Sequence[str],
|
||
fasting: Sequence[float | None],
|
||
postprandial: Sequence[float | None],
|
||
) -> None:
|
||
self._dates = list(dates)
|
||
self._fasting = list(fasting)
|
||
self._postprandial = list(postprandial)
|
||
self.update()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
|
||
super().paintEvent(event)
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
plot = self.rect().adjusted(48, 24, -22, -38)
|
||
values = [value for value in (*self._fasting, *self._postprandial) if value is not None]
|
||
if not values or plot.width() <= 0 or plot.height() <= 0:
|
||
painter.setPen(QColor("#606163"))
|
||
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "当前时间范围暂无血糖数据")
|
||
painter.end()
|
||
return
|
||
upper = max(12.0, max(values) * 1.15)
|
||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||
for step in range(5):
|
||
y = plot.bottom() - round(plot.height() * step / 4)
|
||
painter.drawLine(plot.left(), y, plot.right(), y)
|
||
painter.setPen(QColor("#606163"))
|
||
painter.drawText(
|
||
4, y - 8, 40, 16, Qt.AlignmentFlag.AlignRight, f"{upper * step / 4:.0f}"
|
||
)
|
||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||
count = max(1, len(self._dates) - 1)
|
||
|
||
def point(index: int, value: float) -> QPointF:
|
||
return QPointF(
|
||
plot.left() + plot.width() * index / count,
|
||
plot.bottom() - plot.height() * value / upper,
|
||
)
|
||
|
||
for series, color in (
|
||
(self._fasting, QColor("#4F63D9")),
|
||
(self._postprandial, QColor("#A9691D")),
|
||
):
|
||
previous: QPointF | None = None
|
||
painter.setPen(QPen(color, 2.5))
|
||
painter.setBrush(color)
|
||
for index, value in enumerate(series):
|
||
if value is None:
|
||
previous = None
|
||
continue
|
||
current = point(index, value)
|
||
if previous is not None:
|
||
painter.drawLine(previous, current)
|
||
painter.drawEllipse(current, 3.2, 3.2)
|
||
previous = current
|
||
painter.setPen(QColor("#4F63D9"))
|
||
painter.drawText(plot.left(), 4, 94, 18, Qt.AlignmentFlag.AlignLeft, "● 空腹血糖")
|
||
painter.setPen(QColor("#A9691D"))
|
||
painter.drawText(plot.left() + 100, 4, 110, 18, Qt.AlignmentFlag.AlignLeft, "● 餐后血糖")
|
||
if self._dates:
|
||
painter.setPen(QColor("#606163"))
|
||
painter.drawText(
|
||
plot.left(),
|
||
plot.bottom() + 8,
|
||
80,
|
||
20,
|
||
Qt.AlignmentFlag.AlignLeft,
|
||
self._dates[0][5:],
|
||
)
|
||
painter.drawText(
|
||
plot.right() - 80,
|
||
plot.bottom() + 8,
|
||
80,
|
||
20,
|
||
Qt.AlignmentFlag.AlignRight,
|
||
self._dates[-1][5:],
|
||
)
|
||
painter.end()
|
||
|
||
|
||
class DailyRecordPanel(QFrame):
|
||
"""Date-window matrix, blood trend, and diagnosis todo structure."""
|
||
|
||
range_requested = Signal(str, str)
|
||
refresh_requested = Signal()
|
||
todo_status_requested = Signal(object)
|
||
daily_add_requested = Signal(str)
|
||
daily_edit_requested = Signal(str, object)
|
||
todo_add_requested = Signal()
|
||
todo_cancel_requested = Signal(int)
|
||
|
||
METRICS: tuple[tuple[str, str], ...] = (
|
||
("fasting", "空腹血糖"),
|
||
("postprandial", "餐后2h血糖"),
|
||
("other", "其他血糖"),
|
||
("bp", "血压"),
|
||
("western", "西药"),
|
||
("insulin", "胰岛素"),
|
||
("breakfast", "早餐"),
|
||
("lunch", "午餐"),
|
||
("dinner", "晚餐"),
|
||
("exercise", "运动"),
|
||
("tracking", "跟踪备注"),
|
||
)
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisDailyPanel")
|
||
self._start = QDate.currentDate().addDays(-6)
|
||
self._end = QDate.currentDate()
|
||
self._loading = False
|
||
self._editable = False
|
||
self._record_editable = False
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(16, 16, 16, 16)
|
||
root.setSpacing(14)
|
||
|
||
self.toolbar_host = QWidget()
|
||
self.toolbar_host.setObjectName("DiagnosisDailyToolbar")
|
||
toolbar = FlowLayout(self.toolbar_host, horizontal_spacing=8, vertical_spacing=8)
|
||
self.range_group = QButtonGroup(self)
|
||
self.range_group.setExclusive(True)
|
||
self.range_buttons: dict[str, QPushButton] = {}
|
||
for mode, label in (("7", "最近7天"), ("30", "最近30天"), ("custom", "自定义")):
|
||
button = QPushButton(label)
|
||
button.setCheckable(True)
|
||
button.setProperty("diagnosisRange", True)
|
||
self.range_group.addButton(button)
|
||
self.range_buttons[mode] = button
|
||
toolbar.addWidget(button)
|
||
button.clicked.connect(
|
||
lambda _checked=False, selected=mode: self._select_range(selected)
|
||
)
|
||
self.range_buttons["7"].setChecked(True)
|
||
self.start_date = QDateEdit(self._start)
|
||
self.end_date = QDateEdit(self._end)
|
||
for editor in (self.start_date, self.end_date):
|
||
editor.setCalendarPopup(True)
|
||
editor.setDisplayFormat("yyyy-MM-dd")
|
||
editor.setMaximumDate(QDate.currentDate())
|
||
editor.setProperty("diagnosisField", True)
|
||
editor.setFixedHeight(32)
|
||
editor.hide()
|
||
self.start_date.dateChanged.connect(self._custom_range_changed)
|
||
self.end_date.dateChanged.connect(self._custom_range_changed)
|
||
toolbar.addWidget(self.start_date)
|
||
self.range_separator = QLabel("至")
|
||
self.range_separator.hide()
|
||
toolbar.addWidget(self.range_separator)
|
||
toolbar.addWidget(self.end_date)
|
||
self.add_buttons: dict[str, QPushButton] = {}
|
||
for kind, label in (
|
||
("blood", "+血糖"),
|
||
("diet", "+饮食"),
|
||
("exercise", "+运动"),
|
||
("tracking", "+备注"),
|
||
):
|
||
button = QPushButton(label)
|
||
button.setProperty("variant", "primary" if kind == "blood" else "secondary")
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
button.clicked.connect(
|
||
lambda _checked=False, selected=kind: self.daily_add_requested.emit(selected)
|
||
)
|
||
button.hide()
|
||
self.add_buttons[kind] = button
|
||
toolbar.addWidget(button)
|
||
self.refresh_button = QPushButton("刷新")
|
||
self.refresh_button.setProperty("variant", "ghost")
|
||
self.refresh_button.clicked.connect(self.refresh_requested)
|
||
toolbar.addWidget(self.refresh_button)
|
||
root.addWidget(self.toolbar_host)
|
||
|
||
self.state = MessageStrip()
|
||
root.addWidget(self.state)
|
||
self.matrix = QTableWidget()
|
||
self.matrix.setObjectName("DiagnosisDailyMatrix")
|
||
self.matrix.setProperty("diagnosisTable", True)
|
||
self.matrix.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self.matrix.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||
self.matrix.setAlternatingRowColors(True)
|
||
self.matrix.setWordWrap(True)
|
||
self.matrix.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||
self.matrix.verticalHeader().setVisible(False)
|
||
self.matrix.verticalHeader().setMinimumSectionSize(40)
|
||
self.matrix.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self.matrix.horizontalScrollBar().setSingleStep(28)
|
||
self.matrix.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self.matrix.verticalScrollBar().setSingleStep(28)
|
||
self.matrix.setMinimumHeight(360)
|
||
self.matrix.cellClicked.connect(self._daily_cell_clicked)
|
||
root.addWidget(self.matrix)
|
||
|
||
trend_card = QFrame()
|
||
trend_card.setObjectName("DiagnosisTrendCard")
|
||
trend_layout = QVBoxLayout(trend_card)
|
||
trend_layout.setContentsMargins(16, 14, 16, 14)
|
||
trend_title = QLabel("血糖趋势")
|
||
trend_title.setObjectName("DiagnosisDailySectionTitle")
|
||
trend_layout.addWidget(trend_title)
|
||
self.trend = BloodTrendChart()
|
||
trend_layout.addWidget(self.trend)
|
||
root.addWidget(trend_card)
|
||
|
||
todo_card = QFrame()
|
||
todo_card.setObjectName("DiagnosisTodoCard")
|
||
todo_layout = QVBoxLayout(todo_card)
|
||
todo_layout.setContentsMargins(16, 14, 16, 14)
|
||
todo_title = QLabel("待办事项")
|
||
todo_title.setObjectName("DiagnosisDailySectionTitle")
|
||
todo_layout.addWidget(todo_title)
|
||
self.todo_toolbar = QWidget()
|
||
self.todo_toolbar.setObjectName("DiagnosisTodoToolbar")
|
||
todo_toolbar_layout = FlowLayout(
|
||
self.todo_toolbar,
|
||
horizontal_spacing=8,
|
||
vertical_spacing=8,
|
||
)
|
||
self.todo_add_button = QPushButton("+ 新增待办")
|
||
self.todo_add_button.setProperty("variant", "primary")
|
||
self.todo_add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.todo_add_button.clicked.connect(self.todo_add_requested)
|
||
self.todo_add_button.hide()
|
||
todo_toolbar_layout.addWidget(self.todo_add_button)
|
||
self.todo_group = QButtonGroup(self)
|
||
self.todo_group.setExclusive(True)
|
||
for status, label in (
|
||
(None, "全部"),
|
||
(0, "待执行"),
|
||
(1, "已发送"),
|
||
(3, "失败"),
|
||
(2, "已取消"),
|
||
):
|
||
button = QPushButton(label)
|
||
button.setCheckable(True)
|
||
button.setProperty("diagnosisRange", True)
|
||
button.setProperty("diagnosisTodoStatus", status)
|
||
if status is None:
|
||
button.setChecked(True)
|
||
self.todo_group.addButton(button)
|
||
todo_toolbar_layout.addWidget(button)
|
||
button.clicked.connect(
|
||
lambda _checked=False, selected=status: self.todo_status_requested.emit(selected)
|
||
)
|
||
self.todo_refresh_button = QPushButton("刷新")
|
||
self.todo_refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.todo_refresh_button.clicked.connect(self.refresh_requested)
|
||
todo_toolbar_layout.addWidget(self.todo_refresh_button)
|
||
todo_layout.addWidget(self.todo_toolbar)
|
||
self.todo_table = QTableWidget(0, 7)
|
||
self.todo_table.setObjectName("DiagnosisTodoTable")
|
||
self.todo_table.setProperty("diagnosisTable", True)
|
||
self.todo_table.setHorizontalHeaderLabels(
|
||
["提醒时间", "内容", "状态", "创建人", "推送时间", "失败原因", "操作"]
|
||
)
|
||
self.todo_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self.todo_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||
self.todo_table.setAlternatingRowColors(True)
|
||
self.todo_table.verticalHeader().setVisible(False)
|
||
self.todo_table.verticalHeader().setMinimumSectionSize(40)
|
||
self.todo_table.horizontalHeader().setStretchLastSection(True)
|
||
self.todo_table.setMinimumHeight(180)
|
||
todo_layout.addWidget(self.todo_table)
|
||
self.todo_summary = QLabel("共 0 条")
|
||
self.todo_summary.setStyleSheet("color:#606163; font-size:13px;")
|
||
todo_layout.addWidget(self.todo_summary, 0, Qt.AlignmentFlag.AlignRight)
|
||
root.addWidget(todo_card)
|
||
self.clear()
|
||
|
||
def set_editable(self, editable: bool, *, record_editable: bool | None = None) -> None:
|
||
self._editable = bool(editable)
|
||
self._record_editable = self._editable if record_editable is None else bool(record_editable)
|
||
for button in self.add_buttons.values():
|
||
button.setVisible(self._editable)
|
||
self.todo_add_button.setVisible(self._editable)
|
||
self.toolbar_host.layout().invalidate()
|
||
self.toolbar_host.updateGeometry()
|
||
|
||
def current_range(self) -> tuple[str, str]:
|
||
return self._start.toString("yyyy-MM-dd"), self._end.toString("yyyy-MM-dd")
|
||
|
||
def set_range(self, start: str, end: str) -> None:
|
||
start_date = QDate.fromString(start, "yyyy-MM-dd")
|
||
end_date = QDate.fromString(end, "yyyy-MM-dd")
|
||
if not start_date.isValid() or not end_date.isValid() or start_date > end_date:
|
||
return
|
||
self._start, self._end = start_date, end_date
|
||
self.start_date.blockSignals(True)
|
||
self.end_date.blockSignals(True)
|
||
self.start_date.setDate(start_date)
|
||
self.end_date.setDate(end_date)
|
||
self.start_date.blockSignals(False)
|
||
self.end_date.blockSignals(False)
|
||
|
||
def _select_range(self, mode: str) -> None:
|
||
custom = mode == "custom"
|
||
self.start_date.setVisible(custom)
|
||
self.end_date.setVisible(custom)
|
||
self.range_separator.setVisible(custom)
|
||
if mode == "7":
|
||
self._end = QDate.currentDate()
|
||
self._start = self._end.addDays(-6)
|
||
elif mode == "30":
|
||
self._end = QDate.currentDate()
|
||
self._start = self._end.addDays(-29)
|
||
else:
|
||
self._start = self.start_date.date()
|
||
self._end = self.end_date.date()
|
||
self.range_requested.emit(*self.current_range())
|
||
|
||
def _custom_range_changed(self, _date: QDate | None = None) -> None:
|
||
if not self.range_buttons["custom"].isChecked():
|
||
return
|
||
start = self.start_date.date()
|
||
end = self.end_date.date()
|
||
if start > end:
|
||
return
|
||
self._start, self._end = start, end
|
||
self.range_requested.emit(*self.current_range())
|
||
|
||
def set_loading(self, loading: bool) -> None:
|
||
self._loading = bool(loading)
|
||
self.refresh_button.setEnabled(not self._loading)
|
||
self.todo_refresh_button.setEnabled(not self._loading)
|
||
if loading:
|
||
self.state.show_message("正在加载日常记录、跟踪备注与待办…", "info")
|
||
|
||
def set_error(self, text: str) -> None:
|
||
self.set_loading(False)
|
||
self.state.show_message(text, "danger")
|
||
|
||
@staticmethod
|
||
def _date_map(rows: Sequence[Any], *keys: str) -> dict[str, Any]:
|
||
result: dict[str, Any] = {}
|
||
for row in rows:
|
||
value = str(_pick(row, *keys, default="") or "")[:10]
|
||
if value and value not in result:
|
||
result[value] = row
|
||
return result
|
||
|
||
@staticmethod
|
||
def _blood_date_map(rows: Sequence[Any]) -> dict[str, dict[str, Any]]:
|
||
"""Merge same-day blood records like the Vue DailyMatrix computed map."""
|
||
|
||
fields: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("fasting_blood_sugar", ("fasting_glucose",)),
|
||
("postprandial_blood_sugar", ("postprandial_glucose",)),
|
||
("other_blood_sugar", ("other_glucose",)),
|
||
("systolic_pressure", ("systolic",)),
|
||
("diastolic_pressure", ("diastolic",)),
|
||
("western_medicine", ()),
|
||
("insulin", ()),
|
||
("remark", ()),
|
||
)
|
||
result: dict[str, dict[str, Any]] = {}
|
||
for row in rows:
|
||
date = str(_pick(row, "record_date", "date", default="") or "")[:10]
|
||
if not date:
|
||
continue
|
||
merged = result.setdefault(
|
||
date,
|
||
{
|
||
"id": _pick(row, "id", default=0),
|
||
"diagnosis_id": _pick(row, "diagnosis_id", default=0),
|
||
"patient_id": _pick(row, "patient_id", default=0),
|
||
"record_date": date,
|
||
"record_time": _pick(row, "record_time", default=""),
|
||
"source": _pick(row, "source", default=0),
|
||
"has_patient_self": False,
|
||
},
|
||
)
|
||
if _truth_source(row):
|
||
merged["has_patient_self"] = True
|
||
for field, aliases in fields:
|
||
if merged.get(field) not in (None, ""):
|
||
continue
|
||
value = _pick(row, field, *aliases, default=None)
|
||
if value not in (None, ""):
|
||
merged[field] = value
|
||
return result
|
||
|
||
@staticmethod
|
||
def _number(value: Any) -> float | None:
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return number if number != 0 else None
|
||
|
||
def _dates(self) -> list[str]:
|
||
dates: list[str] = []
|
||
cursor = QDate(self._end)
|
||
while cursor >= self._start and len(dates) < 366:
|
||
dates.append(cursor.toString("yyyy-MM-dd"))
|
||
cursor = cursor.addDays(-1)
|
||
return dates
|
||
|
||
def set_data(self, window: Any, notes: Sequence[Any]) -> None:
|
||
blood_rows = list(_pick(window, "blood_records", default=[]) or [])
|
||
diet_rows = list(_pick(window, "diet_records", default=[]) or [])
|
||
exercise_rows = list(_pick(window, "exercise_records", default=[]) or [])
|
||
blood = self._blood_date_map(blood_rows)
|
||
diet = self._date_map(diet_rows, "record_date", "date")
|
||
exercise = self._date_map(exercise_rows, "record_date", "date")
|
||
tracking = self._date_map(notes, "note_date", "record_date", "date")
|
||
dates = self._dates()
|
||
self.matrix.clear()
|
||
self.matrix.setColumnCount(1 + len(dates))
|
||
self.matrix.setRowCount(len(self.METRICS))
|
||
self.matrix.setHorizontalHeaderLabels(["指标", *(date[5:] for date in dates)])
|
||
self.matrix.setColumnWidth(0, 132)
|
||
for column in range(1, len(dates) + 1):
|
||
self.matrix.setColumnWidth(column, 120)
|
||
fasting_series: list[float | None] = []
|
||
post_series: list[float | None] = []
|
||
has_records = False
|
||
label_font = QFont(self.matrix.font())
|
||
label_font.setPixelSize(13)
|
||
label_font.setWeight(QFont.Weight.Normal)
|
||
for row_index, (metric, label) in enumerate(self.METRICS):
|
||
label_item = QTableWidgetItem(label)
|
||
label_item.setFont(label_font)
|
||
label_item.setForeground(QColor("#606163"))
|
||
label_item.setBackground(QColor("#F7F7F7"))
|
||
self.matrix.setItem(row_index, 0, label_item)
|
||
for date_index, date in enumerate(dates, 1):
|
||
b = blood.get(date)
|
||
d = diet.get(date)
|
||
e = exercise.get(date)
|
||
n = tracking.get(date)
|
||
value = "—"
|
||
high = False
|
||
if metric == "fasting":
|
||
raw = _pick(b, "fasting_blood_sugar")
|
||
number = self._number(raw)
|
||
value = _text(raw) if number is not None else "—"
|
||
high = bool(number is not None and number >= 7)
|
||
elif metric == "postprandial":
|
||
raw = _pick(b, "postprandial_blood_sugar")
|
||
number = self._number(raw)
|
||
value = _text(raw) if number is not None else "—"
|
||
high = bool(number is not None and number >= 11.1)
|
||
elif metric == "other":
|
||
value = _text(_pick(b, "other_blood_sugar"))
|
||
elif metric == "bp":
|
||
high_value = self._number(_pick(b, "systolic_pressure"))
|
||
low_value = self._number(_pick(b, "diastolic_pressure"))
|
||
if high_value is not None or low_value is not None:
|
||
value = f"{_text(_pick(b, 'systolic_pressure'))}/{_text(_pick(b, 'diastolic_pressure'))}"
|
||
high = bool((high_value or 0) >= 140 or (low_value or 0) >= 90)
|
||
elif metric == "western":
|
||
value = _text(_pick(b, "western_medicine"))
|
||
elif metric == "insulin":
|
||
value = _text(_pick(b, "insulin"))
|
||
elif metric in {"breakfast", "lunch", "dinner"}:
|
||
foods = _pick(d, metric, f"{metric}_foods", default="")
|
||
value = "已记录" if foods not in (None, "", [], ()) else "—"
|
||
elif metric == "exercise":
|
||
duration = _pick(e, "duration", default="")
|
||
value = f"{duration}min" if duration not in (None, "", 0, "0") else "—"
|
||
elif metric == "tracking":
|
||
content = str(_pick(n, "content", "tracking_content", default="") or "")
|
||
value = next(
|
||
(line.strip() for line in content.splitlines() if line.strip()), "—"
|
||
)
|
||
if metric in {"fasting", "postprandial", "other", "bp"} and bool(
|
||
_pick(b, "has_patient_self", default=False)
|
||
):
|
||
value = f"{value} · 自录" if value != "—" else value
|
||
if value != "—":
|
||
has_records = True
|
||
if high and value != "—":
|
||
value = f"↑ {value}"
|
||
item = QTableWidgetItem(value)
|
||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
if high:
|
||
item.setForeground(QColor("#BE4B58"))
|
||
item.setBackground(QColor("#FFF1F3"))
|
||
elif value.endswith("· 自录"):
|
||
item.setForeground(QColor("#1A1C1F"))
|
||
item.setBackground(QColor("#F7F7F7"))
|
||
edit_kind = ""
|
||
edit_source: Any = None
|
||
if metric in {"fasting", "postprandial", "other", "bp", "western", "insulin"}:
|
||
edit_kind, edit_source = "blood", b
|
||
elif metric in {"breakfast", "lunch", "dinner"}:
|
||
edit_kind, edit_source = "diet", d
|
||
elif metric == "exercise":
|
||
edit_kind, edit_source = "exercise", e
|
||
edit_id = int(_pick(edit_source, "id", default=0) or 0)
|
||
if self._record_editable and edit_kind and edit_id > 0:
|
||
item.setData(
|
||
Qt.ItemDataRole.UserRole,
|
||
(
|
||
edit_kind,
|
||
dict(edit_source) if isinstance(edit_source, Mapping) else edit_source,
|
||
),
|
||
)
|
||
item.setToolTip(
|
||
f"点击编辑当日{dict(blood='血糖 / 血压', diet='饮食', exercise='运动')[edit_kind]}记录"
|
||
)
|
||
else:
|
||
item.setToolTip(value if len(value) > 16 else "")
|
||
self.matrix.setItem(row_index, date_index, item)
|
||
chart_dates = list(reversed(dates))
|
||
for date in chart_dates:
|
||
b = blood.get(date)
|
||
fasting_series.append(self._number(_pick(b, "fasting_blood_sugar")))
|
||
post_series.append(self._number(_pick(b, "postprandial_blood_sugar")))
|
||
self.trend.set_series(chart_dates, fasting_series, post_series)
|
||
self.set_loading(False)
|
||
if has_records:
|
||
self.state.clear()
|
||
else:
|
||
self.state.show_message("当前时间范围暂无日常记录。", "info")
|
||
self.matrix.resizeRowsToContents()
|
||
|
||
def _daily_cell_clicked(self, row: int, column: int) -> None:
|
||
if not self._record_editable or column <= 0:
|
||
return
|
||
item = self.matrix.item(row, column)
|
||
payload = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||
if (
|
||
not isinstance(payload, tuple)
|
||
or len(payload) != 2
|
||
or payload[0] not in {"blood", "diet", "exercise"}
|
||
or int(_pick(payload[1], "id", default=0) or 0) <= 0
|
||
):
|
||
return
|
||
self.daily_edit_requested.emit(payload[0], payload[1])
|
||
|
||
def set_todos(self, rows: Sequence[Any], total: int | None = None) -> None:
|
||
self.todo_table.clearContents()
|
||
self.todo_table.setRowCount(len(rows))
|
||
for row_index, row in enumerate(rows):
|
||
values = (
|
||
_pick(row, "remind_time_text", "remind_time"),
|
||
_pick(row, "content"),
|
||
_pick(row, "creator_name"),
|
||
_pick(row, "notified_at_text", "notified_at"),
|
||
_pick(row, "error"),
|
||
)
|
||
for column, value in enumerate(values):
|
||
target_column = column if column < 2 else column + 1
|
||
item = QTableWidgetItem(_text(value))
|
||
self.todo_table.setItem(row_index, target_column, item)
|
||
status_text = _text(_pick(row, "status_text", "status"))
|
||
status_kind = (
|
||
"danger"
|
||
if "失败" in status_text
|
||
else "success"
|
||
if "发送" in status_text or "完成" in status_text
|
||
else "warning"
|
||
if "取消" in status_text
|
||
else "info"
|
||
)
|
||
set_status_tag(self.todo_table, row_index, 2, status_text, status_kind)
|
||
status = _pick(row, "status", default=-1)
|
||
can_cancel = bool(_pick(row, "can_cancel", default=False))
|
||
try:
|
||
pending = int(status) == 0
|
||
except (TypeError, ValueError):
|
||
pending = status_text == "待执行"
|
||
if self._editable and pending and can_cancel:
|
||
todo_id = int(_pick(row, "id", default=0) or 0)
|
||
button = QPushButton("取消")
|
||
button.setProperty("variant", "dangerGhost")
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
button.setEnabled(todo_id > 0)
|
||
button.clicked.connect(
|
||
lambda _checked=False, selected=todo_id: self.todo_cancel_requested.emit(
|
||
selected
|
||
)
|
||
)
|
||
self.todo_table.setCellWidget(row_index, 6, button)
|
||
else:
|
||
self.todo_table.setItem(row_index, 6, QTableWidgetItem("—"))
|
||
self.todo_summary.setText(f"共 {total if total is not None else len(rows)} 条")
|
||
self.todo_table.resizeRowsToContents()
|
||
|
||
def clear(self) -> None:
|
||
self.state.show_message("切换到日常记录后加载最近 7 天数据。", "info")
|
||
self.matrix.clear()
|
||
self.matrix.setRowCount(0)
|
||
self.matrix.setColumnCount(0)
|
||
self.trend.set_series([], [], [])
|
||
self.set_todos([], 0)
|
||
|
||
|
||
def _truth_source(record: Any) -> bool:
|
||
try:
|
||
return int(_pick(record, "source", default=0) or 0) == 1
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
class _RemoteImageButton(QPushButton):
|
||
"""Asynchronously render one safe HTTP(S) image without stale-owner updates."""
|
||
|
||
_MAX_IMAGE_BYTES = 12 * 1024 * 1024
|
||
|
||
def __init__(
|
||
self,
|
||
source: str,
|
||
*,
|
||
render_owner: QWidget,
|
||
owner_generation: int,
|
||
maximum_size: QSize,
|
||
fallback_text: str,
|
||
cover: bool,
|
||
object_name: str,
|
||
parent: QWidget | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName(object_name)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setAccessibleName(fallback_text.replace("\n", ""))
|
||
self._source = str(source).strip()
|
||
self._render_owner_ref = weakref.ref(render_owner)
|
||
self._owner_generation = owner_generation
|
||
self._maximum_size = QSize(maximum_size)
|
||
self._fallback_size = QSize(maximum_size) if cover else QSize(132, 64)
|
||
self._fallback_text = fallback_text
|
||
self._cover = cover
|
||
self._manager = shared_image_manager(self)
|
||
self._reply: QNetworkReply | None = None
|
||
self._generation = 0
|
||
self._rendered_pixmap = QPixmap()
|
||
self.setToolTip(self._source)
|
||
self._show_loading()
|
||
self.load_url(self._source)
|
||
|
||
def _owner_is_current(self) -> bool:
|
||
owner = self._render_owner_ref()
|
||
return bool(
|
||
owner is not None
|
||
and QThread.currentThread() == self.thread()
|
||
and getattr(owner, "_image_generation", None) == self._owner_generation
|
||
)
|
||
|
||
def _set_load_state(self, state: str) -> None:
|
||
self.setProperty("loadState", state)
|
||
repolish(self)
|
||
|
||
def _show_loading(self) -> None:
|
||
self.setIcon(QIcon())
|
||
self.setText(self._fallback_text)
|
||
self.setFixedSize(self._fallback_size)
|
||
self._set_load_state("loading")
|
||
|
||
def _show_fallback(self) -> None:
|
||
self._rendered_pixmap = QPixmap()
|
||
self.setIcon(QIcon())
|
||
self.setText(self._fallback_text)
|
||
self.setFixedSize(self._fallback_size)
|
||
self._set_load_state("failed")
|
||
|
||
def _invalidate_request(self) -> int:
|
||
self._generation += 1
|
||
reply, self._reply = self._reply, None
|
||
if reply is not None:
|
||
reply.abort()
|
||
reply.deleteLater()
|
||
return self._generation
|
||
|
||
def abort_pending_request(self) -> None:
|
||
"""Let an owner stop this download before it tears the thumbnail down."""
|
||
|
||
self._invalidate_request()
|
||
|
||
def event(self, event: QEvent) -> bool: # noqa: N802 - Qt virtual
|
||
# Nothing is left to paint once the thumbnail is being deleted, so stop
|
||
# the download instead of letting it run against a dying widget.
|
||
if event.type() in {QEvent.Type.DeferredDelete, QEvent.Type.Close}:
|
||
self._invalidate_request()
|
||
return super().event(event)
|
||
|
||
def load_url(self, source: str) -> None:
|
||
self._source = str(source).strip()
|
||
generation = self._invalidate_request()
|
||
self.setToolTip(self._source)
|
||
self._show_loading()
|
||
url = QUrl(self._source)
|
||
if (
|
||
not self._owner_is_current()
|
||
or not url.isValid()
|
||
or url.scheme().lower() not in {"http", "https"}
|
||
or not url.host()
|
||
):
|
||
if self._owner_is_current() and generation == self._generation:
|
||
self._show_fallback()
|
||
return
|
||
request = QNetworkRequest(url)
|
||
request.setTransferTimeout(10_000)
|
||
request.setMaximumRedirectsAllowed(4)
|
||
request.setAttribute(
|
||
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
||
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy,
|
||
)
|
||
reply = self._manager.get(request)
|
||
self._reply = reply
|
||
reply.setProperty("diagnosisImageGeneration", generation)
|
||
reply.setProperty("diagnosisImageOversize", False)
|
||
reply.downloadProgress.connect(self._download_progress)
|
||
reply.finished.connect(self._reply_finished)
|
||
|
||
def _download_progress(self, bytes_received: int, bytes_total: int) -> None:
|
||
"""Abort a current reply as soon as its received or declared size is unsafe."""
|
||
|
||
reply = self.sender()
|
||
if reply is not self._reply:
|
||
return
|
||
if bytes_received <= self._MAX_IMAGE_BYTES and (
|
||
bytes_total < 0 or bytes_total <= self._MAX_IMAGE_BYTES
|
||
):
|
||
return
|
||
reply.setProperty("diagnosisImageOversize", True)
|
||
reply.abort()
|
||
|
||
def _reply_finished(self) -> None:
|
||
"""Use a QObject receiver connection so destruction auto-disconnects the callback."""
|
||
|
||
reply = self.sender()
|
||
if reply is None:
|
||
return
|
||
try:
|
||
generation = int(reply.property("diagnosisImageGeneration"))
|
||
except (TypeError, ValueError):
|
||
reply.deleteLater()
|
||
return
|
||
self._loaded(reply, generation)
|
||
|
||
def _loaded(self, reply: QNetworkReply, generation: int) -> None:
|
||
if reply is not self._reply or generation != self._generation:
|
||
reply.deleteLater()
|
||
return
|
||
self._reply = None
|
||
if not self._owner_is_current():
|
||
reply.deleteLater()
|
||
return
|
||
error = reply.error()
|
||
if bool(reply.property("diagnosisImageOversize")) or (
|
||
error != QNetworkReply.NetworkError.NoError
|
||
):
|
||
reply.deleteLater()
|
||
self._show_fallback()
|
||
return
|
||
payload = bytes(reply.readAll())
|
||
reply.deleteLater()
|
||
self._apply_payload(payload, generation)
|
||
|
||
def _apply_payload(self, payload: bytes, generation: int) -> bool:
|
||
"""Decode a current reply; kept separate so offline tests can exercise rendering."""
|
||
|
||
if generation != self._generation or not self._owner_is_current():
|
||
return False
|
||
pixmap = QPixmap()
|
||
if (
|
||
not payload
|
||
or len(payload) > self._MAX_IMAGE_BYTES
|
||
or not pixmap.loadFromData(payload)
|
||
or pixmap.isNull()
|
||
):
|
||
self._show_fallback()
|
||
return False
|
||
if self._cover:
|
||
scaled = pixmap.scaled(
|
||
self._maximum_size,
|
||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
left = max(0, (scaled.width() - self._maximum_size.width()) // 2)
|
||
top = max(0, (scaled.height() - self._maximum_size.height()) // 2)
|
||
rendered = scaled.copy(
|
||
left,
|
||
top,
|
||
self._maximum_size.width(),
|
||
self._maximum_size.height(),
|
||
)
|
||
elif (
|
||
pixmap.width() > self._maximum_size.width()
|
||
or pixmap.height() > self._maximum_size.height()
|
||
):
|
||
rendered = pixmap.scaled(
|
||
self._maximum_size,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
else:
|
||
rendered = pixmap
|
||
self._rendered_pixmap = rendered
|
||
self.setText("")
|
||
self.setFixedSize(rendered.size())
|
||
self.setIconSize(rendered.size())
|
||
self.setIcon(QIcon(rendered))
|
||
self._set_load_state("ready")
|
||
return True
|
||
|
||
|
||
class NotesTimeline(QWidget):
|
||
"""Compact visual equivalent of the admin note timeline."""
|
||
|
||
add_requested = Signal()
|
||
upload_requested = Signal(str)
|
||
delete_attachment_requested = Signal(int, str, str)
|
||
open_attachment_requested = Signal(str)
|
||
preview_images_requested = Signal(object, int)
|
||
|
||
def __init__(self, *, editable: bool = False, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisNotesTimeline")
|
||
self._editable = editable
|
||
self._can_delete = editable
|
||
self._image_generation = 0
|
||
self.root = QVBoxLayout(self)
|
||
self.root.setContentsMargins(0, 0, 0, 0)
|
||
self.root.setSpacing(12)
|
||
self.toolbar = QWidget()
|
||
toolbar_layout = QHBoxLayout(self.toolbar)
|
||
toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
||
toolbar_layout.setSpacing(8)
|
||
toolbar_layout.addStretch(1)
|
||
self.add_button = QPushButton("新增 / 追加笔记")
|
||
self.tongue_button = QPushButton("上传舌象")
|
||
self.report_button = QPushButton("上传报告")
|
||
for button in (self.add_button, self.tongue_button, self.report_button):
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
button.setProperty("variant", "primary" if button is self.add_button else "secondary")
|
||
toolbar_layout.addWidget(button)
|
||
self.add_button.clicked.connect(self.add_requested)
|
||
self.tongue_button.clicked.connect(lambda: self.upload_requested.emit("tongue_images"))
|
||
self.report_button.clicked.connect(lambda: self.upload_requested.emit("report_files"))
|
||
self.root.addWidget(self.toolbar)
|
||
self.nodes = QWidget()
|
||
self.nodes_layout = QVBoxLayout(self.nodes)
|
||
self.nodes_layout.setContentsMargins(22, 0, 0, 0)
|
||
self.nodes_layout.setSpacing(0)
|
||
self.root.addWidget(self.nodes)
|
||
self.empty = QLabel("暂无医生备注")
|
||
self.empty.setObjectName("DiagnosisEmptyState")
|
||
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.empty.setMinimumHeight(80)
|
||
self.root.addWidget(self.empty)
|
||
self.set_editable(editable)
|
||
|
||
def set_editable(self, editable: bool, *, can_delete: bool | None = None) -> None:
|
||
self._editable = editable
|
||
self._can_delete = editable if can_delete is None else bool(can_delete)
|
||
self.toolbar.setVisible(self._editable)
|
||
|
||
def set_state(self, text: str) -> None:
|
||
self.empty.setText(text)
|
||
self.empty.setToolTip(text if len(text) > 36 else "")
|
||
self.empty.show()
|
||
|
||
def set_notes(self, notes: Sequence[Any]) -> None:
|
||
self._image_generation += 1
|
||
image_generation = self._image_generation
|
||
while self.nodes_layout.count():
|
||
item = self.nodes_layout.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
for note in notes:
|
||
node = QFrame()
|
||
node.setObjectName("DiagnosisTimelineNode")
|
||
node_layout = QGridLayout(node)
|
||
node_layout.setContentsMargins(12, 4, 8, 16)
|
||
node_layout.setHorizontalSpacing(10)
|
||
node_layout.setVerticalSpacing(7)
|
||
dot = QLabel()
|
||
dot.setObjectName("DiagnosisTimelineDot")
|
||
node_layout.addWidget(dot, 0, 0, Qt.AlignmentFlag.AlignTop)
|
||
date = QLabel(
|
||
_text(_pick(note, "note_date", "create_time", "created_at", "time"), "未记录日期")
|
||
)
|
||
date.setObjectName("DiagnosisTimelineDate")
|
||
node_layout.addWidget(date, 0, 1)
|
||
content = QLabel(_text(_pick(note, "content", "note", "remark"), "无文字备注"))
|
||
content.setObjectName("DiagnosisTimelineContent")
|
||
content.setWordWrap(True)
|
||
node_layout.addWidget(content, 1, 1)
|
||
attachments = QWidget()
|
||
attachments_layout = FlowLayout(attachments, horizontal_spacing=8, vertical_spacing=8)
|
||
images = _pick(note, "tongue_images", "images", default=[]) or []
|
||
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
|
||
images = [images]
|
||
image_paths = [
|
||
str(_pick(image, "url", "path", "file_url", default=image) or "")
|
||
for image in images
|
||
]
|
||
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
|
||
for image_index, path in enumerate(image_paths):
|
||
image_host = QWidget()
|
||
image_layout = QGridLayout(image_host)
|
||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||
thumb = _RemoteImageButton(
|
||
path,
|
||
render_owner=self,
|
||
owner_generation=image_generation,
|
||
maximum_size=QSize(64, 64),
|
||
fallback_text="舌象\n查看",
|
||
cover=True,
|
||
object_name="DiagnosisTongueThumb",
|
||
parent=image_host,
|
||
)
|
||
thumb.setEnabled(bool(path))
|
||
thumb.clicked.connect(
|
||
lambda _checked=False, sources=image_paths, index=image_index: (
|
||
self.preview_images_requested.emit(sources, index)
|
||
)
|
||
)
|
||
image_layout.addWidget(thumb, 0, 0)
|
||
if self._can_delete and note_id > 0 and path:
|
||
remove = QPushButton("×")
|
||
remove.setObjectName("DiagnosisAttachmentRemove")
|
||
remove.setFixedSize(20, 20)
|
||
remove.setToolTip("删除这张舌象图片")
|
||
remove.clicked.connect(
|
||
lambda _checked=False, selected=note_id, target=path: (
|
||
self.delete_attachment_requested.emit(selected, "tongue_images", target)
|
||
)
|
||
)
|
||
image_layout.addWidget(
|
||
remove,
|
||
0,
|
||
0,
|
||
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||
)
|
||
attachments_layout.addWidget(image_host)
|
||
files = _pick(note, "report_files", "files", default=[]) or []
|
||
if not isinstance(files, Sequence) or isinstance(files, (str, bytes, bytearray)):
|
||
files = [files]
|
||
for attachment in files:
|
||
name = _pick(attachment, "name", "file_name", default=attachment)
|
||
path = str(_pick(attachment, "url", "path", "file_url", default=attachment) or "")
|
||
file_host = QWidget()
|
||
file_layout = QHBoxLayout(file_host)
|
||
file_layout.setContentsMargins(0, 0, 0, 0)
|
||
file_layout.setSpacing(4)
|
||
chip = QPushButton(f"报告 · {_text(name, '附件')}")
|
||
chip.setObjectName("DiagnosisAttachmentChip")
|
||
chip.setMaximumWidth(180)
|
||
chip.setToolTip(_text(name, "附件"))
|
||
chip.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
chip.setEnabled(bool(path))
|
||
chip.clicked.connect(
|
||
lambda _checked=False, target=path: self.open_attachment_requested.emit(target)
|
||
)
|
||
file_layout.addWidget(chip)
|
||
if self._can_delete and note_id > 0 and path:
|
||
remove = QPushButton("×")
|
||
remove.setObjectName("DiagnosisAttachmentRemove")
|
||
remove.setFixedSize(20, 20)
|
||
remove.setToolTip("删除这个报告附件")
|
||
remove.clicked.connect(
|
||
lambda _checked=False, selected=note_id, target=path: (
|
||
self.delete_attachment_requested.emit(selected, "report_files", target)
|
||
)
|
||
)
|
||
file_layout.addWidget(remove)
|
||
attachments_layout.addWidget(file_host)
|
||
if images or files:
|
||
node_layout.addWidget(attachments, 2, 1)
|
||
else:
|
||
attachments.deleteLater()
|
||
self.nodes_layout.addWidget(node)
|
||
self.empty.setVisible(not notes)
|
||
|
||
|
||
class ChatPanel(QWidget):
|
||
"""Read-only chat bubbles sourced from the existing detail payload."""
|
||
|
||
sync_requested = Signal()
|
||
reload_requested = Signal()
|
||
open_attachment_requested = Signal(str)
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisChatPanel")
|
||
self._image_generation = 0
|
||
self.root = QVBoxLayout(self)
|
||
self.root.setContentsMargins(0, 0, 0, 0)
|
||
self.root.setSpacing(16)
|
||
self.info = QFrame()
|
||
self.info.setObjectName("DiagnosisChatInfo")
|
||
info_layout = QHBoxLayout(self.info)
|
||
info_layout.setContentsMargins(12, 10, 12, 10)
|
||
info_layout.setSpacing(9)
|
||
info_icon = QLabel("i")
|
||
info_icon.setObjectName("DiagnosisChatInfoIcon")
|
||
info_icon.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
|
||
info_icon.setFixedWidth(18)
|
||
info_layout.addWidget(info_icon)
|
||
info_text = QLabel(
|
||
"说明:展示患者与所有医生 / 医助账号分别产生的单聊会话,并按时间排序。"
|
||
"数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;可在下方立即触发后台同步。"
|
||
)
|
||
info_text.setObjectName("DiagnosisChatInfoText")
|
||
info_text.setWordWrap(True)
|
||
info_layout.addWidget(info_text, 1)
|
||
self.root.addWidget(self.info)
|
||
self.toolbar = QWidget()
|
||
toolbar_layout = QHBoxLayout(self.toolbar)
|
||
toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
||
toolbar_layout.setSpacing(8)
|
||
archive = QLabel("仅展示服务端已归档消息(only_archived=1)")
|
||
archive.setStyleSheet("color:#606163;font-size:12px;")
|
||
toolbar_layout.addWidget(archive)
|
||
toolbar_layout.addStretch(1)
|
||
self.sync_button = QPushButton("同步最新(后台异步)")
|
||
self.reload_button = QPushButton("重新加载已归档")
|
||
for button in (self.sync_button, self.reload_button):
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
button.setProperty("variant", "link")
|
||
toolbar_layout.addWidget(button)
|
||
self.sync_button.clicked.connect(self.sync_requested)
|
||
self.reload_button.clicked.connect(self.reload_requested)
|
||
self.root.addWidget(self.toolbar)
|
||
self.empty = QLabel("暂无 IM 聊天记录")
|
||
self.empty.setObjectName("DiagnosisEmptyState")
|
||
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.empty.setMinimumHeight(90)
|
||
self.root.addWidget(self.empty)
|
||
|
||
def set_unavailable(self, text: str) -> None:
|
||
self.set_messages([])
|
||
self.empty.setText(text)
|
||
self.empty.setToolTip(text if len(text) > 36 else "")
|
||
|
||
def set_messages(self, messages: Sequence[Any]) -> None:
|
||
self._image_generation += 1
|
||
image_generation = self._image_generation
|
||
self.empty.setText("暂无 IM 聊天记录")
|
||
while self.root.count() > 3:
|
||
item = self.root.takeAt(2)
|
||
widget = item.widget()
|
||
if widget is not None and widget not in {self.empty, self.toolbar, self.info}:
|
||
widget.deleteLater()
|
||
self.empty.setVisible(not messages)
|
||
for message in messages:
|
||
doctor_flag = _pick(message, "is_from_doctor", default=False)
|
||
is_doctor = (
|
||
doctor_flag.strip().lower() not in {"", "0", "false", "no"}
|
||
if isinstance(doctor_flag, str)
|
||
else bool(doctor_flag)
|
||
)
|
||
side = (
|
||
"doctor"
|
||
if is_doctor
|
||
or str(_pick(message, "role", "sender_type", default="")).lower()
|
||
in {
|
||
"doctor",
|
||
"staff",
|
||
"2",
|
||
}
|
||
else "patient"
|
||
)
|
||
row = QWidget()
|
||
row_layout = QHBoxLayout(row)
|
||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||
bubble = QFrame()
|
||
bubble.setObjectName("DiagnosisChatBubble")
|
||
bubble.setProperty("side", side)
|
||
bubble.setMaximumWidth(620)
|
||
bubble_layout = QVBoxLayout(bubble)
|
||
bubble_layout.setContentsMargins(12, 10, 12, 10)
|
||
bubble_layout.setSpacing(5)
|
||
meta = QLabel(
|
||
f"{_text(_pick(message, 'sender_name', 'from_staff_name', 'name'), '患者' if side == 'patient' else '医生')} "
|
||
f"{_text(_pick(message, 'create_time', 'time'), '')}"
|
||
)
|
||
meta.setObjectName("DiagnosisChatMeta")
|
||
message_type = str(
|
||
_pick(message, "message_type", "msg_type", "type", default="text") or "text"
|
||
).lower()
|
||
body_text = _text(_pick(message, "content", "text", "message"), "不支持的消息")
|
||
body: QWidget
|
||
if message_type in {"image", "2"}:
|
||
target = str(
|
||
_pick(message, "url", "file_url", "image_url", default=body_text) or ""
|
||
)
|
||
image = _RemoteImageButton(
|
||
target,
|
||
render_owner=self,
|
||
owner_generation=image_generation,
|
||
maximum_size=QSize(240, 200),
|
||
fallback_text="查看图片",
|
||
cover=False,
|
||
object_name="DiagnosisChatImage",
|
||
parent=bubble,
|
||
)
|
||
image.setEnabled(bool(target))
|
||
image.clicked.connect(
|
||
lambda _checked=False, selected=target: self.open_attachment_requested.emit(
|
||
selected
|
||
)
|
||
)
|
||
body = image
|
||
elif message_type in {"file", "3"}:
|
||
target = str(
|
||
_pick(message, "url", "file_url", "image_url", default=body_text) or ""
|
||
)
|
||
label = _text(_pick(message, "file_name"), "打开文件")
|
||
action = QPushButton(label)
|
||
action.setProperty("variant", "link")
|
||
action.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
action.setEnabled(bool(target))
|
||
action.setToolTip(target)
|
||
action.clicked.connect(
|
||
lambda _checked=False, selected=target: self.open_attachment_requested.emit(
|
||
selected
|
||
)
|
||
)
|
||
body = action
|
||
else:
|
||
text = QLabel(body_text)
|
||
text.setObjectName("DiagnosisChatText")
|
||
text.setWordWrap(True)
|
||
body = text
|
||
bubble_layout.addWidget(meta)
|
||
bubble_layout.addWidget(body)
|
||
if side == "doctor":
|
||
row_layout.addStretch(1)
|
||
row_layout.addWidget(bubble)
|
||
else:
|
||
row_layout.addWidget(bubble)
|
||
row_layout.addStretch(1)
|
||
self.root.insertWidget(max(2, self.root.count() - 1), row)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CaseField:
|
||
label: str
|
||
key: str
|
||
span: int = 1
|
||
|
||
|
||
class CaseGrid(QFrame):
|
||
"""Grouped 4/3-column readonly case presentation."""
|
||
|
||
GROUPS: tuple[tuple[str, int, tuple[CaseField, ...]], ...] = (
|
||
(
|
||
"基本信息",
|
||
4,
|
||
(
|
||
CaseField("诊单ID", "id"),
|
||
CaseField("姓名", "patient_name"),
|
||
CaseField("身份证", "id_card"),
|
||
CaseField("手机", "phone"),
|
||
CaseField("性别", "gender"),
|
||
CaseField("年龄", "age"),
|
||
CaseField("婚姻", "marital_status"),
|
||
CaseField("地区", "region"),
|
||
),
|
||
),
|
||
(
|
||
"生命体征",
|
||
4,
|
||
(
|
||
CaseField("身高", "height"),
|
||
CaseField("体重", "weight"),
|
||
CaseField("高压", "systolic_pressure"),
|
||
CaseField("低压", "diastolic_pressure"),
|
||
CaseField("诊断类型", "diagnosis_type"),
|
||
CaseField("空腹血糖", "fasting_blood_sugar"),
|
||
CaseField("在用药物", "current_medications", 2),
|
||
),
|
||
),
|
||
(
|
||
"主诉",
|
||
4,
|
||
(
|
||
CaseField("当地诊断日期", "diagnosis_date"),
|
||
CaseField("糖尿病病史", "diabetes_discovery_year"),
|
||
CaseField("当地医院", "local_hospital_name"),
|
||
CaseField("诊断结果", "local_hospital_diagnosis"),
|
||
),
|
||
),
|
||
(
|
||
"现病史",
|
||
3,
|
||
(
|
||
CaseField("口腔", "appetite"),
|
||
CaseField("饮水", "water_intake"),
|
||
CaseField("体重变化", "weight_change"),
|
||
CaseField("脂肪肝", "fatty_liver_degree"),
|
||
CaseField("饮食", "diet_condition", 2),
|
||
CaseField("肢体", "body_feeling"),
|
||
CaseField("睡眠", "sleep_condition"),
|
||
CaseField("眼", "eye_condition"),
|
||
CaseField("头", "head_feeling"),
|
||
CaseField("出汗", "sweat_condition"),
|
||
CaseField("皮肤", "skin_condition"),
|
||
CaseField("小便", "urine_condition"),
|
||
CaseField("大便", "stool_condition"),
|
||
CaseField("腰肾", "kidney_condition"),
|
||
CaseField("其他补充", "symptoms", 3),
|
||
),
|
||
),
|
||
("既往史", 1, (CaseField("既往史", "past_history"),)),
|
||
(
|
||
"其他病史",
|
||
3,
|
||
(
|
||
CaseField("外伤", "trauma_history"),
|
||
CaseField("手术", "surgery_history"),
|
||
CaseField("过敏", "allergy_history"),
|
||
CaseField("家族", "family_history"),
|
||
CaseField("妊娠", "pregnancy_history"),
|
||
),
|
||
),
|
||
(
|
||
"补充与意见",
|
||
1,
|
||
(
|
||
CaseField("病史补充", "remark"),
|
||
CaseField("处方意见", "prescription_opinion"),
|
||
),
|
||
),
|
||
)
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisReadonlyCaseCard")
|
||
self.root = QVBoxLayout(self)
|
||
self.root.setContentsMargins(18, 18, 18, 18)
|
||
self.root.setSpacing(14)
|
||
title_row = QHBoxLayout()
|
||
title_row.setSpacing(10)
|
||
self.title_label = QLabel("患者病例")
|
||
self.title_label.setObjectName("DiagnosisReadonlyCardTitle")
|
||
title_row.addWidget(self.title_label)
|
||
self.subtitle = QLabel("病例 · 诊断日期 —")
|
||
self.subtitle.setStyleSheet(
|
||
'color:#606163;font-size:12px;'
|
||
)
|
||
title_row.addWidget(self.subtitle)
|
||
title_row.addStretch(1)
|
||
self.root.addLayout(title_row)
|
||
self.value_labels: dict[str, list[QLabel]] = {}
|
||
for group_index, (group_name, columns, fields) in enumerate(self.GROUPS):
|
||
if group_index:
|
||
divider = QFrame()
|
||
divider.setFrameShape(QFrame.Shape.HLine)
|
||
divider.setStyleSheet("border:0; border-top:1px dashed #E4E4E5;")
|
||
self.root.addWidget(divider)
|
||
group_title = QLabel(f"● {group_name}")
|
||
group_title.setProperty("diagnosisCaseGroup", True)
|
||
self.root.addWidget(group_title)
|
||
grid = QGridLayout()
|
||
grid.setContentsMargins(0, 0, 0, 0)
|
||
grid.setHorizontalSpacing(16)
|
||
grid.setVerticalSpacing(8)
|
||
row = 0
|
||
column = 0
|
||
for field in fields:
|
||
if column + field.span > columns:
|
||
row += 1
|
||
column = 0
|
||
cell = QWidget()
|
||
cell_layout = QHBoxLayout(cell)
|
||
cell_layout.setContentsMargins(0, 0, 0, 0)
|
||
cell_layout.setSpacing(7)
|
||
label = QLabel(f"{field.label}:")
|
||
label.setProperty("diagnosisCaseLabel", True)
|
||
value = QLabel("—")
|
||
value.setProperty("diagnosisCaseValue", True)
|
||
value.setWordWrap(True)
|
||
cell_layout.addWidget(label, 0, Qt.AlignmentFlag.AlignTop)
|
||
cell_layout.addWidget(value, 1, Qt.AlignmentFlag.AlignTop)
|
||
grid.addWidget(cell, row, column, 1, field.span)
|
||
self.value_labels.setdefault(field.key, []).append(value)
|
||
column += field.span
|
||
if column >= columns:
|
||
row += 1
|
||
column = 0
|
||
self.root.addLayout(grid)
|
||
|
||
def set_data(self, diagnosis: Any, patient: Any) -> None:
|
||
diagnosis_type = str(_pick(diagnosis, "diagnosis_type", default="") or "")
|
||
type_text = _pick(diagnosis, "diagnosis_type_text", default="")
|
||
if not type_text:
|
||
type_text = {
|
||
"first_visit": "初诊",
|
||
"follow_up": "复诊",
|
||
"consultation": "会诊",
|
||
"1": "初诊",
|
||
"2": "复诊",
|
||
}.get(diagnosis_type, diagnosis_type or "病例")
|
||
diagnosis_date = _pick(diagnosis, "diagnosis_date", "create_time", default="—")
|
||
# create_time 是 Unix 秒级时间戳,直接显示会变成一串数字。
|
||
diagnosis_date = format_timestamp(diagnosis_date, with_time=False) or diagnosis_date
|
||
self.subtitle.setText(f"{type_text} · 诊断日期 {diagnosis_date}")
|
||
for key, labels in self.value_labels.items():
|
||
raw = _pick(
|
||
diagnosis,
|
||
f"{key}_text",
|
||
key,
|
||
default=_pick(patient, f"{key}_text", key, default=""),
|
||
)
|
||
rendered = _text(raw)
|
||
normalized = str(raw).strip().lower()
|
||
if key == "gender":
|
||
rendered = (
|
||
"男"
|
||
if normalized in {"1", "m", "male", "男"}
|
||
else "女"
|
||
if normalized in {"0", "2", "f", "female", "女"}
|
||
else "—"
|
||
)
|
||
elif key == "marital_status":
|
||
rendered = {"0": "未婚", "1": "已婚", "2": "离异"}.get(normalized, rendered)
|
||
elif key == "diagnosis_type":
|
||
rendered = {
|
||
"first_visit": "初诊",
|
||
"follow_up": "复诊",
|
||
"consultation": "会诊",
|
||
"1": "初诊",
|
||
"2": "复诊",
|
||
}.get(normalized, rendered)
|
||
elif key in {
|
||
"trauma_history",
|
||
"surgery_history",
|
||
"allergy_history",
|
||
"family_history",
|
||
"pregnancy_history",
|
||
}:
|
||
rendered = {"0": "无", "1": "有"}.get(normalized, rendered)
|
||
elif key in FIELD_DICTIONARIES and raw not in (None, ""):
|
||
# 后端只读接口会补 `<field>_text`;快照类数据没有时按字典翻译。
|
||
rendered = shared_terms().dictionary_label(key, raw) or rendered
|
||
elif key == "age" and raw not in (None, ""):
|
||
rendered = f"{rendered}岁"
|
||
elif key == "height" and raw not in (None, ""):
|
||
rendered = f"{rendered} cm"
|
||
elif key == "weight" and raw not in (None, ""):
|
||
rendered = f"{rendered} kg"
|
||
elif key in {"systolic_pressure", "diastolic_pressure"} and raw not in (
|
||
None,
|
||
"",
|
||
):
|
||
rendered = f"{rendered} mmHg"
|
||
elif key == "fasting_blood_sugar" and raw not in (None, ""):
|
||
rendered = f"{rendered} mmol/L"
|
||
abnormal = False
|
||
if key == "systolic_pressure":
|
||
try:
|
||
abnormal = float(raw) >= 140
|
||
except (TypeError, ValueError):
|
||
abnormal = False
|
||
elif key == "diastolic_pressure":
|
||
try:
|
||
abnormal = float(raw) >= 90
|
||
except (TypeError, ValueError):
|
||
abnormal = False
|
||
elif key == "fasting_blood_sugar":
|
||
try:
|
||
abnormal = float(raw) >= 7
|
||
except (TypeError, ValueError):
|
||
abnormal = False
|
||
if abnormal:
|
||
rendered = f"↑ {rendered}"
|
||
for label in labels:
|
||
label.setText(rendered)
|
||
label.setProperty("abnormal", abnormal)
|
||
repolish(label)
|
||
|
||
|
||
def section_heading(text: str, object_name: str) -> QWidget:
|
||
container = QFrame()
|
||
container.setObjectName(object_name)
|
||
container.setProperty("diagnosisSection", True)
|
||
layout = QHBoxLayout(container)
|
||
layout.setContentsMargins(12, 10, 12, 10)
|
||
layout.setSpacing(10)
|
||
accent = QFrame()
|
||
accent.setObjectName("DiagnosisSectionAccent")
|
||
accent.setFixedSize(4, 16)
|
||
title = QLabel(text)
|
||
title.setObjectName("DiagnosisSectionTitle")
|
||
layout.addWidget(accent, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addWidget(title, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addStretch(1)
|
||
return container
|
||
|
||
|
||
def readonly_card(title: str, object_name: str, body: QWidget) -> QFrame:
|
||
card = QFrame()
|
||
card.setObjectName(object_name)
|
||
card.setProperty("diagnosisReadonlyCard", True)
|
||
card_layout = QVBoxLayout(card)
|
||
card_layout.setContentsMargins(18, 18, 18, 18)
|
||
card_layout.setSpacing(14)
|
||
title_label = QLabel(title)
|
||
title_label.setObjectName("DiagnosisReadonlyCardTitle")
|
||
card_layout.addWidget(title_label)
|
||
card_layout.addWidget(body)
|
||
return card
|
||
|
||
|
||
def set_tag_item(item: QTableWidgetItem, kind: str) -> None:
|
||
palette = {
|
||
"success": ("#287B65", "#EAF9F3"),
|
||
"warning": ("#A9691D", "#FFF5E6"),
|
||
"danger": ("#BE4B58", "#FFF1F3"),
|
||
"info": ("#4F63D9", "#EEF1FA"),
|
||
"neutral": ("#6A6B6D", "#F7F7F7"),
|
||
}
|
||
foreground, background = palette.get(kind, palette["neutral"])
|
||
item.setForeground(QColor(foreground))
|
||
item.setBackground(QColor(background))
|
||
|
||
|
||
def set_status_tag(
|
||
table: QTableWidget,
|
||
row: int,
|
||
column: int,
|
||
text: str,
|
||
kind: str,
|
||
) -> StatusTag:
|
||
"""Render semantic state as a compact pill instead of painting a whole cell."""
|
||
|
||
host = QWidget(table)
|
||
layout = QHBoxLayout(host)
|
||
layout.setContentsMargins(4, 3, 4, 3)
|
||
layout.setSpacing(0)
|
||
tag = StatusTag(text, kind, host)
|
||
tag.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
layout.addWidget(tag, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addStretch(1)
|
||
table.setCellWidget(row, column, host)
|
||
return tag
|
||
|
||
|
||
__all__ = [
|
||
"CaseGrid",
|
||
"ChatPanel",
|
||
"DIAGNOSIS_QSS",
|
||
"DailyRecordPanel",
|
||
"DiagnosisChoiceButtons",
|
||
"DiagnosisComboBox",
|
||
"DiagnosisDateEdit",
|
||
"DiagnosisLineEdit",
|
||
"DiagnosisNumberEdit",
|
||
"DiagnosisSwitch",
|
||
"DiagnosisTabWidget",
|
||
"DiagnosisTextEdit",
|
||
"LoadingOverlay",
|
||
"MessageStrip",
|
||
"NotesTimeline",
|
||
"RecordTable",
|
||
"SaveStateButton",
|
||
"StatusTag",
|
||
"readonly_card",
|
||
"repolish",
|
||
"section_heading",
|
||
"set_tag_item",
|
||
"set_status_tag",
|
||
]
|