Files
zyt/app/src/doctor_workstation/ui/diagnosis_drawer.py
T
2026-09-09 12:18:17 +08:00

3458 lines
127 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
]