8019 lines
311 KiB
Python
8019 lines
311 KiB
Python
"""Same-day reception queue and active patient workspace."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from collections.abc import Mapping, Sequence
|
||
from contextlib import suppress
|
||
from copy import deepcopy
|
||
from datetime import date, timedelta
|
||
from inspect import Parameter, signature
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from PySide6.QtCore import QDate, QPoint, QPointF, QRectF, QSize, Qt, QTimer, QUrl, Signal
|
||
from PySide6.QtGui import (
|
||
QBrush,
|
||
QColor,
|
||
QDesktopServices,
|
||
QFont,
|
||
QFontMetrics,
|
||
QIcon,
|
||
QPainter,
|
||
QPainterPath,
|
||
QPen,
|
||
QPixmap,
|
||
QTextCursor,
|
||
QTextLayout,
|
||
QTextOption,
|
||
)
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QCalendarWidget,
|
||
QComboBox,
|
||
QDialog,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QStackedWidget,
|
||
QTabBar,
|
||
QTextEdit,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ..diagnosis_drawer import DailyRecordPanel
|
||
from ..diagnosis_editors import FlowLayout
|
||
from ..dialogs import DiagnosisDialog
|
||
from ..dialogs.ai_consult import present_ai_consult
|
||
from ..dialogs.prescription_ai import (
|
||
can_open_diagnosis_ai_report,
|
||
can_use_diagnosis_ai_assistant,
|
||
diagnosis_ai_task,
|
||
present_diagnosis_ai_assistant,
|
||
present_diagnosis_ai_report,
|
||
)
|
||
from ..theme import mark_business_dialog
|
||
from ..widgets import (
|
||
EmptyState,
|
||
MessageBanner,
|
||
StatusBadge,
|
||
clear_layout,
|
||
display_text,
|
||
first_value,
|
||
friendly_error,
|
||
gender_text,
|
||
get_value,
|
||
has_permission,
|
||
invoke,
|
||
page_items,
|
||
run_async,
|
||
section_title,
|
||
show_toast,
|
||
)
|
||
|
||
STATUS_KIND = {1: "warning", 2: "neutral", 3: "success", 4: "danger"}
|
||
STATUS_TEXT = {1: "待接诊", 2: "已取消", 3: "已完成", 4: "已过号"}
|
||
RECEPTION_STATUSES = {1, 4}
|
||
NOTE_LIMIT = 500
|
||
MAX_NOTE_MEDIA = 99
|
||
AI_ANALYSIS_MODELS = ("qwen", "openai")
|
||
AI_ANALYSIS_MODEL_LABELS = {"qwen": "千问", "openai": "OpenAI"}
|
||
AI_MEDICAL_DISCLAIMER = (
|
||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||
)
|
||
|
||
|
||
RECEPTION_QSS = r"""
|
||
QWidget#ReceptionPage {
|
||
background-color: transparent;
|
||
color: #2C3A56;
|
||
font-size: 13px;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionQueuePanel {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E6EAF5;
|
||
border-radius: 12px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueTitle {
|
||
color: #1F2C44;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionDateButton {
|
||
min-height: 32px;
|
||
max-height: 32px;
|
||
padding: 0 22px 0 10px;
|
||
color: #5B6780;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E4E9F6;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionDateButton:hover {
|
||
color: #5469F0;
|
||
background-color: #F7F9FE;
|
||
border-color: #C5CEFF;
|
||
}
|
||
QCalendarWidget#ReceptionDateCalendar {
|
||
min-width: 292px;
|
||
min-height: 280px;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #DDE5FA;
|
||
border-radius: 10px;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] {
|
||
min-height: 32px;
|
||
max-height: 32px;
|
||
min-width: 0;
|
||
padding: 0 6px;
|
||
color: #7A8499;
|
||
background-color: #F4F6FB;
|
||
border: 1px solid transparent;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"]:hover {
|
||
color: #3F4E75;
|
||
background-color: #EEF1FF;
|
||
border-color: rgba(84, 105, 240, 40);
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"]:checked {
|
||
color: #5469F0;
|
||
background-color: #EEF1FF;
|
||
border-color: transparent;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QListWidget#ReceptionQueueList {
|
||
background-color: transparent;
|
||
border: 0;
|
||
border-radius: 0;
|
||
outline: 0;
|
||
}
|
||
QWidget#ReceptionPage QListWidget#ReceptionQueueList::item {
|
||
padding: 0;
|
||
margin: 0;
|
||
color: #2C3A56;
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QListWidget#ReceptionQueueList::item:hover,
|
||
QWidget#ReceptionPage QListWidget#ReceptionQueueList::item:selected {
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionQueueLoading {
|
||
min-height: 40px;
|
||
max-height: 40px;
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueLoadingText {
|
||
color: #8A93A8;
|
||
font-size: 12px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueName {
|
||
color: #1F2C44;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueName[selected="true"] {
|
||
color: #5469F0;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueMeta,
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueSubline,
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueTime {
|
||
color: #8A93A8;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueMetric {
|
||
color: #5B8FF9;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="warn"] { color: #D89A3A; }
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="ok"] { color: #2DBE85; }
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="high"] { color: #5B8FF9; }
|
||
QWidget#ReceptionPage QFrame#ReceptionHero,
|
||
QWidget#ReceptionPage QFrame#ReceptionClinicalCard {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E6EAF5;
|
||
border-radius: 12px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAvatar {
|
||
color: #5469F0;
|
||
background-color: #EEF1FF;
|
||
border: 1px solid #E0E6FF;
|
||
border-radius: 26px;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionPatientName {
|
||
color: #1F2C44;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
|
||
color: #6B7388;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel#StatusBadge {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton {
|
||
min-height: 34px;
|
||
max-height: 34px;
|
||
padding: 0 13px;
|
||
color: #3F4E75;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E4E9F6;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionMoreButton {
|
||
min-height: 34px;
|
||
max-height: 34px;
|
||
padding: 0 12px;
|
||
color: #5B6780;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E4E9F6;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover {
|
||
color: #5469F0;
|
||
background-color: #F7F9FE;
|
||
border-color: #C5CEFF;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionMoreButton:hover {
|
||
color: #3F4E75;
|
||
background-color: #F7F9FE;
|
||
border-color: #C5CEFF;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton {
|
||
min-height: 34px;
|
||
max-height: 34px;
|
||
padding: 0 14px;
|
||
color: #E45B66;
|
||
background-color: #FFF7F8;
|
||
border: 1px solid #F5D0D4;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs {
|
||
background-color: #FFFFFF;
|
||
border-bottom: 1px solid #E6EAF5;
|
||
border-top-left-radius: 12px;
|
||
border-top-right-radius: 12px;
|
||
}
|
||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab {
|
||
min-height: 44px;
|
||
padding: 0 16px;
|
||
margin: 0;
|
||
color: #6B7388;
|
||
background-color: transparent;
|
||
border: 0;
|
||
border-bottom: 2px solid transparent;
|
||
border-radius: 0;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:hover {
|
||
color: #3F4E75;
|
||
background-color: #FAFBFE;
|
||
}
|
||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:selected {
|
||
color: #5469F0;
|
||
background-color: transparent;
|
||
border-bottom: 2px solid #5469F0;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionHistoryCard,
|
||
QWidget#ReceptionPage QFrame#ReceptionVitalsCard,
|
||
QWidget#ReceptionPage QFrame#ReceptionLabsCard,
|
||
QWidget#ReceptionPage QFrame#ReceptionTabSurface {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E8ECF4;
|
||
border-radius: 10px;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionBlockTitle="true"] {
|
||
color: #2C3A56;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionBlockValue="true"] {
|
||
color: #5F6C82;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
line-height: 20px;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll {
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll QScrollBar:vertical {
|
||
width: 9px;
|
||
margin: 1px;
|
||
background-color: #F1F4FA;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll QScrollBar::handle:vertical {
|
||
min-height: 34px;
|
||
background-color: #B8C3DA;
|
||
border-radius: 4px;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll QScrollBar::add-line:vertical,
|
||
QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll QScrollBar::sub-line:vertical {
|
||
height: 0;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionMedicationCaseCard {
|
||
background-color: #FCFDFF;
|
||
border: 1px solid #DFE5F2;
|
||
border-radius: 12px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionMedicationCaseTitle {
|
||
color: #17264D;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionMedicationCaseSubtitle {
|
||
color: #6F7FA3;
|
||
font-size: 11px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionMedicationPrescriptionStatus {
|
||
min-height: 24px;
|
||
padding: 0 9px;
|
||
color: #59698F;
|
||
background-color: #F4F6FB;
|
||
border: 1px solid #E0E5F0;
|
||
border-radius: 7px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionMedicationPrescriptionStatus[casePrescriptionState="ready"] {
|
||
color: #238E72;
|
||
background-color: #ECF8F3;
|
||
border-color: #CDEFE2;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionMedicationPrescriptionStatus[casePrescriptionState="empty"] {
|
||
color: #B06C19;
|
||
background-color: #FFF7E9;
|
||
border-color: #F2DEB9;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionMedicationCaseEmpty {
|
||
min-height: 110px;
|
||
color: #7183A6;
|
||
font-size: 13px;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseMetric="true"] {
|
||
min-height: 66px;
|
||
background-color: #F4F7FD;
|
||
border: 1px solid #E3E8F4;
|
||
border-radius: 9px;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseMetricCaption="true"] {
|
||
color: #6F7FA3;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseMetricValue="true"] {
|
||
color: #1C2B52;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseMetricValue="true"][alert="true"] {
|
||
color: #C64E5D;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseSection="true"] {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E3E8F4;
|
||
border-radius: 9px;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="diagnosis"] {
|
||
background-color: #F3F5FF;
|
||
border-color: #D9DFFF;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="medication"] {
|
||
background-color: #F3F8FF;
|
||
border-color: #D7E4F7;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="note"] {
|
||
background-color: #FFF9F0;
|
||
border-color: #F0DFC3;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseSectionTitle="true"] {
|
||
color: #354BC4;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="medication"]
|
||
QLabel[receptionCaseSectionTitle="true"] {
|
||
color: #2E67A2;
|
||
}
|
||
QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="note"]
|
||
QLabel[receptionCaseSectionTitle="true"] {
|
||
color: #9A641D;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseFieldCaption="true"] {
|
||
color: #617092;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseFieldValue="true"] {
|
||
color: #233052;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseFieldValue="true"][important="true"] {
|
||
color: #253A83;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionCaseFieldValue="true"][risk="true"] {
|
||
color: #B54B59;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionInlineCaption="true"] {
|
||
color: #2C3A56;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
min-width: 64px;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionInlineValue="true"] {
|
||
color: #5F6C82;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionMetricCell,
|
||
QWidget#ReceptionPage QFrame#ReceptionLabCell {
|
||
background-color: #F8FAFD;
|
||
border: 1px solid transparent;
|
||
border-radius: 10px;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionMetricCaption="true"] {
|
||
color: #8A93A8;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionMetricValue="true"] {
|
||
color: #1F2C44;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionMetricUnit="true"] {
|
||
color: #8A93A8;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionLifestyleChip="true"] {
|
||
color: #5F6C82;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionMetricState="true"] {
|
||
color: #2A9A78;
|
||
background-color: #EAF7F2;
|
||
border-radius: 8px;
|
||
padding: 2px 7px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"] {
|
||
color: #E45B66;
|
||
background-color: #FFF1F3;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionBmiState="true"] {
|
||
color: #7484C9;
|
||
background-color: transparent;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionBmiState="true"][alert="true"] {
|
||
color: #E45B66;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionDetailScroll {
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionDetailScroll QScrollBar:vertical {
|
||
width: 8px;
|
||
margin: 4px 2px;
|
||
background: transparent;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionDetailScroll QScrollBar::handle:vertical {
|
||
min-height: 32px;
|
||
background-color: #D5DCEC;
|
||
border-radius: 4px;
|
||
}
|
||
QWidget#ReceptionPage QScrollArea#ReceptionDetailScroll QScrollBar::add-line:vertical,
|
||
QWidget#ReceptionPage QScrollArea#ReceptionDetailScroll QScrollBar::sub-line:vertical {
|
||
height: 0;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard,
|
||
QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard {
|
||
background: qlineargradient(
|
||
x1: 0, y1: 0, x2: 1, y2: 1,
|
||
stop: 0 #F5F8FF, stop: 0.58 #FAFCFF, stop: 1 #FFFFFF
|
||
);
|
||
border: 1px solid #DDE5FA;
|
||
border-radius: 10px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiTitle {
|
||
color: #3F4E75;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector {
|
||
min-width: 82px;
|
||
max-width: 104px;
|
||
min-height: 27px;
|
||
max-height: 27px;
|
||
padding: 0 24px 0 9px;
|
||
color: #3F4E75;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #DCE3F5;
|
||
border-radius: 7px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector:hover,
|
||
QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector:focus {
|
||
color: #4C57E9;
|
||
border-color: #8D9BFF;
|
||
}
|
||
QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector::drop-down {
|
||
width: 22px;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus {
|
||
min-height: 17px;
|
||
color: #7183A6;
|
||
font-size: 10px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus[secondaryState="loading"] {
|
||
color: #5365E8;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus[secondaryState="error"] {
|
||
color: #C95664;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus[secondaryState="success"] {
|
||
color: #238E72;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSnapshotMeta {
|
||
min-height: 16px;
|
||
color: #5365E8;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiMedicalDisclaimer {
|
||
color: #A13D4A;
|
||
background-color: #FFF4F5;
|
||
border: 1px solid #F4CED3;
|
||
border-radius: 7px;
|
||
padding: 3px 6px;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton,
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton {
|
||
min-height: 26px;
|
||
max-height: 26px;
|
||
padding: 0 9px;
|
||
color: #4C57E9;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #D8E0F8;
|
||
border-radius: 7px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton:hover,
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton:hover {
|
||
color: #3F4DD1;
|
||
background-color: #F0F3FF;
|
||
border-color: #AAB7F5;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton:focus,
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton:focus {
|
||
border: 2px solid #8D9BFF;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton:disabled,
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton:disabled {
|
||
color: #9AA6C2;
|
||
background-color: #F5F7FC;
|
||
border-color: #E3E7F1;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton {
|
||
padding: 0;
|
||
background-color: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: 7px;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:hover {
|
||
background-color: #F0F3FF;
|
||
border-color: #D8E0F8;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:pressed {
|
||
background-color: #E7EBFF;
|
||
border-color: #AAB7F5;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:focus {
|
||
border-color: #8D9BFF;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:disabled {
|
||
background-color: transparent;
|
||
border-color: transparent;
|
||
}
|
||
QWidget#ReceptionPage QStackedWidget#ReceptionAiAnalysisStack,
|
||
QWidget#ReceptionPage QWidget#ReceptionAiAnalysisSections {
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisSection {
|
||
background-color: transparent;
|
||
border: 0;
|
||
border-radius: 7px;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisSection[emphasis="true"] {
|
||
background-color: #F3F6FF;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionAiCaption="true"] {
|
||
color: #2C3A56;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionAiCaption="true"][tone="risk"] {
|
||
color: #2C3A56;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionAiCaption="true"][tone="treatment"] {
|
||
color: #2C3A56;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionAiValue="true"] {
|
||
color: #2C3A56;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionAiValue="true"][secondary="true"] {
|
||
color: #5F6C82;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionRiskChip="true"] {
|
||
min-height: 22px;
|
||
padding: 3px 8px;
|
||
color: #287B66;
|
||
background-color: #ECF8F3;
|
||
border: 1px solid #D7EEE5;
|
||
border-radius: 8px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="high"] {
|
||
color: #B13E4D;
|
||
background-color: #FFF0F2;
|
||
border-color: #F3D1D7;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="medium"] {
|
||
color: #9A681D;
|
||
background-color: #FFF7E8;
|
||
border-color: #F1E0BC;
|
||
}
|
||
QWidget#ReceptionPage QLabel[receptionRiskOverflow="true"] {
|
||
min-height: 22px;
|
||
padding: 3px 8px;
|
||
color: #657391;
|
||
background-color: #F2F5FA;
|
||
border: 1px solid #E0E5EF;
|
||
border-radius: 8px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionAiState {
|
||
background-color: #F7F9FF;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiStateText {
|
||
color: #7180A1;
|
||
font-size: 11px;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiRetry {
|
||
min-height: 28px;
|
||
max-height: 28px;
|
||
padding: 0 12px;
|
||
color: #5365E8;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #D8E0F8;
|
||
border-radius: 7px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionAiAssistantHint {
|
||
color: #6B7388;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] {
|
||
min-width: 0;
|
||
min-height: 30px;
|
||
max-height: 30px;
|
||
padding: 0 10px;
|
||
color: #3F4E75;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E0E6F4;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"]:hover,
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiRetry:hover {
|
||
color: #5469F0;
|
||
background-color: #F7F9FE;
|
||
border-color: #C5CEFF;
|
||
}
|
||
QWidget#ReceptionPage QLineEdit#ReceptionAiInput {
|
||
min-height: 36px;
|
||
max-height: 36px;
|
||
padding: 0 11px;
|
||
color: #2C3A56;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E0E6F4;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
}
|
||
QWidget#ReceptionPage QLineEdit#ReceptionAiInput:focus {
|
||
border-color: #C5CEFF;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiSendButton {
|
||
min-width: 36px;
|
||
max-width: 36px;
|
||
min-height: 36px;
|
||
max-height: 36px;
|
||
padding: 0;
|
||
background-color: #5469F0;
|
||
border: 0;
|
||
border-radius: 18px;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionAiSendButton:hover {
|
||
background-color: #4458E0;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionDailyRecordsCard,
|
||
QWidget#ReceptionPage QFrame#ReceptionFollowupCard {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E4E9F6;
|
||
border-radius: 11px;
|
||
}
|
||
QWidget#ReceptionPage QWidget#ReceptionDailyToolbar {
|
||
background-color: transparent;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionDailyTitle,
|
||
QWidget#ReceptionPage QLabel#ReceptionFollowupTitle {
|
||
color: #17264D;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionDailySubtitle,
|
||
QWidget#ReceptionPage QLabel#ReceptionFollowupSubtitle {
|
||
color: #6F7FA3;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionDailyRange="true"] {
|
||
min-height: 30px;
|
||
max-height: 30px;
|
||
padding: 0 13px;
|
||
color: #53627F;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #DDE4F2;
|
||
border-radius: 7px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionDailyRange="true"]:hover {
|
||
color: #4057D6;
|
||
background-color: #F6F8FF;
|
||
border-color: #BFC9FF;
|
||
}
|
||
QWidget#ReceptionPage QPushButton[receptionDailyRange="true"]:checked {
|
||
color: #FFFFFF;
|
||
background-color: #5469F0;
|
||
border-color: #5469F0;
|
||
font-weight: 700;
|
||
}
|
||
QWidget#ReceptionPage QDateEdit[receptionDailyDate="true"] {
|
||
min-height: 30px;
|
||
max-height: 30px;
|
||
padding: 0 9px;
|
||
color: #2D3A5C;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #DDE4F2;
|
||
border-radius: 7px;
|
||
font-size: 12px;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionDailyRefreshButton {
|
||
min-height: 30px;
|
||
max-height: 30px;
|
||
padding: 0 13px;
|
||
color: #4057D6;
|
||
background-color: #F5F7FF;
|
||
border: 1px solid #D5DCFF;
|
||
border-radius: 7px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QPushButton#ReceptionDailyRefreshButton:hover {
|
||
color: #FFFFFF;
|
||
background-color: #5469F0;
|
||
border-color: #5469F0;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState {
|
||
background-color: #F5F7FF;
|
||
border: 1px solid #E0E6F8;
|
||
border-radius: 8px;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState[kind="danger"] {
|
||
background-color: #FFF5F6;
|
||
border-color: #F3D1D6;
|
||
}
|
||
QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState[kind="warning"] {
|
||
background-color: #FFF9EF;
|
||
border-color: #F0DFC3;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable {
|
||
color: #263553;
|
||
background-color: #FFFFFF;
|
||
alternate-background-color: #FBFCFF;
|
||
border: 1px solid #E3E8F3;
|
||
border-radius: 8px;
|
||
gridline-color: #E7EBF4;
|
||
font-size: 12px;
|
||
outline: 0;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable::item {
|
||
padding: 6px 8px;
|
||
border: 0;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QHeaderView::section {
|
||
min-height: 34px;
|
||
color: #53627F;
|
||
background-color: #F5F7FC;
|
||
border: 0;
|
||
border-right: 1px solid #E3E8F3;
|
||
border-bottom: 1px solid #E3E8F3;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:horizontal,
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:vertical {
|
||
background-color: #F3F5FA;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:horizontal {
|
||
height: 9px;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:vertical {
|
||
width: 9px;
|
||
}
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar::handle:horizontal,
|
||
QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar::handle:vertical {
|
||
min-width: 36px;
|
||
min-height: 36px;
|
||
background-color: #B8C3DA;
|
||
border-radius: 4px;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionFollowupText {
|
||
color: #334260;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QWidget#ReceptionPage QLabel#ReceptionQueueSummary {
|
||
color: #8A93A8;
|
||
font-size: 11px;
|
||
}
|
||
"""
|
||
|
||
|
||
RECEPTION_AI_DIALOG_QSS = r"""
|
||
QDialog#ReceptionAiAnalysisDialog {
|
||
background-color: #F4F7FF;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QFrame#DialogSurface {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #DFE5F4;
|
||
border-radius: 14px;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QFrame#DialogHeader {
|
||
min-height: 68px;
|
||
background-color: #FFFFFF;
|
||
border: 0;
|
||
border-bottom: 1px solid #E5EAF5;
|
||
border-top-left-radius: 14px;
|
||
border-top-right-radius: 14px;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogTitle {
|
||
color: #15224A;
|
||
font-size: 19px;
|
||
font-weight: 700;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogSubtitle,
|
||
QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogMeta {
|
||
color: #7481A3;
|
||
font-size: 11px;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector {
|
||
min-width: 112px;
|
||
min-height: 32px;
|
||
max-height: 32px;
|
||
padding: 0 26px 0 10px;
|
||
color: #3F4E75;
|
||
background-color: #F8FAFF;
|
||
border: 1px solid #DCE3F5;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector:hover,
|
||
QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector:focus {
|
||
color: #4C57E9;
|
||
background-color: #FFFFFF;
|
||
border-color: #8D9BFF;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector::drop-down {
|
||
width: 24px;
|
||
border: 0;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QFrame#ReceptionAiAnalysisDialogHistoryBar {
|
||
background-color: #F8FAFF;
|
||
border: 0;
|
||
border-bottom: 1px solid #E5EAF5;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogHistoryLabel {
|
||
color: #596987;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogHistorySelector {
|
||
min-width: 250px;
|
||
min-height: 32px;
|
||
max-height: 32px;
|
||
padding: 0 26px 0 10px;
|
||
color: #3F4E75;
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #DCE3F5;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogDisclaimer {
|
||
color: #9C3543;
|
||
background-color: #FFF3F4;
|
||
border: 1px solid #F1C8CE;
|
||
border-radius: 9px;
|
||
padding: 10px 12px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QScrollArea#ReceptionAiAnalysisDialogScroll,
|
||
QDialog#ReceptionAiAnalysisDialog QWidget#ReceptionAiAnalysisDialogBody {
|
||
background-color: #F8FAFF;
|
||
border: 0;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QFrame#ReceptionAiAnalysisDialogSection {
|
||
background-color: #FFFFFF;
|
||
border: 1px solid #E2E7F4;
|
||
border-radius: 10px;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiCaption="true"] {
|
||
color: #3F4E75;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiBody="true"] {
|
||
color: #314165;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"] {
|
||
min-height: 28px;
|
||
padding: 4px 10px;
|
||
color: #B13E4D;
|
||
background-color: #FFF0F2;
|
||
border: 1px solid #F3D1D7;
|
||
border-radius: 7px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"][riskLevel="medium"] {
|
||
color: #9A681D;
|
||
background-color: #FFF7E8;
|
||
border-color: #F1E0BC;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"][riskLevel="low"] {
|
||
color: #279776;
|
||
background-color: #ECF9F4;
|
||
border-color: #D8F1E8;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QFrame#ReceptionAiAnalysisDialogFooter {
|
||
min-height: 62px;
|
||
background-color: #FFFFFF;
|
||
border: 0;
|
||
border-top: 1px solid #E5EAF5;
|
||
border-bottom-left-radius: 14px;
|
||
border-bottom-right-radius: 14px;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton {
|
||
min-width: 96px;
|
||
min-height: 38px;
|
||
color: #FFFFFF;
|
||
background-color: #5761F4;
|
||
border: 1px solid #5761F4;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton:hover {
|
||
background-color: #4C57E9;
|
||
border-color: #4C57E9;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton:pressed {
|
||
background-color: #414BCF;
|
||
border-color: #414BCF;
|
||
}
|
||
QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton:focus {
|
||
border: 2px solid #AAB7F5;
|
||
}
|
||
"""
|
||
|
||
|
||
def _as_int(value: object, default: int | None = None) -> int | None:
|
||
"""Return an integer identifier/status without raising on API aliases."""
|
||
|
||
if value in (None, ""):
|
||
return default
|
||
try:
|
||
return int(value) # type: ignore[arg-type]
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _as_float(value: object) -> float | None:
|
||
"""Return a finite-looking numeric measurement without raising."""
|
||
|
||
if value in (None, "") or isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
text = str(value).strip().replace(",", "")
|
||
try:
|
||
return float(text)
|
||
except ValueError:
|
||
pass
|
||
token: list[str] = []
|
||
started = False
|
||
for character in text:
|
||
if character.isdigit() or (character == "." and "." not in token):
|
||
token.append(character)
|
||
started = True
|
||
continue
|
||
if character == "-" and not started:
|
||
token.append(character)
|
||
started = True
|
||
continue
|
||
if started:
|
||
break
|
||
parsed = "".join(token)
|
||
try:
|
||
return float(parsed) if parsed not in {"", ".", "-", "-."} else None
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _same_id(left: object, right: object) -> bool:
|
||
"""Compare identifiers while accepting the API's string/int alternation."""
|
||
|
||
if left in (None, "") or right in (None, ""):
|
||
return False
|
||
return str(left) == str(right)
|
||
|
||
|
||
def _record_id(record: Any) -> int | None:
|
||
"""Return the canonical appointment identifier from a queue row."""
|
||
|
||
return _as_int(first_value(record, "id", "appointment_id", default=None))
|
||
|
||
|
||
def _mask_phone(value: object) -> str:
|
||
"""Mask any phone value unless it was already masked by the server."""
|
||
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return "—"
|
||
if "*" in text:
|
||
return text
|
||
digits = "".join(character for character in text if character.isdigit())
|
||
if len(digits) >= 7:
|
||
return f"{digits[:3]}****{digits[-4:]}"
|
||
if len(text) > 4:
|
||
return f"{text[:2]}***{text[-2:]}"
|
||
return "****"
|
||
|
||
|
||
def _sequence(value: object) -> list[Any]:
|
||
"""Normalise direct arrays and comma-separated attachment values."""
|
||
|
||
if isinstance(value, str):
|
||
return [item.strip() for item in value.split(",") if item.strip()]
|
||
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
||
return list(value)
|
||
return []
|
||
|
||
|
||
def _summary_value(value: object, *nested_keys: str) -> str:
|
||
"""Return human text from API values without ever exposing raw containers."""
|
||
|
||
if value in (None, ""):
|
||
return ""
|
||
if isinstance(value, Mapping):
|
||
keys = (*nested_keys, "label", "name", "text", "value", "title")
|
||
for key in keys:
|
||
if key not in value:
|
||
continue
|
||
text = _summary_value(value.get(key), *nested_keys)
|
||
if text:
|
||
return text
|
||
return ""
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
parts = [_summary_value(item, *nested_keys) for item in value]
|
||
return "、".join(part for part in parts if part)
|
||
text = str(value).strip()
|
||
return text if text not in {"{}", "[]"} else ""
|
||
|
||
|
||
_AI_NARRATIVE_KEYS = (
|
||
"text",
|
||
"content",
|
||
"description",
|
||
"value",
|
||
"label",
|
||
"summary",
|
||
"title",
|
||
"name",
|
||
)
|
||
_AI_LIST_MARKER = (
|
||
r"(?:\d{1,3}[.、.](?!\d)|[((]\d{1,3}[))]|"
|
||
r"[一二三四五六七八九十百]+、|[•●▪◦]|-(?=[ \t\u3000]))"
|
||
)
|
||
_AI_LIST_LINE_RE = re.compile(rf"^\s*{_AI_LIST_MARKER}")
|
||
_AI_INLINE_LIST_RE = re.compile(
|
||
rf"(?P<prefix>[。!?;:;: \t\u3000])"
|
||
rf"(?P<marker>{_AI_LIST_MARKER})[ \t\u3000]*"
|
||
)
|
||
|
||
|
||
def _normalize_ai_narrative_string(value: object) -> str:
|
||
"""Preserve authored paragraphs while safely exposing escaped line breaks."""
|
||
|
||
text = str(value or "")
|
||
if not text.strip() or text.strip() in {"{}", "[]"}:
|
||
return ""
|
||
had_real_break = any(separator in text for separator in ("\r", "\n", "\u2028", "\u2029"))
|
||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||
text = text.replace("\u2028", "\n").replace("\u2029", "\n")
|
||
if not had_real_break:
|
||
text = text.replace(r"\r\n", "\n").replace(r"\n", "\n").replace(r"\r", "\n")
|
||
|
||
normalized_lines: list[str] = []
|
||
for raw_line in text.split("\n"):
|
||
line = re.sub(r"[\t\u3000 ]+", " ", raw_line).strip()
|
||
if line:
|
||
line = _AI_INLINE_LIST_RE.sub(
|
||
lambda match: (
|
||
(match.group("prefix") if match.group("prefix") not in " \t\u3000" else "")
|
||
+ "\n"
|
||
+ match.group("marker")
|
||
+ " "
|
||
),
|
||
line,
|
||
)
|
||
for split_line in line.split("\n"):
|
||
split_line = split_line.strip()
|
||
if split_line:
|
||
split_line = re.sub(
|
||
rf"^({_AI_LIST_MARKER})[ \t\u3000]*",
|
||
r"\1 ",
|
||
split_line,
|
||
).rstrip()
|
||
normalized_lines.append(split_line)
|
||
else:
|
||
normalized_lines.append("")
|
||
|
||
compacted: list[str] = []
|
||
for line in normalized_lines:
|
||
if not line and (not compacted or not compacted[-1]):
|
||
continue
|
||
compacted.append(line)
|
||
while compacted and not compacted[-1]:
|
||
compacted.pop()
|
||
return "\n".join(compacted)
|
||
|
||
|
||
def _ai_narrative_text(value: object) -> str:
|
||
"""Return structured AI prose without flattening arrays into one sentence."""
|
||
|
||
if value in (None, ""):
|
||
return ""
|
||
if isinstance(value, Mapping):
|
||
for key in _AI_NARRATIVE_KEYS:
|
||
if key not in value:
|
||
continue
|
||
text = _ai_narrative_text(value.get(key))
|
||
if text:
|
||
return text
|
||
return ""
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
items: list[str] = []
|
||
for item in value:
|
||
text = _ai_narrative_text(item)
|
||
if not text:
|
||
continue
|
||
first_line = next((line for line in text.splitlines() if line.strip()), "")
|
||
if first_line and not _AI_LIST_LINE_RE.match(first_line):
|
||
text = f"• {text}"
|
||
items.append(text)
|
||
return "\n".join(items)
|
||
return _normalize_ai_narrative_string(value)
|
||
|
||
|
||
def _queue_condition_summary(record: Any) -> str:
|
||
"""Build the queue's disease/complaint summary from scalar or nested DTOs."""
|
||
|
||
nested = first_value(record, "diagnosis", "case_record", default=None)
|
||
diagnosis = _summary_value(
|
||
first_value(
|
||
record,
|
||
"clinical_diagnosis",
|
||
"diagnosis_name",
|
||
"disease_name",
|
||
default=nested,
|
||
),
|
||
"clinical_diagnosis",
|
||
"diagnosis_name",
|
||
"disease_name",
|
||
)
|
||
complaint = _summary_value(
|
||
first_value(record, "chief_complaint", "complaint", default=nested),
|
||
"chief_complaint",
|
||
"complaint",
|
||
)
|
||
course = _summary_value(
|
||
first_value(
|
||
record,
|
||
"disease_course_text",
|
||
"disease_course",
|
||
"course_text",
|
||
"course",
|
||
default=nested,
|
||
),
|
||
"disease_course_text",
|
||
"disease_course",
|
||
"course_text",
|
||
"course",
|
||
)
|
||
primary = diagnosis or complaint
|
||
return " · ".join(part for part in (primary, course) if part) or "暂无病情摘要"
|
||
|
||
|
||
def _queue_time_text(record: Any) -> str:
|
||
"""Return a compact appointment clock for waiting-row cards."""
|
||
|
||
raw = first_value(
|
||
record, "appointment_time_text", "appointment_time", "time", default=""
|
||
)
|
||
text = display_text(raw)
|
||
if text in {"", "—"}:
|
||
return ""
|
||
if len(text) >= 5 and text[2] == ":":
|
||
return text[:5]
|
||
return text
|
||
|
||
|
||
def _queue_glucose_value(record: Any) -> object:
|
||
diagnosis = first_value(record, "diagnosis", "case_record", default={}) or {}
|
||
return first_value(
|
||
record,
|
||
"fasting_blood_sugar",
|
||
"fasting_glucose",
|
||
"blood_sugar",
|
||
default=first_value(
|
||
diagnosis,
|
||
"fasting_blood_sugar",
|
||
"fasting_glucose",
|
||
"blood_sugar",
|
||
default=None,
|
||
),
|
||
)
|
||
|
||
|
||
def _glucose_tone(value: object) -> str:
|
||
number = _as_float(value)
|
||
if number is None:
|
||
return "high"
|
||
if number >= 8.0:
|
||
return "high"
|
||
if number >= 6.1:
|
||
return "warn"
|
||
return "ok"
|
||
|
||
|
||
def _queue_shows_status_badge(status_number: int, status_text: str) -> bool:
|
||
text = str(status_text or "")
|
||
if "问诊中" in text or "已完成" in text or "已过号" in text:
|
||
return True
|
||
return status_number in {3, 4}
|
||
|
||
|
||
def _queue_badge_kind(status_number: int, status_text: str) -> str:
|
||
text = str(status_text or "")
|
||
if "问诊中" in text:
|
||
return "accent"
|
||
return STATUS_KIND.get(status_number, "neutral")
|
||
|
||
|
||
def _lab_status_for_value(key: str, value: object) -> tuple[str, bool] | None:
|
||
"""Return a design-matching lab badge from a numeric reading."""
|
||
|
||
number = _as_float(value)
|
||
if number is None:
|
||
return None
|
||
rules = {
|
||
"fasting": ("high", 6.1),
|
||
"postprandial": ("high", 7.8),
|
||
"hba1c": ("high", 6.0),
|
||
"cholesterol": ("high", 5.21),
|
||
"triglycerides": ("high", 1.7),
|
||
"hdl": ("low", 1.0),
|
||
"ldl": ("high", 3.4),
|
||
"albumin": ("high", 30.0),
|
||
}
|
||
rule = rules.get(key)
|
||
if rule is None:
|
||
return None
|
||
kind, threshold = rule
|
||
if kind == "high" and number > threshold:
|
||
return ("↑ 偏高", True)
|
||
if kind == "low" and number < threshold:
|
||
return ("↓ 偏低", True)
|
||
return ("正常", False)
|
||
|
||
|
||
def _bmi_status(bmi: float | None) -> tuple[str, bool] | None:
|
||
if bmi is None:
|
||
return None
|
||
if bmi < 18.5:
|
||
return ("偏低", True)
|
||
if bmi >= 24.0:
|
||
return ("偏高", True)
|
||
return ("正常", False)
|
||
|
||
|
||
def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon:
|
||
pixmap = QPixmap(size, size)
|
||
pixmap.fill(Qt.GlobalColor.transparent)
|
||
painter = QPainter(pixmap)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor("#7481A3"), 1.4)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
try:
|
||
if kind == "consult":
|
||
painter.drawRoundedRect(QRectF(2.5, 2, 11, 12.5), 1.8, 1.8)
|
||
painter.drawLine(QPointF(5, 6), QPointF(11, 6))
|
||
painter.drawLine(QPointF(5, 9), QPointF(10, 9))
|
||
elif kind == "report":
|
||
painter.drawRoundedRect(QRectF(3, 2, 10, 12.5), 1.5, 1.5)
|
||
painter.drawLine(QPointF(5.5, 6), QPointF(10.5, 6))
|
||
painter.drawLine(QPointF(5.5, 9), QPointF(10.5, 9))
|
||
painter.drawLine(QPointF(5.5, 12), QPointF(8.5, 12))
|
||
elif kind == "meds":
|
||
painter.drawRoundedRect(QRectF(3, 5.5, 10, 5), 2.4, 2.4)
|
||
painter.drawLine(QPointF(8, 5.5), QPointF(8, 10.5))
|
||
elif kind == "daily":
|
||
painter.drawRoundedRect(QRectF(2.5, 2.5, 11, 11), 1.6, 1.6)
|
||
painter.drawLine(QPointF(5.2, 5.5), QPointF(10.8, 5.5))
|
||
painter.drawLine(QPointF(5.2, 8), QPointF(10.8, 8))
|
||
painter.drawLine(QPointF(5.2, 10.5), QPointF(8.8, 10.5))
|
||
elif kind == "followup":
|
||
painter.drawRoundedRect(QRectF(2.5, 3.5, 11, 10.5), 2, 2)
|
||
painter.drawLine(QPointF(2.5, 7), QPointF(13.5, 7))
|
||
painter.drawLine(QPointF(6, 2.2), QPointF(6, 5))
|
||
painter.drawLine(QPointF(10, 2.2), QPointF(10, 5))
|
||
else:
|
||
painter.drawEllipse(QRectF(3, 3, 10, 10))
|
||
painter.drawLine(QPointF(5.5, 8), QPointF(7.2, 10.2))
|
||
painter.drawLine(QPointF(7.2, 10.2), QPointF(11, 5.8))
|
||
finally:
|
||
painter.end()
|
||
return QIcon(pixmap)
|
||
|
||
|
||
def _painted_reception_action_icon(kind: str, color: str, size: int = 14) -> QIcon:
|
||
pixmap = QPixmap(size, size)
|
||
pixmap.fill(Qt.GlobalColor.transparent)
|
||
painter = QPainter(pixmap)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor(color), 1.4)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
try:
|
||
if kind == "stop":
|
||
painter.drawEllipse(QRectF(1.5, 1.5, 11, 11))
|
||
painter.drawLine(QPointF(4.8, 4.8), QPointF(9.2, 9.2))
|
||
painter.drawLine(QPointF(9.2, 4.8), QPointF(4.8, 9.2))
|
||
else:
|
||
painter.drawEllipse(QRectF(1.5, 1.5, 11, 11))
|
||
painter.drawLine(QPointF(7, 4.5), QPointF(7, 7.5))
|
||
painter.drawEllipse(QRectF(6.2, 9.2, 1.6, 1.6))
|
||
finally:
|
||
painter.end()
|
||
return QIcon(pixmap)
|
||
|
||
|
||
def _queue_row_signature(record: Any) -> tuple[str, ...]:
|
||
"""Return the values that materially affect a rendered queue row."""
|
||
|
||
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
|
||
status_text = display_text(
|
||
first_value(
|
||
record,
|
||
"status_desc",
|
||
"status_text",
|
||
default=STATUS_TEXT.get(status_number, status_number),
|
||
)
|
||
)
|
||
metric = _queue_glucose_value(record)
|
||
return (
|
||
display_text(first_value(record, "patient_name", "name", default=None)),
|
||
gender_text(first_value(record, "gender_desc", "gender")),
|
||
display_text(first_value(record, "age")),
|
||
status_text,
|
||
STATUS_KIND.get(status_number, "neutral"),
|
||
_queue_condition_summary(record),
|
||
_queue_time_text(record),
|
||
display_text(metric),
|
||
)
|
||
|
||
|
||
def _normalize_ai_risks(value: object) -> list[dict[str, str]]:
|
||
"""Keep every valid structured risk while constraining its visual severity."""
|
||
|
||
normalized: list[dict[str, str]] = []
|
||
items = [value] if isinstance(value, Mapping) else _sequence(value)
|
||
for item in items:
|
||
if isinstance(item, Mapping):
|
||
label = _ai_narrative_text(item.get("label"))
|
||
level = str(item.get("level") or "low").strip().lower()
|
||
else:
|
||
label = _ai_narrative_text(item)
|
||
level = "low"
|
||
if not label:
|
||
continue
|
||
if level not in {"high", "medium", "low"}:
|
||
level = "low"
|
||
normalized.append({"label": label, "level": level})
|
||
return normalized
|
||
|
||
|
||
def _analysis_content(payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||
"""Return the structured section while leaving the original payload untouched."""
|
||
|
||
current = payload
|
||
for _depth in range(2):
|
||
if "diagnosis_advice" in current:
|
||
break
|
||
nested = current.get("data")
|
||
if not isinstance(nested, Mapping):
|
||
break
|
||
current = nested
|
||
return current
|
||
|
||
|
||
def _analysis_value(payload: Mapping[str, Any], key: str) -> Any:
|
||
"""Read a display field from a direct or nested server payload."""
|
||
|
||
content = _analysis_content(payload)
|
||
value = content.get(key)
|
||
return value if value not in (None, "") else payload.get(key)
|
||
|
||
|
||
def _patient_report_model(payload: Mapping[str, Any]) -> str:
|
||
"""Return the only two model identifiers accepted by the desktop."""
|
||
|
||
raw = str(
|
||
payload.get("model_key")
|
||
or payload.get("model")
|
||
or payload.get("provider")
|
||
or ""
|
||
).strip().lower()
|
||
if raw in {"qwen", "tongyi", "dashscope"} or "qwen" in raw:
|
||
return "qwen"
|
||
if raw in {"openai", "gpt"} or raw.startswith("gpt"):
|
||
return "openai"
|
||
return ""
|
||
|
||
|
||
def _normalize_patient_report(payload: Mapping[str, Any]) -> dict[str, Any] | None:
|
||
"""Flatten a saved server snapshot into the existing structured card contract."""
|
||
|
||
model = _patient_report_model(payload)
|
||
if not model:
|
||
return None
|
||
normalized = deepcopy(dict(payload))
|
||
structured: dict[str, Any] = {}
|
||
report = payload.get("report")
|
||
if isinstance(report, Mapping):
|
||
structured.update(report)
|
||
content = payload.get("content")
|
||
if isinstance(content, str) and content.strip().startswith(("{", "[")):
|
||
try:
|
||
decoded = json.loads(content)
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
decoded = None
|
||
if isinstance(decoded, Mapping):
|
||
structured.update(decoded)
|
||
|
||
def first_nonempty(*values: Any) -> Any:
|
||
return next((value for value in values if value not in (None, "")), None)
|
||
|
||
diagnosis = _ai_narrative_text(
|
||
first_nonempty(
|
||
payload.get("diagnosis_advice"),
|
||
payload.get("diagnosis"),
|
||
structured.get("diagnosis_advice"),
|
||
structured.get("diagnosis"),
|
||
structured.get("summary"),
|
||
)
|
||
)
|
||
treatment = _ai_narrative_text(
|
||
first_nonempty(
|
||
payload.get("treatment_advice"),
|
||
structured.get("treatment_advice"),
|
||
structured.get("compatibility_analysis"),
|
||
)
|
||
)
|
||
risks = first_nonempty(
|
||
payload.get("risk_assessment"),
|
||
structured.get("risk_assessment"),
|
||
)
|
||
if not diagnosis or not treatment:
|
||
return None
|
||
normalized.update(
|
||
{
|
||
"model_key": model,
|
||
"model_label": _summary_value(payload.get("model_label"))
|
||
or AI_ANALYSIS_MODEL_LABELS[model],
|
||
"diagnosis_advice": diagnosis,
|
||
"risk_assessment": _normalize_ai_risks(risks),
|
||
"treatment_advice": treatment,
|
||
"disclaimer": AI_MEDICAL_DISCLAIMER,
|
||
}
|
||
)
|
||
return normalized
|
||
|
||
|
||
def _patient_report_payload(
|
||
payload: Any,
|
||
*,
|
||
expected_patient_id: int,
|
||
) -> Mapping[str, Any] | None:
|
||
"""Unwrap one response only when its top-level patient matches the request."""
|
||
|
||
if type(expected_patient_id) is not int or expected_patient_id <= 0:
|
||
return None
|
||
current = payload
|
||
if isinstance(current, Mapping):
|
||
if "patient_id" in current:
|
||
outer_patient_id = current.get("patient_id")
|
||
if (
|
||
type(outer_patient_id) is not int
|
||
or outer_patient_id != expected_patient_id
|
||
):
|
||
return None
|
||
if isinstance(current.get("data"), Mapping):
|
||
current = current["data"]
|
||
if not isinstance(current, Mapping):
|
||
return None
|
||
raw_patient_id = current.get("patient_id")
|
||
if type(raw_patient_id) is not int or raw_patient_id != expected_patient_id:
|
||
return None
|
||
return current
|
||
|
||
|
||
def _patient_report_rows(
|
||
payload: Any,
|
||
*,
|
||
expected_patient_id: int,
|
||
) -> list[dict[str, Any]] | None:
|
||
"""Validate and de-duplicate GET history for exactly one requested patient."""
|
||
|
||
current = _patient_report_payload(
|
||
payload,
|
||
expected_patient_id=expected_patient_id,
|
||
)
|
||
if current is None:
|
||
return None
|
||
candidates: list[Any] = []
|
||
for key in ("reports", "items", "list"):
|
||
if key not in current:
|
||
continue
|
||
value = current.get(key)
|
||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
||
return None
|
||
candidates.extend(value)
|
||
break
|
||
latest = current.get("latest_by_model")
|
||
if latest is not None and not isinstance(latest, Mapping):
|
||
return None
|
||
if isinstance(latest, Mapping):
|
||
candidates.extend(value for value in latest.values() if value is not None)
|
||
generated = current.get("generated_report")
|
||
if isinstance(generated, Mapping):
|
||
candidates.append(generated)
|
||
direct_report = current.get("report")
|
||
if isinstance(direct_report, Mapping) and _patient_report_model(direct_report):
|
||
candidates.append(direct_report)
|
||
if _patient_report_model(current):
|
||
candidates.append(current)
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for candidate in candidates:
|
||
if not isinstance(candidate, Mapping):
|
||
return None
|
||
raw_patient_id = candidate.get("patient_id")
|
||
if type(raw_patient_id) is not int or raw_patient_id != expected_patient_id:
|
||
return None
|
||
normalized = _normalize_patient_report(candidate)
|
||
if normalized is None:
|
||
return None
|
||
identity = str(normalized.get("id") or normalized.get("report_id") or "").strip()
|
||
if not identity:
|
||
identity = "|".join(
|
||
(
|
||
str(normalized.get("model_key") or ""),
|
||
str(normalized.get("generated_at") or normalized.get("created_at") or ""),
|
||
str(normalized.get("version") or ""),
|
||
str(normalized.get("diagnosis_advice") or ""),
|
||
)
|
||
)
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
rows.append(normalized)
|
||
rows.sort(
|
||
key=lambda row: (
|
||
str(row.get("generated_at") or row.get("created_at") or ""),
|
||
_as_int(row.get("id") or row.get("report_id"), 0) or 0,
|
||
),
|
||
reverse=True,
|
||
)
|
||
return rows
|
||
|
||
|
||
def _generated_patient_report(
|
||
payload: Any,
|
||
*,
|
||
expected_patient_id: int,
|
||
expected_model: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Return only the newly persisted POST snapshot matching this request."""
|
||
|
||
current = _patient_report_payload(
|
||
payload,
|
||
expected_patient_id=expected_patient_id,
|
||
)
|
||
if current is None:
|
||
return None
|
||
candidate = current.get("generated_report")
|
||
if not isinstance(candidate, Mapping) or not candidate:
|
||
return None
|
||
raw_patient_id = candidate.get("patient_id")
|
||
if type(raw_patient_id) is not int or raw_patient_id != expected_patient_id:
|
||
return None
|
||
report_id = candidate.get("id")
|
||
if type(report_id) is not int or report_id <= 0:
|
||
return None
|
||
model_key = str(candidate.get("model_key") or "").strip().lower()
|
||
if model_key != expected_model:
|
||
return None
|
||
normalized = _normalize_patient_report(candidate)
|
||
if normalized is None or _patient_report_model(normalized) != expected_model:
|
||
return None
|
||
return normalized
|
||
|
||
|
||
def _patient_report_display_version(
|
||
payload: Mapping[str, Any],
|
||
*,
|
||
fallback: int,
|
||
) -> str:
|
||
"""Render only the numeric snapshot version; prompt versions are not UI versions."""
|
||
|
||
raw_version = _analysis_value(payload, "version")
|
||
version = None if isinstance(raw_version, bool) else _as_int(raw_version)
|
||
return str(version if version is not None and version > 0 else max(fallback, 1))
|
||
|
||
|
||
def _patient_report_raw_count(payload: Any) -> int:
|
||
"""Count persisted rows before validation so malformed history is never overwritten."""
|
||
|
||
current = payload
|
||
if isinstance(current, Mapping) and isinstance(current.get("data"), Mapping):
|
||
current = current["data"]
|
||
if not isinstance(current, Mapping):
|
||
return len(current) if isinstance(current, Sequence) else 0
|
||
for key in ("reports", "items", "list"):
|
||
value = current.get(key)
|
||
if (
|
||
isinstance(value, Sequence)
|
||
and not isinstance(value, (str, bytes, bytearray))
|
||
and value
|
||
):
|
||
return len(value)
|
||
latest = current.get("latest_by_model")
|
||
if isinstance(latest, Mapping):
|
||
return sum(isinstance(value, Mapping) for value in latest.values())
|
||
return int(bool(_patient_report_model(current)))
|
||
|
||
|
||
def _accepts_ai_model(method: Any) -> bool:
|
||
"""Detect legacy repositories so OpenAI is never emulated by a duplicate call."""
|
||
|
||
try:
|
||
parameters = signature(method).parameters.values()
|
||
except (TypeError, ValueError):
|
||
return True
|
||
return any(
|
||
parameter.name == "model" or parameter.kind is Parameter.VAR_KEYWORD
|
||
for parameter in parameters
|
||
)
|
||
|
||
|
||
def _clear_ai_layout(layout: Any) -> None:
|
||
"""Detach dynamic AI labels immediately so model switches cannot duplicate them."""
|
||
|
||
while layout.count():
|
||
item = layout.takeAt(0)
|
||
widget = item.widget()
|
||
child_layout = item.layout()
|
||
if widget is not None:
|
||
widget.setParent(None)
|
||
widget.deleteLater()
|
||
elif child_layout is not None:
|
||
_clear_ai_layout(child_layout)
|
||
|
||
|
||
def _attachment_name(value: object) -> str:
|
||
"""Return a compact, human-readable attachment label."""
|
||
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return "未命名附件"
|
||
name = text.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
||
return name or text
|
||
|
||
|
||
def _is_image_attachment(value: object) -> bool:
|
||
"""Return whether a server attachment can be previewed as an image."""
|
||
|
||
path = str(value or "").split("?", 1)[0].split("#", 1)[0].lower()
|
||
return path.endswith((".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"))
|
||
|
||
|
||
def _is_local_material_reference(value: str) -> bool:
|
||
"""Reject local filesystem references before a note JSON is constructed."""
|
||
|
||
text = value.strip()
|
||
return (
|
||
text.lower().startswith("file:")
|
||
or text.startswith(("\\\\", "//"))
|
||
or (len(text) >= 3 and text[0].isalpha() and text[1] == ":" and text[2] in "\\/")
|
||
or "\\" in text
|
||
)
|
||
|
||
|
||
class _NoteAttachmentPreview(QPushButton):
|
||
"""Responsive inline thumbnail that remains clickable for a full preview."""
|
||
|
||
_THUMBNAIL_HEIGHT = 132
|
||
|
||
def __init__(self, caption: str, parent: QWidget | None = None) -> None:
|
||
super().__init__("正在加载图片…", parent)
|
||
self._source_pixmap = QPixmap()
|
||
self.setObjectName("NoteAttachmentPreview")
|
||
self.setProperty("variant", "secondary")
|
||
self.setProperty("loadState", "loading")
|
||
self.setAccessibleName(f"查看{caption}大图")
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setMinimumWidth(168)
|
||
self.setFixedHeight(self._THUMBNAIL_HEIGHT)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
def _refresh_preview(self) -> None:
|
||
if self._source_pixmap.isNull():
|
||
return
|
||
available = QSize(max(120, self.width() - 12), self._THUMBNAIL_HEIGHT - 12)
|
||
rendered = self._source_pixmap.scaled(
|
||
available,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
self.setIcon(QIcon(rendered))
|
||
self.setIconSize(rendered.size())
|
||
|
||
def show_loading(self) -> None:
|
||
self._source_pixmap = QPixmap()
|
||
self.setIcon(QIcon())
|
||
self.setText("正在加载图片…")
|
||
self.setProperty("loadState", "loading")
|
||
self.setEnabled(False)
|
||
|
||
def show_preview(self, pixmap: QPixmap) -> None:
|
||
"""Render the decoded image directly in the note card."""
|
||
|
||
self._source_pixmap = pixmap.scaled(
|
||
QSize(960, 720),
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
self.setText("")
|
||
self._refresh_preview()
|
||
self.setProperty("loadState", "ready")
|
||
self.setEnabled(True)
|
||
self.style().unpolish(self)
|
||
self.style().polish(self)
|
||
|
||
def show_failure(self) -> None:
|
||
"""Keep a failed attachment actionable so the user can retry it."""
|
||
|
||
self._source_pixmap = QPixmap()
|
||
self.setIcon(QIcon())
|
||
self.setText("图片加载失败\n点击重试")
|
||
self.setProperty("loadState", "failed")
|
||
self.setEnabled(True)
|
||
self.style().unpolish(self)
|
||
self.style().polish(self)
|
||
|
||
def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().resizeEvent(event)
|
||
self._refresh_preview()
|
||
|
||
|
||
class _SpacedTextLabel(QLabel):
|
||
"""Shrinkable plain-text label using Qt's native, reliable word wrapping."""
|
||
|
||
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
|
||
super().__init__(text, parent)
|
||
self.setWordWrap(True)
|
||
self.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.setMinimumWidth(0)
|
||
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||
|
||
def setText(self, text: str) -> None: # noqa: N802 - Qt override
|
||
super().setText(text)
|
||
self.updateGeometry()
|
||
|
||
|
||
class _AiPreviewLabel(QLabel):
|
||
"""Width-aware multi-line preview that keeps the complete report separately."""
|
||
|
||
def __init__(
|
||
self,
|
||
text: str = "",
|
||
parent: QWidget | None = None,
|
||
*,
|
||
maximum_lines: int = 3,
|
||
) -> None:
|
||
super().__init__("", parent)
|
||
self._full_text = ""
|
||
self._maximum_lines = max(1, maximum_lines)
|
||
self._refreshing = False
|
||
self.setWordWrap(True)
|
||
self.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.setMinimumWidth(0)
|
||
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||
self.setText(text)
|
||
|
||
def fullText(self) -> str: # noqa: N802 - Qt-style compatibility helper
|
||
return self._full_text
|
||
|
||
def setText(self, text: str) -> None: # noqa: N802 - Qt override
|
||
self._full_text = str(text or "")
|
||
self._refresh_preview()
|
||
|
||
def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().resizeEvent(event)
|
||
self._refresh_preview()
|
||
|
||
def _refresh_preview(self) -> None:
|
||
if self._refreshing:
|
||
return
|
||
self._refreshing = True
|
||
try:
|
||
source = self._full_text or "—"
|
||
margins = self.contentsMargins()
|
||
width = self.contentsRect().width()
|
||
if width < 32:
|
||
width = max(220, self.width() - margins.left() - margins.right())
|
||
|
||
option = QTextOption()
|
||
option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
||
pieces: list[str] = []
|
||
truncated = False
|
||
logical_lines = source.split("\n")
|
||
for logical_index, logical_line in enumerate(logical_lines):
|
||
if not logical_line:
|
||
pieces.append("")
|
||
else:
|
||
text_layout = QTextLayout(logical_line, self.font())
|
||
text_layout.setTextOption(option)
|
||
text_layout.beginLayout()
|
||
while True:
|
||
line = text_layout.createLine()
|
||
if not line.isValid():
|
||
break
|
||
line.setLineWidth(width)
|
||
pieces.append(
|
||
logical_line[
|
||
line.textStart() : line.textStart() + line.textLength()
|
||
]
|
||
)
|
||
if len(pieces) > self._maximum_lines:
|
||
truncated = True
|
||
break
|
||
text_layout.endLayout()
|
||
if truncated:
|
||
break
|
||
if (
|
||
len(pieces) >= self._maximum_lines
|
||
and logical_index < len(logical_lines) - 1
|
||
):
|
||
truncated = True
|
||
break
|
||
|
||
pieces = pieces[: self._maximum_lines]
|
||
if not pieces:
|
||
pieces = ["—"]
|
||
if truncated:
|
||
last = pieces[-1].rstrip()
|
||
pieces[-1] = QFontMetrics(self.font()).elidedText(
|
||
f"{last}…",
|
||
Qt.TextElideMode.ElideRight,
|
||
width,
|
||
)
|
||
preview = "\n".join(pieces) if truncated else source
|
||
QLabel.setText(self, preview)
|
||
self.setToolTip(source if truncated else "")
|
||
line_height = QFontMetrics(self.font()).lineSpacing()
|
||
self.setMaximumHeight(line_height * self._maximum_lines + 4)
|
||
self.updateGeometry()
|
||
finally:
|
||
self._refreshing = False
|
||
|
||
|
||
class _AiFlowHost(QWidget):
|
||
"""Flow-layout host that reserves the wrapped rows at its current width."""
|
||
|
||
def __init__(
|
||
self,
|
||
parent: QWidget | None = None,
|
||
*,
|
||
horizontal_spacing: int = 7,
|
||
vertical_spacing: int = 7,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setMinimumWidth(0)
|
||
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
policy.setHeightForWidth(True)
|
||
self.setSizePolicy(policy)
|
||
self.flow = FlowLayout(
|
||
self,
|
||
horizontal_spacing=horizontal_spacing,
|
||
vertical_spacing=vertical_spacing,
|
||
)
|
||
|
||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt virtual
|
||
return True
|
||
|
||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual
|
||
usable = max(48, int(width)) if int(width) > 1 else 220
|
||
return max(0, self.flow.heightForWidth(usable))
|
||
|
||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
return QSize(0, 0)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||
width = self.width() if self.width() > 1 else 220
|
||
return QSize(width, self.heightForWidth(width))
|
||
|
||
def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().resizeEvent(event)
|
||
self.syncFlowHeight()
|
||
|
||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().showEvent(event)
|
||
self.syncFlowHeight()
|
||
|
||
def syncFlowHeight(self) -> None: # noqa: N802 - Qt-style compatibility helper
|
||
needed = self.heightForWidth(self.width())
|
||
if self.minimumHeight() == needed and self.maximumHeight() == needed:
|
||
return
|
||
self.setFixedHeight(needed)
|
||
self.updateGeometry()
|
||
parent = self.parentWidget()
|
||
if parent is not None:
|
||
parent.updateGeometry()
|
||
|
||
|
||
class _ReceptionSparkIcon(QWidget):
|
||
"""Small vector sparkle used by the two AI panels."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setFixedSize(18, 18)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(QPen(QColor("#5761F4"), 1.7, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||
painter.drawLine(8, 1, 8, 15)
|
||
painter.drawLine(1, 8, 15, 8)
|
||
painter.drawLine(4, 4, 12, 12)
|
||
painter.drawLine(12, 4, 4, 12)
|
||
painter.setPen(QPen(QColor("#8B91FF"), 1.3, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||
painter.drawLine(15, 1, 15, 5)
|
||
painter.drawLine(13, 3, 17, 3)
|
||
|
||
|
||
class _ReceptionAiSectionIcon(QWidget):
|
||
"""Small painted analysis glyph; keeps the reference iconography asset-free."""
|
||
|
||
def __init__(self, tone: str, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._tone = tone
|
||
self.setFixedSize(28, 28)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
colors = {
|
||
"diagnosis": ("#E8EEFF", "#6476EE"),
|
||
"risk": ("#E0F9FC", "#35B6C5"),
|
||
"treatment": ("#E1F9F1", "#2CB38E"),
|
||
}
|
||
background, foreground = colors.get(self._tone, colors["diagnosis"])
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor(background))
|
||
painter.drawRoundedRect(0, 0, 28, 28, 7, 7)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
painter.setPen(
|
||
QPen(
|
||
QColor(foreground),
|
||
1.5,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
if self._tone == "risk":
|
||
painter.drawLine(14, 7, 14, 17)
|
||
painter.drawEllipse(12, 20, 4, 2)
|
||
painter.drawArc(8, 6, 12, 14, 0, 180 * 16)
|
||
elif self._tone == "treatment":
|
||
painter.drawEllipse(7, 7, 14, 14)
|
||
painter.drawLine(14, 10, 14, 18)
|
||
painter.drawLine(10, 14, 18, 14)
|
||
else:
|
||
painter.drawLine(14, 6, 14, 22)
|
||
painter.drawLine(6, 14, 22, 14)
|
||
painter.drawLine(9, 9, 19, 19)
|
||
painter.drawLine(19, 9, 9, 19)
|
||
|
||
|
||
class _ReceptionAssistantIcon(QWidget):
|
||
"""Paint the compact AI roundel used by the assistant card header."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setFixedSize(20, 20)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor("#7180F5"))
|
||
painter.drawEllipse(1, 1, 18, 18)
|
||
painter.setPen(QColor("#FFFFFF"))
|
||
font = painter.font()
|
||
font.setPixelSize(7)
|
||
font.setBold(True)
|
||
painter.setFont(font)
|
||
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "AI")
|
||
|
||
|
||
class _ReceptionSendButton(QPushButton):
|
||
"""Circular painted send control matching the reference without emoji assets."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__("", parent)
|
||
self.setObjectName("ReceptionAiSendButton")
|
||
self.setAccessibleName("发送 AI 问诊问题")
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setFixedSize(36, 36)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().paintEvent(event)
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(
|
||
QPen(
|
||
QColor("#FFFFFF"),
|
||
1.8,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
painter.drawLine(11, 12, 24, 18)
|
||
painter.drawLine(24, 18, 11, 24)
|
||
painter.drawLine(11, 12, 14, 18)
|
||
painter.drawLine(14, 18, 11, 24)
|
||
painter.drawLine(14, 18, 21, 18)
|
||
|
||
|
||
class _ReceptionDateButton(QPushButton):
|
||
"""ISO date chip with a painted caret and a popup calendar."""
|
||
|
||
date_selected = Signal(str)
|
||
|
||
def __init__(self, iso_date: str, parent: QWidget | None = None) -> None:
|
||
super().__init__(iso_date, parent)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setAccessibleName("接诊日期")
|
||
self.setToolTip("选择接诊日期")
|
||
self._iso_date = iso_date
|
||
self._calendar = QCalendarWidget(self)
|
||
self._calendar.setObjectName("ReceptionDateCalendar")
|
||
self._calendar.setWindowFlags(Qt.WindowType.Popup)
|
||
self._calendar.setGridVisible(False)
|
||
self._calendar.setVerticalHeaderFormat(
|
||
QCalendarWidget.VerticalHeaderFormat.NoVerticalHeader
|
||
)
|
||
self._calendar.setHorizontalHeaderFormat(
|
||
QCalendarWidget.HorizontalHeaderFormat.ShortDayNames
|
||
)
|
||
self._calendar.setFixedSize(292, 280)
|
||
self._calendar.hide()
|
||
self._calendar.clicked.connect(self._pick_date)
|
||
self._calendar.activated.connect(self._pick_date)
|
||
self.clicked.connect(self._toggle_calendar)
|
||
|
||
def iso_date(self) -> str:
|
||
return self._iso_date
|
||
|
||
def set_iso_date(self, iso_date: str) -> None:
|
||
self._iso_date = iso_date
|
||
self.setText(iso_date)
|
||
|
||
def calendarWidget(self) -> QCalendarWidget: # noqa: N802 - Qt compatibility
|
||
return self._calendar
|
||
|
||
def _toggle_calendar(self) -> None:
|
||
if self._calendar.isVisible():
|
||
self._calendar.hide()
|
||
return
|
||
selected = QDate.fromString(self._iso_date, Qt.DateFormat.ISODate)
|
||
if not selected.isValid():
|
||
selected = QDate.currentDate()
|
||
self._calendar.setSelectedDate(selected)
|
||
origin = self.mapToGlobal(QPoint(0, self.height() + 4))
|
||
self._calendar.move(origin)
|
||
self._calendar.show()
|
||
self._calendar.raise_()
|
||
|
||
def _pick_date(self, value: QDate) -> None:
|
||
self._calendar.hide()
|
||
if not value.isValid():
|
||
return
|
||
iso_date = value.toString(Qt.DateFormat.ISODate)
|
||
if iso_date == self._iso_date:
|
||
return
|
||
self.set_iso_date(iso_date)
|
||
self.date_selected.emit(iso_date)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().paintEvent(event)
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
color = QColor("#8A95AF")
|
||
painter.setPen(
|
||
QPen(
|
||
color,
|
||
1.4,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
center_x = self.width() - 12
|
||
center_y = self.height() // 2
|
||
painter.drawLine(center_x - 4, center_y - 1, center_x, center_y + 3)
|
||
painter.drawLine(center_x, center_y + 3, center_x + 4, center_y - 1)
|
||
|
||
|
||
class _ReceptionAiExpandButton(QPushButton):
|
||
"""Accessible four-corner expand glyph for the complete analysis."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__("", parent)
|
||
self.setObjectName("ReceptionAiAnalysisExpandButton")
|
||
self.setProperty("iconKind", "expand-corners")
|
||
self.setAccessibleName("查看完整 AI 智能分析")
|
||
self.setToolTip("放大查看 AI 智能分析")
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setFixedSize(28, 28)
|
||
self.setEnabled(False)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().paintEvent(event)
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
color = QColor("#5365E8" if self.isEnabled() else "#AEB8D2")
|
||
painter.setPen(
|
||
QPen(
|
||
color,
|
||
1.7,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
# Four independent L-shaped corners remain legible at the compact 28 px size.
|
||
painter.drawLine(7, 12, 7, 7)
|
||
painter.drawLine(7, 7, 12, 7)
|
||
painter.drawLine(16, 7, 21, 7)
|
||
painter.drawLine(21, 7, 21, 12)
|
||
painter.drawLine(7, 16, 7, 21)
|
||
painter.drawLine(7, 21, 12, 21)
|
||
painter.drawLine(16, 21, 21, 21)
|
||
painter.drawLine(21, 21, 21, 16)
|
||
|
||
|
||
class _ReceptionAiModelCombo(QComboBox):
|
||
"""Compact model selector with a consistently visible painted caret."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
super().paintEvent(event)
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
color = QColor("#5365E8" if self.isEnabled() else "#AEB8D2")
|
||
painter.setPen(
|
||
QPen(
|
||
color,
|
||
1.5,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
center_x = self.width() - 12
|
||
center_y = self.height() // 2
|
||
painter.drawLine(center_x - 4, center_y - 2, center_x, center_y + 2)
|
||
painter.drawLine(center_x, center_y + 2, center_x + 4, center_y - 2)
|
||
|
||
|
||
class _ReceptionAiAnalysisDialog(QDialog):
|
||
"""Scrollable cached analyses with an in-dialog model selector."""
|
||
|
||
def __init__(
|
||
self,
|
||
payloads: Mapping[str, Any],
|
||
parent: QWidget | None = None,
|
||
*,
|
||
preferred_model: str = "qwen",
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.histories = self._coerce_histories(payloads)
|
||
self.payloads = {
|
||
model: snapshots[0]
|
||
for model, snapshots in self.histories.items()
|
||
if snapshots
|
||
}
|
||
self.active_model = "qwen"
|
||
self.active_history_index = 0
|
||
self.setObjectName("ReceptionAiAnalysisDialog")
|
||
self.setWindowTitle("患者级 AI 诊断报告")
|
||
self.setAccessibleName("患者级 AI 诊断报告")
|
||
self.setModal(True)
|
||
self.resize(920, 760)
|
||
self.setMinimumSize(720, 560)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||
self.setStyleSheet(RECEPTION_AI_DIALOG_QSS)
|
||
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(18, 18, 18, 18)
|
||
root.setSpacing(0)
|
||
|
||
surface = QFrame(self)
|
||
surface.setObjectName("DialogSurface")
|
||
surface.setProperty("dialogSurface", True)
|
||
surface_layout = QVBoxLayout(surface)
|
||
surface_layout.setContentsMargins(0, 0, 0, 0)
|
||
surface_layout.setSpacing(0)
|
||
root.addWidget(surface)
|
||
|
||
header = QFrame(surface)
|
||
header.setObjectName("DialogHeader")
|
||
header.setProperty("dialogRole", "header")
|
||
header_layout = QHBoxLayout(header)
|
||
header_layout.setContentsMargins(20, 12, 20, 12)
|
||
header_layout.setSpacing(10)
|
||
header_layout.addWidget(_ReceptionSparkIcon(header), 0, Qt.AlignmentFlag.AlignTop)
|
||
heading = QVBoxLayout()
|
||
heading.setContentsMargins(0, 0, 0, 0)
|
||
heading.setSpacing(3)
|
||
title = QLabel("患者级 AI 诊断报告", header)
|
||
title.setObjectName("ReceptionAiAnalysisDialogTitle")
|
||
title.setProperty("dialogRole", "title")
|
||
subtitle = QLabel("查看千问与 OpenAI 的历次患者报告快照", header)
|
||
subtitle.setObjectName("ReceptionAiAnalysisDialogSubtitle")
|
||
subtitle.setProperty("dialogRole", "subtitle")
|
||
heading.addWidget(title)
|
||
heading.addWidget(subtitle)
|
||
header_layout.addLayout(heading, 1)
|
||
|
||
model_selector = _ReceptionAiModelCombo(header)
|
||
model_selector.setObjectName("ReceptionAiAnalysisDialogModelSelector")
|
||
model_selector.setAccessibleName("选择详情分析模型")
|
||
model_selector.setToolTip("切换已完成的 AI 模型分析")
|
||
for model_key in AI_ANALYSIS_MODELS:
|
||
if model_key in self.payloads:
|
||
model_selector.addItem(AI_ANALYSIS_MODEL_LABELS[model_key], model_key)
|
||
for model_key in self.payloads:
|
||
if model_selector.findData(model_key) < 0:
|
||
model_selector.addItem(model_key, model_key)
|
||
self.model_selector = model_selector
|
||
self.model_combo = model_selector
|
||
header_layout.addWidget(model_selector, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
|
||
meta = QLabel("", header)
|
||
meta.setObjectName("ReceptionAiAnalysisDialogMeta")
|
||
meta.setTextFormat(Qt.TextFormat.PlainText)
|
||
meta.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||
self.meta_label = meta
|
||
header_layout.addWidget(meta)
|
||
surface_layout.addWidget(header)
|
||
|
||
history_bar = QFrame(surface)
|
||
history_bar.setObjectName("ReceptionAiAnalysisDialogHistoryBar")
|
||
history_layout = QHBoxLayout(history_bar)
|
||
history_layout.setContentsMargins(20, 8, 20, 8)
|
||
history_layout.setSpacing(10)
|
||
history_label = QLabel("历史报告", history_bar)
|
||
history_label.setObjectName("ReceptionAiAnalysisDialogHistoryLabel")
|
||
history_label.setAccessibleName("历史报告版本")
|
||
history_layout.addWidget(history_label)
|
||
history_selector = _ReceptionAiModelCombo(history_bar)
|
||
history_selector.setObjectName("ReceptionAiAnalysisDialogHistorySelector")
|
||
history_selector.setAccessibleName("选择历史报告快照")
|
||
history_selector.setToolTip("按时间选择该模型的历史报告")
|
||
self.history_selector = history_selector
|
||
self.history_combo = history_selector
|
||
history_layout.addWidget(history_selector)
|
||
history_layout.addStretch(1)
|
||
surface_layout.addWidget(history_bar)
|
||
|
||
scroll = QScrollArea(surface)
|
||
scroll.setObjectName("ReceptionAiAnalysisDialogScroll")
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.scroll_area = scroll
|
||
body = QWidget(scroll)
|
||
body.setObjectName("ReceptionAiAnalysisDialogBody")
|
||
body_layout = QVBoxLayout(body)
|
||
body_layout.setContentsMargins(18, 16, 18, 20)
|
||
body_layout.setSpacing(12)
|
||
|
||
disclaimer = QLabel(AI_MEDICAL_DISCLAIMER, body)
|
||
disclaimer.setObjectName("ReceptionAiAnalysisDialogDisclaimer")
|
||
disclaimer.setAccessibleName("AI 医疗免责声明")
|
||
disclaimer.setTextFormat(Qt.TextFormat.PlainText)
|
||
disclaimer.setWordWrap(True)
|
||
body_layout.addWidget(disclaimer)
|
||
self.disclaimer_label = disclaimer
|
||
|
||
def add_text_section(
|
||
caption: str,
|
||
tone: str,
|
||
text: str,
|
||
object_name: str,
|
||
) -> QLabel:
|
||
section = QFrame(body)
|
||
section.setObjectName("ReceptionAiAnalysisDialogSection")
|
||
section_layout = QHBoxLayout(section)
|
||
section_layout.setContentsMargins(14, 13, 16, 15)
|
||
section_layout.setSpacing(11)
|
||
section_layout.addWidget(
|
||
_ReceptionAiSectionIcon(tone, section),
|
||
0,
|
||
Qt.AlignmentFlag.AlignTop,
|
||
)
|
||
copy = QVBoxLayout()
|
||
copy.setContentsMargins(0, 0, 0, 0)
|
||
copy.setSpacing(7)
|
||
caption_label = QLabel(caption, section)
|
||
caption_label.setProperty("dialogAiCaption", True)
|
||
copy.addWidget(caption_label)
|
||
value_label = QLabel(text or "—", section)
|
||
value_label.setObjectName(object_name)
|
||
value_label.setProperty("dialogAiBody", True)
|
||
value_label.setTextFormat(Qt.TextFormat.PlainText)
|
||
value_label.setWordWrap(True)
|
||
value_label.setMinimumWidth(0)
|
||
value_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
value_label.setSizePolicy(
|
||
QSizePolicy.Policy.Ignored,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
copy.addWidget(value_label)
|
||
section_layout.addLayout(copy, 1)
|
||
body_layout.addWidget(section)
|
||
return value_label
|
||
|
||
self.diagnosis_label = add_text_section(
|
||
"诊断建议",
|
||
"diagnosis",
|
||
"",
|
||
"ReceptionAiAnalysisDialogDiagnosisText",
|
||
)
|
||
|
||
risk_section = QFrame(body)
|
||
risk_section.setObjectName("ReceptionAiAnalysisDialogSection")
|
||
risk_layout = QHBoxLayout(risk_section)
|
||
risk_layout.setContentsMargins(14, 13, 16, 15)
|
||
risk_layout.setSpacing(11)
|
||
risk_layout.addWidget(
|
||
_ReceptionAiSectionIcon("risk", risk_section),
|
||
0,
|
||
Qt.AlignmentFlag.AlignTop,
|
||
)
|
||
risk_copy = QVBoxLayout()
|
||
risk_copy.setContentsMargins(0, 0, 0, 0)
|
||
risk_copy.setSpacing(7)
|
||
risk_caption = QLabel("风险评估", risk_section)
|
||
risk_caption.setProperty("dialogAiCaption", True)
|
||
risk_copy.addWidget(risk_caption)
|
||
risk_items = _AiFlowHost(
|
||
risk_section,
|
||
horizontal_spacing=7,
|
||
vertical_spacing=7,
|
||
)
|
||
risk_items.setObjectName("ReceptionAiAnalysisDialogRiskItems")
|
||
risk_items_layout = risk_items.flow
|
||
self.risk_items = risk_items
|
||
self.risk_items_layout = risk_items_layout
|
||
risk_copy.addWidget(risk_items)
|
||
risk_layout.addLayout(risk_copy, 1)
|
||
body_layout.addWidget(risk_section)
|
||
|
||
self.treatment_label = add_text_section(
|
||
"治疗建议",
|
||
"treatment",
|
||
"",
|
||
"ReceptionAiAnalysisDialogTreatmentText",
|
||
)
|
||
body_layout.addStretch(1)
|
||
scroll.setWidget(body)
|
||
surface_layout.addWidget(scroll, 1)
|
||
|
||
footer = QFrame(surface)
|
||
footer.setObjectName("ReceptionAiAnalysisDialogFooter")
|
||
footer_layout = QHBoxLayout(footer)
|
||
footer_layout.setContentsMargins(18, 11, 18, 11)
|
||
footer_layout.addStretch(1)
|
||
close_button = QPushButton("关闭", footer)
|
||
close_button.setObjectName("ReceptionAiAnalysisDialogCloseButton")
|
||
close_button.setAccessibleName("关闭 AI 智能分析详情")
|
||
close_button.setProperty("variant", "primary")
|
||
close_button.setDefault(True)
|
||
close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
close_button.clicked.connect(self.reject)
|
||
self.close_button = close_button
|
||
footer_layout.addWidget(close_button)
|
||
surface_layout.addWidget(footer)
|
||
|
||
mark_business_dialog(self, "ReceptionAiAnalysisDialog")
|
||
|
||
initial_model = preferred_model if preferred_model in self.payloads else "qwen"
|
||
initial_index = model_selector.findData(initial_model)
|
||
if initial_index < 0 and model_selector.count():
|
||
initial_index = 0
|
||
if initial_index >= 0:
|
||
model_selector.setCurrentIndex(initial_index)
|
||
self._populate_history_selector(str(model_selector.itemData(initial_index)))
|
||
model_selector.currentIndexChanged.connect(self._model_changed)
|
||
history_selector.currentIndexChanged.connect(self._history_changed)
|
||
|
||
@staticmethod
|
||
def _coerce_payloads(payloads: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
||
"""Accept both the former single-payload input and the dual-model cache."""
|
||
|
||
single_payload = any(
|
||
key in payloads
|
||
for key in (
|
||
"diagnosis_advice",
|
||
"risk_assessment",
|
||
"treatment_advice",
|
||
"data",
|
||
"model_key",
|
||
)
|
||
)
|
||
if single_payload:
|
||
requested_model = str(
|
||
_analysis_value(payloads, "model_key") or "qwen"
|
||
).strip().lower()
|
||
model_key = requested_model if requested_model in AI_ANALYSIS_MODELS else "qwen"
|
||
return {model_key: deepcopy(dict(payloads))}
|
||
normalized: dict[str, dict[str, Any]] = {}
|
||
for raw_model, payload in payloads.items():
|
||
model_key = str(raw_model).strip().lower()
|
||
if isinstance(payload, Mapping):
|
||
normalized[model_key] = deepcopy(dict(payload))
|
||
return normalized
|
||
|
||
@classmethod
|
||
def _coerce_histories(
|
||
cls,
|
||
payloads: Mapping[str, Any],
|
||
) -> dict[str, list[dict[str, Any]]]:
|
||
"""Accept legacy latest-only payloads and the new model history mapping."""
|
||
|
||
single = cls._coerce_payloads(payloads)
|
||
if single:
|
||
return {model: [payload] for model, payload in single.items()}
|
||
histories: dict[str, list[dict[str, Any]]] = {}
|
||
for raw_model, value in payloads.items():
|
||
model = str(raw_model).strip().lower()
|
||
if model not in AI_ANALYSIS_MODELS:
|
||
continue
|
||
if isinstance(value, Sequence) and not isinstance(
|
||
value, (str, bytes, bytearray)
|
||
):
|
||
snapshots = [deepcopy(dict(item)) for item in value if isinstance(item, Mapping)]
|
||
if snapshots:
|
||
histories[model] = snapshots
|
||
return histories
|
||
|
||
def _model_changed(self, index: int) -> None:
|
||
if index < 0:
|
||
return
|
||
self._populate_history_selector(str(self.model_selector.itemData(index)))
|
||
|
||
def _populate_history_selector(self, model_key: str) -> None:
|
||
snapshots = self.histories.get(model_key, [])
|
||
self.history_selector.blockSignals(True)
|
||
self.history_selector.clear()
|
||
total = len(snapshots)
|
||
for index, payload in enumerate(snapshots):
|
||
generated_at = _summary_value(
|
||
payload.get("generated_at") or payload.get("created_at")
|
||
)
|
||
version = _patient_report_display_version(
|
||
payload,
|
||
fallback=total - index,
|
||
)
|
||
label = f"第 {version} 版"
|
||
if generated_at:
|
||
label = f"{label} | {generated_at}"
|
||
self.history_selector.addItem(label, index)
|
||
self.history_selector.setEnabled(total > 1)
|
||
if total:
|
||
self.history_selector.setCurrentIndex(0)
|
||
self.history_selector.blockSignals(False)
|
||
if total:
|
||
self._render_model(model_key, 0)
|
||
|
||
def _history_changed(self, index: int) -> None:
|
||
if index < 0:
|
||
return
|
||
model_index = self.model_selector.currentIndex()
|
||
if model_index < 0:
|
||
return
|
||
model = str(self.model_selector.itemData(model_index))
|
||
history_index = _as_int(self.history_selector.itemData(index), index) or 0
|
||
self._render_model(model, history_index)
|
||
|
||
def _render_model(self, model_key: str, history_index: int = 0) -> None:
|
||
snapshots = self.histories.get(model_key, [])
|
||
if not 0 <= history_index < len(snapshots):
|
||
return
|
||
payload = snapshots[history_index]
|
||
self.active_model = model_key
|
||
self.active_history_index = history_index
|
||
self.payload = payload
|
||
self.diagnosis_label.setText(
|
||
_ai_narrative_text(_analysis_value(payload, "diagnosis_advice")) or "—"
|
||
)
|
||
self.treatment_label.setText(
|
||
_ai_narrative_text(_analysis_value(payload, "treatment_advice")) or "—"
|
||
)
|
||
_clear_ai_layout(self.risk_items_layout)
|
||
risks = _normalize_ai_risks(_analysis_value(payload, "risk_assessment"))
|
||
if not risks:
|
||
risks = [{"label": "暂无明确风险", "level": "low"}]
|
||
for risk in risks:
|
||
label = QLabel(risk["label"], self.risk_items)
|
||
label.setTextFormat(Qt.TextFormat.PlainText)
|
||
label.setWordWrap(True)
|
||
label.setMinimumWidth(0)
|
||
label.setMaximumWidth(340)
|
||
label.setSizePolicy(
|
||
QSizePolicy.Policy.Maximum,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
label.setProperty("dialogAiRisk", True)
|
||
label.setProperty("riskLevel", risk["level"])
|
||
self.risk_items_layout.addWidget(label)
|
||
self.risk_items_layout.invalidate()
|
||
self.risk_items.syncFlowHeight()
|
||
|
||
model = _summary_value(
|
||
_analysis_value(payload, "model_label")
|
||
or _analysis_value(payload, "model_name")
|
||
or _analysis_value(payload, "model_key")
|
||
or AI_ANALYSIS_MODEL_LABELS.get(model_key, model_key)
|
||
)
|
||
generated_at = _summary_value(
|
||
_analysis_value(payload, "generated_at")
|
||
or _analysis_value(payload, "created_at")
|
||
)
|
||
version = _patient_report_display_version(
|
||
payload,
|
||
fallback=len(snapshots) - history_index,
|
||
)
|
||
self.meta_label.setText(
|
||
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
|
||
)
|
||
self.meta_label.setVisible(bool(self.meta_label.text()))
|
||
self.scroll_area.verticalScrollBar().setValue(0)
|
||
|
||
|
||
class _GlucoseDropIcon(QWidget):
|
||
"""Outline blood-drop glyph used on queue cards."""
|
||
|
||
def __init__(self, tone: str = "high", parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._tone = tone
|
||
self.setFixedSize(14, 16)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
def set_tone(self, tone: str) -> None:
|
||
if tone == self._tone:
|
||
return
|
||
self._tone = tone
|
||
self.update()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
colors = {"high": "#5B8FF9", "warn": "#E8A23C", "ok": "#2DBE85"}
|
||
color = QColor(colors.get(self._tone, "#5B8FF9"))
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(
|
||
QPen(
|
||
color,
|
||
1.35,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
path = QPainterPath()
|
||
path.moveTo(7, 1.2)
|
||
path.cubicTo(12.4, 7.2, 12.2, 13.4, 7, 14.6)
|
||
path.cubicTo(1.8, 13.4, 1.6, 7.2, 7, 1.2)
|
||
painter.drawPath(path)
|
||
painter.setPen(
|
||
QPen(
|
||
color,
|
||
1.05,
|
||
Qt.PenStyle.SolidLine,
|
||
Qt.PenCapStyle.RoundCap,
|
||
Qt.PenJoinStyle.RoundJoin,
|
||
)
|
||
)
|
||
painter.drawLine(QPointF(7, 5.6), QPointF(5.6, 9.2))
|
||
painter.drawLine(QPointF(7, 5.6), QPointF(8.4, 9.2))
|
||
painter.drawLine(QPointF(5.6, 9.2), QPointF(7, 11.4))
|
||
painter.drawLine(QPointF(8.4, 9.2), QPointF(7, 11.4))
|
||
|
||
|
||
class _VitalMetricIcon(QWidget):
|
||
"""Compact line icon for a vital-sign tile."""
|
||
|
||
def __init__(self, kind: str, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._kind = kind
|
||
self.setFixedSize(22, 22)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor("#EEF2FF"))
|
||
painter.drawRoundedRect(QRectF(0, 0, 22, 22), 6, 6)
|
||
pen = QPen(QColor("#5469F0"), 1.35)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
kind = self._kind
|
||
if kind == "height":
|
||
painter.drawLine(QPointF(11, 5), QPointF(11, 17))
|
||
painter.drawLine(QPointF(8, 6.5), QPointF(11, 4.5))
|
||
painter.drawLine(QPointF(14, 6.5), QPointF(11, 4.5))
|
||
painter.drawLine(QPointF(8, 15.5), QPointF(11, 17.5))
|
||
painter.drawLine(QPointF(14, 15.5), QPointF(11, 17.5))
|
||
elif kind == "weight":
|
||
painter.drawRoundedRect(QRectF(5, 9, 12, 8), 2, 2)
|
||
painter.drawArc(QRectF(7.5, 5, 7, 6), 0, 180 * 16)
|
||
elif kind == "bmi":
|
||
painter.drawEllipse(QRectF(8, 4.5, 6, 6))
|
||
painter.drawLine(QPointF(11, 10.5), QPointF(11, 16))
|
||
painter.drawLine(QPointF(7.5, 12.5), QPointF(14.5, 12.5))
|
||
elif kind == "bp":
|
||
painter.drawEllipse(QRectF(6, 7, 10, 9))
|
||
painter.drawLine(QPointF(11, 7), QPointF(11, 5))
|
||
painter.drawLine(QPointF(8, 11.5), QPointF(14, 11.5))
|
||
else:
|
||
painter.drawPath(_heart_path(11, 11, 6.5))
|
||
painter.drawLine(QPointF(6, 11), QPointF(8.5, 11))
|
||
painter.drawLine(QPointF(8.5, 11), QPointF(10, 8.5))
|
||
painter.drawLine(QPointF(10, 8.5), QPointF(12.2, 13.5))
|
||
painter.drawLine(QPointF(12.2, 13.5), QPointF(14, 11))
|
||
painter.drawLine(QPointF(14, 11), QPointF(16.5, 11))
|
||
|
||
|
||
class _LifestyleIcon(QWidget):
|
||
"""Line glyph for smoking / drinking / exercise."""
|
||
|
||
def __init__(self, kind: str, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._kind = kind
|
||
self.setFixedSize(16, 16)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor("#8A95AF"), 1.3)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
if self._kind == "smoke":
|
||
painter.drawRoundedRect(QRectF(1.5, 9, 9, 3.2), 1, 1)
|
||
painter.drawLine(QPointF(11.2, 10.6), QPointF(14.5, 7.2))
|
||
painter.drawLine(QPointF(12.4, 8.4), QPointF(14.2, 5.6))
|
||
elif self._kind == "drink":
|
||
painter.drawLine(QPointF(5, 3.5), QPointF(11, 3.5))
|
||
painter.drawLine(QPointF(5, 3.5), QPointF(7.2, 9))
|
||
painter.drawLine(QPointF(11, 3.5), QPointF(8.8, 9))
|
||
painter.drawLine(QPointF(7.2, 9), QPointF(8.8, 9))
|
||
painter.drawLine(QPointF(8, 9), QPointF(8, 13.5))
|
||
painter.drawLine(QPointF(6.2, 13.5), QPointF(9.8, 13.5))
|
||
else:
|
||
painter.drawEllipse(QRectF(9.5, 2.2, 3.6, 3.6))
|
||
painter.drawLine(QPointF(10.2, 6.2), QPointF(6.2, 8.8))
|
||
painter.drawLine(QPointF(6.2, 8.8), QPointF(4.2, 13.5))
|
||
painter.drawLine(QPointF(10.2, 6.2), QPointF(12.8, 10.2))
|
||
painter.drawLine(QPointF(12.8, 10.2), QPointF(15, 8.5))
|
||
painter.drawLine(QPointF(8.4, 9.4), QPointF(11.5, 13.5))
|
||
|
||
|
||
class _GenderMark(QWidget):
|
||
"""Mars / Venus glyph used next to the patient name."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._kind = ""
|
||
self.setFixedSize(16, 16)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
self.hide()
|
||
|
||
def set_gender(self, gender: str) -> None:
|
||
text = str(gender or "")
|
||
if "男" in text or text.lower() in {"male", "m"}:
|
||
kind = "male"
|
||
elif "女" in text or text.lower() in {"female", "f"}:
|
||
kind = "female"
|
||
else:
|
||
kind = ""
|
||
if kind == self._kind:
|
||
self.setVisible(bool(kind))
|
||
return
|
||
self._kind = kind
|
||
self.setVisible(bool(kind))
|
||
self.update()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
if not self._kind:
|
||
return
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor("#7484C9"), 1.35)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
if self._kind == "male":
|
||
painter.drawEllipse(QRectF(1.8, 5.6, 8.4, 8.4))
|
||
painter.drawLine(QPointF(9.2, 6.6), QPointF(13.4, 2.4))
|
||
painter.drawLine(QPointF(10.2, 2.4), QPointF(13.4, 2.4))
|
||
painter.drawLine(QPointF(13.4, 2.4), QPointF(13.4, 5.6))
|
||
return
|
||
painter.drawEllipse(QRectF(3.6, 1.6, 8.4, 8.4))
|
||
painter.drawLine(QPointF(7.8, 10), QPointF(7.8, 14.4))
|
||
painter.drawLine(QPointF(5.6, 12.4), QPointF(10, 12.4))
|
||
|
||
|
||
def _heart_path(cx: float, cy: float, radius: float) -> QPainterPath:
|
||
path = QPainterPath()
|
||
path.moveTo(cx, cy + radius * 0.55)
|
||
path.cubicTo(
|
||
cx - radius * 1.15,
|
||
cy - radius * 0.05,
|
||
cx - radius * 0.45,
|
||
cy - radius * 0.95,
|
||
cx,
|
||
cy - radius * 0.25,
|
||
)
|
||
path.cubicTo(
|
||
cx + radius * 0.45,
|
||
cy - radius * 0.95,
|
||
cx + radius * 1.15,
|
||
cy - radius * 0.05,
|
||
cx,
|
||
cy + radius * 0.55,
|
||
)
|
||
return path
|
||
|
||
|
||
class _ReceptionQueueSpinner(QWidget):
|
||
"""Compact arc spinner used while the reception queue fetches a page."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setFixedSize(18, 18)
|
||
self._angle = 0
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(45)
|
||
self._timer.timeout.connect(self._advance)
|
||
|
||
def start(self) -> None:
|
||
if not self._timer.isActive():
|
||
self._timer.start()
|
||
self.update()
|
||
|
||
def stop(self) -> None:
|
||
self._timer.stop()
|
||
|
||
def _advance(self) -> None:
|
||
self._angle = (self._angle + 24) % 360
|
||
self.update()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor("#5469F0"), 2.2)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
painter.setPen(pen)
|
||
painter.drawArc(QRectF(2, 2, 14, 14), self._angle * 16, 250 * 16)
|
||
|
||
|
||
class _ReceptionQueueLoading(QFrame):
|
||
"""Footer shown while the queue is loading the first or next page."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("ReceptionQueueLoading")
|
||
self.setAccessibleName("队列加载中")
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(0, 4, 0, 4)
|
||
layout.setSpacing(8)
|
||
layout.addStretch(1)
|
||
self.spinner = _ReceptionQueueSpinner(self)
|
||
layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
self.label = QLabel("正在加载…", self)
|
||
self.label.setObjectName("ReceptionQueueLoadingText")
|
||
self.label.setTextFormat(Qt.TextFormat.PlainText)
|
||
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addStretch(1)
|
||
self.hide()
|
||
|
||
def start(self) -> None:
|
||
self.show()
|
||
self.spinner.start()
|
||
|
||
def stop(self) -> None:
|
||
self.spinner.stop()
|
||
self.hide()
|
||
|
||
|
||
class QueueRow(QWidget):
|
||
"""Compact appointment summary rendered inside the queue list."""
|
||
|
||
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("ReceptionQueueRow")
|
||
self._selected = False
|
||
self.setFixedHeight(86)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False)
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(16, 10, 12, 10)
|
||
layout.setSpacing(6)
|
||
top = QHBoxLayout()
|
||
top.setSpacing(7)
|
||
name = QLabel(
|
||
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
|
||
)
|
||
name.setObjectName("ReceptionQueueName")
|
||
name.setMinimumWidth(0)
|
||
name.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Preferred)
|
||
self.name_label = name
|
||
top.addWidget(name)
|
||
gender_age = QLabel(
|
||
f"{gender_text(first_value(record, 'gender_desc', 'gender'))} · "
|
||
f"{display_text(first_value(record, 'age'))}岁"
|
||
)
|
||
gender_age.setObjectName("ReceptionQueueMeta")
|
||
top.addWidget(gender_age)
|
||
top.addStretch(1)
|
||
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
|
||
status_text = display_text(
|
||
first_value(
|
||
record,
|
||
"status_desc",
|
||
"status_text",
|
||
default=STATUS_TEXT.get(status_number, status_number),
|
||
)
|
||
)
|
||
badge = StatusBadge(
|
||
status_text,
|
||
_queue_badge_kind(status_number, status_text),
|
||
)
|
||
badge.setMinimumWidth(max(54, badge.sizeHint().width()))
|
||
show_badge = _queue_shows_status_badge(status_number, status_text)
|
||
badge.setVisible(show_badge)
|
||
self.status_badge = badge
|
||
top.addWidget(
|
||
badge,
|
||
0,
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop,
|
||
)
|
||
time_label = QLabel(_queue_time_text(record))
|
||
time_label.setObjectName("ReceptionQueueTime")
|
||
time_label.setVisible(not show_badge and bool(time_label.text()))
|
||
top.addWidget(time_label, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
|
||
layout.addLayout(top)
|
||
bottom = QHBoxLayout()
|
||
bottom.setSpacing(8)
|
||
subline = QLabel(_queue_condition_summary(record))
|
||
subline.setObjectName("ReceptionQueueSubline")
|
||
subline.setMinimumWidth(0)
|
||
subline.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||
bottom.addWidget(subline, 1)
|
||
metric = _queue_glucose_value(record)
|
||
drop = _GlucoseDropIcon(_glucose_tone(metric), self)
|
||
drop.setVisible(metric not in (None, ""))
|
||
self.glucose_drop = drop
|
||
bottom.addWidget(drop, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||
metric_label = QLabel(
|
||
f"{display_text(metric)} mmol/L" if metric not in (None, "") else ""
|
||
)
|
||
metric_label.setObjectName("ReceptionQueueMetric")
|
||
tone = _glucose_tone(metric)
|
||
metric_label.setProperty("tone", tone)
|
||
metric_label.setVisible(metric not in (None, ""))
|
||
bottom.addWidget(metric_label, 0, Qt.AlignmentFlag.AlignRight)
|
||
layout.addLayout(bottom)
|
||
|
||
def set_selected(self, selected: bool) -> None:
|
||
if self._selected == selected:
|
||
return
|
||
self._selected = selected
|
||
self.name_label.setProperty("selected", selected)
|
||
self.name_label.style().unpolish(self.name_label)
|
||
self.name_label.style().polish(self.name_label)
|
||
self.update()
|
||
|
||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||
if self._selected:
|
||
painter.setBrush(QColor("#EEF2FF"))
|
||
painter.setPen(QPen(QColor("#C5CEFF"), 1))
|
||
else:
|
||
painter.setBrush(QColor("#FFFFFF"))
|
||
painter.setPen(QPen(QColor("#E7EBF5"), 1))
|
||
painter.drawRoundedRect(rect, 11, 11)
|
||
if self._selected:
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor("#5469F0"))
|
||
painter.drawRoundedRect(QRectF(1.2, 8, 3.2, self.height() - 16), 1.6, 1.6)
|
||
|
||
|
||
class ReceptionDailyRecordsPanel(DailyRecordPanel):
|
||
"""Read-only reception matrix backed by the diagnosis tracking endpoints."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("ReceptionDailyRecordsCard")
|
||
self.setAccessibleName("患者日常记录")
|
||
self._patient_age: int | None = None
|
||
self.set_editable(False, record_editable=False)
|
||
|
||
panel_layout = self.layout()
|
||
if isinstance(panel_layout, QVBoxLayout):
|
||
panel_layout.setContentsMargins(16, 14, 16, 16)
|
||
panel_layout.setSpacing(10)
|
||
heading = QWidget(self)
|
||
heading_layout = QHBoxLayout(heading)
|
||
heading_layout.setContentsMargins(0, 0, 0, 0)
|
||
heading_layout.setSpacing(12)
|
||
heading_text = QVBoxLayout()
|
||
heading_text.setContentsMargins(0, 0, 0, 0)
|
||
heading_text.setSpacing(2)
|
||
title = QLabel("日常记录", heading)
|
||
title.setObjectName("ReceptionDailyTitle")
|
||
title.setTextFormat(Qt.TextFormat.PlainText)
|
||
heading_text.addWidget(title)
|
||
subtitle = QLabel("血糖、血压、用药、饮食、运动与跟踪备注", heading)
|
||
subtitle.setObjectName("ReceptionDailySubtitle")
|
||
subtitle.setTextFormat(Qt.TextFormat.PlainText)
|
||
heading_text.addWidget(subtitle)
|
||
heading_layout.addLayout(heading_text)
|
||
heading_layout.addStretch(1)
|
||
panel_layout.insertWidget(0, heading)
|
||
|
||
self.toolbar_host.setObjectName("ReceptionDailyToolbar")
|
||
for mode, button in self.range_buttons.items():
|
||
button.setObjectName(
|
||
{
|
||
"7": "ReceptionDailyRange7Button",
|
||
"30": "ReceptionDailyRange30Button",
|
||
"custom": "ReceptionDailyRangeCustomButton",
|
||
}[mode]
|
||
)
|
||
button.setProperty("receptionDailyRange", True)
|
||
button.setAccessibleName(button.text())
|
||
self.start_date.setObjectName("ReceptionDailyCustomStart")
|
||
self.end_date.setObjectName("ReceptionDailyCustomEnd")
|
||
for editor, name in (
|
||
(self.start_date, "日常记录开始日期"),
|
||
(self.end_date, "日常记录结束日期"),
|
||
):
|
||
editor.setProperty("receptionDailyDate", True)
|
||
editor.setAccessibleName(name)
|
||
self.refresh_button.setObjectName("ReceptionDailyRefreshButton")
|
||
self.refresh_button.setAccessibleName("刷新日常记录")
|
||
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.state.setObjectName("ReceptionDailyRecordsState")
|
||
self.state.setAccessibleName("日常记录加载状态")
|
||
self.matrix.setObjectName("ReceptionDailyRecordsTable")
|
||
self.matrix.setAccessibleName("患者日常记录矩阵")
|
||
self.matrix.setMinimumHeight(330)
|
||
self.matrix.setMaximumHeight(460)
|
||
self.matrix.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||
|
||
for object_name in ("DiagnosisTrendCard", "DiagnosisTodoCard"):
|
||
child = self.findChild(QFrame, object_name)
|
||
if child is not None:
|
||
child.hide()
|
||
self.clear_with_message("选择患者后加载最近 7 天日常记录。")
|
||
|
||
def set_patient_age(self, age: Any) -> None:
|
||
parsed = _as_int(age, None)
|
||
self._patient_age = parsed if parsed is not None and parsed >= 0 else None
|
||
|
||
def clear_with_message(self, message: str) -> None:
|
||
super().clear()
|
||
self.state.show_message(message, "info")
|
||
|
||
def set_loading(self, loading: bool) -> None:
|
||
super().set_loading(loading)
|
||
for button in self.range_buttons.values():
|
||
button.setEnabled(not loading)
|
||
self.start_date.setEnabled(not loading)
|
||
self.end_date.setEnabled(not loading)
|
||
if loading:
|
||
self.state.show_message("正在从后端读取日常记录与跟踪备注…", "info")
|
||
|
||
def _glucose_is_high(self, metric: str, number: float | None) -> bool:
|
||
if number is None or self._patient_age is None:
|
||
return False
|
||
older = self._patient_age >= 50
|
||
threshold = 8.0 if older and metric == "fasting" else 7.0
|
||
if metric in {"postprandial", "other"}:
|
||
threshold = 10.0 if older else 9.0
|
||
return number >= threshold
|
||
|
||
@staticmethod
|
||
def _daily_value(record: Any, *keys: str) -> Any:
|
||
return first_value(record or {}, *keys, default=None)
|
||
|
||
def set_data(self, window: Any, notes: Sequence[Any]) -> None:
|
||
"""Render canonical production fields and the admin age thresholds."""
|
||
|
||
super().set_data(window, notes)
|
||
blood_rows = list(first_value(window, "blood_records", default=[]) or [])
|
||
blood = self._blood_date_map(blood_rows)
|
||
dates = self._dates()
|
||
for column, day in enumerate(dates, 1):
|
||
record = blood.get(day)
|
||
patient_self = bool(self._daily_value(record, "has_patient_self"))
|
||
for row, metric in enumerate(("fasting", "postprandial", "other", "bp")):
|
||
item = self.matrix.item(row, column)
|
||
if item is None:
|
||
continue
|
||
high = False
|
||
value = "—"
|
||
if metric == "fasting":
|
||
raw = self._daily_value(record, "fasting_blood_sugar")
|
||
number = self._number(raw)
|
||
value = display_text(raw) if number is not None else "—"
|
||
high = self._glucose_is_high(metric, number)
|
||
elif metric == "postprandial":
|
||
raw = self._daily_value(record, "postprandial_blood_sugar")
|
||
number = self._number(raw)
|
||
value = display_text(raw) if number is not None else "—"
|
||
high = self._glucose_is_high(metric, number)
|
||
elif metric == "other":
|
||
raw = self._daily_value(record, "other_blood_sugar")
|
||
number = self._number(raw)
|
||
value = display_text(raw) if number is not None else "—"
|
||
high = self._glucose_is_high(metric, number)
|
||
else:
|
||
systolic_raw = self._daily_value(record, "systolic_pressure")
|
||
diastolic_raw = self._daily_value(record, "diastolic_pressure")
|
||
systolic = self._number(systolic_raw)
|
||
diastolic = self._number(diastolic_raw)
|
||
if systolic is not None or diastolic is not None:
|
||
value = f"{display_text(systolic_raw)}/{display_text(diastolic_raw)}"
|
||
high = bool((systolic or 0) > 140 or (diastolic or 0) > 90)
|
||
if value != "—" and patient_self:
|
||
value = f"{value} · 自录"
|
||
if value != "—" and high:
|
||
value = f"{value} ↑"
|
||
item.setText(value)
|
||
item.setData(
|
||
Qt.ItemDataRole.UserRole,
|
||
{"date": day, "metric": metric, "high": high, "patient_self": patient_self},
|
||
)
|
||
item.setToolTip(value if value != "—" else "")
|
||
font = item.font()
|
||
font.setWeight(QFont.Weight.Bold if high else QFont.Weight.Normal)
|
||
item.setFont(font)
|
||
item.setForeground(QBrush(QColor("#D94856" if high else "#334260")))
|
||
item.setBackground(
|
||
QBrush(
|
||
QColor(
|
||
"#FFF1F3"
|
||
if high
|
||
else "#F0F4FF"
|
||
if patient_self and value != "—"
|
||
else Qt.GlobalColor.transparent
|
||
)
|
||
)
|
||
)
|
||
|
||
|
||
class ReceptionPage(QWidget):
|
||
"""Run the complete same-day reception workflow without blocking Qt."""
|
||
|
||
video_requested = Signal(dict)
|
||
|
||
def __init__(
|
||
self,
|
||
repository: Any,
|
||
permissions: Any = None,
|
||
current_user: Any = None,
|
||
parent: QWidget | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("ReceptionPage")
|
||
self.setStyleSheet(RECEPTION_QSS)
|
||
self.repository = repository
|
||
self.permissions = permissions
|
||
self.current_user = current_user
|
||
self._queue_generation = 0
|
||
self._detail_generation = 0
|
||
self._selected_record: Any = None
|
||
self._selected_detail: Any = None
|
||
self._selected_appointment_id: int | None = None
|
||
self._queue_loading = False
|
||
self._queue_requests: set[int] = set()
|
||
self._queue_records: list[Any] = []
|
||
self._queue_render_signature: tuple[tuple[str, ...], ...] = ()
|
||
self._queue_page = 0
|
||
self._queue_page_size = 15
|
||
self._queue_total = 0
|
||
self._queue_query: dict[str, Any] | None = None
|
||
self._queue_query_key: tuple[Any, ...] | None = None
|
||
self._detail_loading = False
|
||
self._detail_requests: set[tuple[int, int]] = set()
|
||
self._daily_generation = 0
|
||
self._daily_loading = False
|
||
self._daily_diagnosis_id: int | None = None
|
||
self._daily_requests: set[tuple[int, int, int]] = set()
|
||
self._daily_tracking_fallback: list[Any] = []
|
||
self._ai_analysis_generation = 0
|
||
self._ai_analysis_loading = False
|
||
self._ai_analysis_diagnosis_id: int | None = None
|
||
self._ai_analysis_patient_id: int | None = None
|
||
self._ai_analysis_state = "empty"
|
||
self._ai_analysis_payload: dict[str, Any] | None = None
|
||
self._ai_analysis_payloads: dict[str, dict[str, Any]] = {}
|
||
self._ai_analysis_histories: dict[str, list[dict[str, Any]]] = {
|
||
model: [] for model in AI_ANALYSIS_MODELS
|
||
}
|
||
self._ai_analysis_model_states = {model: "empty" for model in AI_ANALYSIS_MODELS}
|
||
self._ai_analysis_model_messages = {model: "" for model in AI_ANALYSIS_MODELS}
|
||
self._ai_analysis_requests: set[tuple[int, int, int, str]] = set()
|
||
self._patient_ai_list_requests: set[tuple[int, int, int]] = set()
|
||
self._ai_analysis_active_model = "qwen"
|
||
self._ai_analysis_supports_models = True
|
||
self._ai_analysis_patient_flow = False
|
||
self._ai_analysis_regenerating = False
|
||
self._ai_analysis_regeneration_model: str | None = None
|
||
self._ai_analysis_operation_error = ""
|
||
self._ai_analysis_rendered_model: str | None = None
|
||
self._ai_analysis_rendered_payload: dict[str, Any] | None = None
|
||
self._pending_tongue_images: list[str] = []
|
||
self._pending_report_files: list[str] = []
|
||
self._note_busy = False
|
||
self._attachment_preview_generation = 0
|
||
self._attachment_render_generation = 0
|
||
self._queue_filter_status: int | None = 1
|
||
self._queue_filter_buttons: dict[int | None, QPushButton] = {}
|
||
self._queue_date = date.today().isoformat()
|
||
|
||
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
|
||
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
|
||
self._can_edit = has_permission(self.permissions, "tcm.diagnosis/edit")
|
||
self._can_ai_report = can_open_diagnosis_ai_report(self.permissions)
|
||
self._can_patient_ai_read = has_permission(
|
||
self.permissions,
|
||
"tcm.diagnosis/patientAiReports",
|
||
)
|
||
self._can_patient_ai_generate = has_permission(
|
||
self.permissions,
|
||
"tcm.diagnosis/generatePatientAiReport",
|
||
)
|
||
self._can_ai_analysis = self._can_patient_ai_read or has_permission(
|
||
self.permissions,
|
||
"tcm.diagnosis/aiAnalysis",
|
||
)
|
||
self._can_ai_regenerate = (
|
||
self._can_patient_ai_read and self._can_patient_ai_generate
|
||
)
|
||
self._can_ai_assistant = can_use_diagnosis_ai_assistant(self.permissions)
|
||
self._can_phone_plain = has_permission(
|
||
self.permissions, "tcm.diagnosis/phonePlain", default=False
|
||
)
|
||
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(27, 20, 18, 15)
|
||
root.setSpacing(0)
|
||
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
splitter.setChildrenCollapsible(False)
|
||
splitter.addWidget(self._build_queue_panel())
|
||
splitter.addWidget(self._build_detail_panel())
|
||
splitter.setStretchFactor(0, 0)
|
||
splitter.setStretchFactor(1, 1)
|
||
splitter.setHandleWidth(28)
|
||
splitter.setSizes([350, 1062])
|
||
root.addWidget(splitter, 1)
|
||
|
||
self.poll_timer = QTimer(self)
|
||
self.poll_timer.setInterval(5_000)
|
||
self.poll_timer.timeout.connect(self._poll_queue)
|
||
|
||
def _ensure_diagnosis_dialog(self) -> DiagnosisDialog:
|
||
dialog = getattr(self, "diagnosis_dialog", None)
|
||
if dialog is None:
|
||
dialog = DiagnosisDialog(self.repository, self)
|
||
dialog.saved.connect(self._diagnosis_saved)
|
||
self.diagnosis_dialog = dialog
|
||
return dialog
|
||
|
||
def _build_queue_panel(self) -> QWidget:
|
||
panel = QFrame()
|
||
panel.setObjectName("ReceptionQueuePanel")
|
||
panel.setMinimumWidth(350)
|
||
panel.setMaximumWidth(350)
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(6, 11, 6, 12)
|
||
layout.setSpacing(10)
|
||
|
||
header = QWidget(panel)
|
||
header_layout = QHBoxLayout(header)
|
||
header_layout.setContentsMargins(2, 0, 0, 0)
|
||
header_layout.setSpacing(8)
|
||
title = QLabel("今日接诊", header)
|
||
title.setObjectName("ReceptionQueueTitle")
|
||
header_layout.addWidget(title)
|
||
header_layout.addStretch(1)
|
||
self.queue_date_button = _ReceptionDateButton(self._queue_date, header)
|
||
self.queue_date_button.setObjectName("ReceptionDateButton")
|
||
self.queue_date_button.setFixedWidth(118)
|
||
self.queue_date_button.date_selected.connect(self._queue_date_changed)
|
||
header_layout.addWidget(self.queue_date_button)
|
||
layout.addWidget(header)
|
||
|
||
filter_row = QHBoxLayout()
|
||
filter_row.setSpacing(4)
|
||
for status, label in ((None, "全部"), (1, "待接诊"), (2, "问诊中"), (3, "已完成")):
|
||
button = QPushButton(label, panel)
|
||
button.setCheckable(True)
|
||
button.setProperty("receptionQueueChip", True)
|
||
button.clicked.connect(
|
||
lambda _checked=False, value=status: self._set_queue_filter(value)
|
||
)
|
||
self._queue_filter_buttons[status] = button
|
||
filter_row.addWidget(button, 1)
|
||
self._queue_filter_buttons[1].setChecked(True)
|
||
layout.addLayout(filter_row)
|
||
|
||
# These controls remain as the stable integration surface used by the
|
||
# shell search and legacy parity tests. The compact chip row above is
|
||
# the visible presentation.
|
||
compatibility_host = QWidget(panel)
|
||
compatibility_host.setFixedSize(0, 0)
|
||
compatibility_host.move(-100, -100)
|
||
self.queue_tabs = QTabBar(compatibility_host)
|
||
self.queue_tabs.setExpanding(True)
|
||
self.queue_tabs.addTab("待接诊")
|
||
self.queue_tabs.addTab("已过号")
|
||
self.queue_tabs.currentChanged.connect(self._on_tab_changed)
|
||
self.search_edit = QLineEdit(compatibility_host)
|
||
self.search_edit.setPlaceholderText("搜索患者姓名")
|
||
self.search_edit.setClearButtonEnabled(True)
|
||
self.search_edit.returnPressed.connect(lambda: self.refresh())
|
||
self.search_edit.textChanged.connect(self._search_changed)
|
||
|
||
self.queue_banner = MessageBanner(parent=panel)
|
||
layout.addWidget(self.queue_banner)
|
||
self.queue_stack = QStackedWidget(panel)
|
||
self.queue_list = QListWidget(self.queue_stack)
|
||
self.queue_list.setObjectName("ReceptionQueueList")
|
||
self.queue_list.setSpacing(8)
|
||
self.queue_list.setUniformItemSizes(True)
|
||
self.queue_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self.queue_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.queue_list.currentItemChanged.connect(self._on_queue_selection)
|
||
self.queue_list.verticalScrollBar().valueChanged.connect(self._on_queue_scroll)
|
||
self.queue_stack.addWidget(self.queue_list)
|
||
self.queue_empty = EmptyState("队列为空", "当前筛选下没有待处理患者。", "重新加载")
|
||
self.queue_empty.action_requested.connect(lambda: self.refresh())
|
||
self.queue_stack.addWidget(self.queue_empty)
|
||
layout.addWidget(self.queue_stack, 1)
|
||
self.queue_loading_indicator = _ReceptionQueueLoading(panel)
|
||
layout.addWidget(self.queue_loading_indicator)
|
||
self.queue_summary = QLabel("等待加载", panel)
|
||
self.queue_summary.setObjectName("ReceptionQueueSummary")
|
||
self.queue_summary.hide()
|
||
layout.addWidget(self.queue_summary)
|
||
return panel
|
||
|
||
def _build_detail_panel(self) -> QWidget:
|
||
panel = QFrame()
|
||
panel.setObjectName("ReceptionDetailPanel")
|
||
panel.setMinimumWidth(430)
|
||
panel_layout = QVBoxLayout(panel)
|
||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||
panel_layout.setSpacing(0)
|
||
|
||
self.detail_stack = QStackedWidget(panel)
|
||
self.detail_empty = EmptyState("选择一位患者", "从左侧队列选择患者后,这里会显示接诊详情。")
|
||
self.detail_stack.addWidget(self.detail_empty)
|
||
scroll = QScrollArea(panel)
|
||
self.detail_scroll = scroll
|
||
scroll.setObjectName("ReceptionDetailScroll")
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
content = QWidget(scroll)
|
||
content.setMinimumWidth(390)
|
||
content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||
detail_layout = QVBoxLayout(content)
|
||
detail_layout.setContentsMargins(0, 0, 0, 12)
|
||
detail_layout.setSpacing(10)
|
||
|
||
hero = QFrame(content)
|
||
hero.setObjectName("ReceptionHero")
|
||
hero.setMinimumHeight(92)
|
||
hero.setMaximumHeight(92)
|
||
hero_layout = QVBoxLayout(hero)
|
||
hero_layout.setContentsMargins(18, 10, 18, 10)
|
||
hero_layout.setSpacing(4)
|
||
self.detail_banner = MessageBanner(parent=hero)
|
||
hero_layout.addWidget(self.detail_banner)
|
||
patient_head = QHBoxLayout()
|
||
patient_head.setSpacing(12)
|
||
self.patient_avatar_label = QLabel("患", hero)
|
||
self.patient_avatar_label.setObjectName("ReceptionAvatar")
|
||
self.patient_avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.patient_avatar_label.setFixedSize(52, 52)
|
||
patient_head.addWidget(self.patient_avatar_label)
|
||
identity = QVBoxLayout()
|
||
identity.setSpacing(4)
|
||
identity_title = QHBoxLayout()
|
||
identity_title.setSpacing(8)
|
||
self.patient_name_label = QLabel("—", hero)
|
||
self.patient_name_label.setObjectName("ReceptionPatientName")
|
||
identity_title.addWidget(self.patient_name_label)
|
||
self.patient_gender_mark = _GenderMark(hero)
|
||
identity_title.addWidget(self.patient_gender_mark, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
self.detail_status = StatusBadge("—", "neutral", hero)
|
||
identity_title.addWidget(self.detail_status)
|
||
identity_title.addStretch(1)
|
||
identity.addLayout(identity_title)
|
||
self.patient_meta_label = QLabel("—", hero)
|
||
self.patient_meta_label.setObjectName("ReceptionPatientMeta")
|
||
identity.addWidget(self.patient_meta_label)
|
||
patient_head.addLayout(identity, 1)
|
||
patient_head.addStretch(1)
|
||
|
||
self.complete_button = QPushButton("结束问诊", hero)
|
||
self.complete_button.setObjectName("ReceptionCompleteButton")
|
||
self.complete_button.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
|
||
self.complete_button.setIconSize(QSize(14, 14))
|
||
self.complete_button.clicked.connect(self._complete_appointment)
|
||
self.complete_button.setVisible(self._can_complete)
|
||
patient_head.addWidget(self.complete_button)
|
||
self.history_button = QPushButton("查看历史", hero)
|
||
self.history_button.setObjectName("ReceptionHistoryButton")
|
||
self.history_button.setIcon(_painted_reception_action_icon("info", "#3F4E75"))
|
||
self.history_button.setIconSize(QSize(14, 14))
|
||
self.history_button.clicked.connect(lambda: self.detail_tabs.setCurrentIndex(4))
|
||
patient_head.addWidget(self.history_button)
|
||
self.more_button = QPushButton("更多", hero)
|
||
self.more_button.setObjectName("ReceptionMoreButton")
|
||
more_menu = QMenu(self.more_button)
|
||
more_menu.addAction("通知医助").triggered.connect(self._notify_assistant)
|
||
more_menu.addAction("IM 问诊").triggered.connect(self._request_video)
|
||
edit_action = more_menu.addAction("编辑病历")
|
||
edit_action.setVisible(self._can_edit)
|
||
edit_action.triggered.connect(self._edit_diagnosis)
|
||
ai_action = more_menu.addAction("查看 AI 报告")
|
||
ai_action.setVisible(self._can_ai_report)
|
||
ai_action.triggered.connect(self._open_ai_report)
|
||
consult_action = more_menu.addAction("AI 分析")
|
||
consult_action.setVisible(self._can_ai_assistant)
|
||
consult_action.triggered.connect(self._open_ai_consult)
|
||
self.more_button.setMenu(more_menu)
|
||
patient_head.addWidget(self.more_button)
|
||
|
||
# Stable action objects are retained for permissions, tests and
|
||
# external integrations; their visible entry points live in "更多".
|
||
action_compat = QWidget(hero)
|
||
action_compat.setFixedSize(0, 0)
|
||
action_compat.move(-100, -100)
|
||
self.notify_button = QPushButton("通知医助", action_compat)
|
||
self.notify_button.clicked.connect(self._notify_assistant)
|
||
self.video_button = QPushButton("IM 问诊", action_compat)
|
||
self.video_button.setToolTip("打开患者 IM,可发送消息并从会话中发起视频")
|
||
self.video_button.setProperty("variant", "secondary")
|
||
self.video_button.clicked.connect(self._request_video)
|
||
self.edit_button = QPushButton("编辑病历", action_compat)
|
||
self.edit_button.setProperty("variant", "secondary")
|
||
self.edit_button.clicked.connect(self._edit_diagnosis)
|
||
self.edit_button.setVisible(self._can_edit)
|
||
self.ai_button = QPushButton("AI 报告", action_compat)
|
||
self.ai_button.setProperty("variant", "secondary")
|
||
self.ai_button.clicked.connect(self._open_ai_report)
|
||
self.ai_button.setVisible(self._can_ai_report)
|
||
self.ai_consult_button = QPushButton("AI 分析", action_compat)
|
||
self.ai_consult_button.setProperty("variant", "secondary")
|
||
self.ai_consult_button.clicked.connect(self._open_ai_consult)
|
||
self.ai_consult_button.setVisible(self._can_ai_assistant)
|
||
hero_layout.addLayout(patient_head)
|
||
detail_layout.addWidget(hero)
|
||
|
||
clinical_card = QFrame(content)
|
||
clinical_card.setObjectName("ReceptionClinicalCard")
|
||
clinical_card.setMinimumHeight(434)
|
||
clinical_card.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Minimum,
|
||
)
|
||
clinical_layout = QVBoxLayout(clinical_card)
|
||
clinical_layout.setContentsMargins(0, 0, 0, 0)
|
||
clinical_layout.setSpacing(0)
|
||
self.detail_tabs = QTabBar(clinical_card)
|
||
self.detail_tabs.setObjectName("ReceptionDetailTabs")
|
||
self.detail_tabs.setDrawBase(False)
|
||
self.detail_tabs.setExpanding(False)
|
||
self.detail_tabs.setIconSize(QSize(16, 16))
|
||
for label, kind in (
|
||
("问诊信息", "consult"),
|
||
("检查报告", "report"),
|
||
("用药记录", "meds"),
|
||
("日常记录", "daily"),
|
||
("随访记录", "followup"),
|
||
("健康数据", "health"),
|
||
):
|
||
self.detail_tabs.addTab(_painted_reception_tab_icon(kind), label)
|
||
clinical_layout.addWidget(self.detail_tabs)
|
||
|
||
self.clinical_pages = QStackedWidget(clinical_card)
|
||
self.clinical_pages.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Minimum,
|
||
)
|
||
clinical_layout.addWidget(self.clinical_pages, 1)
|
||
self.clinical_scroll = self.detail_scroll
|
||
|
||
consultation_page = QWidget(self.clinical_pages)
|
||
consultation_page.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Minimum,
|
||
)
|
||
consultation_layout = QHBoxLayout(consultation_page)
|
||
consultation_layout.setContentsMargins(14, 10, 14, 12)
|
||
consultation_layout.setSpacing(10)
|
||
self.clinical_info_group = consultation_page
|
||
|
||
history_card = QFrame(consultation_page)
|
||
history_card.setObjectName("ReceptionHistoryCard")
|
||
history_layout = QVBoxLayout(history_card)
|
||
history_layout.setContentsMargins(16, 14, 16, 14)
|
||
history_layout.setSpacing(14)
|
||
self.case_labels: dict[str, QLabel] = {}
|
||
|
||
def add_case_field(
|
||
target: QVBoxLayout,
|
||
key: str,
|
||
caption: str,
|
||
*,
|
||
stacked: bool = False,
|
||
) -> None:
|
||
field = QWidget(history_card)
|
||
if stacked:
|
||
field_layout = QVBoxLayout(field)
|
||
field_layout.setContentsMargins(0, 0, 0, 0)
|
||
field_layout.setSpacing(6)
|
||
title_label = QLabel(caption, field)
|
||
title_label.setProperty("receptionBlockTitle", True)
|
||
field_layout.addWidget(title_label)
|
||
value_label = _SpacedTextLabel("—", field)
|
||
value_label.setProperty("receptionBlockValue", True)
|
||
field_layout.addWidget(value_label)
|
||
else:
|
||
field_layout = QHBoxLayout(field)
|
||
field_layout.setContentsMargins(0, 0, 0, 0)
|
||
field_layout.setSpacing(10)
|
||
title_label = QLabel(caption, field)
|
||
title_label.setProperty("receptionInlineCaption", True)
|
||
title_label.setAlignment(
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||
)
|
||
field_layout.addWidget(title_label, 0, Qt.AlignmentFlag.AlignTop)
|
||
value_label = _SpacedTextLabel("—", field)
|
||
value_label.setProperty("receptionInlineValue", True)
|
||
field_layout.addWidget(value_label, 1)
|
||
self.case_labels[key] = value_label
|
||
target.addWidget(field)
|
||
|
||
add_case_field(history_layout, "chief", "主诉", stacked=True)
|
||
add_case_field(history_layout, "present", "现病史", stacked=True)
|
||
add_case_field(history_layout, "past", "既往史")
|
||
add_case_field(history_layout, "family", "家族史")
|
||
add_case_field(history_layout, "allergy", "过敏史")
|
||
|
||
lifestyle_field = QWidget(history_card)
|
||
lifestyle_layout = QHBoxLayout(lifestyle_field)
|
||
lifestyle_layout.setContentsMargins(0, 0, 0, 0)
|
||
lifestyle_layout.setSpacing(10)
|
||
lifestyle_title = QLabel("生活习惯", lifestyle_field)
|
||
lifestyle_title.setProperty("receptionInlineCaption", True)
|
||
lifestyle_title.setAlignment(
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||
)
|
||
lifestyle_layout.addWidget(lifestyle_title, 0, Qt.AlignmentFlag.AlignTop)
|
||
lifestyle_chips = QWidget(lifestyle_field)
|
||
lifestyle_row = QHBoxLayout(lifestyle_chips)
|
||
lifestyle_row.setContentsMargins(0, 0, 0, 0)
|
||
lifestyle_row.setSpacing(14)
|
||
self.lifestyle_value_labels: dict[str, QLabel] = {}
|
||
for kind, caption in (("smoke", "吸烟"), ("drink", "饮酒"), ("exercise", "运动")):
|
||
chip = QWidget(lifestyle_chips)
|
||
chip_layout = QHBoxLayout(chip)
|
||
chip_layout.setContentsMargins(0, 0, 0, 0)
|
||
chip_layout.setSpacing(4)
|
||
chip_layout.addWidget(_LifestyleIcon(kind, chip))
|
||
value_label = QLabel(f"{caption}:—", chip)
|
||
value_label.setProperty("receptionLifestyleChip", True)
|
||
chip_layout.addWidget(value_label)
|
||
self.lifestyle_value_labels[kind] = value_label
|
||
lifestyle_row.addWidget(chip)
|
||
lifestyle_row.addStretch(1)
|
||
lifestyle_layout.addWidget(lifestyle_chips, 1)
|
||
self.case_labels["lifestyle"] = QLabel("", lifestyle_field)
|
||
self.case_labels["lifestyle"].hide()
|
||
history_layout.addWidget(lifestyle_field)
|
||
|
||
add_case_field(history_layout, "diet", "饮食习惯")
|
||
history_layout.addStretch(1)
|
||
consultation_layout.addWidget(history_card, 7)
|
||
|
||
right_clinical = QWidget(consultation_page)
|
||
right_layout = QVBoxLayout(right_clinical)
|
||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||
right_layout.setSpacing(10)
|
||
|
||
vitals_card = QFrame(right_clinical)
|
||
vitals_card.setObjectName("ReceptionVitalsCard")
|
||
vitals_card.setFixedHeight(118)
|
||
vitals_layout = QVBoxLayout(vitals_card)
|
||
vitals_layout.setContentsMargins(12, 9, 12, 10)
|
||
vitals_layout.setSpacing(8)
|
||
vitals_title = QLabel("生命体征", vitals_card)
|
||
vitals_title.setProperty("receptionBlockTitle", True)
|
||
vitals_layout.addWidget(vitals_title)
|
||
vitals_grid = QGridLayout()
|
||
vitals_grid.setContentsMargins(0, 0, 0, 0)
|
||
vitals_grid.setHorizontalSpacing(7)
|
||
self.vital_labels: dict[str, QLabel] = {}
|
||
self.vital_unit_labels: dict[str, QLabel] = {}
|
||
self.vital_bmi_state = QLabel("", vitals_card)
|
||
self.vital_bmi_state.setProperty("receptionBmiState", True)
|
||
self.vital_bmi_state.setVisible(False)
|
||
vital_units = {
|
||
"height": "cm",
|
||
"weight": "kg",
|
||
"bmi": "",
|
||
"bp": "mmHg",
|
||
"pulse": "次/分",
|
||
}
|
||
for column, (key, caption) in enumerate(
|
||
(("height", "身高"), ("weight", "体重"), ("bmi", "BMI"), ("bp", "血压"), ("pulse", "心率"))
|
||
):
|
||
cell = QFrame(vitals_card)
|
||
cell.setObjectName("ReceptionMetricCell")
|
||
cell_layout = QHBoxLayout(cell)
|
||
cell_layout.setContentsMargins(8, 8, 8, 8)
|
||
cell_layout.setSpacing(6)
|
||
cell_layout.addWidget(
|
||
_VitalMetricIcon(key, cell),
|
||
0,
|
||
Qt.AlignmentFlag.AlignTop,
|
||
)
|
||
copy = QVBoxLayout()
|
||
copy.setContentsMargins(0, 0, 0, 0)
|
||
copy.setSpacing(2)
|
||
caption_label = QLabel(caption, cell)
|
||
caption_label.setProperty("receptionMetricCaption", True)
|
||
copy.addWidget(caption_label)
|
||
value_row = QHBoxLayout()
|
||
value_row.setSpacing(4)
|
||
value_label = QLabel("—", cell)
|
||
value_label.setProperty("receptionMetricValue", True)
|
||
value_row.addWidget(value_label)
|
||
unit_label = QLabel(vital_units[key], cell)
|
||
unit_label.setProperty("receptionMetricUnit", True)
|
||
unit_label.setVisible(False)
|
||
value_row.addWidget(unit_label)
|
||
if key == "bmi":
|
||
value_row.addWidget(self.vital_bmi_state)
|
||
value_row.addStretch(1)
|
||
copy.addLayout(value_row)
|
||
cell_layout.addLayout(copy, 1)
|
||
self.vital_labels[key] = value_label
|
||
self.vital_unit_labels[key] = unit_label
|
||
vitals_grid.addWidget(cell, 0, column)
|
||
vitals_grid.setColumnStretch(column, 1)
|
||
vitals_layout.addLayout(vitals_grid)
|
||
right_layout.addWidget(vitals_card)
|
||
|
||
labs_card = QFrame(right_clinical)
|
||
labs_card.setObjectName("ReceptionLabsCard")
|
||
labs_layout = QVBoxLayout(labs_card)
|
||
labs_layout.setContentsMargins(12, 9, 12, 10)
|
||
labs_layout.setSpacing(7)
|
||
labs_title = QLabel("检查结果", labs_card)
|
||
labs_title.setProperty("receptionBlockTitle", True)
|
||
labs_layout.addWidget(labs_title)
|
||
labs_grid = QGridLayout()
|
||
labs_grid.setContentsMargins(0, 0, 0, 0)
|
||
labs_grid.setHorizontalSpacing(7)
|
||
labs_grid.setVerticalSpacing(7)
|
||
self.lab_labels: dict[str, QLabel] = {}
|
||
self.lab_state_labels: dict[str, QLabel] = {}
|
||
lab_definitions = (
|
||
("fasting", "空腹血糖"),
|
||
("postprandial", "餐后2小时血糖"),
|
||
("hba1c", "糖化血红蛋白"),
|
||
("cholesterol", "总胆固醇"),
|
||
("triglycerides", "甘油三酯"),
|
||
("hdl", "HDL-C"),
|
||
("ldl", "LDL-C"),
|
||
("albumin", "尿微量白蛋白"),
|
||
)
|
||
for index, (key, caption) in enumerate(lab_definitions):
|
||
cell = QFrame(labs_card)
|
||
cell.setObjectName("ReceptionLabCell")
|
||
cell_layout = QVBoxLayout(cell)
|
||
cell_layout.setContentsMargins(9, 6, 8, 6)
|
||
cell_layout.setSpacing(2)
|
||
caption_label = QLabel(caption, cell)
|
||
caption_label.setProperty("receptionMetricCaption", True)
|
||
cell_layout.addWidget(caption_label)
|
||
value_row = QHBoxLayout()
|
||
value_row.setSpacing(4)
|
||
value_label = QLabel("—", cell)
|
||
value_label.setProperty("receptionMetricValue", True)
|
||
value_row.addWidget(value_label)
|
||
value_row.addStretch(1)
|
||
state_label = QLabel("正常", cell)
|
||
state_label.setProperty("receptionMetricState", True)
|
||
state_label.setVisible(False)
|
||
value_row.addWidget(state_label)
|
||
cell_layout.addLayout(value_row)
|
||
self.lab_labels[key] = value_label
|
||
self.lab_state_labels[key] = state_label
|
||
row, column = divmod(index, 4)
|
||
labs_grid.addWidget(cell, row, column)
|
||
labs_grid.setColumnStretch(column, 1)
|
||
labs_layout.addLayout(labs_grid, 1)
|
||
right_layout.addWidget(labs_card, 1)
|
||
consultation_layout.addWidget(right_clinical, 13)
|
||
self.clinical_pages.addWidget(consultation_page)
|
||
|
||
appointment_card, self.appointment_labels = self._field_card(
|
||
"预约信息",
|
||
(
|
||
("预约时间", "time"),
|
||
("联系方式", "phone"),
|
||
("接诊医生", "doctor"),
|
||
("协助医助", "assistant"),
|
||
("预约类型", "type"),
|
||
("预约渠道", "channel"),
|
||
("当前状态", "status"),
|
||
("预约备注", "remark"),
|
||
),
|
||
)
|
||
self.appointment_card = appointment_card
|
||
patient_card, self.patient_labels = self._field_card(
|
||
"患者信息",
|
||
(
|
||
("患者编号", "patient_id"),
|
||
("手机", "phone"),
|
||
("性别 / 年龄", "gender_age"),
|
||
("身高 / 体重", "body"),
|
||
("所在地区", "region"),
|
||
("身份证", "id_card"),
|
||
("客服 / 来源", "customer"),
|
||
("处方状态", "prescription"),
|
||
),
|
||
)
|
||
self.patient_card = patient_card
|
||
compatibility_fields = QWidget(clinical_card)
|
||
compatibility_fields.setFixedSize(0, 0)
|
||
compatibility_fields.move(-100, -100)
|
||
self.appointment_card.setParent(compatibility_fields)
|
||
self.patient_card.setParent(compatibility_fields)
|
||
self.appointment_card.setVisible(False)
|
||
self.patient_card.setVisible(False)
|
||
|
||
diagnosis_card = QFrame()
|
||
self.diagnosis_card = diagnosis_card
|
||
diagnosis_card.setObjectName("ReceptionMedicationCaseCard")
|
||
diagnosis_card.setAccessibleName("完整病例")
|
||
diagnosis_layout = QVBoxLayout(diagnosis_card)
|
||
diagnosis_layout.setContentsMargins(18, 16, 18, 18)
|
||
diagnosis_layout.setSpacing(14)
|
||
|
||
case_header = QWidget(diagnosis_card)
|
||
case_header_layout = QHBoxLayout(case_header)
|
||
case_header_layout.setContentsMargins(0, 0, 0, 0)
|
||
case_header_layout.setSpacing(12)
|
||
case_heading = QVBoxLayout()
|
||
case_heading.setContentsMargins(0, 0, 0, 0)
|
||
case_heading.setSpacing(3)
|
||
case_title = QLabel("完整病例", case_header)
|
||
case_title.setObjectName("ReceptionMedicationCaseTitle")
|
||
case_title.setAccessibleName("完整病例")
|
||
case_heading.addWidget(case_title)
|
||
case_subtitle = QLabel(
|
||
"优先呈现当前诊断、关键指标、用药安全与病史风险",
|
||
case_header,
|
||
)
|
||
case_subtitle.setObjectName("ReceptionMedicationCaseSubtitle")
|
||
case_subtitle.setTextFormat(Qt.TextFormat.PlainText)
|
||
case_heading.addWidget(case_subtitle)
|
||
case_header_layout.addLayout(case_heading, 1)
|
||
self.prescription_hint = QLabel("处方:—", case_header)
|
||
self.prescription_hint.setObjectName("ReceptionMedicationPrescriptionStatus")
|
||
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
|
||
self.prescription_hint.setAccessibleName("处方状态")
|
||
self.prescription_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
case_header_layout.addWidget(self.prescription_hint, 0, Qt.AlignmentFlag.AlignTop)
|
||
diagnosis_layout.addWidget(case_header)
|
||
|
||
self.case_empty_label = QLabel("尚未填写病例信息", diagnosis_card)
|
||
self.case_empty_label.setObjectName("ReceptionMedicationCaseEmpty")
|
||
self.case_empty_label.setAccessibleName("完整病例状态")
|
||
self.case_empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.case_empty_label.setWordWrap(True)
|
||
diagnosis_layout.addWidget(self.case_empty_label)
|
||
|
||
self.case_overview_widget = QWidget(diagnosis_card)
|
||
self.case_overview_widget.setObjectName("ReceptionMedicationCaseOverview")
|
||
case_overview_layout = QVBoxLayout(self.case_overview_widget)
|
||
case_overview_layout.setContentsMargins(0, 0, 0, 0)
|
||
case_overview_layout.setSpacing(12)
|
||
|
||
case_metrics = QWidget(self.case_overview_widget)
|
||
case_metrics.setObjectName("ReceptionMedicationCaseMetrics")
|
||
case_metrics_grid = QGridLayout(case_metrics)
|
||
case_metrics_grid.setContentsMargins(0, 0, 0, 0)
|
||
case_metrics_grid.setHorizontalSpacing(8)
|
||
case_metrics_grid.setVerticalSpacing(8)
|
||
self.case_metric_labels: dict[str, QLabel] = {}
|
||
for column, (key, caption) in enumerate(
|
||
(
|
||
("diagnosis_type", "诊断类型"),
|
||
("diagnosis_date", "诊断日期"),
|
||
("blood_pressure", "血压"),
|
||
("fasting_glucose", "空腹血糖"),
|
||
)
|
||
):
|
||
metric = QFrame(case_metrics)
|
||
metric.setProperty("receptionCaseMetric", True)
|
||
metric_layout = QVBoxLayout(metric)
|
||
metric_layout.setContentsMargins(11, 9, 11, 9)
|
||
metric_layout.setSpacing(4)
|
||
metric_caption = QLabel(caption, metric)
|
||
metric_caption.setProperty("receptionCaseMetricCaption", True)
|
||
metric_caption.setTextFormat(Qt.TextFormat.PlainText)
|
||
metric_layout.addWidget(metric_caption)
|
||
metric_value = QLabel("未记录", metric)
|
||
metric_value.setProperty("receptionCaseMetricValue", True)
|
||
metric_value.setTextFormat(Qt.TextFormat.PlainText)
|
||
metric_value.setWordWrap(True)
|
||
metric_value.setTextInteractionFlags(
|
||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||
)
|
||
metric_value.setAccessibleName(f"{caption}:未记录")
|
||
metric_layout.addWidget(metric_value)
|
||
self.case_metric_labels[key] = metric_value
|
||
case_metrics_grid.addWidget(metric, 0, column)
|
||
case_metrics_grid.setColumnStretch(column, 1)
|
||
case_overview_layout.addWidget(case_metrics)
|
||
|
||
self.case_section_frames: dict[str, QFrame] = {}
|
||
self.case_section_layouts: dict[str, QVBoxLayout] = {}
|
||
for key, title, kind in (
|
||
("diagnosis", "当前诊断", "diagnosis"),
|
||
("medication", "用药与安全提醒", "medication"),
|
||
("symptoms", "症状与现病史", "clinical"),
|
||
("tcm", "中医辨证记录", "tcm"),
|
||
("history", "既往史与风险", "history"),
|
||
("lifestyle", "生活与身体状态", "lifestyle"),
|
||
("notes", "医生补充备注", "note"),
|
||
):
|
||
section = QFrame(self.case_overview_widget)
|
||
section.setObjectName("ReceptionCaseSection")
|
||
section.setProperty("receptionCaseSection", True)
|
||
section.setProperty("caseKind", kind)
|
||
section.setAccessibleName(title)
|
||
section_layout = QVBoxLayout(section)
|
||
section_layout.setContentsMargins(13, 11, 13, 12)
|
||
section_layout.setSpacing(8)
|
||
title_label = QLabel(title, section)
|
||
title_label.setProperty("receptionCaseSectionTitle", True)
|
||
title_label.setTextFormat(Qt.TextFormat.PlainText)
|
||
section_layout.addWidget(title_label)
|
||
rows = QWidget(section)
|
||
rows.setObjectName("ReceptionCaseSectionRows")
|
||
rows_layout = QVBoxLayout(rows)
|
||
rows_layout.setContentsMargins(0, 0, 0, 0)
|
||
rows_layout.setSpacing(7)
|
||
section_layout.addWidget(rows)
|
||
self.case_section_frames[key] = section
|
||
self.case_section_layouts[key] = rows_layout
|
||
case_overview_layout.addWidget(section)
|
||
self.case_overview_widget.setVisible(False)
|
||
diagnosis_layout.addWidget(self.case_overview_widget)
|
||
|
||
# Keep the canonical plain-text summary for legacy callers and AI report context.
|
||
# The visible presentation above is structured Qt content and never leaks markup.
|
||
self._case_summary_text = "尚未填写病例信息"
|
||
self.diagnosis_text = QLabel("尚未填写病例信息", diagnosis_card)
|
||
self.diagnosis_text.setObjectName("ReceptionMedicationCaseText")
|
||
self.diagnosis_text.setAccessibleName("完整病例原始文本")
|
||
self.diagnosis_text.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.diagnosis_text.setWordWrap(True)
|
||
self.diagnosis_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
self.diagnosis_text.setVisible(False)
|
||
|
||
self.daily_panel = ReceptionDailyRecordsPanel()
|
||
self.daily_panel.range_requested.connect(self._request_daily_range)
|
||
self.daily_panel.refresh_requested.connect(self._refresh_daily_records)
|
||
|
||
self.followup_card = QFrame()
|
||
self.followup_card.setObjectName("ReceptionFollowupCard")
|
||
self.followup_card.setAccessibleName("患者随访记录")
|
||
followup_layout = QVBoxLayout(self.followup_card)
|
||
followup_layout.setContentsMargins(16, 14, 16, 16)
|
||
followup_layout.setSpacing(10)
|
||
followup_title = QLabel("随访记录", self.followup_card)
|
||
followup_title.setObjectName("ReceptionFollowupTitle")
|
||
followup_layout.addWidget(followup_title)
|
||
followup_subtitle = QLabel(
|
||
"展示医生跟踪备注;日常指标请在“日常记录”页查看。",
|
||
self.followup_card,
|
||
)
|
||
followup_subtitle.setObjectName("ReceptionFollowupSubtitle")
|
||
followup_subtitle.setWordWrap(True)
|
||
followup_layout.addWidget(followup_subtitle)
|
||
self.followup_text = QLabel("选择患者后加载跟踪备注。", self.followup_card)
|
||
self.followup_text.setObjectName("ReceptionFollowupText")
|
||
self.followup_text.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.followup_text.setWordWrap(True)
|
||
self.followup_text.setTextInteractionFlags(
|
||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||
)
|
||
followup_layout.addWidget(self.followup_text)
|
||
followup_layout.addStretch(1)
|
||
|
||
self.notes_group = QFrame()
|
||
self.notes_group.setObjectName("SubtleCard")
|
||
notes_group_layout = QVBoxLayout(self.notes_group)
|
||
notes_group_layout.setContentsMargins(14, 12, 14, 14)
|
||
notes_group_layout.setSpacing(8)
|
||
notes_header = QWidget(self.notes_group)
|
||
notes_header_layout = QHBoxLayout(notes_header)
|
||
notes_header_layout.setContentsMargins(0, 0, 0, 0)
|
||
note_title = QLabel("医生备注与附件")
|
||
note_title.setProperty("role", "sectionTitle")
|
||
notes_header_layout.addWidget(note_title)
|
||
notes_header_layout.addStretch(1)
|
||
self.notes_count = QLabel("0 条")
|
||
self.notes_count.setProperty("role", "muted")
|
||
notes_header_layout.addWidget(self.notes_count)
|
||
notes_group_layout.addWidget(notes_header)
|
||
self.notes_container = QWidget(self.notes_group)
|
||
self.notes_layout = QVBoxLayout(self.notes_container)
|
||
self.notes_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.notes_layout.setSpacing(8)
|
||
notes_group_layout.addWidget(self.notes_container)
|
||
|
||
self.note_edit = QTextEdit(self.notes_group)
|
||
self.note_edit.setPlaceholderText("记录本次沟通要点(最多 500 字,不会自动提交)")
|
||
self.note_edit.setMaximumHeight(105)
|
||
self.note_edit.textChanged.connect(self._limit_note_text)
|
||
self.note_edit.setVisible(self._can_note)
|
||
notes_group_layout.addWidget(self.note_edit)
|
||
self.note_counter = QLabel(f"0 / {NOTE_LIMIT}", self.notes_group)
|
||
self.note_counter.setProperty("role", "muted")
|
||
self.note_counter.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||
self.note_counter.setVisible(self._can_note)
|
||
notes_group_layout.addWidget(self.note_counter)
|
||
|
||
attachment_actions = QHBoxLayout()
|
||
self.add_tongue_button = QPushButton("添加舌苔图", self.notes_group)
|
||
self.add_tongue_button.setProperty("variant", "secondary")
|
||
self.add_tongue_button.clicked.connect(self._choose_tongue_images)
|
||
self.add_tongue_button.setVisible(self._can_note)
|
||
attachment_actions.addWidget(self.add_tongue_button)
|
||
self.add_report_button = QPushButton("添加检查报告", self.notes_group)
|
||
self.add_report_button.setProperty("variant", "secondary")
|
||
self.add_report_button.clicked.connect(self._choose_report_files)
|
||
self.add_report_button.setVisible(self._can_note)
|
||
attachment_actions.addWidget(self.add_report_button)
|
||
attachment_actions.addStretch(1)
|
||
notes_group_layout.addLayout(attachment_actions)
|
||
self.pending_attachments = QWidget(self.notes_group)
|
||
self.pending_attachments_layout = QVBoxLayout(self.pending_attachments)
|
||
self.pending_attachments_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.pending_attachments_layout.setSpacing(4)
|
||
self.pending_attachments.setVisible(self._can_note)
|
||
notes_group_layout.addWidget(self.pending_attachments)
|
||
|
||
note_action = QHBoxLayout()
|
||
note_action.addStretch(1)
|
||
self.save_note_button = QPushButton("保存备注", self.notes_group)
|
||
self.save_note_button.setProperty("variant", "secondary")
|
||
self.save_note_button.clicked.connect(self._save_note)
|
||
self.save_note_button.setVisible(self._can_note)
|
||
note_action.addWidget(self.save_note_button)
|
||
notes_group_layout.addLayout(note_action)
|
||
|
||
def add_scrolling_page(widget: QWidget) -> QScrollArea:
|
||
page = QWidget(self.clinical_pages)
|
||
page_layout = QVBoxLayout(page)
|
||
page_layout.setContentsMargins(14, 10, 14, 12)
|
||
scroll_area = QScrollArea(page)
|
||
scroll_area.setWidgetResizable(True)
|
||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
holder = QWidget(scroll_area)
|
||
holder_layout = QVBoxLayout(holder)
|
||
holder_layout.setContentsMargins(0, 0, 0, 0)
|
||
holder_layout.addWidget(widget)
|
||
holder_layout.addStretch(1)
|
||
scroll_area.setWidget(holder)
|
||
page_layout.addWidget(scroll_area)
|
||
self.clinical_pages.addWidget(page)
|
||
return scroll_area
|
||
|
||
self.report_scroll = add_scrolling_page(self.notes_group)
|
||
self.medication_scroll = add_scrolling_page(self.diagnosis_card)
|
||
self.medication_scroll.setObjectName("ReceptionMedicationScroll")
|
||
self.medication_scroll.setAccessibleName("用药记录与完整病例滚动区域")
|
||
self.daily_scroll = add_scrolling_page(self.daily_panel)
|
||
self.daily_scroll.setObjectName("ReceptionDailyRecordsScroll")
|
||
self.daily_scroll.setAccessibleName("患者日常记录滚动区域")
|
||
self.followup_scroll = add_scrolling_page(self.followup_card)
|
||
self.followup_scroll.setObjectName("ReceptionFollowupScroll")
|
||
self.followup_scroll.setAccessibleName("患者随访记录滚动区域")
|
||
|
||
health_card = QFrame()
|
||
health_card.setObjectName("ReceptionTabSurface")
|
||
health_layout = QVBoxLayout(health_card)
|
||
health_layout.setContentsMargins(16, 14, 16, 16)
|
||
health_layout.setSpacing(9)
|
||
health_layout.addWidget(section_title("健康数据概览"))
|
||
self.health_summary_label = QLabel("选择患者后加载生命体征与健康记录。", health_card)
|
||
self.health_summary_label.setWordWrap(True)
|
||
self.health_summary_label.setProperty("role", "muted")
|
||
health_layout.addWidget(self.health_summary_label)
|
||
self.health_scroll = add_scrolling_page(health_card)
|
||
detail_layout.addWidget(clinical_card)
|
||
|
||
ai_row = QHBoxLayout()
|
||
ai_row.setSpacing(1)
|
||
analysis_card = QFrame(content)
|
||
analysis_card.setObjectName("ReceptionAiAnalysisCard")
|
||
analysis_card.setMinimumWidth(0)
|
||
analysis_card.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
self.ai_analysis_card = analysis_card
|
||
analysis_layout = QVBoxLayout(analysis_card)
|
||
analysis_layout.setContentsMargins(11, 12, 11, 14)
|
||
analysis_layout.setSpacing(5)
|
||
analysis_header = QHBoxLayout()
|
||
analysis_header.setContentsMargins(0, 0, 0, 0)
|
||
analysis_header.setSpacing(6)
|
||
analysis_header.addWidget(_ReceptionSparkIcon(analysis_card))
|
||
analysis_title = QLabel("AI 智能分析", analysis_card)
|
||
analysis_title.setObjectName("ReceptionAiTitle")
|
||
self.ai_analysis_title = analysis_title
|
||
analysis_header.addWidget(analysis_title)
|
||
self.ai_analysis_snapshot_meta = QLabel("", analysis_card)
|
||
self.ai_analysis_snapshot_meta.setObjectName("ReceptionAiAnalysisSnapshotMeta")
|
||
self.ai_analysis_snapshot_meta.setAccessibleName("当前 AI 报告快照时间与版本")
|
||
self.ai_analysis_snapshot_meta.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.ai_analysis_snapshot_meta.setVisible(False)
|
||
self.ai_analysis_snapshot_meta.setMinimumWidth(0)
|
||
self.ai_analysis_snapshot_meta.setSizePolicy(
|
||
QSizePolicy.Policy.Ignored,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
analysis_header.addWidget(self.ai_analysis_snapshot_meta, 1)
|
||
analysis_header.addStretch(1)
|
||
self.ai_analysis_model_selector = _ReceptionAiModelCombo(analysis_card)
|
||
self.ai_analysis_model_selector.setObjectName("ReceptionAiAnalysisModelSelector")
|
||
self.ai_analysis_model_selector.setAccessibleName("选择卡片分析模型")
|
||
self.ai_analysis_model_selector.setToolTip("切换千问与 OpenAI 分析")
|
||
for model_key in AI_ANALYSIS_MODELS:
|
||
self.ai_analysis_model_selector.addItem(
|
||
AI_ANALYSIS_MODEL_LABELS[model_key],
|
||
model_key,
|
||
)
|
||
self.ai_analysis_model_combo = self.ai_analysis_model_selector
|
||
self.ai_analysis_model_selector.currentIndexChanged.connect(
|
||
self._ai_analysis_model_changed
|
||
)
|
||
analysis_header.addWidget(
|
||
self.ai_analysis_model_selector,
|
||
0,
|
||
Qt.AlignmentFlag.AlignVCenter,
|
||
)
|
||
self.ai_analysis_expand_button = _ReceptionAiExpandButton(analysis_card)
|
||
self.ai_analysis_expand_button.setVisible(self._can_ai_analysis)
|
||
self.ai_analysis_expand_button.clicked.connect(self._open_ai_analysis_dialog)
|
||
analysis_header.addWidget(
|
||
self.ai_analysis_expand_button,
|
||
0,
|
||
Qt.AlignmentFlag.AlignVCenter,
|
||
)
|
||
analysis_layout.addLayout(analysis_header)
|
||
self.ai_analysis_secondary_status = QLabel("", analysis_card)
|
||
self.ai_analysis_secondary_status.setObjectName(
|
||
"ReceptionAiAnalysisSecondaryStatus"
|
||
)
|
||
self.ai_analysis_secondary_status.setAccessibleName("第二模型分析状态")
|
||
self.ai_analysis_secondary_status.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.ai_analysis_secondary_status.setWordWrap(True)
|
||
self.ai_analysis_secondary_status.setVisible(False)
|
||
analysis_layout.addWidget(self.ai_analysis_secondary_status)
|
||
analysis_layout.addSpacing(3)
|
||
|
||
self.ai_analysis_stack = QStackedWidget(analysis_card)
|
||
self.ai_analysis_stack.setObjectName("ReceptionAiAnalysisStack")
|
||
analysis_sections = QWidget(self.ai_analysis_stack)
|
||
analysis_sections.setObjectName("ReceptionAiAnalysisSections")
|
||
self.ai_analysis_sections = analysis_sections
|
||
sections_layout = QVBoxLayout(analysis_sections)
|
||
sections_layout.setContentsMargins(0, 0, 0, 0)
|
||
sections_layout.setSpacing(8)
|
||
|
||
def add_analysis_section(
|
||
caption: str,
|
||
tone: str,
|
||
*,
|
||
emphasis: bool = False,
|
||
) -> tuple[QFrame, QVBoxLayout]:
|
||
section = QFrame(analysis_sections)
|
||
section.setObjectName("ReceptionAiAnalysisSection")
|
||
section.setProperty("emphasis", emphasis)
|
||
section.setMinimumWidth(0)
|
||
section.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Minimum,
|
||
)
|
||
row = QHBoxLayout(section)
|
||
row.setContentsMargins(8, 8, 8, 8)
|
||
row.setSpacing(8)
|
||
row.addWidget(
|
||
_ReceptionAiSectionIcon(tone, section),
|
||
0,
|
||
Qt.AlignmentFlag.AlignTop,
|
||
)
|
||
copy = QVBoxLayout()
|
||
copy.setContentsMargins(0, 0, 0, 0)
|
||
copy.setSpacing(4)
|
||
caption_label = QLabel(caption, section)
|
||
caption_label.setProperty("receptionAiCaption", True)
|
||
caption_label.setProperty("tone", tone)
|
||
copy.addWidget(caption_label)
|
||
copy_host = QWidget(section)
|
||
copy_host.setLayout(copy)
|
||
copy_host.setMinimumWidth(0)
|
||
copy_host.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Minimum,
|
||
)
|
||
row.addWidget(copy_host, 1, Qt.AlignmentFlag.AlignTop)
|
||
sections_layout.addWidget(section)
|
||
return section, copy
|
||
|
||
_diagnosis_section, diagnosis_copy = add_analysis_section(
|
||
"诊断建议", "diagnosis", emphasis=True
|
||
)
|
||
self.ai_summary_label = _AiPreviewLabel(
|
||
"—",
|
||
analysis_sections,
|
||
maximum_lines=3,
|
||
)
|
||
self.ai_summary_label.setProperty("receptionAiValue", True)
|
||
self.ai_summary_label.setMinimumWidth(0)
|
||
diagnosis_copy.addWidget(self.ai_summary_label)
|
||
|
||
_risk_section, risk_copy = add_analysis_section("风险评估", "risk")
|
||
self.ai_risk_chip_host = _AiFlowHost(
|
||
analysis_sections,
|
||
horizontal_spacing=5,
|
||
vertical_spacing=5,
|
||
)
|
||
self.ai_risk_chip_host.setMinimumWidth(0)
|
||
self.ai_risk_chip_layout = self.ai_risk_chip_host.flow
|
||
risk_copy.addWidget(self.ai_risk_chip_host)
|
||
self.ai_risk_label = QLabel("", analysis_sections)
|
||
self.ai_risk_label.setVisible(False)
|
||
|
||
_treatment_section, treatment_copy = add_analysis_section(
|
||
"治疗建议", "treatment"
|
||
)
|
||
self.ai_treatment_label = _AiPreviewLabel(
|
||
"—",
|
||
analysis_sections,
|
||
maximum_lines=2,
|
||
)
|
||
self.ai_treatment_label.setProperty("receptionAiValue", True)
|
||
self.ai_treatment_label.setProperty("secondary", True)
|
||
self.ai_treatment_label.setMinimumWidth(0)
|
||
treatment_copy.addWidget(self.ai_treatment_label)
|
||
|
||
analysis_sections.setMinimumWidth(0)
|
||
analysis_sections.setSizePolicy(
|
||
QSizePolicy.Policy.Ignored,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
|
||
self.ai_analysis_content_page = analysis_sections
|
||
self.ai_analysis_stack.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
self.ai_analysis_stack.addWidget(analysis_sections)
|
||
analysis_state = QFrame(self.ai_analysis_stack)
|
||
analysis_state.setObjectName("ReceptionAiState")
|
||
self.ai_analysis_state_page = analysis_state
|
||
state_layout = QVBoxLayout(analysis_state)
|
||
state_layout.setContentsMargins(12, 12, 12, 12)
|
||
state_layout.setSpacing(8)
|
||
state_layout.addStretch(1)
|
||
self.ai_analysis_state_label = QLabel("选择患者后加载 AI 智能分析。", analysis_state)
|
||
self.ai_analysis_state_label.setTextFormat(Qt.TextFormat.PlainText)
|
||
self.ai_analysis_state_label.setObjectName("ReceptionAiStateText")
|
||
self.ai_analysis_state_label.setAccessibleName("当前模型分析状态")
|
||
self.ai_analysis_state_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.ai_analysis_state_label.setWordWrap(True)
|
||
state_layout.addWidget(self.ai_analysis_state_label)
|
||
self.ai_analysis_retry_button = QPushButton("重试", analysis_state)
|
||
self.ai_analysis_retry_button.setObjectName("ReceptionAiRetry")
|
||
self.ai_analysis_retry_button.setAccessibleName("重试当前模型分析")
|
||
self.ai_analysis_retry_button.clicked.connect(self._retry_ai_analysis)
|
||
state_layout.addWidget(
|
||
self.ai_analysis_retry_button,
|
||
0,
|
||
Qt.AlignmentFlag.AlignHCenter,
|
||
)
|
||
state_layout.addStretch(1)
|
||
self.ai_analysis_stack.addWidget(analysis_state)
|
||
analysis_layout.addWidget(self.ai_analysis_stack)
|
||
|
||
report_footer = QHBoxLayout()
|
||
report_footer.setContentsMargins(0, 2, 0, 0)
|
||
report_footer.setSpacing(6)
|
||
disclaimer = QLabel(AI_MEDICAL_DISCLAIMER, analysis_card)
|
||
disclaimer.setObjectName("ReceptionAiMedicalDisclaimer")
|
||
disclaimer.setAccessibleName("AI 医疗免责声明")
|
||
disclaimer.setTextFormat(Qt.TextFormat.PlainText)
|
||
disclaimer.setWordWrap(True)
|
||
disclaimer.setMinimumWidth(0)
|
||
disclaimer.setSizePolicy(
|
||
QSizePolicy.Policy.Ignored,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
self.ai_analysis_disclaimer = disclaimer
|
||
disclaimer.setVisible(False)
|
||
report_footer.addWidget(disclaimer, 1)
|
||
self.ai_analysis_history_button = QPushButton("历史报告", analysis_card)
|
||
self.ai_analysis_history_button.setObjectName("ReceptionAiHistoryButton")
|
||
self.ai_analysis_history_button.setAccessibleName("查看历次 AI 报告快照")
|
||
self.ai_analysis_history_button.setToolTip("查看并切换千问与 OpenAI 历史报告")
|
||
self.ai_analysis_history_button.clicked.connect(self._open_ai_analysis_dialog)
|
||
report_footer.addWidget(self.ai_analysis_history_button)
|
||
self.ai_analysis_regenerate_button = QPushButton("重新分析", analysis_card)
|
||
self.ai_analysis_regenerate_button.setObjectName("ReceptionAiRegenerateButton")
|
||
self.ai_analysis_regenerate_button.setAccessibleName("重新生成患者级 AI 报告")
|
||
self.ai_analysis_regenerate_button.setToolTip("依次生成新的千问和 OpenAI 快照")
|
||
self.ai_analysis_regenerate_button.clicked.connect(
|
||
self._regenerate_patient_ai_reports
|
||
)
|
||
report_footer.addWidget(self.ai_analysis_regenerate_button)
|
||
analysis_layout.addLayout(report_footer)
|
||
|
||
self.ai_report_link = QPushButton("查看完整 AI 报告", analysis_card)
|
||
self.ai_report_link.setProperty("variant", "link")
|
||
self.ai_report_link.clicked.connect(self._open_ai_report)
|
||
self.ai_report_link.setVisible(False)
|
||
ai_row.addWidget(analysis_card, 4)
|
||
|
||
assistant_card = QFrame(content)
|
||
assistant_card.setObjectName("ReceptionAiAssistantCard")
|
||
assistant_card.setMinimumWidth(0)
|
||
assistant_card.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
self.ai_assistant_card = assistant_card
|
||
assistant_layout = QVBoxLayout(assistant_card)
|
||
assistant_layout.setContentsMargins(12, 11, 12, 14)
|
||
assistant_layout.setSpacing(6)
|
||
assistant_header = QHBoxLayout()
|
||
assistant_header.setContentsMargins(0, 0, 0, 0)
|
||
assistant_header.setSpacing(6)
|
||
assistant_header.addWidget(_ReceptionAssistantIcon(assistant_card))
|
||
assistant_title = QLabel("AI 问诊助手", assistant_card)
|
||
assistant_title.setObjectName("ReceptionAiTitle")
|
||
assistant_header.addWidget(assistant_title)
|
||
assistant_header.addStretch(1)
|
||
assistant_layout.addLayout(assistant_header)
|
||
assistant_hint = QLabel(
|
||
"您好,我是 AI 问诊助手,有什么可以帮助您了解患者情况的吗?",
|
||
assistant_card,
|
||
)
|
||
assistant_hint.setObjectName("ReceptionAiAssistantHint")
|
||
assistant_hint.setWordWrap(True)
|
||
assistant_hint.setMinimumWidth(0)
|
||
assistant_hint.setSizePolicy(
|
||
QSizePolicy.Policy.Ignored,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
assistant_layout.addWidget(assistant_hint)
|
||
assistant_layout.addSpacing(9)
|
||
|
||
self.ai_prompt_buttons: list[QPushButton] = []
|
||
|
||
def prompt_button(label: str) -> QPushButton:
|
||
button = QPushButton(label, assistant_card)
|
||
button.setProperty("receptionAiPrompt", True)
|
||
button.setMinimumWidth(0)
|
||
button.setSizePolicy(
|
||
QSizePolicy.Policy.Maximum,
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
button.clicked.connect(
|
||
lambda _checked=False, prompt=label: self._open_ai_report_for_prompt(prompt)
|
||
)
|
||
self.ai_prompt_buttons.append(button)
|
||
return button
|
||
|
||
first_prompt_row = QHBoxLayout()
|
||
first_prompt_row.setSpacing(6)
|
||
first_prompt_row.addWidget(prompt_button("基于当前病史,下一步检查建议?"))
|
||
first_prompt_row.addStretch(1)
|
||
assistant_layout.addLayout(first_prompt_row)
|
||
assistant_layout.addSpacing(9)
|
||
second_prompt_row = QHBoxLayout()
|
||
second_prompt_row.setSpacing(6)
|
||
for label in (
|
||
"该患者的用药调整建议?",
|
||
"糖尿病教育要点有哪些?",
|
||
"并发症筛查建议?",
|
||
):
|
||
second_prompt_row.addWidget(prompt_button(label))
|
||
second_prompt_row.addStretch(1)
|
||
assistant_layout.addLayout(second_prompt_row)
|
||
assistant_layout.addStretch(1)
|
||
|
||
assistant_input_row = QHBoxLayout()
|
||
assistant_input_row.setSpacing(6)
|
||
self.ai_question_edit = QLineEdit(assistant_card)
|
||
self.ai_question_edit.setObjectName("ReceptionAiInput")
|
||
self.ai_question_edit.setPlaceholderText("输入您的问题...")
|
||
self.ai_question_edit.setMaxLength(500)
|
||
self.ai_question_edit.returnPressed.connect(self._submit_ai_question)
|
||
assistant_input_row.addWidget(self.ai_question_edit, 1)
|
||
self.ai_send_button = _ReceptionSendButton(assistant_card)
|
||
self.ai_send_button.clicked.connect(self._submit_ai_question)
|
||
assistant_input_row.addWidget(self.ai_send_button)
|
||
assistant_layout.addLayout(assistant_input_row)
|
||
ai_row.addWidget(assistant_card, 5)
|
||
detail_layout.addLayout(ai_row)
|
||
|
||
self.detail_tabs.currentChanged.connect(self._detail_tab_changed)
|
||
self._detail_tab_changed(0)
|
||
|
||
scroll.setWidget(content)
|
||
self.detail_stack.addWidget(scroll)
|
||
panel_layout.addWidget(self.detail_stack)
|
||
self._reset_detail_content()
|
||
return panel
|
||
|
||
def _detail_tab_changed(self, index: int) -> None:
|
||
if not 0 <= index < self.detail_tabs.count():
|
||
return
|
||
self.clinical_pages.setCurrentIndex(index)
|
||
self.clinical_scroll.verticalScrollBar().setValue(0)
|
||
for scroll_area in (
|
||
getattr(self, "report_scroll", None),
|
||
getattr(self, "medication_scroll", None),
|
||
getattr(self, "daily_scroll", None),
|
||
getattr(self, "followup_scroll", None),
|
||
getattr(self, "health_scroll", None),
|
||
):
|
||
if scroll_area is not None:
|
||
scroll_area.verticalScrollBar().setValue(0)
|
||
|
||
def _set_ai_analysis_state(self, state: str, message: str = "") -> None:
|
||
"""Reset the dual-model card for a new patient or a terminal preflight state."""
|
||
|
||
if not hasattr(self, "ai_analysis_stack"):
|
||
return
|
||
if state == "success":
|
||
self._sync_ai_analysis_view()
|
||
return
|
||
self._ai_analysis_payloads.clear()
|
||
for snapshots in self._ai_analysis_histories.values():
|
||
snapshots.clear()
|
||
self._ai_analysis_payload = None
|
||
self._ai_analysis_rendered_model = None
|
||
self._ai_analysis_rendered_payload = None
|
||
self._ai_analysis_regenerating = False
|
||
self._ai_analysis_regeneration_model = None
|
||
self._ai_analysis_operation_error = ""
|
||
self._ai_analysis_active_model = "qwen"
|
||
self._ai_analysis_model_states["qwen"] = state
|
||
self._ai_analysis_model_messages["qwen"] = message
|
||
self._ai_analysis_model_states["openai"] = (
|
||
state if state in {"empty", "permission"} else "waiting"
|
||
)
|
||
self._ai_analysis_model_messages["openai"] = ""
|
||
selector_index = self.ai_analysis_model_selector.findData("qwen")
|
||
if selector_index >= 0:
|
||
self.ai_analysis_model_selector.blockSignals(True)
|
||
self.ai_analysis_model_selector.setCurrentIndex(selector_index)
|
||
self.ai_analysis_model_selector.blockSignals(False)
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _ai_analysis_model_changed(self, index: int) -> None:
|
||
if index < 0:
|
||
return
|
||
model = str(self.ai_analysis_model_selector.itemData(index) or "qwen")
|
||
if model not in AI_ANALYSIS_MODELS:
|
||
return
|
||
self._ai_analysis_active_model = model
|
||
self._sync_ai_analysis_view()
|
||
|
||
@staticmethod
|
||
def _ai_analysis_default_message(model: str, state: str) -> str:
|
||
label = AI_ANALYSIS_MODEL_LABELS.get(model, model)
|
||
messages = {
|
||
"empty": "选择患者后加载 AI 智能分析。",
|
||
"waiting": f"{label} 将在千问分析成功后自动生成。",
|
||
"loading": f"正在结合患者病历生成{label}分析…",
|
||
"permission": "当前账号没有查看 AI 智能分析的权限。",
|
||
"error": f"{label}智能分析加载失败。",
|
||
"unsupported": f"当前数据源暂不支持{label}分析。",
|
||
"missing": f"暂无已保存的{label}快照,可点击重新分析生成。",
|
||
}
|
||
return messages.get(state, "")
|
||
|
||
def _set_ai_analysis_secondary_status(self, state: str, message: str) -> None:
|
||
label = self.ai_analysis_secondary_status
|
||
label.setText(message)
|
||
label.setVisible(bool(message))
|
||
if label.property("secondaryState") == state:
|
||
return
|
||
label.setProperty("secondaryState", state)
|
||
label.style().unpolish(label)
|
||
label.style().polish(label)
|
||
|
||
def _sync_ai_analysis_view(self) -> None:
|
||
"""Render only the selected model while retaining every cached response."""
|
||
|
||
if not hasattr(self, "ai_analysis_stack"):
|
||
return
|
||
model = self._ai_analysis_active_model
|
||
state = self._ai_analysis_model_states.get(model, "empty")
|
||
payload = self._ai_analysis_payloads.get(model)
|
||
success = state == "success" and payload is not None
|
||
self._ai_analysis_state = state
|
||
self._ai_analysis_payload = payload if success else None
|
||
# Preserve the legacy flag as the primary-model state. OpenAI has its own
|
||
# explicit state so its longer request never makes a finished qwen look busy.
|
||
self._ai_analysis_loading = self._ai_analysis_model_states["qwen"] == "loading"
|
||
self.ai_analysis_stack.setCurrentWidget(
|
||
self.ai_analysis_content_page if success else self.ai_analysis_state_page
|
||
)
|
||
if success and payload is not None:
|
||
if (
|
||
self._ai_analysis_rendered_model != model
|
||
or self._ai_analysis_rendered_payload is not payload
|
||
):
|
||
self._render_ai_analysis_payload(payload, model)
|
||
self._ai_analysis_rendered_model = model
|
||
self._ai_analysis_rendered_payload = payload
|
||
else:
|
||
message = self._ai_analysis_model_messages.get(model, "")
|
||
self.ai_analysis_state_label.setText(
|
||
message or self._ai_analysis_default_message(model, state)
|
||
)
|
||
self.ai_analysis_snapshot_meta.clear()
|
||
self.ai_analysis_snapshot_meta.setVisible(False)
|
||
|
||
retry_visible = state == "error" and self._can_ai_analysis
|
||
self.ai_analysis_retry_button.setVisible(retry_visible)
|
||
self.ai_analysis_retry_button.setEnabled(retry_visible)
|
||
self.ai_analysis_expand_button.setEnabled(bool(self._ai_analysis_payloads))
|
||
self.ai_analysis_history_button.setEnabled(
|
||
any(self._ai_analysis_histories.values())
|
||
)
|
||
self.ai_analysis_regenerate_button.setEnabled(
|
||
self._can_ai_regenerate
|
||
and self._ai_analysis_patient_flow
|
||
and self._ai_analysis_patient_id is not None
|
||
and not self._ai_analysis_regenerating
|
||
)
|
||
self.ai_analysis_model_selector.setEnabled(
|
||
self._can_ai_analysis and state != "permission"
|
||
)
|
||
|
||
qwen_ready = "qwen" in self._ai_analysis_payloads
|
||
openai_state = self._ai_analysis_model_states["openai"]
|
||
openai_message = self._ai_analysis_model_messages["openai"]
|
||
if qwen_ready and openai_state == "loading":
|
||
secondary_message = "千问结果已显示,OpenAI 正在生成…"
|
||
elif qwen_ready and openai_state == "error":
|
||
secondary_message = f"千问结果已保留;{openai_message}"
|
||
elif qwen_ready and openai_state == "success":
|
||
secondary_message = "千问与 OpenAI 均已完成,可切换对照。"
|
||
elif qwen_ready and openai_state == "unsupported":
|
||
secondary_message = "千问结果已显示;当前数据源仅支持千问分析。"
|
||
else:
|
||
secondary_message = ""
|
||
current_snapshot_time = (
|
||
_summary_value(
|
||
_analysis_value(payload, "generated_at")
|
||
or _analysis_value(payload, "created_at")
|
||
)
|
||
if success and payload is not None
|
||
else ""
|
||
)
|
||
if current_snapshot_time and openai_state == "success":
|
||
secondary_message = (
|
||
f"快照时间:{current_snapshot_time[:16]} | 双模型可切换"
|
||
)
|
||
elif current_snapshot_time and not secondary_message:
|
||
secondary_message = f"快照时间:{current_snapshot_time[:16]}"
|
||
if self._ai_analysis_regenerating:
|
||
active_label = AI_ANALYSIS_MODEL_LABELS.get(
|
||
self._ai_analysis_regeneration_model or "qwen",
|
||
self._ai_analysis_regeneration_model or "千问",
|
||
)
|
||
secondary_message = f"正在生成新的{active_label}快照,历史版本会保留。"
|
||
openai_state = "loading"
|
||
elif self._ai_analysis_operation_error:
|
||
secondary_message = self._ai_analysis_operation_error
|
||
openai_state = "error"
|
||
self._set_ai_analysis_secondary_status(openai_state, secondary_message)
|
||
|
||
self.ai_analysis_card.setProperty("analysisState", state)
|
||
self.ai_analysis_card.style().unpolish(self.ai_analysis_card)
|
||
self.ai_analysis_card.style().polish(self.ai_analysis_card)
|
||
|
||
def _render_ai_analysis_payload(
|
||
self,
|
||
payload: Mapping[str, Any],
|
||
model: str,
|
||
) -> None:
|
||
"""Render a validated cached payload without mutating or truncating it."""
|
||
|
||
diagnosis_advice = _ai_narrative_text(
|
||
_analysis_value(payload, "diagnosis_advice")
|
||
)
|
||
treatment_advice = _ai_narrative_text(
|
||
_analysis_value(payload, "treatment_advice")
|
||
)
|
||
risks = _normalize_ai_risks(_analysis_value(payload, "risk_assessment"))
|
||
self.ai_summary_label.setText(diagnosis_advice)
|
||
self._render_ai_risk_chips(risks)
|
||
self.ai_treatment_label.setText(treatment_advice)
|
||
model_text = _summary_value(
|
||
_analysis_value(payload, "model_label")
|
||
or _analysis_value(payload, "model_name")
|
||
or _analysis_value(payload, "model_key")
|
||
or AI_ANALYSIS_MODEL_LABELS.get(model, model)
|
||
)
|
||
generated_at = _summary_value(
|
||
_analysis_value(payload, "generated_at")
|
||
or _analysis_value(payload, "created_at")
|
||
)
|
||
model_history = self._ai_analysis_histories.get(model, [])
|
||
try:
|
||
history_index = next(
|
||
index for index, item in enumerate(model_history) if item is payload
|
||
)
|
||
except StopIteration:
|
||
history_index = 0
|
||
version = _patient_report_display_version(
|
||
payload,
|
||
fallback=len(model_history) - history_index,
|
||
)
|
||
model_hint = " | ".join(
|
||
part for part in (model_text, generated_at) if part
|
||
)
|
||
self.ai_analysis_title.setToolTip(
|
||
f"本次分析:{model_hint}" if model_hint else "服务端 AI 智能分析"
|
||
)
|
||
compact_time = generated_at
|
||
if len(generated_at) >= 16 and generated_at[4:5] in {"-", "/"}:
|
||
compact_time = generated_at[5:16]
|
||
meta_text = " | ".join(
|
||
part for part in (f"第 {version} 版", compact_time) if part
|
||
)
|
||
self.ai_analysis_snapshot_meta.setText(meta_text)
|
||
self.ai_analysis_snapshot_meta.setToolTip(
|
||
" | ".join(part for part in (f"第 {version} 版", generated_at) if part)
|
||
)
|
||
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
|
||
self.ai_analysis_card.setToolTip("")
|
||
|
||
def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None:
|
||
_clear_ai_layout(self.ai_risk_chip_layout)
|
||
labels: list[str] = []
|
||
normalized_risks: list[tuple[str, str]] = []
|
||
for risk in risks:
|
||
label = _ai_narrative_text(risk.get("label"))
|
||
if not label:
|
||
continue
|
||
level = str(risk.get("level") or "low").strip().lower()
|
||
if level not in {"high", "medium", "low"}:
|
||
level = "low"
|
||
normalized_risks.append((label, level))
|
||
labels.append(label)
|
||
|
||
for label, level in normalized_risks[:3]:
|
||
chip = QLabel(label, self.ai_risk_chip_host)
|
||
chip.setTextFormat(Qt.TextFormat.PlainText)
|
||
chip.setWordWrap(True)
|
||
chip.setMinimumWidth(0)
|
||
chip.setMaximumWidth(220)
|
||
chip.setSizePolicy(
|
||
QSizePolicy.Policy.Maximum,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
chip.setProperty("receptionRiskChip", True)
|
||
chip.setProperty("riskLevel", level)
|
||
self.ai_risk_chip_layout.addWidget(chip)
|
||
if not normalized_risks:
|
||
chip = QLabel("暂无明确风险", self.ai_risk_chip_host)
|
||
chip.setTextFormat(Qt.TextFormat.PlainText)
|
||
chip.setSizePolicy(
|
||
QSizePolicy.Policy.Maximum,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
chip.setProperty("receptionRiskChip", True)
|
||
chip.setProperty("riskLevel", "low")
|
||
self.ai_risk_chip_layout.addWidget(chip)
|
||
labels.append(chip.text())
|
||
elif len(normalized_risks) > 3:
|
||
overflow = QLabel(
|
||
f"+{len(normalized_risks) - 3} 项",
|
||
self.ai_risk_chip_host,
|
||
)
|
||
overflow.setTextFormat(Qt.TextFormat.PlainText)
|
||
overflow.setSizePolicy(
|
||
QSizePolicy.Policy.Maximum,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
overflow.setProperty("receptionRiskOverflow", True)
|
||
overflow.setToolTip("其余风险请在完整 AI 报告中查看")
|
||
self.ai_risk_chip_layout.addWidget(overflow)
|
||
self.ai_risk_chip_layout.invalidate()
|
||
self.ai_risk_chip_host.syncFlowHeight()
|
||
self.ai_risk_label.setText("、".join(labels))
|
||
|
||
def _ai_analysis_context_current(
|
||
self,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
) -> bool:
|
||
if request_generation != self._ai_analysis_generation:
|
||
return False
|
||
if not _same_id(appointment_id, self._selected_appointment_id):
|
||
return False
|
||
context = self._selection_context()
|
||
return context is not None and _same_id(context[2], diagnosis_id)
|
||
|
||
def _patient_ai_context_current(
|
||
self,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
) -> bool:
|
||
if request_generation != self._ai_analysis_generation:
|
||
return False
|
||
if not _same_id(appointment_id, self._selected_appointment_id):
|
||
return False
|
||
context = self._selection_context()
|
||
return context is not None and _same_id(context[3], patient_id)
|
||
|
||
def _install_patient_ai_rows(
|
||
self,
|
||
rows: Sequence[Mapping[str, Any]],
|
||
*,
|
||
replace: bool,
|
||
) -> None:
|
||
"""Install validated snapshots, keeping newest-first history per model."""
|
||
|
||
combined: list[dict[str, Any]] = []
|
||
if not replace:
|
||
for snapshots in self._ai_analysis_histories.values():
|
||
combined.extend(deepcopy(snapshots))
|
||
combined.extend(deepcopy(dict(row)) for row in rows)
|
||
grouped: dict[str, list[dict[str, Any]]] = {
|
||
model: [] for model in AI_ANALYSIS_MODELS
|
||
}
|
||
seen: set[str] = set()
|
||
for row in sorted(
|
||
combined,
|
||
key=lambda item: (
|
||
str(item.get("generated_at") or item.get("created_at") or ""),
|
||
_as_int(item.get("id") or item.get("report_id"), 0) or 0,
|
||
),
|
||
reverse=True,
|
||
):
|
||
model = _patient_report_model(row)
|
||
if model not in grouped:
|
||
continue
|
||
identity = str(row.get("id") or row.get("report_id") or "").strip()
|
||
if not identity:
|
||
identity = "|".join(
|
||
(
|
||
model,
|
||
str(row.get("generated_at") or row.get("created_at") or ""),
|
||
str(row.get("version") or ""),
|
||
str(row.get("diagnosis_advice") or ""),
|
||
)
|
||
)
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
grouped[model].append(deepcopy(row))
|
||
|
||
self._ai_analysis_payloads.clear()
|
||
for model in AI_ANALYSIS_MODELS:
|
||
self._ai_analysis_histories[model] = grouped[model]
|
||
if grouped[model]:
|
||
self._ai_analysis_payloads[model] = grouped[model][0]
|
||
self._ai_analysis_model_states[model] = "success"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
else:
|
||
self._ai_analysis_model_states[model] = "missing"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
if self._ai_analysis_active_model not in self._ai_analysis_payloads:
|
||
self._ai_analysis_active_model = (
|
||
"qwen" if "qwen" in self._ai_analysis_payloads else "openai"
|
||
)
|
||
selector_index = self.ai_analysis_model_selector.findData(
|
||
self._ai_analysis_active_model
|
||
)
|
||
if selector_index >= 0:
|
||
self.ai_analysis_model_selector.blockSignals(True)
|
||
self.ai_analysis_model_selector.setCurrentIndex(selector_index)
|
||
self.ai_analysis_model_selector.blockSignals(False)
|
||
self._ai_analysis_rendered_model = None
|
||
self._ai_analysis_rendered_payload = None
|
||
|
||
def _load_patient_ai_reports(
|
||
self,
|
||
patient_id: int,
|
||
*,
|
||
appointment_id: int,
|
||
diagnosis_id: int | None,
|
||
force: bool,
|
||
) -> None:
|
||
if (
|
||
not force
|
||
and _same_id(patient_id, self._ai_analysis_patient_id)
|
||
and self._ai_analysis_model_states["qwen"]
|
||
in {"loading", "success", "missing", "error", "permission"}
|
||
):
|
||
return
|
||
self._ai_analysis_generation += 1
|
||
request_generation = self._ai_analysis_generation
|
||
self._ai_analysis_patient_flow = True
|
||
self._ai_analysis_patient_id = patient_id
|
||
self._ai_analysis_diagnosis_id = diagnosis_id
|
||
if not self._can_patient_ai_read:
|
||
self._set_ai_analysis_state("permission")
|
||
return
|
||
method = getattr(self.repository, "list_patient_ai_reports", None)
|
||
generate_method = getattr(self.repository, "generate_patient_ai_report", None)
|
||
if not callable(method) or not callable(generate_method):
|
||
self._set_ai_analysis_state(
|
||
"error",
|
||
"当前数据源暂不支持患者级 AI 报告。",
|
||
)
|
||
return
|
||
self._set_ai_analysis_state(
|
||
"loading",
|
||
"正在查询该患者已保存的 AI 报告…",
|
||
)
|
||
request_key = (request_generation, appointment_id, patient_id)
|
||
self._patient_ai_list_requests.add(request_key)
|
||
run_async(
|
||
lambda: method(patient_id),
|
||
on_success=lambda result: self._apply_patient_ai_report_list(
|
||
result,
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
),
|
||
on_error=lambda error: self._patient_ai_report_list_error(
|
||
error,
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
),
|
||
on_finished=lambda: self._patient_ai_report_list_finished(
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
),
|
||
)
|
||
|
||
def _apply_patient_ai_report_list(
|
||
self,
|
||
result: Any,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
) -> None:
|
||
self._patient_ai_list_requests.discard(
|
||
(request_generation, appointment_id, patient_id)
|
||
)
|
||
if not self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
return
|
||
rows = _patient_report_rows(
|
||
result,
|
||
expected_patient_id=patient_id,
|
||
)
|
||
if rows is None:
|
||
self._set_ai_analysis_state(
|
||
"error",
|
||
"患者历史报告响应与当前患者不匹配或格式无效。",
|
||
)
|
||
return
|
||
if rows:
|
||
self._install_patient_ai_rows(rows, replace=True)
|
||
self._ai_analysis_operation_error = ""
|
||
self._sync_ai_analysis_view()
|
||
return
|
||
if _patient_report_raw_count(result):
|
||
self._set_ai_analysis_state(
|
||
"error",
|
||
"已保存的患者级 AI 报告格式无效,已停止自动生成以保护历史数据。",
|
||
)
|
||
return
|
||
|
||
if not self._can_ai_regenerate:
|
||
self._set_ai_analysis_state(
|
||
"missing",
|
||
"数据库暂无患者报告;当前账号没有生成患者报告的权限。",
|
||
)
|
||
return
|
||
|
||
self._set_ai_analysis_state(
|
||
"loading",
|
||
"数据库暂无患者报告,正在生成千问快照…",
|
||
)
|
||
self._ai_analysis_patient_flow = True
|
||
self._ai_analysis_patient_id = patient_id
|
||
self._ai_analysis_regenerating = True
|
||
self._ai_analysis_regeneration_model = "qwen"
|
||
self._request_patient_ai_generation(
|
||
"qwen",
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
)
|
||
|
||
def _patient_ai_report_list_error(
|
||
self,
|
||
error: Exception,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
) -> None:
|
||
self._patient_ai_list_requests.discard(
|
||
(request_generation, appointment_id, patient_id)
|
||
)
|
||
if not self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
return
|
||
self._set_ai_analysis_state(
|
||
"error",
|
||
f"患者历史报告查询失败:{friendly_error(error)}",
|
||
)
|
||
|
||
def _patient_ai_report_list_finished(
|
||
self,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
) -> None:
|
||
self._patient_ai_list_requests.discard(
|
||
(request_generation, appointment_id, patient_id)
|
||
)
|
||
if self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _request_patient_ai_generation(
|
||
self,
|
||
model: str,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
) -> None:
|
||
if not self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
return
|
||
if not self._can_ai_regenerate:
|
||
self._patient_ai_generation_error(
|
||
PermissionError("当前账号必须同时具备查看和生成患者报告的权限。"),
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
model,
|
||
)
|
||
return
|
||
method = getattr(self.repository, "generate_patient_ai_report", None)
|
||
if not callable(method):
|
||
self._patient_ai_generation_error(
|
||
RuntimeError("当前数据源暂不支持患者级 AI 报告生成。"),
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
model,
|
||
)
|
||
return
|
||
request_key = (request_generation, appointment_id, patient_id, model)
|
||
if request_key in self._ai_analysis_requests:
|
||
return
|
||
self._ai_analysis_requests.add(request_key)
|
||
self._ai_analysis_regenerating = True
|
||
self._ai_analysis_regeneration_model = model
|
||
self._ai_analysis_operation_error = ""
|
||
if model not in self._ai_analysis_payloads:
|
||
self._ai_analysis_model_states[model] = "loading"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
self._sync_ai_analysis_view()
|
||
run_async(
|
||
lambda: method(patient_id, model=model),
|
||
on_success=lambda result: self._apply_patient_ai_generation(
|
||
result,
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
model,
|
||
),
|
||
on_error=lambda error: self._patient_ai_generation_error(
|
||
error,
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
model,
|
||
),
|
||
on_finished=lambda: self._patient_ai_generation_finished(
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
model,
|
||
),
|
||
)
|
||
|
||
def _apply_patient_ai_generation(
|
||
self,
|
||
result: Any,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
model: str,
|
||
) -> None:
|
||
self._ai_analysis_requests.discard(
|
||
(request_generation, appointment_id, patient_id, model)
|
||
)
|
||
if not self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
return
|
||
generated = _generated_patient_report(
|
||
result,
|
||
expected_patient_id=patient_id,
|
||
expected_model=model,
|
||
)
|
||
if generated is None:
|
||
self._patient_ai_generation_error(
|
||
ValueError("服务端未返回本次生成且匹配患者、模型的完整报告。"),
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
model,
|
||
)
|
||
return
|
||
self._install_patient_ai_rows([generated], replace=False)
|
||
self._ai_analysis_model_states[model] = "success"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
self._sync_ai_analysis_view()
|
||
if model == "qwen":
|
||
self._request_patient_ai_generation(
|
||
"openai",
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
)
|
||
return
|
||
self._ai_analysis_regenerating = False
|
||
self._ai_analysis_regeneration_model = None
|
||
self._ai_analysis_operation_error = ""
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _patient_ai_generation_error(
|
||
self,
|
||
error: Exception,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
model: str,
|
||
) -> None:
|
||
self._ai_analysis_requests.discard(
|
||
(request_generation, appointment_id, patient_id, model)
|
||
)
|
||
if not self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
return
|
||
self._ai_analysis_regenerating = False
|
||
self._ai_analysis_regeneration_model = None
|
||
label = AI_ANALYSIS_MODEL_LABELS.get(model, model)
|
||
message = f"{label}新快照生成失败:{friendly_error(error)}"
|
||
if model in self._ai_analysis_payloads:
|
||
self._ai_analysis_model_states[model] = "success"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
else:
|
||
self._ai_analysis_model_states[model] = "error"
|
||
self._ai_analysis_model_messages[model] = message
|
||
prefix = "千问新快照已保留;" if model == "openai" and "qwen" in self._ai_analysis_payloads else ""
|
||
self._ai_analysis_operation_error = f"{prefix}{message}"
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _patient_ai_generation_finished(
|
||
self,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
patient_id: int,
|
||
model: str,
|
||
) -> None:
|
||
self._ai_analysis_requests.discard(
|
||
(request_generation, appointment_id, patient_id, model)
|
||
)
|
||
if self._patient_ai_context_current(
|
||
request_generation, appointment_id, patient_id
|
||
):
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _regenerate_patient_ai_reports(self) -> None:
|
||
"""Append a qwen snapshot, then append OpenAI only after qwen succeeds."""
|
||
|
||
if not self._can_ai_regenerate:
|
||
show_toast(self, "当前账号没有重新分析患者报告的权限。", "danger")
|
||
return
|
||
context = self._selection_context()
|
||
if context is None or context[3] is None:
|
||
show_toast(self, "当前患者缺少患者编号,无法重新分析。", "danger")
|
||
return
|
||
_detail_generation, appointment_id, diagnosis_id, patient_id = context
|
||
if not callable(getattr(self.repository, "generate_patient_ai_report", None)):
|
||
show_toast(self, "当前数据源暂不支持患者级 AI 报告。", "danger")
|
||
return
|
||
self._ai_analysis_generation += 1
|
||
request_generation = self._ai_analysis_generation
|
||
self._ai_analysis_patient_flow = True
|
||
self._ai_analysis_patient_id = patient_id
|
||
self._ai_analysis_diagnosis_id = diagnosis_id
|
||
self._ai_analysis_regenerating = True
|
||
self._ai_analysis_regeneration_model = "qwen"
|
||
self._ai_analysis_operation_error = ""
|
||
if "qwen" not in self._ai_analysis_payloads:
|
||
self._ai_analysis_model_states["qwen"] = "loading"
|
||
self._sync_ai_analysis_view()
|
||
self._request_patient_ai_generation(
|
||
"qwen",
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
)
|
||
|
||
def _load_ai_analysis(
|
||
self,
|
||
diagnosis_id: int,
|
||
*,
|
||
appointment_id: int,
|
||
patient_id: int | None = None,
|
||
force: bool = False,
|
||
) -> None:
|
||
"""Start qwen once; only its validated success may start OpenAI."""
|
||
|
||
patient_list = getattr(self.repository, "list_patient_ai_reports", None)
|
||
patient_generate = getattr(self.repository, "generate_patient_ai_report", None)
|
||
if patient_id is None:
|
||
context = self._selection_context()
|
||
patient_id = context[3] if context is not None else None
|
||
if (
|
||
self._can_patient_ai_read
|
||
and callable(patient_list)
|
||
and callable(patient_generate)
|
||
and patient_id is not None
|
||
):
|
||
self._load_patient_ai_reports(
|
||
patient_id,
|
||
appointment_id=appointment_id,
|
||
diagnosis_id=diagnosis_id if diagnosis_id > 0 else None,
|
||
force=force,
|
||
)
|
||
return
|
||
|
||
self._ai_analysis_patient_flow = False
|
||
self._ai_analysis_patient_id = patient_id
|
||
|
||
if (
|
||
not force
|
||
and _same_id(diagnosis_id, self._ai_analysis_diagnosis_id)
|
||
and self._ai_analysis_model_states["qwen"]
|
||
in {"loading", "success", "error", "permission"}
|
||
):
|
||
return
|
||
self._ai_analysis_generation += 1
|
||
request_generation = self._ai_analysis_generation
|
||
self._ai_analysis_diagnosis_id = diagnosis_id
|
||
if not self._can_ai_analysis:
|
||
self._ai_analysis_loading = False
|
||
self._set_ai_analysis_state("permission")
|
||
return
|
||
method = getattr(self.repository, "get_diagnosis_ai_analysis", None)
|
||
if not callable(method):
|
||
self._ai_analysis_loading = False
|
||
self._set_ai_analysis_state("error", "当前数据源暂不支持 AI 智能分析。")
|
||
return
|
||
self._ai_analysis_supports_models = _accepts_ai_model(method)
|
||
self._set_ai_analysis_state("loading")
|
||
self._request_ai_analysis_model(
|
||
"qwen",
|
||
method,
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
)
|
||
|
||
def _request_ai_analysis_model(
|
||
self,
|
||
model: str,
|
||
method: Any,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
) -> None:
|
||
if not self._ai_analysis_context_current(
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
):
|
||
return
|
||
if model == "openai" and not self._ai_analysis_supports_models:
|
||
self._ai_analysis_model_states[model] = "unsupported"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
self._sync_ai_analysis_view()
|
||
return
|
||
request_key = (
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
model,
|
||
)
|
||
if request_key in self._ai_analysis_requests:
|
||
return
|
||
if self._ai_analysis_model_states.get(model) == "success":
|
||
return
|
||
self._ai_analysis_requests.add(request_key)
|
||
self._ai_analysis_model_states[model] = "loading"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
self._sync_ai_analysis_view()
|
||
|
||
def request() -> Any:
|
||
if self._ai_analysis_supports_models:
|
||
return method(diagnosis_id, model=model)
|
||
return method(diagnosis_id)
|
||
|
||
run_async(
|
||
request,
|
||
on_success=lambda result: self._apply_ai_analysis(
|
||
result,
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
model,
|
||
),
|
||
on_error=lambda error: self._ai_analysis_error(
|
||
error,
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
model,
|
||
),
|
||
on_finished=lambda: self._ai_analysis_finished(
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
model,
|
||
),
|
||
)
|
||
|
||
def _apply_ai_analysis(
|
||
self,
|
||
result: Any,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
model: str = "qwen",
|
||
) -> None:
|
||
self._ai_analysis_requests.discard(
|
||
(request_generation, appointment_id, diagnosis_id, model)
|
||
)
|
||
if not self._ai_analysis_context_current(
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
):
|
||
return
|
||
if self._ai_analysis_model_states.get(model) != "loading":
|
||
return
|
||
payload: Mapping[str, Any] = result if isinstance(result, Mapping) else {}
|
||
returned_model = str(_analysis_value(payload, "model_key") or "").strip().lower()
|
||
if returned_model and returned_model != model:
|
||
self._ai_analysis_error(
|
||
ValueError("服务端返回了不匹配的 AI 模型结果。"),
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
model,
|
||
)
|
||
return
|
||
diagnosis_advice = _ai_narrative_text(
|
||
_analysis_value(payload, "diagnosis_advice")
|
||
)
|
||
treatment_advice = _ai_narrative_text(
|
||
_analysis_value(payload, "treatment_advice")
|
||
)
|
||
if not diagnosis_advice or not treatment_advice:
|
||
self._ai_analysis_error(
|
||
ValueError("服务端未返回完整的诊断建议与治疗建议。"),
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
model,
|
||
)
|
||
return
|
||
cached_payload = deepcopy(dict(payload))
|
||
self._ai_analysis_payloads[model] = cached_payload
|
||
self._ai_analysis_histories[model] = [cached_payload]
|
||
self._ai_analysis_model_states[model] = "success"
|
||
self._ai_analysis_model_messages[model] = ""
|
||
self._sync_ai_analysis_view()
|
||
|
||
if model == "qwen":
|
||
method = getattr(self.repository, "get_diagnosis_ai_analysis", None)
|
||
if callable(method):
|
||
self._request_ai_analysis_model(
|
||
"openai",
|
||
method,
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
)
|
||
|
||
def _ai_analysis_error(
|
||
self,
|
||
error: Exception,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
model: str = "qwen",
|
||
) -> None:
|
||
self._ai_analysis_requests.discard(
|
||
(request_generation, appointment_id, diagnosis_id, model)
|
||
)
|
||
if not self._ai_analysis_context_current(
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
):
|
||
return
|
||
if self._ai_analysis_model_states.get(model) != "loading":
|
||
return
|
||
self._ai_analysis_payloads.pop(model, None)
|
||
self._ai_analysis_model_states[model] = "error"
|
||
label = AI_ANALYSIS_MODEL_LABELS.get(model, model)
|
||
self._ai_analysis_model_messages[model] = (
|
||
f"{label}分析加载失败:{friendly_error(error)}"
|
||
)
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _ai_analysis_finished(
|
||
self,
|
||
request_generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
model: str = "qwen",
|
||
) -> None:
|
||
self._ai_analysis_requests.discard(
|
||
(request_generation, appointment_id, diagnosis_id, model)
|
||
)
|
||
if not self._ai_analysis_context_current(
|
||
request_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
):
|
||
return
|
||
self._sync_ai_analysis_view()
|
||
|
||
def _retry_ai_analysis(self) -> None:
|
||
context = self._selection_context()
|
||
if context is None:
|
||
self._set_ai_analysis_state("error", "当前患者信息不完整,无法重试。")
|
||
return
|
||
_detail_generation, appointment_id, diagnosis_id, patient_id = context
|
||
if self._ai_analysis_patient_flow:
|
||
if patient_id is None:
|
||
self._set_ai_analysis_state("error", "当前患者缺少患者编号,无法重试。")
|
||
return
|
||
if not any(self._ai_analysis_histories.values()):
|
||
self._load_patient_ai_reports(
|
||
patient_id,
|
||
appointment_id=appointment_id,
|
||
diagnosis_id=diagnosis_id,
|
||
force=True,
|
||
)
|
||
return
|
||
model = self._ai_analysis_active_model
|
||
self._ai_analysis_generation += 1
|
||
request_generation = self._ai_analysis_generation
|
||
self._ai_analysis_regenerating = True
|
||
self._ai_analysis_regeneration_model = model
|
||
self._ai_analysis_operation_error = ""
|
||
self._request_patient_ai_generation(
|
||
model,
|
||
request_generation,
|
||
appointment_id,
|
||
patient_id,
|
||
)
|
||
return
|
||
if diagnosis_id is None:
|
||
self._set_ai_analysis_state("error", "当前患者缺少诊单编号,无法重试。")
|
||
return
|
||
model = self._ai_analysis_active_model
|
||
if model == "openai" and self._ai_analysis_model_states[model] == "error":
|
||
method = getattr(self.repository, "get_diagnosis_ai_analysis", None)
|
||
if callable(method):
|
||
self._request_ai_analysis_model(
|
||
model,
|
||
method,
|
||
self._ai_analysis_generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
)
|
||
return
|
||
self._load_ai_analysis(
|
||
diagnosis_id,
|
||
appointment_id=appointment_id,
|
||
force=True,
|
||
)
|
||
|
||
def _open_ai_analysis_dialog(self) -> None:
|
||
"""Show the full cached analysis without issuing another model request."""
|
||
|
||
if not self._ai_analysis_payloads:
|
||
return
|
||
preferred_model = self._ai_analysis_active_model
|
||
if preferred_model not in self._ai_analysis_payloads:
|
||
preferred_model = "qwen"
|
||
dialog = _ReceptionAiAnalysisDialog(
|
||
self._ai_analysis_histories,
|
||
self,
|
||
preferred_model=preferred_model,
|
||
)
|
||
dialog.exec()
|
||
|
||
@staticmethod
|
||
def _field_card(
|
||
title: str, fields: Sequence[tuple[str, str]]
|
||
) -> tuple[QFrame, dict[str, QLabel]]:
|
||
"""Build a compact two-column field card and return its value labels."""
|
||
|
||
card = QFrame()
|
||
card.setObjectName("SubtleCard")
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(16, 14, 16, 14)
|
||
layout.setSpacing(10)
|
||
layout.addWidget(section_title(title))
|
||
grid = QGridLayout()
|
||
grid.setHorizontalSpacing(24)
|
||
grid.setVerticalSpacing(10)
|
||
labels: dict[str, QLabel] = {}
|
||
for index, (caption_text, key) in enumerate(fields):
|
||
row, column = divmod(index, 2)
|
||
box = QVBoxLayout()
|
||
caption = QLabel(caption_text)
|
||
caption.setProperty("role", "muted")
|
||
value = QLabel("—")
|
||
value.setStyleSheet("font-weight:600;")
|
||
value.setWordWrap(True)
|
||
box.addWidget(caption)
|
||
box.addWidget(value)
|
||
labels[key] = value
|
||
grid.addLayout(box, row, column)
|
||
layout.addLayout(grid)
|
||
return card, labels
|
||
|
||
@property
|
||
def queue_status(self) -> int:
|
||
"""Return the admin queue status represented by the current tab."""
|
||
|
||
return 1 if self.queue_tabs.currentIndex() == 0 else 4
|
||
|
||
def _set_queue_filter(self, status: int | None) -> None:
|
||
self._queue_filter_status = status
|
||
for value, button in self._queue_filter_buttons.items():
|
||
button.setChecked(value == status)
|
||
button.style().unpolish(button)
|
||
button.style().polish(button)
|
||
self._clear_selection()
|
||
self._reset_queue_state()
|
||
self.refresh()
|
||
|
||
def _search_changed(self, value: str) -> None:
|
||
if not value and self.isVisible():
|
||
self.refresh()
|
||
|
||
def _on_tab_changed(self, _index: int) -> None:
|
||
self._queue_filter_status = self.queue_status
|
||
for button in self._queue_filter_buttons.values():
|
||
button.setChecked(False)
|
||
if self._queue_filter_status in self._queue_filter_buttons:
|
||
self._queue_filter_buttons[self._queue_filter_status].setChecked(True)
|
||
self._clear_selection()
|
||
self._reset_queue_state()
|
||
self.refresh()
|
||
|
||
def _queue_date_changed(self, iso_date: str) -> None:
|
||
self._queue_date = iso_date
|
||
self._clear_selection()
|
||
self._reset_queue_state()
|
||
self.refresh()
|
||
|
||
def refresh(self, silent: bool = False) -> None:
|
||
"""Replace the queue with page one using a GUI-thread query snapshot."""
|
||
|
||
if silent and self._queue_loading:
|
||
return
|
||
selected_date = self._queue_date or date.today().isoformat()
|
||
page_size = self._queue_page_size
|
||
if silent:
|
||
loaded = max(len(self._queue_records), self._queue_page * self._queue_page_size)
|
||
if loaded > page_size:
|
||
page_size = loaded
|
||
query = {
|
||
"status": self._queue_filter_status,
|
||
"start_date": selected_date,
|
||
"end_date": selected_date,
|
||
"page_no": 1,
|
||
"page_size": page_size,
|
||
"patient_name": self.search_edit.text().strip(),
|
||
"include_status_counts": 1,
|
||
}
|
||
query_key = self._query_key(query)
|
||
if query_key != self._queue_query_key:
|
||
self._clear_selection()
|
||
self._reset_queue_state()
|
||
self._queue_query = dict(query)
|
||
self._queue_query["page_size"] = self._queue_page_size
|
||
self._queue_query_key = query_key
|
||
self._request_queue_page(query, append=False, silent=silent)
|
||
|
||
def _poll_queue(self) -> None:
|
||
"""Do not let the timer supersede an explicit or slower queue request."""
|
||
|
||
if self._queue_loading:
|
||
return
|
||
scrollbar = self.queue_list.verticalScrollBar()
|
||
if scrollbar.maximum() > 0 and scrollbar.value() >= scrollbar.maximum() - 80:
|
||
return
|
||
self.refresh(silent=True)
|
||
|
||
@staticmethod
|
||
def _query_key(query: dict[str, Any]) -> tuple[Any, ...]:
|
||
return (
|
||
query["status"],
|
||
query["start_date"],
|
||
query["end_date"],
|
||
query["patient_name"],
|
||
)
|
||
|
||
def _reset_queue_state(self) -> None:
|
||
self._queue_records = []
|
||
self._queue_render_signature = ()
|
||
self._queue_page = 0
|
||
self._queue_total = 0
|
||
self._queue_query = None
|
||
self._queue_query_key = None
|
||
if hasattr(self, "queue_list"):
|
||
self.queue_list.blockSignals(True)
|
||
self.queue_list.clear()
|
||
self.queue_list.blockSignals(False)
|
||
self.queue_stack.setCurrentIndex(1)
|
||
self.queue_summary.setText("等待加载")
|
||
self._set_queue_loading_visible(False)
|
||
|
||
def _load_more(self) -> None:
|
||
if self._queue_loading or self._queue_query is None or not self._queue_has_more():
|
||
return
|
||
query = dict(self._queue_query)
|
||
query["page_no"] = self._queue_page + 1
|
||
self._request_queue_page(query, append=True, silent=True)
|
||
|
||
def _on_queue_scroll(self, _value: int = 0) -> None:
|
||
self._maybe_load_more_queue()
|
||
|
||
def _maybe_load_more_queue(self) -> None:
|
||
if not self.queue_list.isVisible() or self._queue_loading or not self._queue_has_more():
|
||
return
|
||
scrollbar = self.queue_list.verticalScrollBar()
|
||
if scrollbar.maximum() <= 0 or scrollbar.value() <= 0:
|
||
return
|
||
if scrollbar.value() >= scrollbar.maximum() - 80:
|
||
self._load_more()
|
||
|
||
def _queue_has_more(self) -> bool:
|
||
return (
|
||
len(self._queue_records) < self._queue_total
|
||
and self._queue_page * self._queue_page_size < self._queue_total
|
||
)
|
||
|
||
def _add_queue_row(self, record: Any) -> None:
|
||
item = QListWidgetItem()
|
||
item.setData(Qt.ItemDataRole.UserRole, record)
|
||
item.setSizeHint(QSize(0, 86))
|
||
self.queue_list.addItem(item)
|
||
self.queue_list.setItemWidget(item, QueueRow(record))
|
||
|
||
def _sync_queue_rows(self, records: list[Any], *, append: bool) -> None:
|
||
render_signature = tuple(_queue_row_signature(record) for record in records)
|
||
existing = self.queue_list.count()
|
||
existing_ids = tuple(
|
||
_record_id(self.queue_list.item(index).data(Qt.ItemDataRole.UserRole))
|
||
for index in range(existing)
|
||
)
|
||
record_ids = tuple(_record_id(record) for record in records)
|
||
stable_order = (
|
||
existing == len(records)
|
||
and len(self._queue_render_signature) == existing
|
||
and all(record_id is not None for record_id in record_ids)
|
||
and existing_ids == record_ids
|
||
)
|
||
if stable_order and render_signature == self._queue_render_signature:
|
||
for index, record in enumerate(records):
|
||
self.queue_list.item(index).setData(Qt.ItemDataRole.UserRole, record)
|
||
self._queue_render_signature = render_signature
|
||
return
|
||
if stable_order:
|
||
for index, record in enumerate(records):
|
||
item = self.queue_list.item(index)
|
||
item.setData(Qt.ItemDataRole.UserRole, record)
|
||
if render_signature[index] != self._queue_render_signature[index]:
|
||
self.queue_list.setItemWidget(item, QueueRow(record))
|
||
self._queue_render_signature = render_signature
|
||
return
|
||
if (
|
||
append
|
||
and existing
|
||
and existing < len(records)
|
||
and all(record_id is not None for record_id in record_ids[:existing])
|
||
and existing_ids == record_ids[:existing]
|
||
and render_signature[:existing] == self._queue_render_signature
|
||
):
|
||
for index in range(existing):
|
||
self.queue_list.item(index).setData(Qt.ItemDataRole.UserRole, records[index])
|
||
for record in records[existing:]:
|
||
self._add_queue_row(record)
|
||
self._queue_render_signature = render_signature
|
||
return
|
||
scrollbar = self.queue_list.verticalScrollBar()
|
||
previous = scrollbar.value()
|
||
self.queue_list.clear()
|
||
for record in records:
|
||
self._add_queue_row(record)
|
||
self._queue_render_signature = render_signature
|
||
if not append and previous > 0:
|
||
scrollbar.setValue(min(previous, scrollbar.maximum()))
|
||
|
||
def _merge_silent_queue_records(
|
||
self,
|
||
page_records: list[Any],
|
||
loaded_records: list[Any],
|
||
) -> list[Any]:
|
||
if len(loaded_records) <= len(page_records):
|
||
return list(page_records)
|
||
merged = list(page_records)
|
||
seen = {
|
||
record_id
|
||
for record in merged
|
||
if (record_id := _record_id(record)) is not None
|
||
}
|
||
for record in loaded_records:
|
||
record_id = _record_id(record)
|
||
if record_id is None or record_id not in seen:
|
||
merged.append(record)
|
||
if record_id is not None:
|
||
seen.add(record_id)
|
||
return merged
|
||
|
||
def _remember_queue_page(self, *, page_no: int, append: bool, record_count: int) -> None:
|
||
if append:
|
||
self._queue_page = max(self._queue_page, page_no)
|
||
return
|
||
if page_no == 1 and record_count > self._queue_page_size:
|
||
self._queue_page = max(
|
||
self._queue_page,
|
||
(record_count + self._queue_page_size - 1) // self._queue_page_size,
|
||
)
|
||
return
|
||
self._queue_page = page_no
|
||
|
||
def _request_queue_page(
|
||
self,
|
||
query: dict[str, Any],
|
||
*,
|
||
append: bool,
|
||
silent: bool,
|
||
) -> None:
|
||
self._queue_generation += 1
|
||
generation = self._queue_generation
|
||
self._queue_requests.add(generation)
|
||
self._queue_loading = True
|
||
self._set_queue_loading_visible(append or not silent)
|
||
if not silent:
|
||
self.queue_banner.show_message("正在加载队列…", "info")
|
||
frozen_query = dict(query)
|
||
page_no = int(frozen_query["page_no"])
|
||
query_key = self._query_key(frozen_query)
|
||
run_async(
|
||
lambda frozen_query=frozen_query: invoke(
|
||
self.repository,
|
||
"list_appointments",
|
||
**frozen_query,
|
||
),
|
||
on_success=lambda result: self._apply_queue(
|
||
result,
|
||
generation,
|
||
page_no=page_no,
|
||
append=append,
|
||
query_key=query_key,
|
||
silent=silent,
|
||
),
|
||
on_error=lambda error: self._queue_error(error, generation, silent=silent),
|
||
on_finished=lambda: self._queue_finished(generation),
|
||
)
|
||
|
||
def _apply_queue(
|
||
self,
|
||
result: Any,
|
||
generation: int,
|
||
*,
|
||
page_no: int = 1,
|
||
append: bool = False,
|
||
query_key: tuple[Any, ...] | None = None,
|
||
silent: bool = False,
|
||
) -> None:
|
||
if generation != self._queue_generation or query_key not in (None, self._queue_query_key):
|
||
return
|
||
page_records = page_items(result)
|
||
if append:
|
||
records = list(self._queue_records)
|
||
existing_ids = {
|
||
record_id for record in records if (record_id := _record_id(record)) is not None
|
||
}
|
||
for record in page_records:
|
||
record_id = _record_id(record)
|
||
if record_id is None or record_id not in existing_ids:
|
||
records.append(record)
|
||
if record_id is not None:
|
||
existing_ids.add(record_id)
|
||
elif silent and self._queue_page > 1 and len(self._queue_records) > len(page_records):
|
||
records = self._merge_silent_queue_records(page_records, self._queue_records)
|
||
else:
|
||
records = list(page_records)
|
||
raw_total = get_value(result, "total", None)
|
||
if raw_total is None:
|
||
raw_total = get_value(result, "count", None)
|
||
total = _as_int(raw_total, None)
|
||
if total is None:
|
||
loaded_through = (page_no - 1) * self._queue_page_size + len(page_records)
|
||
total = (
|
||
loaded_through if len(page_records) < self._queue_page_size else loaded_through + 1
|
||
)
|
||
self._queue_records = records
|
||
self._remember_queue_page(page_no=page_no, append=append, record_count=len(records))
|
||
self._queue_total = max(total, len(records))
|
||
self._update_queue_filter_counts(result, records)
|
||
selected_id = self._selected_appointment_id
|
||
row_to_select = next(
|
||
(
|
||
index
|
||
for index, record in enumerate(records)
|
||
if selected_id is not None and _same_id(_record_id(record), selected_id)
|
||
),
|
||
-1,
|
||
)
|
||
if row_to_select < 0 and records:
|
||
row_to_select = 0
|
||
self.queue_list.blockSignals(True)
|
||
self._sync_queue_rows(records, append=append)
|
||
if row_to_select >= 0 and self.queue_list.currentRow() != row_to_select:
|
||
self.queue_list.setCurrentRow(row_to_select)
|
||
self.queue_list.blockSignals(False)
|
||
self._sync_queue_row_selection()
|
||
self.queue_summary.setText(f"已加载 {len(records)} / 共 {self._queue_total} 位患者")
|
||
self.queue_stack.setCurrentIndex(0 if records else 1)
|
||
self.queue_banner.clear()
|
||
if not records:
|
||
self._clear_selection()
|
||
return
|
||
chosen = records[row_to_select]
|
||
if selected_id is not None and _same_id(_record_id(chosen), selected_id):
|
||
self._selected_record = chosen
|
||
if not append and not silent:
|
||
self._load_detail(chosen, silent=True, clear=False)
|
||
else:
|
||
self._select_record(chosen, silent=True)
|
||
|
||
def _update_queue_filter_counts(self, result: Any, records: list[Any]) -> None:
|
||
extend = get_value(result, "extend", None)
|
||
if not isinstance(extend, Mapping):
|
||
extend = get_value(result, "meta", None)
|
||
raw_counts = extend.get("status_count", {}) if isinstance(extend, Mapping) else {}
|
||
counts: dict[int, int] = {}
|
||
if isinstance(raw_counts, Mapping):
|
||
for raw_status, raw_count in raw_counts.items():
|
||
status = _as_int(raw_status, None)
|
||
count = _as_int(raw_count, 0)
|
||
if status is not None:
|
||
counts[status] = max(0, count or 0)
|
||
if not counts:
|
||
for record in records:
|
||
status = _as_int(first_value(record, "status", default=None), None)
|
||
if status is not None:
|
||
counts[status] = counts.get(status, 0) + 1
|
||
total = sum(counts.values()) if counts else self._queue_total
|
||
labels = {
|
||
None: f"全部 {total}",
|
||
1: f"待接诊 {counts.get(1, 0)}",
|
||
2: f"问诊中 {counts.get(2, 0)}",
|
||
3: f"已完成 {counts.get(3, 0)}",
|
||
}
|
||
for status, button in self._queue_filter_buttons.items():
|
||
button.setText(labels[status])
|
||
|
||
def _queue_error(
|
||
self,
|
||
error: Exception,
|
||
generation: int,
|
||
*,
|
||
silent: bool = False,
|
||
) -> None:
|
||
if generation == self._queue_generation and not silent:
|
||
self.queue_banner.show_message(friendly_error(error), "danger")
|
||
|
||
def _queue_finished(self, generation: int) -> None:
|
||
self._queue_requests.discard(generation)
|
||
self._queue_loading = self._queue_generation in self._queue_requests
|
||
if generation == self._queue_generation or not self._queue_loading:
|
||
self._maybe_load_more_queue()
|
||
if not self._queue_loading:
|
||
self._set_queue_loading_visible(False)
|
||
|
||
def _set_queue_loading_visible(self, visible: bool) -> None:
|
||
indicator = getattr(self, "queue_loading_indicator", None)
|
||
if indicator is None:
|
||
return
|
||
if visible:
|
||
indicator.start()
|
||
else:
|
||
indicator.stop()
|
||
|
||
def _on_queue_selection(
|
||
self, current: QListWidgetItem | None, _previous: QListWidgetItem | None
|
||
) -> None:
|
||
self._sync_queue_row_selection()
|
||
if current is None:
|
||
self._clear_selection()
|
||
return
|
||
self._select_record(current.data(Qt.ItemDataRole.UserRole))
|
||
|
||
def _sync_queue_row_selection(self) -> None:
|
||
current = self.queue_list.currentItem()
|
||
for index in range(self.queue_list.count()):
|
||
item = self.queue_list.item(index)
|
||
row = self.queue_list.itemWidget(item)
|
||
if isinstance(row, QueueRow):
|
||
row.set_selected(item is current)
|
||
|
||
def _select_record(self, record: Any, *, silent: bool = False) -> None:
|
||
appointment_id = _record_id(record)
|
||
if appointment_id is None:
|
||
self._clear_selection()
|
||
self.detail_stack.setCurrentIndex(1)
|
||
self.detail_banner.show_message("该队列记录缺少挂号编号。", "danger")
|
||
return
|
||
selection_changed = not _same_id(appointment_id, self._selected_appointment_id)
|
||
if selection_changed:
|
||
self.note_edit.clear()
|
||
self._pending_tongue_images.clear()
|
||
self._pending_report_files.clear()
|
||
self._render_pending_attachments()
|
||
self._selected_record = record
|
||
self._selected_appointment_id = appointment_id
|
||
self._selected_detail = None
|
||
self._load_detail(record, silent=silent, clear=True)
|
||
|
||
def _load_detail(self, record: Any, silent: bool = False, *, clear: bool = True) -> None:
|
||
"""Start a new detail generation even while an older request is running."""
|
||
|
||
appointment_id = _record_id(record)
|
||
if appointment_id is None:
|
||
return
|
||
self._detail_generation += 1
|
||
generation = self._detail_generation
|
||
self._selected_record = record
|
||
self._selected_appointment_id = appointment_id
|
||
if clear:
|
||
self._selected_detail = None
|
||
self._reset_detail_content(seed=record)
|
||
self.detail_stack.setCurrentIndex(1)
|
||
if not silent:
|
||
self.detail_banner.show_message("正在加载患者详情…", "info")
|
||
self._detail_loading = True
|
||
self._detail_requests.add((generation, appointment_id))
|
||
run_async(
|
||
lambda: self._fetch_detail_bundle(record, appointment_id),
|
||
on_success=lambda bundle: self._apply_detail(bundle, generation, appointment_id),
|
||
on_error=lambda error: self._detail_error(error, generation, appointment_id),
|
||
on_finished=lambda: self._detail_finished(generation, appointment_id),
|
||
)
|
||
|
||
def _fetch_detail_bundle(self, record: Any, appointment_id: int) -> dict[str, Any]:
|
||
detail = invoke(
|
||
self.repository,
|
||
"get_reception",
|
||
appointment_id=appointment_id,
|
||
id=appointment_id,
|
||
)
|
||
if (
|
||
get_value(detail, "data", None) is not None
|
||
and get_value(detail, "appointment", None) is None
|
||
):
|
||
detail = get_value(detail, "data", detail)
|
||
diagnosis_id = _as_int(
|
||
first_value(
|
||
get_value(detail, "diagnosis", None) or {},
|
||
"id",
|
||
"diagnosis_id",
|
||
default=first_value(record, "diagnosis_id", default=None),
|
||
)
|
||
)
|
||
notes = get_value(detail, "doctor_notes", None) or get_value(detail, "notes", None) or []
|
||
notes = list(notes) if isinstance(notes, (list, tuple)) else [notes]
|
||
daily: Any = {}
|
||
embedded_tracking = get_value(detail, "tracking_notes", None) or []
|
||
tracking_notes = list(embedded_tracking) if isinstance(
|
||
embedded_tracking, (list, tuple)
|
||
) else ([embedded_tracking] if embedded_tracking else [])
|
||
warnings: list[str] = []
|
||
if diagnosis_id is not None and callable(
|
||
getattr(self.repository, "get_doctor_notes", None)
|
||
):
|
||
try:
|
||
notes = page_items(
|
||
invoke(
|
||
self.repository,
|
||
"get_doctor_notes",
|
||
diagnosis_id=diagnosis_id,
|
||
)
|
||
)
|
||
except Exception as error:
|
||
warnings.append(f"医生备注:{friendly_error(error)}")
|
||
if diagnosis_id is not None and callable(
|
||
getattr(self.repository, "get_tracking_window", None)
|
||
):
|
||
end_date = date.today()
|
||
try:
|
||
daily = invoke(
|
||
self.repository,
|
||
"get_tracking_window",
|
||
diagnosis_id=diagnosis_id,
|
||
start_date=(end_date - timedelta(days=6)).isoformat(),
|
||
end_date=end_date.isoformat(),
|
||
)
|
||
except Exception as error:
|
||
warnings.append(f"日常记录:{friendly_error(error)}")
|
||
if diagnosis_id is not None and callable(
|
||
getattr(self.repository, "list_tracking_notes", None)
|
||
):
|
||
try:
|
||
tracking_notes = page_items(
|
||
invoke(
|
||
self.repository,
|
||
"list_tracking_notes",
|
||
diagnosis_id=diagnosis_id,
|
||
)
|
||
)
|
||
except Exception as error:
|
||
warnings.append(f"跟踪备注:{friendly_error(error)}")
|
||
return {
|
||
"detail": detail,
|
||
"notes": notes,
|
||
"daily": daily,
|
||
"tracking_notes": tracking_notes,
|
||
"warnings": warnings,
|
||
}
|
||
|
||
def _refresh_daily_records(self) -> None:
|
||
start_date, end_date = self.daily_panel.current_range()
|
||
self._request_daily_range(start_date, end_date)
|
||
|
||
def _request_daily_range(self, start_date: str, end_date: str) -> None:
|
||
detail = self._selected_detail or {}
|
||
diagnosis = get_value(detail, "diagnosis", None) or {}
|
||
diagnosis_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"id",
|
||
"diagnosis_id",
|
||
default=first_value(self._selected_record, "diagnosis_id", default=None),
|
||
)
|
||
)
|
||
appointment_id = self._selected_appointment_id
|
||
if appointment_id is None or diagnosis_id is None:
|
||
self.daily_panel.set_error("当前患者缺少诊单编号,无法读取日常记录。")
|
||
return
|
||
start = str(start_date or "").strip()
|
||
end = str(end_date or "").strip()
|
||
if not start or not end or start > end:
|
||
self.daily_panel.set_error("请选择有效的日常记录日期范围。")
|
||
return
|
||
|
||
self._daily_generation += 1
|
||
generation = self._daily_generation
|
||
self._daily_loading = True
|
||
self._daily_diagnosis_id = diagnosis_id
|
||
request_key = (generation, appointment_id, diagnosis_id)
|
||
self._daily_requests.add(request_key)
|
||
self.daily_panel.set_loading(True)
|
||
fallback_notes = list(self._daily_tracking_fallback)
|
||
run_async(
|
||
lambda: self._fetch_daily_bundle(
|
||
diagnosis_id,
|
||
start,
|
||
end,
|
||
fallback_notes=fallback_notes,
|
||
),
|
||
on_success=lambda bundle: self._apply_daily_bundle(
|
||
bundle,
|
||
generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
),
|
||
on_error=lambda error: self._daily_error(
|
||
error,
|
||
generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
),
|
||
on_finished=lambda: self._daily_finished(
|
||
generation,
|
||
appointment_id,
|
||
diagnosis_id,
|
||
),
|
||
)
|
||
|
||
def _fetch_daily_bundle(
|
||
self,
|
||
diagnosis_id: int,
|
||
start_date: str,
|
||
end_date: str,
|
||
*,
|
||
fallback_notes: list[Any],
|
||
) -> dict[str, Any]:
|
||
window_method = getattr(self.repository, "get_tracking_window", None)
|
||
if not callable(window_method):
|
||
raise RuntimeError("当前后端客户端未提供日常记录接口。")
|
||
window = invoke(
|
||
self.repository,
|
||
"get_tracking_window",
|
||
diagnosis_id=diagnosis_id,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
)
|
||
tracking_notes = list(fallback_notes)
|
||
warnings: list[str] = []
|
||
if callable(getattr(self.repository, "list_tracking_notes", None)):
|
||
try:
|
||
tracking_notes = page_items(
|
||
invoke(
|
||
self.repository,
|
||
"list_tracking_notes",
|
||
diagnosis_id=diagnosis_id,
|
||
)
|
||
)
|
||
except Exception as error:
|
||
warnings.append(f"跟踪备注:{friendly_error(error)}")
|
||
return {
|
||
"daily": window,
|
||
"tracking_notes": tracking_notes,
|
||
"warnings": warnings,
|
||
"start_date": start_date,
|
||
"end_date": end_date,
|
||
}
|
||
|
||
def _apply_daily_bundle(
|
||
self,
|
||
bundle: Any,
|
||
generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
) -> None:
|
||
if (
|
||
generation != self._daily_generation
|
||
or not _same_id(appointment_id, self._selected_appointment_id)
|
||
or diagnosis_id != self._daily_diagnosis_id
|
||
):
|
||
return
|
||
tracking_notes = list(get_value(bundle, "tracking_notes", None) or [])
|
||
self._daily_tracking_fallback = tracking_notes
|
||
self._render_daily(get_value(bundle, "daily", None) or {}, tracking_notes)
|
||
warnings = list(get_value(bundle, "warnings", None) or [])
|
||
if warnings:
|
||
self.daily_panel.state.show_message(
|
||
";".join(str(item) for item in warnings),
|
||
"warning",
|
||
)
|
||
|
||
def _daily_error(
|
||
self,
|
||
error: Exception,
|
||
generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
) -> None:
|
||
if (
|
||
generation == self._daily_generation
|
||
and _same_id(appointment_id, self._selected_appointment_id)
|
||
and diagnosis_id == self._daily_diagnosis_id
|
||
):
|
||
self.daily_panel.set_error(
|
||
f"日常记录加载失败:{friendly_error(error)}(已保留上次数据)"
|
||
)
|
||
|
||
def _daily_finished(
|
||
self,
|
||
generation: int,
|
||
appointment_id: int,
|
||
diagnosis_id: int,
|
||
) -> None:
|
||
self._daily_requests.discard((generation, appointment_id, diagnosis_id))
|
||
if (
|
||
generation == self._daily_generation
|
||
and _same_id(appointment_id, self._selected_appointment_id)
|
||
and diagnosis_id == self._daily_diagnosis_id
|
||
):
|
||
self._daily_loading = False
|
||
self.daily_panel.set_loading(False)
|
||
|
||
def _apply_detail(
|
||
self, bundle: Any, generation: int, appointment_id: int | None = None
|
||
) -> None:
|
||
expected_id = appointment_id or self._selected_appointment_id
|
||
if (
|
||
generation != self._detail_generation
|
||
or expected_id is None
|
||
or not _same_id(expected_id, self._selected_appointment_id)
|
||
):
|
||
return
|
||
detail = get_value(bundle, "detail", None)
|
||
if detail is None:
|
||
detail = bundle
|
||
if (
|
||
get_value(detail, "data", None) is not None
|
||
and get_value(detail, "appointment", None) is None
|
||
):
|
||
detail = get_value(detail, "data", detail)
|
||
appointment = get_value(detail, "appointment", None) or self._selected_record or {}
|
||
actual_id = first_value(appointment, "id", "appointment_id", default=expected_id)
|
||
if not _same_id(actual_id, expected_id):
|
||
self._selected_detail = None
|
||
self._reset_detail_content(seed=self._selected_record)
|
||
self._stop_ai_analysis_after_detail_failure(
|
||
"患者详情校验失败,暂时无法生成 AI 智能分析。"
|
||
)
|
||
self.detail_banner.show_message("服务端返回了另一位患者的详情,已拒绝显示。", "danger")
|
||
return
|
||
self._selected_detail = detail
|
||
diagnosis = get_value(detail, "diagnosis", None) or {}
|
||
patient = get_value(detail, "patient", None) or {}
|
||
self._render_identity(appointment, patient, diagnosis)
|
||
self._render_appointment(appointment, patient, diagnosis)
|
||
self._render_patient(appointment, patient, diagnosis)
|
||
self._render_case(appointment, patient, diagnosis)
|
||
notes = get_value(bundle, "notes", None)
|
||
if notes is None:
|
||
notes = get_value(detail, "doctor_notes", None) or get_value(detail, "notes", None)
|
||
self._render_notes(list(notes or []))
|
||
diagnosis_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"id",
|
||
"diagnosis_id",
|
||
default=first_value(self._selected_record, "diagnosis_id", default=None),
|
||
)
|
||
)
|
||
self._daily_generation += 1
|
||
self._daily_loading = False
|
||
self._daily_diagnosis_id = diagnosis_id
|
||
tracking_notes = list(get_value(bundle, "tracking_notes", None) or [])
|
||
self._daily_tracking_fallback = tracking_notes
|
||
self.daily_panel.set_patient_age(
|
||
first_value(
|
||
diagnosis,
|
||
"age",
|
||
default=first_value(patient, "age", default=first_value(appointment, "age")),
|
||
)
|
||
)
|
||
self._render_daily(
|
||
get_value(bundle, "daily", None) or {},
|
||
tracking_notes,
|
||
)
|
||
warnings = list(get_value(bundle, "warnings", None) or [])
|
||
if warnings:
|
||
self.detail_banner.show_message(";".join(str(item) for item in warnings), "warning")
|
||
else:
|
||
self.detail_banner.clear()
|
||
self._update_action_state(appointment, diagnosis)
|
||
patient_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
default=first_value(
|
||
patient,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
"id",
|
||
default=None,
|
||
),
|
||
)
|
||
)
|
||
supports_patient_reports = self._can_patient_ai_read and callable(
|
||
getattr(self.repository, "list_patient_ai_reports", None)
|
||
) and callable(getattr(self.repository, "generate_patient_ai_report", None))
|
||
if patient_id is None and supports_patient_reports:
|
||
self._ai_analysis_generation += 1
|
||
self._ai_analysis_loading = False
|
||
self._ai_analysis_diagnosis_id = None
|
||
self._ai_analysis_patient_id = None
|
||
self._ai_analysis_patient_flow = True
|
||
self._set_ai_analysis_state(
|
||
"empty",
|
||
"当前记录缺少患者编号,无法查询患者级 AI 报告。",
|
||
)
|
||
elif diagnosis_id is None and not supports_patient_reports:
|
||
self._ai_analysis_generation += 1
|
||
self._ai_analysis_loading = False
|
||
self._ai_analysis_diagnosis_id = None
|
||
self._set_ai_analysis_state("empty", "当前患者缺少诊单编号,无法生成 AI 智能分析。")
|
||
else:
|
||
self._load_ai_analysis(
|
||
diagnosis_id or 0,
|
||
appointment_id=expected_id,
|
||
patient_id=patient_id,
|
||
)
|
||
|
||
def _render_identity(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
||
patient_name = first_value(
|
||
appointment,
|
||
"patient_name",
|
||
"name",
|
||
default=first_value(
|
||
diagnosis,
|
||
"patient_name",
|
||
default=first_value(patient, "patient_name", "name", default="未命名患者"),
|
||
),
|
||
)
|
||
patient_name_text = display_text(patient_name)
|
||
self.patient_name_label.setText(patient_name_text)
|
||
self.patient_avatar_label.setText(patient_name_text[:1] if patient_name_text != "—" else "患")
|
||
gender = gender_text(
|
||
first_value(
|
||
diagnosis,
|
||
"gender_desc",
|
||
"gender",
|
||
default=first_value(
|
||
patient,
|
||
"gender_desc",
|
||
"gender",
|
||
default=first_value(appointment, "gender_desc", "gender"),
|
||
),
|
||
)
|
||
)
|
||
self.patient_gender_mark.set_gender(gender)
|
||
age = first_value(
|
||
diagnosis,
|
||
"age",
|
||
default=first_value(patient, "age", default=first_value(appointment, "age")),
|
||
)
|
||
phone = self._phone_text(appointment, patient, diagnosis)
|
||
condition = first_value(
|
||
diagnosis,
|
||
"clinical_diagnosis",
|
||
"diagnosis_name",
|
||
"disease_name",
|
||
"diagnosis",
|
||
default=first_value(appointment, "clinical_diagnosis", "disease_name", default=None),
|
||
)
|
||
course = first_value(
|
||
diagnosis,
|
||
"disease_course_text",
|
||
"disease_course",
|
||
"diabetes_history_text",
|
||
"diabetes_history",
|
||
"course_text",
|
||
"course",
|
||
default=None,
|
||
)
|
||
visit_id = first_value(appointment, "visit_no", "visit_id", "id", "appointment_id")
|
||
condition_parts = [
|
||
str(part).strip()
|
||
for part in (condition, course)
|
||
if part not in (None, "") and str(part).strip()
|
||
]
|
||
meta_parts = [f"{gender} · {display_text(age)}岁 · {phone}"]
|
||
if condition_parts:
|
||
meta_parts.append(" · ".join(condition_parts))
|
||
if visit_id not in (None, ""):
|
||
meta_parts.append(f"就诊号:{display_text(visit_id)}")
|
||
self.patient_meta_label.setText(" | ".join(meta_parts))
|
||
status_number = (
|
||
_as_int(first_value(appointment, "status", default=self.queue_status), 0) or 0
|
||
)
|
||
status_text = first_value(
|
||
appointment,
|
||
"status_desc",
|
||
"status_text",
|
||
default=STATUS_TEXT.get(status_number, status_number),
|
||
)
|
||
self.detail_status.set_status(
|
||
display_text(status_text),
|
||
_queue_badge_kind(status_number, display_text(status_text)),
|
||
)
|
||
|
||
def _render_appointment(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
||
date_text = first_value(appointment, "appointment_date", "date", default="")
|
||
time_text = first_value(
|
||
appointment, "appointment_time_text", "appointment_time", "time", default=""
|
||
)
|
||
period_text = first_value(appointment, "period_text", "period_desc", "period", default="")
|
||
time_parts = [
|
||
str(part).strip()
|
||
for part in (date_text, time_text, period_text)
|
||
if part not in (None, "") and str(part).strip()
|
||
]
|
||
self.appointment_labels["time"].setText(" ".join(time_parts) or "—")
|
||
self.appointment_labels["phone"].setText(self._phone_text(appointment, patient, diagnosis))
|
||
self.appointment_labels["doctor"].setText(
|
||
display_text(
|
||
first_value(
|
||
appointment,
|
||
"doctor_name",
|
||
default=first_value(self.current_user, "name", "display_name"),
|
||
)
|
||
)
|
||
)
|
||
self.appointment_labels["assistant"].setText(
|
||
display_text(first_value(appointment, "assistant_name"))
|
||
)
|
||
self.appointment_labels["type"].setText(
|
||
display_text(
|
||
first_value(
|
||
appointment,
|
||
"appointment_type_text",
|
||
"type_text",
|
||
"appointment_type",
|
||
"type",
|
||
)
|
||
)
|
||
)
|
||
self.appointment_labels["channel"].setText(
|
||
display_text(
|
||
first_value(
|
||
appointment,
|
||
"channel_text",
|
||
"channel_name",
|
||
"appointment_channel_text",
|
||
"channel",
|
||
)
|
||
)
|
||
)
|
||
status_number = _as_int(first_value(appointment, "status", default=0), 0) or 0
|
||
self.appointment_labels["status"].setText(
|
||
display_text(
|
||
first_value(
|
||
appointment,
|
||
"status_desc",
|
||
"status_text",
|
||
default=STATUS_TEXT.get(status_number, status_number),
|
||
)
|
||
)
|
||
)
|
||
self.appointment_labels["remark"].setText(display_text(first_value(appointment, "remark")))
|
||
|
||
def _render_patient(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
||
patient_id = first_value(
|
||
appointment,
|
||
"patient_id",
|
||
default=first_value(
|
||
diagnosis, "patient_id", "source_patient_id", default=first_value(patient, "id")
|
||
),
|
||
)
|
||
gender = gender_text(
|
||
first_value(
|
||
diagnosis,
|
||
"gender_desc",
|
||
"gender",
|
||
default=first_value(
|
||
patient,
|
||
"gender_desc",
|
||
"gender",
|
||
default=first_value(appointment, "gender_desc", "gender"),
|
||
),
|
||
)
|
||
)
|
||
age = first_value(
|
||
diagnosis,
|
||
"age",
|
||
default=first_value(patient, "age", default=first_value(appointment, "age")),
|
||
)
|
||
height = first_value(
|
||
diagnosis,
|
||
"height",
|
||
default=first_value(patient, "height", default=first_value(appointment, "height")),
|
||
)
|
||
weight = first_value(
|
||
diagnosis,
|
||
"weight",
|
||
default=first_value(patient, "weight", default=first_value(appointment, "weight")),
|
||
)
|
||
body = " / ".join(
|
||
part
|
||
for part in (
|
||
f"{display_text(height)} cm" if height not in (None, "") else "",
|
||
f"{display_text(weight)} kg" if weight not in (None, "") else "",
|
||
)
|
||
if part
|
||
)
|
||
region = first_value(
|
||
diagnosis,
|
||
"region_text",
|
||
"region",
|
||
"address_region",
|
||
default=first_value(patient, "region_text", "region"),
|
||
)
|
||
customer = first_value(
|
||
appointment,
|
||
"customer_service_name",
|
||
"service_name",
|
||
"source_text",
|
||
default=first_value(diagnosis, "source_text", "source_name", "source"),
|
||
)
|
||
has_prescription = bool(
|
||
first_value(
|
||
appointment,
|
||
"has_prescription",
|
||
default=first_value(diagnosis, "has_prescription", default=False),
|
||
)
|
||
)
|
||
self.patient_labels["patient_id"].setText(display_text(patient_id))
|
||
self.patient_labels["phone"].setText(self._phone_text(appointment, patient, diagnosis))
|
||
self.patient_labels["gender_age"].setText(f"{gender} / {display_text(age)}岁")
|
||
self.patient_labels["body"].setText(body or "—")
|
||
self.patient_labels["region"].setText(display_text(region))
|
||
self.patient_labels["id_card"].setText(
|
||
display_text(first_value(diagnosis, "id_card", default=first_value(patient, "id_card")))
|
||
)
|
||
self.patient_labels["customer"].setText(display_text(customer))
|
||
self.patient_labels["prescription"].setText("已开具" if has_prescription else "暂未开具")
|
||
|
||
systolic = first_value(
|
||
diagnosis,
|
||
"systolic",
|
||
"systolic_pressure",
|
||
"high_pressure",
|
||
default=first_value(patient, "systolic", "systolic_pressure", default=None),
|
||
)
|
||
diastolic = first_value(
|
||
diagnosis,
|
||
"diastolic",
|
||
"diastolic_pressure",
|
||
"low_pressure",
|
||
default=first_value(patient, "diastolic", "diastolic_pressure", default=None),
|
||
)
|
||
blood_pressure = first_value(diagnosis, "blood_pressure", default=None)
|
||
if systolic not in (None, "") or diastolic not in (None, ""):
|
||
blood_pressure = f"{display_text(systolic)}/{display_text(diastolic)}"
|
||
pulse = first_value(
|
||
diagnosis,
|
||
"heart_rate",
|
||
"pulse_rate",
|
||
"heart_rate_value",
|
||
default=first_value(patient, "heart_rate", "pulse_rate", default=None),
|
||
)
|
||
height_number = _as_float(height)
|
||
weight_number = _as_float(weight)
|
||
bmi = None
|
||
if height_number and height_number > 0 and weight_number is not None:
|
||
bmi = weight_number / ((height_number / 100) ** 2)
|
||
self.vital_labels["height"].setText(display_text(height) if height not in (None, "") else "—")
|
||
self.vital_unit_labels["height"].setVisible(height not in (None, ""))
|
||
self.vital_labels["weight"].setText(display_text(weight) if weight not in (None, "") else "—")
|
||
self.vital_unit_labels["weight"].setVisible(weight not in (None, ""))
|
||
self.vital_labels["bmi"].setText(f"{bmi:.1f}" if bmi is not None else "—")
|
||
bmi_status = _bmi_status(bmi)
|
||
if bmi_status is None:
|
||
self.vital_bmi_state.setVisible(False)
|
||
else:
|
||
text, alert = bmi_status
|
||
self.vital_bmi_state.setText(text)
|
||
self.vital_bmi_state.setVisible(True)
|
||
self.vital_bmi_state.setProperty("alert", alert)
|
||
self.vital_bmi_state.style().unpolish(self.vital_bmi_state)
|
||
self.vital_bmi_state.style().polish(self.vital_bmi_state)
|
||
self.vital_labels["bp"].setText(
|
||
display_text(blood_pressure) if blood_pressure not in (None, "") else "—"
|
||
)
|
||
self.vital_unit_labels["bp"].setVisible(blood_pressure not in (None, ""))
|
||
self.vital_labels["pulse"].setText(display_text(pulse) if pulse not in (None, "") else "—")
|
||
self.vital_unit_labels["pulse"].setVisible(pulse not in (None, ""))
|
||
|
||
@staticmethod
|
||
def _case_display_text(value: Any) -> str:
|
||
"""Return readable, brace-free text for a clinical case value."""
|
||
|
||
if isinstance(value, Mapping):
|
||
parts: list[str] = []
|
||
for raw_key, raw_value in value.items():
|
||
if raw_value in (None, "", [], {}):
|
||
continue
|
||
key = str(raw_key).strip().replace("_", " ")
|
||
item = ReceptionPage._case_display_text(raw_value)
|
||
if item and item != "—":
|
||
parts.append(f"{key}:{item}" if key else item)
|
||
return ";".join(parts) if parts else "—"
|
||
if isinstance(value, (list, tuple, set)):
|
||
parts = [
|
||
ReceptionPage._case_display_text(item)
|
||
for item in value
|
||
if item not in (None, "", [], {})
|
||
]
|
||
return "、".join(item for item in parts if item and item != "—") or "—"
|
||
return display_text(value).strip() or "—"
|
||
|
||
@staticmethod
|
||
def _case_status_is_alert(value: Any) -> bool:
|
||
if value in (None, "", [], {}):
|
||
return False
|
||
normalized = display_text(value).strip().lower()
|
||
return normalized not in {
|
||
"正常",
|
||
"阴性",
|
||
"达标",
|
||
"稳定",
|
||
"normal",
|
||
"negative",
|
||
"0",
|
||
}
|
||
|
||
def _set_case_metric(self, key: str, value: Any, *, alert: bool = False) -> None:
|
||
label = self.case_metric_labels[key]
|
||
caption = {
|
||
"diagnosis_type": "诊断类型",
|
||
"diagnosis_date": "诊断日期",
|
||
"blood_pressure": "血压",
|
||
"fasting_glucose": "空腹血糖",
|
||
}[key]
|
||
text = self._case_display_text(value)
|
||
if text == "—" or (key == "diagnosis_date" and text == "0"):
|
||
text = "未记录"
|
||
label.setText(text)
|
||
label.setAccessibleName(f"{caption}:{text}")
|
||
label.setProperty("alert", alert)
|
||
label.style().unpolish(label)
|
||
label.style().polish(label)
|
||
|
||
def _set_case_section(
|
||
self,
|
||
key: str,
|
||
items: Sequence[tuple[str, str]],
|
||
) -> None:
|
||
frame = self.case_section_frames[key]
|
||
rows_layout = self.case_section_layouts[key]
|
||
clear_layout(rows_layout)
|
||
visible_items = [(caption, value) for caption, value in items if value not in ("", "—")]
|
||
frame.setVisible(bool(visible_items))
|
||
for caption_text, value_text in visible_items:
|
||
row = QWidget(frame)
|
||
row.setObjectName("ReceptionCaseFieldRow")
|
||
row.setAccessibleName(f"{caption_text}:{value_text}")
|
||
row_layout = QHBoxLayout(row)
|
||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||
row_layout.setSpacing(12)
|
||
caption = QLabel(caption_text, row)
|
||
caption.setObjectName("ReceptionCaseFieldCaption")
|
||
caption.setProperty("receptionCaseFieldCaption", True)
|
||
caption.setTextFormat(Qt.TextFormat.PlainText)
|
||
caption.setFixedWidth(108)
|
||
caption.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
||
row_layout.addWidget(caption)
|
||
value = QLabel(value_text, row)
|
||
value.setObjectName("ReceptionCaseFieldValue")
|
||
value.setProperty("receptionCaseFieldValue", True)
|
||
value.setProperty(
|
||
"important",
|
||
caption_text in {"临床诊断", "当前用药", "主诉", "主要症状"},
|
||
)
|
||
allergy_is_risk = caption_text == "过敏史" and value_text.strip().lower() not in {
|
||
"无",
|
||
"否",
|
||
"未发现",
|
||
"无过敏史",
|
||
"none",
|
||
"no",
|
||
}
|
||
value.setProperty("risk", allergy_is_risk)
|
||
value.setTextFormat(Qt.TextFormat.PlainText)
|
||
value.setWordWrap(True)
|
||
value.setMinimumWidth(0)
|
||
value.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
value.setAccessibleName(f"{caption_text}:{value_text}")
|
||
row_layout.addWidget(value, 1)
|
||
rows_layout.addWidget(row)
|
||
|
||
def _set_case_placeholder(self, message: str) -> None:
|
||
self._case_summary_text = message
|
||
self.diagnosis_text.setText(message)
|
||
self.case_empty_label.setText(message)
|
||
self.case_empty_label.setVisible(True)
|
||
self.case_overview_widget.setVisible(False)
|
||
for key, frame in self.case_section_frames.items():
|
||
clear_layout(self.case_section_layouts[key])
|
||
frame.setVisible(False)
|
||
|
||
def _render_case_overview(
|
||
self,
|
||
values: Mapping[str, str],
|
||
*,
|
||
fasting_status: Any = None,
|
||
blood_pressure_status: Any = None,
|
||
) -> None:
|
||
self.case_empty_label.setVisible(False)
|
||
self.case_overview_widget.setVisible(True)
|
||
self._set_case_metric("diagnosis_type", values.get("诊断类型", "未记录"))
|
||
self._set_case_metric("diagnosis_date", values.get("诊断日期", "未记录"))
|
||
self._set_case_metric(
|
||
"blood_pressure",
|
||
values.get("血压", "未记录"),
|
||
alert=self._case_status_is_alert(blood_pressure_status),
|
||
)
|
||
self._set_case_metric(
|
||
"fasting_glucose",
|
||
values.get("空腹血糖", "未记录"),
|
||
alert=self._case_status_is_alert(fasting_status),
|
||
)
|
||
|
||
groups: dict[str, tuple[str, ...]] = {
|
||
"diagnosis": ("临床诊断",),
|
||
"medication": ("当前用药", "过敏史"),
|
||
"symptoms": (
|
||
"主诉",
|
||
"主要症状",
|
||
"现病史",
|
||
"口腔感觉",
|
||
"肢体感觉",
|
||
"睡眠情况",
|
||
"眼睛情况",
|
||
"头部感觉",
|
||
"出汗情况",
|
||
"皮肤情况",
|
||
"小便情况",
|
||
"大便情况",
|
||
"腰肾情况",
|
||
),
|
||
"tcm": ("舌象", "脉象", "治则", "处方意见"),
|
||
"history": (
|
||
"糖尿病史",
|
||
"发现糖尿病病史",
|
||
"既往史",
|
||
"外伤史",
|
||
"手术史",
|
||
"个人史",
|
||
"家族史",
|
||
"妊娠哺乳史",
|
||
"当地就诊医院",
|
||
"当地医院诊断结果",
|
||
"其他病史",
|
||
),
|
||
"lifestyle": (
|
||
"婚姻状态",
|
||
"每日饮水量",
|
||
"近月体重变化",
|
||
"脂肪肝程度",
|
||
"饮食情况",
|
||
),
|
||
"notes": ("病例备注",),
|
||
}
|
||
for key, captions in groups.items():
|
||
self._set_case_section(
|
||
key,
|
||
[(caption, values[caption]) for caption in captions if caption in values],
|
||
)
|
||
|
||
def _render_case(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
||
systolic = first_value(
|
||
diagnosis, "systolic", "systolic_pressure", "high_pressure", default=None
|
||
)
|
||
diastolic = first_value(
|
||
diagnosis, "diastolic", "diastolic_pressure", "low_pressure", default=None
|
||
)
|
||
blood_pressure = (
|
||
f"{display_text(systolic)}/{display_text(diastolic)} mmHg"
|
||
if systolic not in (None, "") or diastolic not in (None, "")
|
||
else first_value(diagnosis, "blood_pressure", default=None)
|
||
)
|
||
fields: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("诊断日期", ("diagnosis_date",)),
|
||
("诊断类型", ("diagnosis_type_text", "diagnosis_type_desc", "diagnosis_type")),
|
||
("婚姻状态", ("marital_status_text", "marital_status_desc", "marital_status")),
|
||
("主诉", ("chief_complaint", "complaint")),
|
||
("主要症状", ("symptoms", "main_symptoms")),
|
||
("现病史", ("present_illness", "present_illness_history")),
|
||
("发现糖尿病病史", ("diabetes_discovery_year_text", "diabetes_discovery_year")),
|
||
("当地就诊医院", ("local_hospital_name", "local_hospital")),
|
||
("当地医院诊断结果", ("local_hospital_diagnosis", "local_diagnosis")),
|
||
("口腔感觉", ("appetite_text", "appetite_desc", "appetite")),
|
||
("每日饮水量", ("water_intake_text", "water_intake_desc", "water_intake")),
|
||
("近月体重变化", ("weight_change_text", "weight_change_desc", "weight_change")),
|
||
(
|
||
"脂肪肝程度",
|
||
("fatty_liver_degree_text", "fatty_liver_degree_desc", "fatty_liver_degree"),
|
||
),
|
||
("饮食情况", ("diet_condition_text", "diet_condition_desc", "diet_condition")),
|
||
("肢体感觉", ("body_feeling_text", "body_feeling_desc", "body_feeling")),
|
||
("睡眠情况", ("sleep_condition_text", "sleep_condition_desc", "sleep_condition")),
|
||
("眼睛情况", ("eye_condition_text", "eye_condition_desc", "eye_condition")),
|
||
("头部感觉", ("head_feeling_text", "head_feeling_desc", "head_feeling")),
|
||
("出汗情况", ("sweat_condition_text", "sweat_condition_desc", "sweat_condition")),
|
||
("皮肤情况", ("skin_condition_text", "skin_condition_desc", "skin_condition")),
|
||
("小便情况", ("urine_condition_text", "urine_condition_desc", "urine_condition")),
|
||
("大便情况", ("stool_condition_text", "stool_condition_desc", "stool_condition")),
|
||
("腰肾情况", ("kidney_condition_text", "kidney_condition_desc", "kidney_condition")),
|
||
("既往史", ("past_history_text", "past_history_desc", "past_history")),
|
||
("外伤史", ("trauma_history_text", "trauma_history_desc", "trauma_history")),
|
||
("手术史", ("surgery_history_text", "surgery_history_desc", "surgery_history")),
|
||
("过敏史", ("allergy_history_text", "allergy_history_desc", "allergy_history")),
|
||
("个人史", ("personal_history_text", "personal_history_desc", "personal_history")),
|
||
("家族史", ("family_history_text", "family_history_desc", "family_history")),
|
||
(
|
||
"妊娠哺乳史",
|
||
("pregnancy_history_text", "pregnancy_history_desc", "pregnancy_history"),
|
||
),
|
||
("糖尿病史", ("diabetes_history_text", "diabetes_history", "diabetes_desc")),
|
||
(
|
||
"当前用药",
|
||
("current_medications", "current_medicine", "current_medication"),
|
||
),
|
||
("临床诊断", ("clinical_diagnosis", "diagnosis")),
|
||
("舌象", ("tongue", "tongue_coating")),
|
||
("脉象", ("pulse", "pulse_condition")),
|
||
("治则", ("treatment_principle",)),
|
||
("处方意见", ("prescription_opinion", "prescription_advice")),
|
||
("其他病史", ("other_history", "medical_history_other")),
|
||
("病例备注", ("remark",)),
|
||
)
|
||
lines: list[str] = []
|
||
case_items: dict[str, str] = {}
|
||
if blood_pressure:
|
||
blood_pressure_text = self._case_display_text(blood_pressure)
|
||
if "mmhg" not in blood_pressure_text.lower():
|
||
blood_pressure_text = f"{blood_pressure_text} mmHg"
|
||
case_items["血压"] = blood_pressure_text
|
||
lines.append(f"血压:{blood_pressure_text}")
|
||
blood_sugar = first_value(
|
||
diagnosis,
|
||
"fasting_blood_sugar",
|
||
"fasting_glucose",
|
||
"fasting_blood_glucose",
|
||
"blood_sugar",
|
||
default=None,
|
||
)
|
||
if blood_sugar not in (None, ""):
|
||
blood_sugar_text = self._case_display_text(blood_sugar)
|
||
if blood_sugar_text.startswith("空腹"):
|
||
blood_sugar_text = blood_sugar_text[2:].lstrip(":: ")
|
||
if "mmol/l" not in blood_sugar_text.lower():
|
||
blood_sugar_text = f"{blood_sugar_text} mmol/L"
|
||
case_items["空腹血糖"] = blood_sugar_text
|
||
lines.append(f"空腹血糖:{blood_sugar_text}")
|
||
|
||
def case_value(*keys: str) -> Any:
|
||
value = first_value(diagnosis, *keys, default=None)
|
||
if value in (None, "", [], {}):
|
||
value = first_value(patient, *keys, default=None)
|
||
if value in (None, "", [], {}):
|
||
return None
|
||
return self._case_display_text(value)
|
||
|
||
for caption, keys in fields:
|
||
value = case_value(*keys)
|
||
if value not in (None, "", [], {}):
|
||
case_items[caption] = str(value)
|
||
lines.append(f"{caption}:{value}")
|
||
if lines:
|
||
self._case_summary_text = "\n".join(lines)
|
||
self.diagnosis_text.setText(self._case_summary_text)
|
||
self._render_case_overview(
|
||
case_items,
|
||
fasting_status=case_value(
|
||
"fasting_blood_sugar_status",
|
||
"fasting_glucose_status",
|
||
),
|
||
blood_pressure_status=case_value(
|
||
"blood_pressure_status",
|
||
"blood_pressure_state",
|
||
),
|
||
)
|
||
else:
|
||
self._set_case_placeholder("尚未填写病例信息。")
|
||
|
||
chief = case_value("chief_complaint", "complaint")
|
||
present = case_value("present_illness", "present_illness_history", "symptoms")
|
||
past = case_value("past_history_text", "past_history_desc", "past_history")
|
||
family = case_value("family_history_text", "family_history_desc", "family_history")
|
||
allergy = case_value("allergy_history_text", "allergy_history_desc", "allergy_history")
|
||
diet = case_value("diet_condition_text", "diet_condition_desc", "diet_condition")
|
||
smoking = case_value("smoking_text", "smoking_desc", "smoking", "smoke")
|
||
drinking = case_value("drinking_text", "drinking_desc", "drinking", "alcohol")
|
||
exercise = case_value("exercise_text", "exercise_desc", "exercise_habit", "exercise")
|
||
lifestyle_parts = [
|
||
f"吸烟:{display_text(smoking)}" if smoking not in (None, "") else "",
|
||
f"饮酒:{display_text(drinking)}" if drinking not in (None, "") else "",
|
||
f"运动:{display_text(exercise)}" if exercise not in (None, "") else "",
|
||
]
|
||
self.lifestyle_value_labels["smoke"].setText(
|
||
f"吸烟:{display_text(smoking) if smoking not in (None, '') else '—'}"
|
||
)
|
||
self.lifestyle_value_labels["drink"].setText(
|
||
f"饮酒:{display_text(drinking) if drinking not in (None, '') else '—'}"
|
||
)
|
||
self.lifestyle_value_labels["exercise"].setText(
|
||
f"运动:{display_text(exercise) if exercise not in (None, '') else '—'}"
|
||
)
|
||
case_values = {
|
||
"chief": chief,
|
||
"present": present,
|
||
"past": past,
|
||
"family": family,
|
||
"allergy": allergy,
|
||
"lifestyle": " ".join(part for part in lifestyle_parts if part),
|
||
"diet": diet,
|
||
}
|
||
for key, value in case_values.items():
|
||
self.case_labels[key].setText(display_text(value))
|
||
|
||
lab_definitions: dict[str, tuple[tuple[str, ...], str, tuple[str, ...]]] = {
|
||
"fasting": (
|
||
("fasting_blood_sugar", "fasting_glucose", "fasting_blood_glucose", "blood_sugar"),
|
||
"mmol/L",
|
||
("fasting_blood_sugar_status", "fasting_glucose_status"),
|
||
),
|
||
"postprandial": (
|
||
("postprandial_blood_sugar", "postprandial_glucose", "two_hour_glucose"),
|
||
"mmol/L",
|
||
("postprandial_blood_sugar_status", "postprandial_glucose_status"),
|
||
),
|
||
"hba1c": (
|
||
("glycated_hemoglobin", "hba1c", "glycosylated_hemoglobin"),
|
||
"%",
|
||
("glycated_hemoglobin_status", "hba1c_status"),
|
||
),
|
||
"cholesterol": (
|
||
("total_cholesterol", "cholesterol"),
|
||
"mmol/L",
|
||
("total_cholesterol_status", "cholesterol_status"),
|
||
),
|
||
"triglycerides": (
|
||
("triglycerides", "triglyceride"),
|
||
"mmol/L",
|
||
("triglycerides_status", "triglyceride_status"),
|
||
),
|
||
"hdl": (("hdl_c", "hdl", "high_density_lipoprotein"), "mmol/L", ("hdl_status",)),
|
||
"ldl": (("ldl_c", "ldl", "low_density_lipoprotein"), "mmol/L", ("ldl_status",)),
|
||
"albumin": (
|
||
("urine_microalbumin", "microalbumin", "urinary_albumin"),
|
||
"mg/L",
|
||
("urine_microalbumin_status", "microalbumin_status"),
|
||
),
|
||
}
|
||
health_lines: list[str] = []
|
||
for key, (value_keys, unit, status_keys) in lab_definitions.items():
|
||
value = case_value(*value_keys)
|
||
text = display_text(value)
|
||
if value not in (None, "") and unit.lower() not in str(value).lower():
|
||
text = f"{text} {unit}"
|
||
self.lab_labels[key].setText(text)
|
||
status_value = case_value(*status_keys)
|
||
computed = _lab_status_for_value(key, value)
|
||
status_label = self.lab_state_labels[key]
|
||
if status_value not in (None, ""):
|
||
status_text = display_text(status_value)
|
||
if status_text.strip().lower() in {"正常", "normal", "0", "阴性"}:
|
||
status_text = "正常"
|
||
alert = False
|
||
elif "低" in status_text:
|
||
status_text = f"↓ {status_text}" if "↓" not in status_text else status_text
|
||
alert = True
|
||
else:
|
||
status_text = (
|
||
status_text if status_text.startswith(("↑", "↓")) else f"↑ {status_text}"
|
||
)
|
||
if status_text in {"↑ 正常"}:
|
||
status_text = "正常"
|
||
alert = False
|
||
else:
|
||
alert = True
|
||
status_label.setText(status_text)
|
||
status_label.setVisible(True)
|
||
status_label.setProperty("alert", alert)
|
||
status_label.style().unpolish(status_label)
|
||
status_label.style().polish(status_label)
|
||
elif computed is not None:
|
||
status_text, alert = computed
|
||
status_label.setText(status_text)
|
||
status_label.setVisible(True)
|
||
status_label.setProperty("alert", alert)
|
||
status_label.style().unpolish(status_label)
|
||
status_label.style().polish(status_label)
|
||
else:
|
||
status_label.setVisible(False)
|
||
if value not in (None, ""):
|
||
health_lines.append(f"{self.lab_labels[key].parentWidget().findChild(QLabel).text()}:{text}")
|
||
|
||
clinical_diagnosis = case_value("clinical_diagnosis", "diagnosis")
|
||
health_header = (
|
||
f"当前诊断:{display_text(clinical_diagnosis)}"
|
||
if clinical_diagnosis not in (None, "")
|
||
else "当前诊断:—"
|
||
)
|
||
self.health_summary_label.setText(
|
||
"\n".join([health_header, *health_lines])
|
||
if health_lines or clinical_diagnosis not in (None, "")
|
||
else "暂无结构化健康数据。"
|
||
)
|
||
has_prescription = bool(
|
||
first_value(
|
||
appointment,
|
||
"has_prescription",
|
||
default=first_value(diagnosis, "has_prescription", default=False),
|
||
)
|
||
)
|
||
self.prescription_hint.setText("处方:已开具" if has_prescription else "处方:暂未开具")
|
||
self.prescription_hint.setProperty(
|
||
"casePrescriptionState",
|
||
"ready" if has_prescription else "empty",
|
||
)
|
||
self.prescription_hint.style().unpolish(self.prescription_hint)
|
||
self.prescription_hint.style().polish(self.prescription_hint)
|
||
|
||
def _phone_text(self, appointment: Any, patient: Any, diagnosis: Any) -> str:
|
||
masked = first_value(
|
||
patient,
|
||
"phone_masked",
|
||
default=first_value(
|
||
diagnosis,
|
||
"phone_masked",
|
||
default=first_value(appointment, "phone_masked", default=None),
|
||
),
|
||
)
|
||
raw = first_value(
|
||
diagnosis,
|
||
"phone",
|
||
"patient_phone",
|
||
default=first_value(
|
||
patient,
|
||
"phone",
|
||
"patient_phone",
|
||
default=first_value(appointment, "patient_phone", "phone", default=masked),
|
||
),
|
||
)
|
||
if self._can_phone_plain:
|
||
return display_text(raw or masked)
|
||
return _mask_phone(masked or raw)
|
||
|
||
def _render_daily(self, daily: Any, tracking_notes: list[Any]) -> None:
|
||
start_date = str(first_value(daily, "start_date", default="") or "")[:10]
|
||
end_date = str(first_value(daily, "end_date", default="") or "")[:10]
|
||
if start_date and end_date:
|
||
self.daily_panel.set_range(start_date, end_date)
|
||
self.daily_panel.set_data(daily, tracking_notes)
|
||
|
||
followup_lines: list[str] = []
|
||
for row in tracking_notes[:60]:
|
||
note_date = display_text(
|
||
first_value(row, "note_date", "record_date", "create_time")
|
||
)
|
||
content = display_text(
|
||
first_value(row, "content", "tracking_content", "remark")
|
||
)
|
||
if content == "—":
|
||
continue
|
||
followup_lines.append(f"{note_date} · {content}")
|
||
self.followup_text.setText(
|
||
"\n\n".join(followup_lines) if followup_lines else "暂无跟踪备注。"
|
||
)
|
||
|
||
def _render_notes(self, notes: list[Any]) -> None:
|
||
self._attachment_render_generation += 1
|
||
render_generation = self._attachment_render_generation
|
||
clear_layout(self.notes_layout)
|
||
self.notes_count.setText(f"{len(notes)} 条")
|
||
if not notes:
|
||
empty = QLabel("暂无医生备注")
|
||
empty.setProperty("role", "muted")
|
||
self.notes_layout.addWidget(empty)
|
||
return
|
||
for note in notes:
|
||
card = QFrame()
|
||
card.setObjectName("SubtleCard")
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(12, 10, 12, 10)
|
||
layout.setSpacing(5)
|
||
content = QLabel(display_text(first_value(note, "content", "note", "remark")))
|
||
content.setWordWrap(True)
|
||
content.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
layout.addWidget(content)
|
||
meta = QLabel(
|
||
f"{display_text(first_value(note, 'doctor_name', 'creator_name'), '医生')} · "
|
||
f"{display_text(first_value(note, 'note_date', 'create_time', 'created_at', 'time'))}"
|
||
)
|
||
meta.setProperty("role", "muted")
|
||
layout.addWidget(meta)
|
||
note_id = _as_int(first_value(note, "id", "note_id", default=None))
|
||
image_attachments: list[tuple[str, str, str]] = []
|
||
file_attachments: list[tuple[str, str, str]] = []
|
||
for image_type, caption in (
|
||
("tongue_images", "舌苔图"),
|
||
("report_files", "检查报告"),
|
||
):
|
||
for path in _sequence(first_value(note, image_type, default=[])):
|
||
path_text = str(path).strip()
|
||
attachment = (image_type, caption, path_text)
|
||
if _is_image_attachment(path_text):
|
||
image_attachments.append(attachment)
|
||
else:
|
||
file_attachments.append(attachment)
|
||
|
||
if image_attachments:
|
||
image_title = QLabel("图片附件 · 点击图片查看大图")
|
||
image_title.setProperty("role", "muted")
|
||
layout.addWidget(image_title)
|
||
image_host = QWidget()
|
||
image_grid = QGridLayout(image_host)
|
||
image_grid.setContentsMargins(0, 0, 0, 0)
|
||
image_grid.setHorizontalSpacing(10)
|
||
image_grid.setVerticalSpacing(10)
|
||
for index, (image_type, caption, path_text) in enumerate(image_attachments):
|
||
tile = QFrame()
|
||
tile.setObjectName("NoteAttachmentTile")
|
||
tile_layout = QVBoxLayout(tile)
|
||
tile_layout.setContentsMargins(8, 8, 8, 8)
|
||
tile_layout.setSpacing(6)
|
||
preview = _NoteAttachmentPreview(caption)
|
||
preview.setToolTip(f"{caption}:{path_text}\n点击查看大图")
|
||
preview.clicked.connect(
|
||
lambda _checked=False, current_path=path_text, button=preview: (
|
||
self._preview_note_attachment(current_path, button)
|
||
)
|
||
)
|
||
tile_layout.addWidget(preview)
|
||
footer = QHBoxLayout()
|
||
footer.setSpacing(6)
|
||
label = QLabel(f"{caption} · {_attachment_name(path_text)}")
|
||
label.setObjectName("NoteAttachmentName")
|
||
label.setToolTip(path_text)
|
||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
label.setMinimumWidth(0)
|
||
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||
footer.addWidget(label, 1)
|
||
if self._can_note and note_id is not None:
|
||
delete_button = QPushButton("删除")
|
||
delete_button.setProperty("variant", "secondary")
|
||
delete_button.clicked.connect(
|
||
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=path_text, button=delete_button: (
|
||
self._delete_note_attachment(
|
||
current_note_id, current_type, current_path, button
|
||
)
|
||
)
|
||
)
|
||
footer.addWidget(delete_button)
|
||
tile_layout.addLayout(footer)
|
||
row, column = divmod(index, 3)
|
||
image_grid.addWidget(tile, row, column)
|
||
self._load_note_thumbnail(path_text, preview, render_generation)
|
||
for column in range(3):
|
||
image_grid.setColumnStretch(column, 1)
|
||
layout.addWidget(image_host)
|
||
|
||
for image_type, caption, path_text in file_attachments:
|
||
attachment = QWidget()
|
||
attachment_layout = QHBoxLayout(attachment)
|
||
attachment_layout.setContentsMargins(0, 0, 0, 0)
|
||
label = QLabel(f"{caption}:{_attachment_name(path_text)}")
|
||
label.setToolTip(path_text)
|
||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
attachment_layout.addWidget(label, 1)
|
||
open_button = QPushButton("打开")
|
||
open_button.setProperty("variant", "secondary")
|
||
open_button.setEnabled(bool(path_text))
|
||
open_button.clicked.connect(
|
||
lambda _checked=False, current_path=path_text, button=open_button: (
|
||
self._preview_note_attachment(current_path, button)
|
||
)
|
||
)
|
||
attachment_layout.addWidget(open_button)
|
||
if self._can_note and note_id is not None:
|
||
delete_button = QPushButton("删除")
|
||
delete_button.setProperty("variant", "secondary")
|
||
delete_button.clicked.connect(
|
||
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=path_text, button=delete_button: (
|
||
self._delete_note_attachment(
|
||
current_note_id, current_type, current_path, button
|
||
)
|
||
)
|
||
)
|
||
attachment_layout.addWidget(delete_button)
|
||
layout.addWidget(attachment)
|
||
self.notes_layout.addWidget(card)
|
||
|
||
def _load_note_thumbnail(
|
||
self,
|
||
path: str,
|
||
preview: _NoteAttachmentPreview,
|
||
generation: int,
|
||
) -> None:
|
||
"""Download and paint a note image without opening another window."""
|
||
|
||
download = getattr(self.repository, "download_public_image", None)
|
||
if not callable(download):
|
||
preview.show_failure()
|
||
return
|
||
run_async(
|
||
lambda: invoke(self.repository, "download_public_image", url=path),
|
||
on_success=lambda payload: self._apply_note_thumbnail(payload, preview, generation),
|
||
on_error=lambda _error: self._fail_note_thumbnail(preview, generation),
|
||
)
|
||
|
||
def _apply_note_thumbnail(
|
||
self,
|
||
payload: Any,
|
||
preview: _NoteAttachmentPreview,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._attachment_render_generation:
|
||
return
|
||
content = bytes(payload or b"")
|
||
pixmap = QPixmap()
|
||
if (
|
||
not content
|
||
or len(content) > 10 * 1024 * 1024
|
||
or not pixmap.loadFromData(content)
|
||
or pixmap.isNull()
|
||
):
|
||
self._fail_note_thumbnail(preview, generation)
|
||
return
|
||
with suppress(RuntimeError):
|
||
preview.show_preview(pixmap)
|
||
|
||
def _fail_note_thumbnail(
|
||
self,
|
||
preview: _NoteAttachmentPreview,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._attachment_render_generation:
|
||
return
|
||
with suppress(RuntimeError):
|
||
preview.show_failure()
|
||
|
||
def _preview_note_attachment(self, path: str, button: QPushButton) -> None:
|
||
"""Preview note images in-app and open non-image reports safely."""
|
||
|
||
target = str(path or "").strip()
|
||
if not target:
|
||
return
|
||
if not _is_image_attachment(target):
|
||
url = QUrl(target)
|
||
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
||
show_toast(self, "附件地址无效,无法打开。", "warning", 4200)
|
||
return
|
||
if not QDesktopServices.openUrl(url):
|
||
show_toast(self, "系统未能打开该附件。", "danger", 4200)
|
||
return
|
||
|
||
download = getattr(self.repository, "download_public_image", None)
|
||
if not callable(download):
|
||
show_toast(self, "当前数据源不支持图片预览。", "warning", 4200)
|
||
return
|
||
self._attachment_preview_generation += 1
|
||
generation = self._attachment_preview_generation
|
||
if isinstance(button, _NoteAttachmentPreview):
|
||
button.show_loading()
|
||
else:
|
||
button.setEnabled(False)
|
||
button.setText("加载中…")
|
||
run_async(
|
||
lambda: invoke(self.repository, "download_public_image", url=target),
|
||
on_success=lambda payload: self._show_note_image_preview(
|
||
target, payload, button, generation
|
||
),
|
||
on_error=lambda error: self._note_image_preview_failed(error, button, generation),
|
||
)
|
||
|
||
def _show_note_image_preview(
|
||
self,
|
||
path: str,
|
||
payload: Any,
|
||
button: QPushButton,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._attachment_preview_generation:
|
||
return
|
||
content = bytes(payload or b"")
|
||
pixmap = QPixmap()
|
||
if not content or len(content) > 10 * 1024 * 1024 or not pixmap.loadFromData(content):
|
||
if isinstance(button, _NoteAttachmentPreview):
|
||
button.show_failure()
|
||
else:
|
||
button.setEnabled(True)
|
||
button.setText("预览")
|
||
show_toast(self, "服务器返回的图片无法预览。", "danger", 4600)
|
||
return
|
||
if isinstance(button, _NoteAttachmentPreview):
|
||
button.show_preview(pixmap)
|
||
else:
|
||
button.setEnabled(True)
|
||
button.setText("预览")
|
||
dialog = QDialog(self)
|
||
dialog.setWindowTitle(f"预览 · {_attachment_name(path)}")
|
||
dialog.setModal(True)
|
||
dialog.resize(920, 680)
|
||
layout = QVBoxLayout(dialog)
|
||
layout.setContentsMargins(14, 14, 14, 14)
|
||
scroll = QScrollArea(dialog)
|
||
scroll.setWidgetResizable(True)
|
||
image = QLabel()
|
||
image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
image.setPixmap(
|
||
pixmap.scaled(
|
||
880,
|
||
620,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
)
|
||
scroll.setWidget(image)
|
||
layout.addWidget(scroll, 1)
|
||
close_button = QPushButton("关闭")
|
||
close_button.setProperty("variant", "primary")
|
||
close_button.clicked.connect(dialog.accept)
|
||
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
|
||
dialog.exec()
|
||
|
||
def _note_image_preview_failed(
|
||
self,
|
||
error: Exception,
|
||
button: QPushButton,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._attachment_preview_generation:
|
||
return
|
||
if isinstance(button, _NoteAttachmentPreview):
|
||
button.show_failure()
|
||
else:
|
||
button.setEnabled(True)
|
||
button.setText("预览")
|
||
show_toast(self, f"图片预览失败:{friendly_error(error)}", "danger", 5200)
|
||
|
||
def _reset_detail_content(self, seed: Any = None) -> None:
|
||
patient_name = display_text(first_value(seed, "patient_name", "name", default="—"))
|
||
self.patient_name_label.setText(patient_name)
|
||
self.patient_avatar_label.setText(patient_name[:1] if patient_name != "—" else "患")
|
||
self.patient_gender_mark.set_gender(
|
||
gender_text(first_value(seed, "gender_desc", "gender", default=""))
|
||
if seed
|
||
else ""
|
||
)
|
||
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
|
||
self.detail_status.set_status("—", "neutral")
|
||
for label in getattr(self, "appointment_labels", {}).values():
|
||
label.setText("—")
|
||
for label in getattr(self, "patient_labels", {}).values():
|
||
label.setText("—")
|
||
if hasattr(self, "diagnosis_text"):
|
||
self._set_case_placeholder(
|
||
"正在加载病例…" if seed else "尚未填写病例信息"
|
||
)
|
||
self.prescription_hint.setText("处方:—")
|
||
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
|
||
self.prescription_hint.style().unpolish(self.prescription_hint)
|
||
self.prescription_hint.style().polish(self.prescription_hint)
|
||
self._daily_generation += 1
|
||
self._daily_loading = False
|
||
self._daily_diagnosis_id = None
|
||
self._daily_tracking_fallback = []
|
||
self.daily_panel.clear_with_message(
|
||
"正在读取患者日常记录…" if seed else "选择患者后加载最近 7 天日常记录。"
|
||
)
|
||
self.followup_text.setText(
|
||
"正在加载跟踪备注…" if seed else "选择患者后加载跟踪备注。"
|
||
)
|
||
self._ai_analysis_generation += 1
|
||
self._ai_analysis_loading = False
|
||
self._ai_analysis_diagnosis_id = None
|
||
self._ai_analysis_patient_id = None
|
||
self._ai_analysis_patient_flow = self._can_patient_ai_read and callable(
|
||
getattr(self.repository, "list_patient_ai_reports", None)
|
||
)
|
||
if not self._can_ai_analysis:
|
||
self._set_ai_analysis_state("permission")
|
||
elif seed:
|
||
self._set_ai_analysis_state("loading", "正在读取患者病历,准备 AI 智能分析…")
|
||
else:
|
||
self._set_ai_analysis_state("empty")
|
||
self.health_summary_label.setText(
|
||
"正在加载健康数据…" if seed else "选择患者后加载生命体征与健康记录。"
|
||
)
|
||
for label in self.case_labels.values():
|
||
label.setText("正在加载…" if seed else "—")
|
||
for kind, caption in (
|
||
("smoke", "吸烟"),
|
||
("drink", "饮酒"),
|
||
("exercise", "运动"),
|
||
):
|
||
self.lifestyle_value_labels[kind].setText(f"{caption}:—")
|
||
for label in self.vital_labels.values():
|
||
label.setText("—")
|
||
for label in self.vital_unit_labels.values():
|
||
label.setVisible(False)
|
||
self.vital_bmi_state.setVisible(False)
|
||
for key, label in self.lab_labels.items():
|
||
label.setText("—")
|
||
self.lab_state_labels[key].setVisible(False)
|
||
self._render_notes([])
|
||
self._update_action_state(seed or {}, {})
|
||
|
||
def _update_action_state(self, appointment: Any, diagnosis: Any) -> None:
|
||
appointment_id = _record_id(appointment) or self._selected_appointment_id
|
||
diagnosis_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"id",
|
||
"diagnosis_id",
|
||
default=first_value(self._selected_record, "diagnosis_id", default=None),
|
||
)
|
||
)
|
||
patient_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
default=first_value(
|
||
appointment,
|
||
"source_patient_id",
|
||
"stable_patient_id",
|
||
default=None,
|
||
),
|
||
)
|
||
)
|
||
status = _as_int(first_value(appointment, "status", default=None))
|
||
self.notify_button.setEnabled(appointment_id is not None)
|
||
self.video_button.setEnabled(
|
||
appointment_id is not None and diagnosis_id is not None and patient_id is not None
|
||
)
|
||
self.edit_button.setEnabled(diagnosis_id is not None)
|
||
report_enabled = (
|
||
self._can_ai_report
|
||
and diagnosis_id is not None
|
||
and not self._detail_loading
|
||
)
|
||
assistant_enabled = (
|
||
self._can_ai_assistant
|
||
and diagnosis_id is not None
|
||
and not self._detail_loading
|
||
)
|
||
self.ai_button.setEnabled(report_enabled)
|
||
self.ai_consult_button.setEnabled(assistant_enabled)
|
||
self.ai_report_link.setEnabled(report_enabled)
|
||
self.ai_question_edit.setEnabled(assistant_enabled)
|
||
self.ai_send_button.setEnabled(assistant_enabled)
|
||
for button in self.ai_prompt_buttons:
|
||
button.setEnabled(assistant_enabled)
|
||
self.history_button.setEnabled(appointment_id is not None)
|
||
self.more_button.setEnabled(appointment_id is not None)
|
||
self.complete_button.setEnabled(
|
||
self._can_complete and appointment_id is not None and status in RECEPTION_STATUSES
|
||
)
|
||
note_enabled = self._can_note and diagnosis_id is not None and not self._note_busy
|
||
self.note_edit.setEnabled(note_enabled)
|
||
self.add_tongue_button.setEnabled(note_enabled)
|
||
self.add_report_button.setEnabled(note_enabled)
|
||
self.save_note_button.setEnabled(note_enabled)
|
||
|
||
def _detail_error(
|
||
self, error: Exception, generation: int, appointment_id: int | None = None
|
||
) -> None:
|
||
expected_id = appointment_id or self._selected_appointment_id
|
||
if (
|
||
generation == self._detail_generation
|
||
and expected_id is not None
|
||
and _same_id(expected_id, self._selected_appointment_id)
|
||
):
|
||
self._selected_detail = None
|
||
self._stop_ai_analysis_after_detail_failure(
|
||
"患者病历加载失败,暂时无法生成 AI 智能分析。"
|
||
)
|
||
self.detail_banner.show_message(friendly_error(error), "danger")
|
||
self._update_action_state(self._selected_record or {}, {})
|
||
|
||
def _stop_ai_analysis_after_detail_failure(self, message: str) -> None:
|
||
"""Leave the AI card in a terminal, retryable state after detail failure."""
|
||
|
||
self._ai_analysis_generation += 1
|
||
self._ai_analysis_loading = False
|
||
diagnosis_id = _as_int(
|
||
first_value(self._selected_record, "diagnosis_id", default=None)
|
||
)
|
||
patient_id = _as_int(
|
||
first_value(
|
||
self._selected_record,
|
||
"source_patient_id",
|
||
"stable_patient_id",
|
||
default=None,
|
||
)
|
||
)
|
||
self._ai_analysis_diagnosis_id = diagnosis_id
|
||
self._ai_analysis_patient_id = patient_id
|
||
self._ai_analysis_patient_flow = self._can_patient_ai_read and callable(
|
||
getattr(self.repository, "list_patient_ai_reports", None)
|
||
)
|
||
if not self._can_ai_analysis:
|
||
self._set_ai_analysis_state("permission")
|
||
elif self._ai_analysis_patient_flow and patient_id is None:
|
||
self._set_ai_analysis_state(
|
||
"empty",
|
||
"当前记录缺少患者编号,无法查询患者级 AI 报告。",
|
||
)
|
||
elif diagnosis_id is None:
|
||
self._set_ai_analysis_state(
|
||
"empty",
|
||
"当前患者缺少诊单编号,无法生成 AI 智能分析。",
|
||
)
|
||
else:
|
||
self._set_ai_analysis_state("error", message)
|
||
|
||
def _detail_finished(self, generation: int, appointment_id: int) -> None:
|
||
self._detail_requests.discard((generation, appointment_id))
|
||
current_request = (
|
||
self._detail_generation,
|
||
self._selected_appointment_id,
|
||
)
|
||
self._detail_loading = (
|
||
current_request[1] is not None and current_request in self._detail_requests
|
||
)
|
||
if not self._detail_loading and self._context_current(generation, appointment_id):
|
||
appointment = (
|
||
get_value(self._selected_detail, "appointment", None)
|
||
or self._selected_record
|
||
or {}
|
||
)
|
||
diagnosis = get_value(self._selected_detail, "diagnosis", None) or {}
|
||
self._update_action_state(appointment, diagnosis)
|
||
|
||
def _clear_selection(self) -> None:
|
||
self._detail_generation += 1
|
||
self._selected_record = None
|
||
self._selected_detail = None
|
||
self._selected_appointment_id = None
|
||
self._pending_tongue_images.clear()
|
||
self._pending_report_files.clear()
|
||
if hasattr(self, "pending_attachments_layout"):
|
||
self._render_pending_attachments()
|
||
if hasattr(self, "detail_stack"):
|
||
self._reset_detail_content()
|
||
self.detail_banner.clear()
|
||
self.detail_stack.setCurrentIndex(0)
|
||
|
||
def _selection_context(self) -> tuple[int, int, int | None, int | None] | None:
|
||
appointment_id = self._selected_appointment_id
|
||
if appointment_id is None:
|
||
return None
|
||
appointment = (
|
||
get_value(self._selected_detail, "appointment", None) or self._selected_record or {}
|
||
)
|
||
diagnosis = get_value(self._selected_detail, "diagnosis", None) or {}
|
||
patient = get_value(self._selected_detail, "patient", None) or {}
|
||
diagnosis_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"id",
|
||
"diagnosis_id",
|
||
default=first_value(self._selected_record, "diagnosis_id", default=None),
|
||
)
|
||
)
|
||
patient_id = _as_int(
|
||
first_value(
|
||
diagnosis,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
default=first_value(
|
||
patient,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
"id",
|
||
default=first_value(
|
||
appointment,
|
||
"source_patient_id",
|
||
"stable_patient_id",
|
||
default=None,
|
||
),
|
||
),
|
||
)
|
||
)
|
||
return self._detail_generation, appointment_id, diagnosis_id, patient_id
|
||
|
||
def _context_current(self, generation: int, appointment_id: int) -> bool:
|
||
return generation == self._detail_generation and _same_id(
|
||
appointment_id, self._selected_appointment_id
|
||
)
|
||
|
||
def _notify_assistant(self) -> None:
|
||
context = self._selection_context()
|
||
if context is None:
|
||
return
|
||
generation, appointment_id, _diagnosis_id, _patient_id = context
|
||
self.notify_button.setEnabled(False)
|
||
run_async(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"notify_assistant",
|
||
appointment_id=appointment_id,
|
||
id=appointment_id,
|
||
),
|
||
on_success=lambda _result: self._notification_sent(generation, appointment_id),
|
||
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200),
|
||
on_finished=lambda: self._restore_action_state(generation, appointment_id),
|
||
)
|
||
|
||
def _notification_sent(self, generation: int, appointment_id: int) -> None:
|
||
show_toast(self, "已通知医助。", "success")
|
||
self.refresh(silent=True)
|
||
if self._context_current(generation, appointment_id) and self._selected_record is not None:
|
||
self._load_detail(self._selected_record, silent=True, clear=False)
|
||
|
||
def _limit_note_text(self) -> None:
|
||
text = self.note_edit.toPlainText()
|
||
if len(text) > NOTE_LIMIT:
|
||
self.note_edit.blockSignals(True)
|
||
self.note_edit.setPlainText(text[:NOTE_LIMIT])
|
||
self.note_edit.moveCursor(QTextCursor.MoveOperation.End)
|
||
self.note_edit.blockSignals(False)
|
||
text = text[:NOTE_LIMIT]
|
||
self.note_counter.setText(f"{len(text)} / {NOTE_LIMIT}")
|
||
|
||
def _choose_tongue_images(self) -> None:
|
||
paths, _selected_filter = QFileDialog.getOpenFileNames(
|
||
self,
|
||
"选择舌苔图片",
|
||
"",
|
||
"图片 (*.png *.jpg *.jpeg *.webp *.bmp);;所有文件 (*)",
|
||
)
|
||
self._add_pending_files("tongue_images", paths)
|
||
|
||
def _choose_report_files(self) -> None:
|
||
paths, _selected_filter = QFileDialog.getOpenFileNames(
|
||
self,
|
||
"选择检查报告",
|
||
"",
|
||
"报告文件 (*.pdf *.png *.jpg *.jpeg *.doc *.docx);;所有文件 (*)",
|
||
)
|
||
self._add_pending_files("report_files", paths)
|
||
|
||
def _add_pending_files(self, image_type: str, paths: Sequence[str]) -> None:
|
||
target = (
|
||
self._pending_tongue_images
|
||
if image_type == "tongue_images"
|
||
else self._pending_report_files
|
||
)
|
||
for raw_path in paths:
|
||
path = str(Path(raw_path))
|
||
if path not in target:
|
||
target.append(path)
|
||
if len(target) > MAX_NOTE_MEDIA:
|
||
del target[MAX_NOTE_MEDIA:]
|
||
show_toast(self, f"每类附件最多 {MAX_NOTE_MEDIA} 个。", "warning")
|
||
self._render_pending_attachments()
|
||
|
||
def _render_pending_attachments(self) -> None:
|
||
clear_layout(self.pending_attachments_layout)
|
||
pairs = (
|
||
("舌苔图", "tongue_images", self._pending_tongue_images),
|
||
("检查报告", "report_files", self._pending_report_files),
|
||
)
|
||
for caption, image_type, paths in pairs:
|
||
for path in paths:
|
||
row = QWidget()
|
||
layout = QHBoxLayout(row)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
label = QLabel(f"待上传 {caption}:{_attachment_name(path)}")
|
||
label.setToolTip(path)
|
||
layout.addWidget(label, 1)
|
||
remove = QPushButton("移除")
|
||
remove.setProperty("variant", "secondary")
|
||
remove.clicked.connect(
|
||
lambda _checked=False, current_type=image_type, current_path=path: (
|
||
self._remove_pending_file(current_type, current_path)
|
||
)
|
||
)
|
||
layout.addWidget(remove)
|
||
self.pending_attachments_layout.addWidget(row)
|
||
|
||
def _remove_pending_file(self, image_type: str, path: str) -> None:
|
||
target = (
|
||
self._pending_tongue_images
|
||
if image_type == "tongue_images"
|
||
else self._pending_report_files
|
||
)
|
||
if path in target:
|
||
target.remove(path)
|
||
self._render_pending_attachments()
|
||
|
||
def _save_note(self) -> None:
|
||
context = self._selection_context()
|
||
if context is None:
|
||
return
|
||
generation, appointment_id, diagnosis_id, _patient_id = context
|
||
content = self.note_edit.toPlainText().strip()
|
||
tongue_images = list(self._pending_tongue_images)
|
||
report_files = list(self._pending_report_files)
|
||
if not content and not tongue_images and not report_files:
|
||
show_toast(self, "请填写备注或添加附件。", "danger")
|
||
self.note_edit.setFocus()
|
||
return
|
||
if len(content) > NOTE_LIMIT:
|
||
show_toast(self, f"备注不能超过 {NOTE_LIMIT} 字。", "danger")
|
||
return
|
||
if diagnosis_id is None:
|
||
show_toast(self, "当前患者缺少诊单编号,无法保存备注。", "danger")
|
||
return
|
||
self._note_busy = True
|
||
self._update_action_state(
|
||
get_value(self._selected_detail, "appointment", None) or self._selected_record or {},
|
||
get_value(self._selected_detail, "diagnosis", None) or {},
|
||
)
|
||
run_async(
|
||
lambda: self._upload_and_add_note(
|
||
diagnosis_id,
|
||
content,
|
||
tongue_images,
|
||
report_files,
|
||
),
|
||
on_success=lambda _result: self._note_saved(generation, appointment_id),
|
||
on_error=lambda error: self._note_error(error, generation, appointment_id),
|
||
on_finished=lambda: self._note_finished(generation, appointment_id),
|
||
)
|
||
|
||
def _upload_and_add_note(
|
||
self,
|
||
diagnosis_id: int,
|
||
content: str,
|
||
tongue_paths: Sequence[str],
|
||
report_paths: Sequence[str],
|
||
) -> Any:
|
||
"""Upload every local file, then submit one server-reference-only note."""
|
||
|
||
uploaded_tongue = self._upload_note_materials(
|
||
tongue_paths, material_type="image", label="舌苔图"
|
||
)
|
||
uploaded_reports = self._upload_note_materials(
|
||
report_paths, material_type="file", label="检查报告"
|
||
)
|
||
return invoke(
|
||
self.repository,
|
||
"add_doctor_note",
|
||
diagnosis_id=diagnosis_id,
|
||
content=content,
|
||
tongue_images=uploaded_tongue,
|
||
report_files=uploaded_reports,
|
||
)
|
||
|
||
def _upload_note_materials(
|
||
self,
|
||
paths: Sequence[str],
|
||
*,
|
||
material_type: str,
|
||
label: str,
|
||
) -> list[str]:
|
||
"""Upload files in selection order with filename-specific failures."""
|
||
|
||
uploaded: list[str] = []
|
||
for path in paths:
|
||
try:
|
||
result = invoke(
|
||
self.repository,
|
||
"upload_material",
|
||
path=path,
|
||
material_type=material_type,
|
||
cid=0,
|
||
)
|
||
except Exception as error:
|
||
raise RuntimeError(
|
||
f"{label}“{_attachment_name(path)}”上传失败:{friendly_error(error)}"
|
||
) from error
|
||
reference = str(result or "").strip()
|
||
if not reference or _is_local_material_reference(reference):
|
||
raise RuntimeError(
|
||
f"{label}“{_attachment_name(path)}”上传失败:服务端未返回安全的 uri/url"
|
||
)
|
||
uploaded.append(reference)
|
||
return uploaded
|
||
|
||
def _note_error(
|
||
self,
|
||
error: Exception,
|
||
generation: int,
|
||
appointment_id: int,
|
||
) -> None:
|
||
if self._context_current(generation, appointment_id):
|
||
show_toast(self, friendly_error(error), "danger", 5200)
|
||
|
||
def _note_saved(self, generation: int, appointment_id: int) -> None:
|
||
show_toast(self, "备注已保存。", "success")
|
||
if not self._context_current(generation, appointment_id):
|
||
return
|
||
self.note_edit.clear()
|
||
self._pending_tongue_images.clear()
|
||
self._pending_report_files.clear()
|
||
self._render_pending_attachments()
|
||
self.refresh(silent=True)
|
||
if self._selected_record is not None:
|
||
self._load_detail(self._selected_record, silent=True, clear=False)
|
||
|
||
def _note_finished(self, generation: int, appointment_id: int) -> None:
|
||
self._note_busy = False
|
||
if self._context_current(generation, appointment_id):
|
||
self._restore_action_state(generation, appointment_id)
|
||
|
||
def _delete_note_attachment(
|
||
self,
|
||
note_id: int,
|
||
image_type: str,
|
||
image_path: str,
|
||
button: QPushButton,
|
||
) -> None:
|
||
context = self._selection_context()
|
||
if context is None:
|
||
return
|
||
generation, appointment_id, _diagnosis_id, _patient_id = context
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"删除附件",
|
||
f"确认删除“{_attachment_name(image_path)}”吗?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||
QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
button.setEnabled(False)
|
||
run_async(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"delete_doctor_note_image",
|
||
note_id=note_id,
|
||
image_type=image_type,
|
||
image_path=image_path,
|
||
),
|
||
on_success=lambda _result: self._attachment_deleted(generation, appointment_id),
|
||
on_error=lambda error: self._attachment_delete_error(error, button),
|
||
on_finished=lambda: None,
|
||
)
|
||
|
||
def _attachment_deleted(self, generation: int, appointment_id: int) -> None:
|
||
show_toast(self, "附件已删除。", "success")
|
||
if self._context_current(generation, appointment_id) and self._selected_record is not None:
|
||
self._load_detail(self._selected_record, silent=True, clear=False)
|
||
|
||
def _attachment_delete_error(self, error: Exception, button: QPushButton) -> None:
|
||
with suppress(RuntimeError):
|
||
button.setEnabled(True)
|
||
show_toast(self, friendly_error(error), "danger", 4200)
|
||
|
||
def _edit_diagnosis(self) -> None:
|
||
if not self._can_edit:
|
||
show_toast(self, "当前账号没有编辑病历权限。", "danger")
|
||
return
|
||
context = self._selection_context()
|
||
if context is None or context[2] is None:
|
||
show_toast(self, "当前患者缺少诊单编号。", "danger")
|
||
return
|
||
self._ensure_diagnosis_dialog().open_for(
|
||
context[2], editable=True, seed=self._selected_detail or self._selected_record
|
||
)
|
||
|
||
def _open_ai_report_for_prompt(self, prompt: str) -> None:
|
||
self._open_ai_assistant(prompt)
|
||
|
||
def _submit_ai_question(self) -> None:
|
||
prompt = self.ai_question_edit.text().strip()
|
||
if not prompt:
|
||
show_toast(self, "请先输入需要分析的问题。", "warning")
|
||
self.ai_question_edit.setFocus()
|
||
return
|
||
self._open_ai_assistant(prompt)
|
||
|
||
def _open_ai_assistant(self, prompt: str) -> None:
|
||
if not self._can_ai_assistant:
|
||
show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger")
|
||
return
|
||
if self._detail_loading:
|
||
show_toast(self, "患者病历仍在加载,请稍候。", "warning")
|
||
return
|
||
context = self._selection_context()
|
||
if context is None or context[2] is None:
|
||
show_toast(self, "当前患者缺少诊单编号。", "danger")
|
||
return
|
||
present_diagnosis_ai_assistant(
|
||
self.repository,
|
||
self,
|
||
diagnosis_id=context[2],
|
||
prompt=prompt,
|
||
task=diagnosis_ai_task(prompt),
|
||
)
|
||
|
||
def _open_ai_consult(self) -> None:
|
||
if not self._can_ai_assistant:
|
||
show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger")
|
||
return
|
||
if self._detail_loading:
|
||
show_toast(self, "患者病历仍在加载,请稍候。", "warning")
|
||
return
|
||
context = self._selection_context()
|
||
if context is None or context[2] is None:
|
||
show_toast(self, "当前患者缺少诊单编号。", "danger")
|
||
return
|
||
seed = self._selected_detail or self._selected_record or {}
|
||
present_ai_consult(
|
||
self.repository,
|
||
self.permissions,
|
||
self,
|
||
diagnosis_id=int(context[2]),
|
||
patient_id=int(context[3] or 0),
|
||
seed=seed,
|
||
source_title="接诊台",
|
||
)
|
||
|
||
def _open_ai_report(
|
||
self,
|
||
) -> None:
|
||
if not self._can_ai_report:
|
||
show_toast(self, "当前账号没有查看 AI 报告权限。", "danger")
|
||
return
|
||
if self._detail_loading:
|
||
show_toast(self, "患者病历仍在加载,请稍候。", "warning")
|
||
return
|
||
context = self._selection_context()
|
||
if context is None or context[2] is None:
|
||
show_toast(self, "当前患者缺少诊单编号。", "danger")
|
||
return
|
||
diagnosis = get_value(self._selected_detail, "diagnosis", None) or {}
|
||
appointment = (
|
||
get_value(self._selected_detail, "appointment", None) or self._selected_record or {}
|
||
)
|
||
case_text = ""
|
||
if hasattr(self, "_case_summary_text"):
|
||
case_text = str(self._case_summary_text or "").strip()
|
||
elif hasattr(self, "diagnosis_text"):
|
||
case_text = str(self.diagnosis_text.text() or "").strip()
|
||
row = dict(diagnosis) if isinstance(diagnosis, dict) else {}
|
||
row.update(
|
||
{
|
||
"id": context[2],
|
||
"diagnosis_id": context[2],
|
||
"patient_name": first_value(
|
||
appointment,
|
||
"patient_name",
|
||
"name",
|
||
default=first_value(diagnosis, "patient_name", "name", default="患者"),
|
||
),
|
||
"case_summary": case_text,
|
||
}
|
||
)
|
||
present_diagnosis_ai_report(
|
||
self.repository,
|
||
self.permissions,
|
||
self,
|
||
row,
|
||
)
|
||
|
||
def _diagnosis_saved(self) -> None:
|
||
show_toast(self, "病历已保存,正在刷新接诊信息。", "success")
|
||
self.refresh(silent=True)
|
||
if self._selected_record is not None:
|
||
self._ai_analysis_generation += 1
|
||
self._ai_analysis_loading = False
|
||
self._ai_analysis_diagnosis_id = None
|
||
self._ai_analysis_patient_id = None
|
||
if self._can_ai_analysis:
|
||
self._set_ai_analysis_state(
|
||
"loading",
|
||
"病历已更新,正在重新读取已保存的患者报告…",
|
||
)
|
||
self._load_detail(self._selected_record, silent=True, clear=False)
|
||
|
||
def _complete_appointment(self) -> None:
|
||
if not self._can_complete:
|
||
show_toast(self, "当前账号没有完成接诊权限。", "danger")
|
||
return
|
||
context = self._selection_context()
|
||
if context is None:
|
||
return
|
||
generation, appointment_id, _diagnosis_id, _patient_id = context
|
||
appointment = (
|
||
get_value(self._selected_detail, "appointment", None) or self._selected_record or {}
|
||
)
|
||
status = _as_int(first_value(appointment, "status", default=None))
|
||
if status not in RECEPTION_STATUSES:
|
||
show_toast(self, "仅待接诊或已过号记录可以完成接诊。", "danger")
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"确认完成接诊",
|
||
"系统会再次核对服务端挂号状态;完成后不可撤销。确认继续吗?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||
QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self.complete_button.setEnabled(False)
|
||
run_async(
|
||
lambda: self._complete_after_revalidation(appointment_id),
|
||
on_success=lambda _result: self._appointment_completed(generation, appointment_id),
|
||
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200),
|
||
on_finished=lambda: self._restore_action_state(generation, appointment_id),
|
||
)
|
||
|
||
def _complete_after_revalidation(self, appointment_id: int) -> Any:
|
||
detail = invoke(
|
||
self.repository,
|
||
"reception_detail",
|
||
appointment_id=appointment_id,
|
||
id=appointment_id,
|
||
)
|
||
if (
|
||
get_value(detail, "data", None) is not None
|
||
and get_value(detail, "appointment", None) is None
|
||
):
|
||
detail = get_value(detail, "data", detail)
|
||
appointment = get_value(detail, "appointment", None) or {}
|
||
actual_id = first_value(appointment, "id", "appointment_id", default=None)
|
||
status = _as_int(first_value(appointment, "status", default=None))
|
||
if not _same_id(actual_id, appointment_id):
|
||
raise ValueError("服务端挂号记录与当前患者不一致,已停止完成操作")
|
||
if status not in RECEPTION_STATUSES:
|
||
raise ValueError("挂号状态已变化,请刷新队列后重试")
|
||
return invoke(
|
||
self.repository,
|
||
"complete_appointment",
|
||
appointment_id=appointment_id,
|
||
id=appointment_id,
|
||
)
|
||
|
||
def _appointment_completed(self, generation: int, appointment_id: int) -> None:
|
||
show_toast(self, "接诊已完成。", "success")
|
||
if self._context_current(generation, appointment_id):
|
||
self._clear_selection()
|
||
self.refresh(silent=True)
|
||
|
||
def _restore_action_state(self, generation: int, appointment_id: int) -> None:
|
||
if not self._context_current(generation, appointment_id):
|
||
return
|
||
appointment = (
|
||
get_value(self._selected_detail, "appointment", None) or self._selected_record or {}
|
||
)
|
||
diagnosis = get_value(self._selected_detail, "diagnosis", None) or {}
|
||
self._update_action_state(appointment, diagnosis)
|
||
|
||
def _request_video(self) -> None:
|
||
context = self._selection_context()
|
||
if context is None:
|
||
return
|
||
_generation, appointment_id, diagnosis_id, patient_id = context
|
||
if diagnosis_id is None or patient_id is None:
|
||
show_toast(self, "患者或诊单编号不完整,无法发起视频。", "danger")
|
||
return
|
||
appointment = get_value(self._selected_detail, "appointment", None) or self._selected_record
|
||
diagnosis = get_value(self._selected_detail, "diagnosis", None) or {}
|
||
payload = {
|
||
"source": "reception",
|
||
"appointment_id": appointment_id,
|
||
"patient_id": patient_id,
|
||
"diagnosis_id": diagnosis_id,
|
||
"patient_name": first_value(
|
||
appointment,
|
||
"patient_name",
|
||
default=first_value(diagnosis, "patient_name", default="患者"),
|
||
),
|
||
"mode": "im",
|
||
"record": self._selected_record,
|
||
}
|
||
self.video_requested.emit(payload)
|
||
|
||
def showEvent(self, event: Any) -> None:
|
||
super().showEvent(event)
|
||
if not self.poll_timer.isActive():
|
||
self.poll_timer.start()
|
||
if self.queue_list.count() == 0 and not self._queue_loading:
|
||
self.refresh()
|
||
|
||
def hideEvent(self, event: Any) -> None:
|
||
self.poll_timer.stop()
|
||
super().hideEvent(event)
|
||
|
||
|
||
__all__ = ["ReceptionDailyRecordsPanel", "ReceptionPage"]
|