"""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 threading import BoundedSemaphore, Event, Lock from typing import Any from PySide6.QtCore import ( QCoreApplication, QDate, QEvent, QPoint, QPointF, QRectF, QSize, Qt, QThreadPool, QTimer, QUrl, Signal, ) from PySide6.QtGui import ( QBrush, QColor, QDesktopServices, QFont, QFontMetrics, QIcon, QKeySequence, QPainter, QPainterPath, QPen, QPixmap, QShortcut, QTextCharFormat, QTextCursor, QTextLayout, QTextOption, ) from PySide6.QtWidgets import ( QAbstractItemView, QBoxLayout, QCalendarWidget, QComboBox, QDialog, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSplitter, QStackedWidget, QTabBar, QTextEdit, QVBoxLayout, QWidget, ) from ...core.appointment_modes import appointment_type_description, appointment_type_value from .. import icons from ..diagnosis_drawer import DailyRecordPanel from ..diagnosis_editors import FlowLayout from ..dialogs import DiagnosisDialog from ..dialogs.ai_consult import present_ai_consult from ..dialogs.appointment_complete import COMPLETION_NOTE_LIMIT, AppointmentCompleteDialog 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 ..filter_disclosure import FilterDisclosure from ..reception_style import body_family, heading_family, number_family from ..report_provenance import report_provenance_lines 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"} _ASYNC_REQUEST_CANCELLED = object() _ASYNC_REQUEST_DEFERRED = object() _AI_AUTOMATIC_GENERATION_SETTLE_SECONDS = 0.25 _AI_AUTOMATIC_REQUEST_SLOTS = BoundedSemaphore(2) _AI_GENERATION_POOL = QThreadPool() _AI_GENERATION_POOL.setMaxThreadCount(4) _AI_GENERATION_POOL.setExpiryTimeout(30_000) # Keep explicit recovery reads independent of automatic analysis workers. _RECEPTION_REFRESH_POOL = QThreadPool() _RECEPTION_REFRESH_POOL.setMaxThreadCount(3) _RECEPTION_REFRESH_POOL.setExpiryTimeout(30_000) _REFRESH_TIMEOUT_MS = 30_000 _REFRESH_COOLDOWN_MS = 1_500 AI_MEDICAL_DISCLAIMER = ( "仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。" "系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。" ) RECEPTION_QSS = r""" QWidget#ReceptionPage { background-color: transparent; color: #273244; font-size: 14px; font-family: "$body_family"; } QWidget#ReceptionPage QFrame#ReceptionQueuePanel { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 12px; } QWidget#ReceptionPage QLabel#ReceptionQueueTitle { color: #202C3F; font-size: 20px; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel#ReceptionFilterSummary { color: #5D6B80; font-size: 12px; font-weight: 400; } QWidget#ReceptionPage QLineEdit#ReceptionQueueSearch { min-height: 30px; max-height: 30px; padding: 0 8px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; font-size: 13px; } QWidget#ReceptionPage QLineEdit#ReceptionQueueSearch:focus { border-color: #1769E8; } QWidget#ReceptionPage QPushButton#ReceptionDateButton { min-height: 30px; max-height: 30px; padding: 0 22px 0 10px; color: #5D6B80; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionDateButton:hover { color: #273244; background-color: #F7FAFE; border-color: #CEDBEB; } QCalendarWidget#ReceptionDateCalendar { min-width: 292px; min-height: 280px; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 10px; } QWidget#ReceptionPage QPushButton#ReceptionRefreshButton { min-height: 30px; max-height: 30px; padding: 0; color: #273244; background-color: #FFFFFF; border: 1px solid transparent; border-radius: 8px; font-size: 13px; } QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:hover { background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:pressed { background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:focus { border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:disabled { color: #5D6B80; background-color: #F7FAFE; border-color: #DBE5F2; } QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] { min-height: 32px; max-height: 32px; min-width: 0; padding: 0 6px; color: #5D6B80; background-color: #F7FAFE; border: 1px solid transparent; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton[receptionQueueChip="true"]:hover { color: #1769E8; background-color: #EAF2FF; border-color: rgba(26, 28, 31, 40); } QWidget#ReceptionPage QPushButton[receptionQueueChip="true"]:checked { color: #1769E8; background-color: #EAF2FF; border-color: transparent; font-weight: 400; } QWidget#ReceptionPage QListWidget#ReceptionQueueList { background-color: transparent; border: 0; border-radius: 0; outline: 0; } QWidget#ReceptionPage QListWidget#ReceptionQueueList::item { padding: 0; margin: 0; color: #273244; 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: #5D6B80; font-size: 13px; } QWidget#ReceptionPage QLabel#ReceptionQueueName { color: #273244; font-size: 16px; font-weight: 500; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel#ReceptionQueueName[selected="true"] { color: #1555B6; } QWidget#ReceptionPage QLabel#ReceptionQueueMeta, QWidget#ReceptionPage QLabel#ReceptionQueueSubline, QWidget#ReceptionPage QLabel#ReceptionQueueTime { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionQueueMetric { color: #BE4B58; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="warn"] { color: #B96C00; } QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="ok"] { color: #1555B6; } QWidget#ReceptionPage QLabel#ReceptionQueueMetric[tone="high"] { color: #E13245; } QWidget#ReceptionPage QFrame#ReceptionHero, QWidget#ReceptionPage QFrame#ReceptionClinicalCard { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 12px; } QWidget#ReceptionPage QLabel#ReceptionAvatar { color: #1555B6; background-color: #EAF2FF; border: 0; border-radius: 8px; font-size: 24px; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel#ReceptionPatientName { color: #202C3F; font-size: 22px; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel#ReceptionPatientMeta { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#StatusBadge { font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionNotifyButton, QWidget#ReceptionPage QPushButton#ReceptionHistoryButton, QWidget#ReceptionPage QPushButton#ReceptionImButton { min-height: 34px; max-height: 34px; padding: 0 13px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionMoreButton { min-height: 34px; max-height: 34px; padding: 0 10px; color: #5D6B80; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 16px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionNotifyButton:hover, QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover, QWidget#ReceptionPage QPushButton#ReceptionImButton:hover { color: #1769E8; background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionMoreButton:hover { color: #273244; background-color: #F7FAFE; border-color: #CEDBEB; } QWidget#ReceptionPage QPushButton#ReceptionMoreButton::menu-indicator { image: none; width: 0; } QWidget#ReceptionPage QPushButton#ReceptionCompleteButton { min-height: 34px; max-height: 34px; padding: 0 14px; color: #BE4B58; background-color: #FFF7F8; border: 1px solid #F5D0D4; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:hover { color: #BE4B58; background-color: #FFF0F2; border-color: #EFA3AD; } QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:pressed { color: #BE4B58; background-color: #FFE4E8; border-color: #E58A98; } QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:focus { border-color: #BE4B58; } QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:disabled { color: #8E8F90; background-color: #F7FAFE; border-color: #DBE5F2; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs { background-color: #FFFFFF; border-bottom: 1px solid #DBE5F2; border-top-left-radius: 12px; border-top-right-radius: 12px; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab { min-height: 44px; padding: 0 16px; margin: 0; color: #5D6B80; background-color: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: 0; font-size: 14px; font-weight: 400; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:hover { color: #1769E8; background-color: #EAF2FF; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:selected { color: #1769E8; background-color: transparent; border-bottom: 2px solid #1769E8; font-weight: 400; } QWidget#ReceptionPage QFrame#ReceptionHistoryCard, QWidget#ReceptionPage QFrame#ReceptionVitalsCard, QWidget#ReceptionPage QFrame#ReceptionLabsCard, QWidget#ReceptionPage QFrame#ReceptionTabSurface { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 12px; } QWidget#ReceptionPage QLabel[receptionBlockTitle="true"] { color: #202C3F; font-size: 16px; font-weight: 500; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionBlockValue="true"] { color: #273244; font-size: 15px; font-weight: 400; } QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll { background-color: transparent; border: 0; } QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll QScrollBar:vertical { width: 9px; margin: 1px; background-color: #F7FAFE; border: 0; border-radius: 4px; } QWidget#ReceptionPage QScrollArea#ReceptionMedicationScroll QScrollBar::handle:vertical { min-height: 34px; background-color: #C4D2E5; 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: #F7FAFE; border: 1px solid #DBE5F2; border-radius: 12px; } QWidget#ReceptionPage QLabel#ReceptionMedicationCaseTitle { color: #273244; font-size: 18px; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel#ReceptionMedicationCaseSubtitle { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionMedicationPrescriptionStatus { min-height: 24px; padding: 0 9px; color: #5D6B80; background-color: #F7FAFE; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionMedicationPrescriptionStatus[casePrescriptionState="ready"] { color: #1555B6; background-color: #EAF2FF; border-color: #D6E5FB; } QWidget#ReceptionPage QLabel#ReceptionMedicationPrescriptionStatus[casePrescriptionState="empty"] { color: #A9691D; background-color: #FFF7E9; border-color: #F2DEB9; } QWidget#ReceptionPage QLabel#ReceptionMedicationCaseEmpty { min-height: 110px; color: #5D6B80; font-size: 13px; } QWidget#ReceptionPage QFrame[receptionCaseMetric="true"] { min-height: 66px; background-color: #F7FAFE; border: 1px solid #DBE5F2; border-radius: 9px; } QWidget#ReceptionPage QLabel[receptionCaseMetricCaption="true"] { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionCaseMetricValue="true"] { color: #273244; font-size: 16px; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionCaseMetricValue="true"][alert="true"] { color: #BE4B58; } QWidget#ReceptionPage QFrame[receptionCaseSection="true"] { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 9px; } QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="diagnosis"] { background-color: #F7FAFE; border-color: #DBE5F2; } QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="medication"] { background-color: #F7FAFE; border-color: #DBE5F2; } QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="note"] { background-color: #FFF9F0; border-color: #F0DFC3; } QWidget#ReceptionPage QLabel[receptionCaseSectionTitle="true"] { color: #273244; font-size: 14px; font-weight: 400; } QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="medication"] QLabel[receptionCaseSectionTitle="true"] { color: #273244; } QWidget#ReceptionPage QFrame[receptionCaseSection="true"][caseKind="note"] QLabel[receptionCaseSectionTitle="true"] { color: #A9691D; } QWidget#ReceptionPage QLabel[receptionCaseFieldCaption="true"] { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionCaseFieldValue="true"] { color: #273244; font-size: 14px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionCaseFieldValue="true"][important="true"] { color: #273244; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionCaseFieldValue="true"][risk="true"] { color: #BE4B58; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionInlineCaption="true"] { color: #273244; font-size: 15px; font-weight: 500; min-width: 64px; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionInlineValue="true"] { color: #273244; font-size: 15px; font-weight: 400; } QWidget#ReceptionPage QFrame#ReceptionMetricCell, QWidget#ReceptionPage QFrame#ReceptionLabCell { background-color: #F7FAFE; border: 1px solid transparent; border-radius: 10px; } QWidget#ReceptionPage QLabel[receptionMetricCaption="true"] { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionMetricValue="true"] { color: #273244; font-size: 20px; font-weight: 600; font-family: "$number_family"; } QWidget#ReceptionPage QLabel[receptionMetricUnit="true"] { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QFrame#ReceptionLabCell QLabel[receptionMetricValue="true"] { font-size: 16px; font-weight: 400; font-family: "$number_family"; } QWidget#ReceptionPage QLabel[receptionLifestyleChip="true"] { color: #273244; font-size: 14px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionMetricState="true"] { color: #1555B6; background-color: #EAF2FF; border-radius: 8px; padding: 2px 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"] { color: #BE4B58; background-color: #FFF1F3; } QWidget#ReceptionPage QLabel[receptionBmiState="true"] { color: #5D6B80; background-color: transparent; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionBmiState="true"][alert="true"] { color: #BE4B58; } 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: #C4D2E5; 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-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 10px; } QWidget#ReceptionPage QLabel#ReceptionAiTitle { color: #273244; font-size: 15px; font-weight: 500; font-family: "$heading_family"; } QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector { min-width: 82px; max-width: 104px; min-height: 27px; max-height: 27px; padding: 0 24px 0 9px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector:hover, QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector:focus { color: #273244; border-color: #75A5F0; } QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector::drop-down { width: 22px; border: 0; } QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus { min-height: 17px; color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus[secondaryState="loading"] { color: #1769E8; } QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus[secondaryState="error"] { color: #BE4B58; } QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus[secondaryState="success"] { color: #5D6B80; } QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSnapshotMeta { min-height: 16px; color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionAiMedicalDisclaimer { color: #BE4B58; background-color: #FFF4F5; border: 1px solid #F4CED3; border-radius: 7px; padding: 3px 6px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton, QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton { min-height: 26px; max-height: 26px; padding: 0 9px; color: #1769E8; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton:hover, QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton:hover { color: #155BCC; background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton:focus, QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton:focus { border: 2px solid #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionAiHistoryButton:disabled, QWidget#ReceptionPage QPushButton#ReceptionAiRegenerateButton:disabled { color: #8E8F90; background-color: #F7FAFE; border-color: #DBE5F2; } QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton { padding: 0; background-color: transparent; border: 1px solid transparent; border-radius: 7px; } QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:hover { background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:pressed { background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionAiAnalysisExpandButton:focus { border-color: #75A5F0; } 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: #F7FAFE; } QWidget#ReceptionPage QLabel[receptionAiCaption="true"] { color: #273244; font-size: 14px; font-weight: 500; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionAiCaption="true"][tone="risk"] { color: #273244; } QWidget#ReceptionPage QLabel[receptionAiCaption="true"][tone="treatment"] { color: #273244; } QWidget#ReceptionPage QLabel[receptionAiValue="true"] { color: #273244; font-size: 14px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionAiValue="true"][secondary="true"] { color: #273244; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionRiskChip="true"] { min-height: 22px; padding: 3px 8px; color: #1555B6; background-color: #EAF2FF; border: 1px solid #D6E5FB; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="high"] { color: #BE4B58; background-color: #FFF0F2; border-color: #F3D1D7; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="medium"] { color: #A9691D; background-color: #FFF7E8; border-color: #F1E0BC; } QWidget#ReceptionPage QLabel[receptionRiskOverflow="true"] { min-height: 22px; padding: 3px 8px; color: #5D6B80; background-color: #F7FAFE; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QFrame#ReceptionAiState { background-color: #F7FAFE; border: 0; border-radius: 8px; } QWidget#ReceptionPage QLabel#ReceptionAiStateText { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionAiRetry { min-height: 28px; max-height: 28px; padding: 0 12px; color: #1769E8; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionAiAssistantHint { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] { min-width: 0; min-height: 30px; max-height: 30px; padding: 0 10px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"]:hover, QWidget#ReceptionPage QPushButton#ReceptionAiRetry:hover { color: #1769E8; background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QLineEdit#ReceptionAiInput { min-height: 36px; max-height: 36px; padding: 0 11px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 14px; } QWidget#ReceptionPage QLineEdit#ReceptionAiInput:focus { border-color: #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionAiSendButton { min-width: 36px; max-width: 36px; min-height: 36px; max-height: 36px; padding: 0; background-color: #1769E8; border: 0; border-radius: 18px; } QWidget#ReceptionPage QPushButton#ReceptionAiSendButton:hover { background-color: #155BCC; } QWidget#ReceptionPage QFrame#ReceptionDailyRecordsCard, QWidget#ReceptionPage QFrame#ReceptionFollowupCard { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 12px; } QWidget#ReceptionPage QWidget#ReceptionDailyToolbar { background-color: transparent; border: 0; } QWidget#ReceptionPage QLabel#ReceptionDailyTitle, QWidget#ReceptionPage QLabel#ReceptionFollowupTitle { color: #273244; font-size: 16px; font-weight: 600; font-family: "$heading_family"; } QWidget#ReceptionPage QLabel#ReceptionDailySubtitle, QWidget#ReceptionPage QLabel#ReceptionFollowupSubtitle { color: #5D6B80; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton[receptionDailyRange="true"] { min-height: 30px; max-height: 30px; padding: 0 13px; color: #5D6B80; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton[receptionDailyRange="true"]:hover { color: #1769E8; background-color: #EAF2FF; border-color: #75A5F0; } QWidget#ReceptionPage QPushButton[receptionDailyRange="true"]:checked { color: #FFFFFF; background-color: #1769E8; border-color: #1769E8; font-weight: 400; } QWidget#ReceptionPage QDateEdit[receptionDailyDate="true"] { min-height: 30px; max-height: 30px; padding: 0 9px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; } QWidget#ReceptionPage QPushButton#ReceptionDailyRefreshButton { min-height: 30px; max-height: 30px; padding: 0 13px; color: #1769E8; background-color: #EAF2FF; border: 1px solid #DBE5F2; border-radius: 7px; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QPushButton#ReceptionDailyRefreshButton:hover { color: #FFFFFF; background-color: #1769E8; border-color: #1769E8; } QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState { background-color: #F7FAFE; border: 1px solid #DBE5F2; 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: #273244; background-color: #FFFFFF; alternate-background-color: #F7FAFE; border: 1px solid #DBE5F2; border-radius: 8px; gridline-color: #DBE5F2; font-size: 14px; outline: 0; } QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable::item { padding: 6px 8px; border: 0; } QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QHeaderView::section { min-height: 34px; color: #5D6B80; background-color: #F7FAFE; border: 0; border-right: 1px solid #DBE5F2; border-bottom: 1px solid #DBE5F2; font-size: 13px; font-weight: 400; } QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:horizontal, QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:vertical { background-color: #F7FAFE; 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: #C4D2E5; border-radius: 4px; } QWidget#ReceptionPage QLabel#ReceptionFollowupText { color: #273244; font-size: 14px; font-weight: 400; } QWidget#ReceptionPage QLabel#ReceptionQueueSummary { color: #5D6B80; font-size: 13px; font-weight: 400; } /* The approved type and surface system stays inside the reception page. */ QWidget#ReceptionPage QWidget { font-family: "$body_family"; } QWidget#ReceptionPage QLabel#StatusBadge[kind="success"] { color: #1555B6; background-color: #EAF2FF; border-color: #D6E5FB; } QWidget#ReceptionPage QFrame#ReceptionHistoryCard, QWidget#ReceptionPage QFrame#ReceptionVitalsCard, QWidget#ReceptionPage QFrame#ReceptionLabsCard { border: 0; border-radius: 0; } QWidget#ReceptionPage QFrame#ReceptionHistoryCard { border-right: 1px solid #DBE5F2; } QWidget#ReceptionPage QFrame#ReceptionMetricCell { background-color: transparent; border: 0; border-left: 1px solid #DBE5F2; border-radius: 0; } QWidget#ReceptionPage QFrame#ReceptionMetricCell[firstMetric="true"] { border-left: 0; } QWidget#ReceptionPage QFrame#ReceptionLabCell { border: 1px solid #E5ECF6; border-radius: 6px; } QWidget#ReceptionPage QPushButton#ReceptionImButton:enabled { color: #FFFFFF; background-color: #1769E8; border-color: #1769E8; } QWidget#ReceptionPage QPushButton#ReceptionImButton:enabled:hover { background-color: #155BCC; border-color: #155BCC; } QWidget#ReceptionPage QPushButton#ReceptionImButton:enabled:pressed { background-color: #124EA9; border-color: #124EA9; } QWidget#ReceptionPage QPushButton#ReceptionImButton:focus { border: 1px solid #75A5F0; } QWidget#ReceptionPage QPushButton#ReceptionNotifyButton:disabled, QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:disabled, QWidget#ReceptionPage QPushButton#ReceptionImButton:disabled, QWidget#ReceptionPage QPushButton#ReceptionMoreButton:disabled { color: #8E8F90; background-color: #F2F5FA; border-color: #DBE5F2; } QWidget#ReceptionPage QLabel#ReceptionQueueMetric, QWidget#ReceptionPage QLabel#ReceptionQueueTime { font-family: "$number_family"; } /* Approved geometry with the user's smaller, denser typography scale. */ QWidget#ReceptionPage QFrame#ReceptionQueuePanel { border-color: #E5ECF6; border-radius: 6px; } QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] { min-height: 30px; max-height: 30px; color: #273244; border: 1px solid #E5ECF6; border-radius: 6px; font-size: 13px; } QWidget#ReceptionPage QPushButton[receptionQueueChip="true"]:checked { color: #1769E8; border-color: #E5ECF6; font-family: "$heading_family"; font-weight: 500; } QWidget#ReceptionPage QPushButton#ReceptionDateButton { color: #273244; font-size: 13px; border-radius: 5px; } QWidget#ReceptionPage QLabel#ReceptionQueueName { font-size: 16px; color: #202C3F; } QWidget#ReceptionPage QLabel#ReceptionQueueName[selected="true"] { color: #1555B6; } QWidget#ReceptionPage QLabel#ReceptionQueueMeta, QWidget#ReceptionPage QLabel#ReceptionQueueSubline, QWidget#ReceptionPage QLabel#ReceptionQueueTime, QWidget#ReceptionPage QLabel#ReceptionQueueSummary { color: #273244; font-size: 13px; } QWidget#ReceptionPage QLabel#ReceptionQueueMetric { font-size: 14px; } QWidget#ReceptionPage QLabel#ReceptionQueueSubline[selected="true"] { color: #334363; } QWidget#ReceptionPage QFrame#ReceptionHero { border-color: #E5ECF6; border-radius: 0; border-top-left-radius: 6px; border-top-right-radius: 6px; border-bottom: 1px solid #E5ECF6; } QWidget#ReceptionPage QFrame#ReceptionClinicalCard { border-color: #E5ECF6; border-top: 0; border-radius: 0; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; } QWidget#ReceptionPage QLabel#ReceptionPatientName { font-size: 22px; } QWidget#ReceptionPage QLabel#ReceptionAvatar { font-size: 24px; } QWidget#ReceptionPage QLabel#ReceptionPatientMeta { color: #273244; font-size: 13px; } QWidget#ReceptionPage QLabel#StatusBadge { font-size: 13px; } QWidget#ReceptionPage QFrame#ReceptionHero QLabel#StatusBadge { min-height: 28px; max-height: 28px; padding: 0 9px; border-radius: 5px; font-size: 13px; } QWidget#ReceptionPage QPushButton#ReceptionNotifyButton, QWidget#ReceptionPage QPushButton#ReceptionHistoryButton, QWidget#ReceptionPage QPushButton#ReceptionImButton, QWidget#ReceptionPage QPushButton#ReceptionMoreButton, QWidget#ReceptionPage QPushButton#ReceptionCompleteButton { min-height: 38px; max-height: 38px; padding: 0 12px; font-size: 13px; border-radius: 5px; } QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled { background-color: #FFFFFF; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs { border-top-left-radius: 0; border-top-right-radius: 0; border-bottom-color: #E5ECF6; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab { min-height: 52px; padding: 0 31px; color: #273244; font-size: 14px; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:first { margin-left: 10px; padding-left: 21px; } QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab:selected { color: #1769E8; } QWidget#ReceptionPage QLabel[receptionBlockTitle="true"], QWidget#ReceptionPage QLabel[receptionInlineCaption="true"] { font-size: 15px; } QWidget#ReceptionPage QLabel[receptionBlockValue="true"], QWidget#ReceptionPage QLabel[receptionInlineValue="true"], QWidget#ReceptionPage QLabel[receptionLifestyleChip="true"] { font-size: 14px; } QWidget#ReceptionPage QFrame#ReceptionHistoryCard { border-right-color: #E5ECF6; } QWidget#ReceptionPage QFrame#ReceptionVitalsCard { border-bottom: 1px solid #E5ECF6; } QWidget#ReceptionPage QFrame#ReceptionMetricCell { border-left-color: #E5ECF6; } QWidget#ReceptionPage QLabel[receptionMetricCaption="true"], QWidget#ReceptionPage QLabel[receptionMetricUnit="true"], QWidget#ReceptionPage QLabel[receptionBmiState="true"] { color: #273244; font-size: 13px; } QWidget#ReceptionPage QFrame#ReceptionMetricCell QLabel[receptionMetricValue="true"] { font-size: 18px; } QWidget#ReceptionPage QFrame#ReceptionLabCell { min-height: 110px; } QWidget#ReceptionPage QFrame#ReceptionLabCell QLabel[receptionMetricValue="true"] { font-size: 13px; } QWidget#ReceptionPage QLabel[receptionMetricState="true"] { min-height: 24px; padding: 0 8px; border-radius: 5px; font-size: 13px; } QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"][warning="true"] { color: #A9691D; background-color: #FFF7E9; border-radius: 5px; } QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard, QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard { border-color: #E5ECF6; border-radius: 6px; } QWidget#ReceptionPage QLabel#ReceptionAiTitle, QWidget#ReceptionPage QPushButton#ReceptionAiTitle { color: #202C3F; font-family: "$heading_family"; font-size: 15px; font-weight: 500; background: transparent; border: 0; padding: 0; text-align: left; } QWidget#ReceptionPage QPushButton#ReceptionAiTitle:hover { color: #1769E8; } QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSnapshotMeta, QWidget#ReceptionPage QLabel#ReceptionAiAnalysisSecondaryStatus, QWidget#ReceptionPage QLabel#ReceptionAiAssistantHint { color: #273244; font-size: 13px; } QWidget#ReceptionPage QLabel#ReceptionAiAssistantHint { font-size: 14px; } QWidget#ReceptionPage QComboBox#ReceptionAiAnalysisModelSelector { min-width: 60px; max-width: 64px; min-height: 30px; max-height: 30px; font-size: 13px; } QWidget#ReceptionPage QFrame#ReceptionAiAnalysisSection[emphasis="true"] { border: 1px solid #E5ECF6; border-radius: 6px; } QWidget#ReceptionPage QLabel[receptionAiCaption="true"] { font-size: 15px; } QWidget#ReceptionPage QLabel[receptionAiValue="true"] { font-size: 14px; } QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] { min-height: 38px; max-height: 38px; font-size: 14px; border-radius: 6px; border-color: #E5ECF6; } """ RECEPTION_AI_DIALOG_QSS = r""" QDialog#ReceptionAiAnalysisDialog { background-color: #F7FAFE; } QDialog#ReceptionAiAnalysisDialog QFrame#DialogSurface { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 14px; } QDialog#ReceptionAiAnalysisDialog QFrame#DialogHeader { min-height: 68px; background-color: #FFFFFF; border: 0; border-bottom: 1px solid #DBE5F2; border-top-left-radius: 14px; border-top-right-radius: 14px; } QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogTitle { color: #273244; font-size: 20px; font-weight: 600; } QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogSubtitle, QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogMeta { color: #5D6B80; font-size: 13px; } QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector { min-width: 112px; min-height: 32px; max-height: 32px; padding: 0 26px 0 10px; color: #273244; background-color: #F7FAFE; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 13px; font-weight: 400; } QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector:hover, QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector:focus { color: #273244; background-color: #FFFFFF; border-color: #75A5F0; } QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogModelSelector::drop-down { width: 24px; border: 0; } QDialog#ReceptionAiAnalysisDialog QFrame#ReceptionAiAnalysisDialogHistoryBar { background-color: #F7FAFE; border: 0; border-bottom: 1px solid #DBE5F2; } QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogHistoryLabel { color: #5D6B80; font-size: 13px; font-weight: 400; } QDialog#ReceptionAiAnalysisDialog QComboBox#ReceptionAiAnalysisDialogHistorySelector { min-width: 250px; min-height: 32px; max-height: 32px; padding: 0 26px 0 10px; color: #273244; background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px; font-size: 13px; } QDialog#ReceptionAiAnalysisDialog QLabel#ReceptionAiAnalysisDialogDisclaimer { color: #BE4B58; background-color: #FFF3F4; border: 1px solid #F1C8CE; border-radius: 9px; padding: 10px 12px; font-size: 13px; font-weight: 500; } QDialog#ReceptionAiAnalysisDialog QScrollArea#ReceptionAiAnalysisDialogScroll, QDialog#ReceptionAiAnalysisDialog QWidget#ReceptionAiAnalysisDialogBody { background-color: #F7FAFE; border: 0; } QDialog#ReceptionAiAnalysisDialog QFrame#ReceptionAiAnalysisDialogSection { background-color: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 12px; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiCaption="true"] { color: #273244; font-size: 13px; font-weight: 500; padding-bottom: 2px; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiBody="true"] { color: #273244; font-size: 14px; font-weight: 400; line-height: 1.85; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiStructured="true"] { color: #273244; font-size: 14px; font-weight: 400; line-height: 175%; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"] { min-height: 30px; padding: 5px 12px; color: #BE4B58; background-color: #FFF0F2; border: 1px solid #F3D1D7; border-radius: 8px; font-size: 13px; font-weight: 500; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"][riskLevel="medium"] { color: #A9691D; background-color: #FFF7E8; border-color: #F1E0BC; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"][riskLevel="low"] { color: #1555B6; background-color: #ECF9F4; border-color: #D8F1E8; } QDialog#ReceptionAiAnalysisDialog QFrame#ReceptionAiAnalysisDialogFooter { min-height: 62px; background-color: #FFFFFF; border: 0; border-top: 1px solid #DBE5F2; border-bottom-left-radius: 14px; border-bottom-right-radius: 14px; } QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton { min-width: 96px; min-height: 38px; color: #FFFFFF; background-color: #1769E8; border: 1px solid #1769E8; border-radius: 8px; font-size: 13px; font-weight: 500; } QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton:hover { background-color: #155BCC; border-color: #155BCC; } QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton:pressed { background-color: #124EA9; border-color: #124EA9; } QDialog#ReceptionAiAnalysisDialog QPushButton#ReceptionAiAnalysisDialogCloseButton:focus { border: 2px solid #75A5F0; } """ 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[。!?;:;: \t\u3000])" rf"(?P{_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) _AI_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)") _AI_INSIGHT_LIST_LOOKAHEAD_RE = re.compile( r"(?:^|(?<=[::;;。\s]))(\d{1,2})\s*([\.、))])\s*" ) _AI_INSIGHT_LABEL_RE = re.compile( r"^([\u4e00-\u9fa5A-Za-z()()·\s]{1,12})[::]\s*(.*)$" ) def _ai_escape_html(value: str) -> str: """转义富文本片段,防止分析文本中带 HTML/Markdown 误伤样式。""" return ( (value or "") .replace("&", "&") .replace("<", "<") .replace(">", ">") ) def _ai_split_label_or_sentence( segment: str, *, marker: str | None = None, ) -> tuple[str, str, str, bool]: """识别单段是否为 '标签:内容' 结构, 决定是否加粗展示。""" label_match = _AI_INSIGHT_LABEL_RE.match(segment) if label_match: label = label_match.group(1).strip() body = label_match.group(2).strip() if label and body: return marker or "", label, body, True if marker: return marker, "", segment, False return "", "", segment, False def _ai_split_numbered_list(text: str) -> list[dict[str, str]]: """把以 '1.'/'2、'/'3)' 开头的连续列表拆成独立项。""" items: list[dict[str, str]] = [] cursor = 0 length = len(text) while cursor < length: remainder = text[cursor:] list_match = _AI_INSIGHT_LIST_RE.match(remainder) if not list_match: tail = remainder.strip() if tail: marker, label, body, has_label = _ai_split_label_or_sentence(tail) items.append( {"marker": marker, "label": label, "body": body, "has_label": has_label} ) break marker = f"{list_match.group(1)}." after_marker = remainder[list_match.end():] sentence_match = re.search(r"[。;;\n]", after_marker) candidate_ends: list[int] = [] if sentence_match is not None: candidate_ends.append(sentence_match.end()) next_marker_match = re.search(r"\s\d{1,2}\s*[\.、))]", after_marker) if next_marker_match is not None: candidate_ends.append(next_marker_match.start() + 1) inner_end = min(candidate_ends) if candidate_ends else len(after_marker) segment = after_marker[:inner_end].strip("。;; \n").strip() if segment: inner_marker, inner_label, inner_body, has_label = _ai_split_label_or_sentence( segment, marker=marker ) items.append( { "marker": inner_marker, "label": inner_label, "body": inner_body, "has_label": has_label, } ) cursor += list_match.end() + inner_end while cursor < length and text[cursor] in "。;;\n": cursor += 1 return items def _ai_split_plain_paragraph(text: str) -> list[dict[str, str]]: """把密集段落按 。;拆成结构化条目, 每行一条更易阅读。""" items: list[dict[str, str]] = [] if not text.strip(): return items cursor = 0 length = len(text) while cursor < length: remainder = text[cursor:] sentence_match = re.search(r"[。;;\n]", remainder) if sentence_match is not None: segment = remainder[: sentence_match.start()].strip() if segment: marker, label, body, has_label = _ai_split_label_or_sentence(segment) items.append( {"marker": marker, "label": label, "body": body, "has_label": has_label} ) cursor += sentence_match.end() continue tail = remainder.strip() if tail: marker, label, body, has_label = _ai_split_label_or_sentence(tail) items.append( {"marker": marker, "label": label, "body": body, "has_label": has_label} ) break return items def _ai_split_into_segments(text: str) -> list[dict[str, str]]: """对一段 AI 叙事文本进行结构化拆分, 便于分条阅读。 识别三种模式: - 编号开头 (1. / 2、 / 3) / 4)) -> list_item - "短中文标签:内容" 形式 -> lead_item, 标签部分加粗 - 其余按 。 / ;分句 -> plain_item 每种类型都会渲染成独立行, 改善阅读节奏。 """ raw = (text or "").strip() if not raw: return [] items: list[dict[str, str]] = [] paragraphs = [line.strip() for line in raw.split("\n") if line.strip()] if not paragraphs: return [] for paragraph in paragraphs: lookahead_match = _AI_INSIGHT_LIST_LOOKAHEAD_RE.search(paragraph) starts_with_list = _AI_INSIGHT_LIST_RE.match(paragraph) is not None if not starts_with_list and lookahead_match is not None: list_start = lookahead_match.start() preamble_candidate = paragraph[:list_start].rstrip() sentence_tail = re.search(r"[。;;][^。;;]*$", preamble_candidate) if sentence_tail is not None: preamble = preamble_candidate[: sentence_tail.end()].strip( "。;;:: \t\n" ) else: preamble = re.sub( r"\s*\d{1,2}\s*[\.、))]?\s*$", "", preamble_candidate, ).strip("。;;:: \t\n") body = paragraph[list_start:].lstrip() if preamble: items.extend(_ai_split_plain_paragraph(preamble)) items.extend(_ai_split_numbered_list(body)) elif starts_with_list: items.extend(_ai_split_numbered_list(paragraph)) else: items.extend(_ai_split_plain_paragraph(paragraph)) return items def _ai_segments_to_html(items: list[dict[str, str]]) -> str: """把拆分出的条目渲染为 RichText HTML 片段, 用于布局层分段展示。""" if not items: return "" fragments: list[str] = [] for item in items: marker = _ai_escape_html(item.get("marker", "")) label = _ai_escape_html(item.get("label", "")) body = _ai_escape_html(item.get("body", "")) has_label = bool(item.get("has_label")) and label indent_open = "" indent_close = "" if marker or has_label: indent_open = ( '
' ) indent_close = "
" block_parts: list[str] = [] if marker: block_parts.append( "" f"{marker}" "" ) label_html = label body_html = body if has_label: inline = ( "" f"{label_html}" "" ":" f"{body_html}" ) else: inline = f"{body_html}" block_parts.append( "" f"{inline}" "" ) fragments.append(indent_open + "".join(block_parts) + indent_close) else: fragments.append( "
" f"{body}" "
" ) return "".join(fragments) def _ai_narrative_structured_html(value: object) -> str: """把 AI 叙事文本先归一化, 再切成可阅读片段, 渲染为 HTML。""" normalized = _ai_narrative_text(value) if not normalized: return "" items = _ai_split_into_segments(normalized) return _ai_segments_to_html(items) 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: """Return a detail-tab icon from the shared 24-unit icon grid. The five tabs used to share three near-identical "document with lines" silhouettes, which made them unreadable at 16 px; each now has a distinct outline (bubble / chart page / capsule / clipboard / calendar-clock). """ return icons.icon(kind, "#5D6B80", size) def _painted_reception_action_icon(kind: str, color: str, size: int = 14) -> QIcon: """Return an inline action icon from the shared 24-unit icon grid.""" return icons.icon(kind, color, size) 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: """Hide dynamic AI labels before their deferred deletion. Keeping the widget parented until ``deleteLater`` is delivered is important on Windows. Reparenting a visible child to ``None`` promotes it to a native top-level window, which can briefly flash a small title-bar window while a patient or AI model is loading. """ while layout.count(): item = layout.takeAt(0) widget = item.widget() child_layout = item.layout() if widget is not None: widget.hide() widget.deleteLater() QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete) 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 _ReceptionCompleteButton(QPushButton): """Completion action with a pointer only while it can be activated.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__("结束问诊", parent) self.setObjectName("ReceptionCompleteButton") self.setCursor(Qt.CursorShape.PointingHandCursor) def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API super().changeEvent(event) if event.type() == QEvent.Type.EnabledChange: self.setCursor( Qt.CursorShape.PointingHandCursor if self.isEnabled() else Qt.CursorShape.ArrowCursor ) 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 _ReceptionHero(QFrame): """Move actions below the patient identity before either becomes clipped.""" def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override super().resizeEvent(event) layout = getattr(self, "header_layout", None) if not isinstance(layout, QBoxLayout): return direction = ( QBoxLayout.Direction.TopToBottom if self.width() < 860 else QBoxLayout.Direction.LeftToRight ) if layout.direction() != direction: layout.setDirection(direction) self.updateGeometry() actions = getattr(self, "actions_layout", None) if actions is not None: compact = self.width() < 520 actions.setSpacing(6 if compact else 14) more = getattr(self, "more_control", None) if more is not None: more.setFixedWidth(38 if compact else 46) gap = getattr(self, "complete_gap", None) if gap is not None: gap.changeSize(0 if compact else 16, 0) actions.invalidate() class _ClinicalInfoPanel(QWidget): """Stack history and measurements before their text gets squeezed.""" def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override super().resizeEvent(event) layout = self.layout() if not isinstance(layout, QBoxLayout): return stacked = self.width() < 760 direction = ( QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight ) if layout.direction() != direction: layout.setDirection(direction) layout.setStretch(0, 0 if stacked else 42) layout.setStretch(1, 0 if stacked else 58) class _ClinicalPageStack(QStackedWidget): """Size the clinical card from its current page, including after a resize.""" def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual page = self.currentWidget() return page.sizeHint() if page is not None else super().sizeHint() def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual page = self.currentWidget() return page.minimumSizeHint() if page is not None else super().minimumSizeHint() class _ReceptionAiPanels(QWidget): """Give both AI cards reading space before switching to a vertical layout.""" def event(self, event: QEvent) -> bool: result = super().event(event) if event.type() == QEvent.Type.LayoutRequest: self._fit_visible_height() return result def _fit_visible_height(self) -> None: layout = self.layout() if layout is None or self.width() < 1: return needed = max(layout.minimumSize().height(), layout.totalHeightForWidth(self.width())) if needed > 0 and (self.minimumHeight() != needed or self.maximumHeight() != needed): self.setFixedHeight(needed) self.updateGeometry() def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual super().resizeEvent(event) layout = self.layout() if not isinstance(layout, QBoxLayout): return stacked = self.width() < 800 direction = QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight if layout.direction() != direction: layout.setDirection(direction) layout.setStretch(0, 0 if stacked else 17) layout.setStretch(1, 0 if stacked else 23) self.updateGeometry() self._fit_visible_height() class _ReceptionAiStack(_ClinicalPageStack): """Measure the visible preview at its real width, not QLabel's ideal width.""" def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override return True def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override page = self.currentWidget() layout = page.layout() if page is not None else None if layout is None: return super().sizeHint().height() height = layout.totalHeightForWidth(max(1, width)) return max(layout.minimumSize().height(), height) def sizeHint(self) -> QSize: # noqa: N802 - Qt override hint = super().sizeHint() hint.setHeight(self.heightForWidth(self.width())) return hint 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 _paragraph_layout(self, width: int) -> tuple[QTextLayout, int]: """Keep native glyph rendering while giving clinical paragraphs leading.""" layout = QTextLayout(self.text().replace("\n", "\u2028"), self.font()) option = QTextOption() option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) layout.setTextOption(option) layout.beginLayout() y = 0.0 while True: line = layout.createLine() if not line.isValid(): break line.setLineWidth(max(1, width)) line.setPosition(QPointF(0, y)) y += max(line.height(), self.fontMetrics().height() * 1.28) layout.endLayout() return layout, max(self.fontMetrics().height(), int(y + 0.5)) def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override if not self.property("receptionBlockValue"): return super().heightForWidth(width) return self._paragraph_layout(width)[1] def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override if not self.property("receptionBlockValue"): super().paintEvent(event) return painter = QPainter(self) painter.setPen(self.palette().color(self.foregroundRole())) layout, _height = self._paragraph_layout(self.contentsRect().width()) layout.draw(painter, QPointF(self.contentsRect().topLeft())) def setText(self, text: str) -> None: # noqa: N802 - Qt override super().setText(text) self.updateGeometry() if not self.isVisible(): return # Qt otherwise propagates LayoutRequest through one ancestor at a time. # A loaded record can therefore retain the old one-line allocation even # though this label already reports its correct wrapped height. parents: list[QWidget] = [] parent = self.parentWidget() while parent is not None and not isinstance(parent, QScrollArea): if parent.layout() is not None: parent.layout().invalidate() parent.updateGeometry() parents.append(parent) parent = parent.parentWidget() if isinstance(parent, QScrollArea): # Let the resizable scroll content negotiate its natural height; # do not pin a label/card height that would go stale on resize. QCoreApplication.sendEvent(parent, QEvent(QEvent.Type.LayoutRequest)) for container in reversed(parents): container.layout().activate() class _LabValueLabel(QLabel): """Emphasize the measured number while retaining the plain value and unit.""" def __init__(self, text: str, parent: QWidget | None = None) -> None: super().__init__(text, parent) self.setTextFormat(Qt.TextFormat.PlainText) self.setWordWrap(True) self.setMinimumWidth(0) self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) def _value_layout(self, width: int) -> tuple[QTextLayout, QSize]: layout = QTextLayout(self.text(), self.font()) number = re.match(r"^[<>≤≥]?\s*[-+]?\d+(?:\.\d+)?", self.text()) if number is not None: emphasis = QTextLayout.FormatRange() emphasis.start = 0 emphasis.length = number.end() emphasis.format = QTextCharFormat() font = QFont(self.font()) font.setPixelSize(18) font.setWeight(QFont.Weight.DemiBold) emphasis.format.setFont(font) layout.setFormats([emphasis]) option = QTextOption() option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) layout.setTextOption(option) layout.beginLayout() y = natural_width = 0.0 while True: line = layout.createLine() if not line.isValid(): break line.setLineWidth(max(1, width)) line.setPosition(QPointF(0, y)) y += line.height() natural_width = max(natural_width, line.naturalTextWidth()) layout.endLayout() return layout, QSize(int(natural_width + 0.99), max(24, int(y + 0.99))) def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override return self._value_layout(width)[1].height() def sizeHint(self) -> QSize: # noqa: N802 - Qt override return self._value_layout(10000)[1] def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt override return QSize(0, self.sizeHint().height()) def setText(self, text: str) -> None: # noqa: N802 - Qt override super().setText(text) self.updateGeometry() def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override painter = QPainter(self) painter.setPen(self.palette().color(self.foregroundRole())) layout, _size = self._value_layout(self.contentsRect().width()) layout.draw(painter, QPointF(self.contentsRect().topLeft())) 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 sparkle used by the two AI panels, from the shared icon grid. The painter this replaces drew eight spokes through a centre point at 1.7 px and 1.3 px, which read as an asterisk rather than the four-point sparkle the rest of the product uses for AI. """ 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) glyph = icons.pixmap("ai", "#1769E8", 18) painter.drawPixmap(QRectF(0, 0, 18, 18), glyph, QRectF(glyph.rect())) painter.end() class _ReceptionAiSectionIcon(QWidget): """Tinted tile carrying one shared glyph per AI analysis section. The three glyphs used to be a plus-in-a-circle for "treatment" - the symbol for adding something - and another asterisk for "diagnosis". """ #: Section tone -> (plate, glyph tint, shared glyph name). _TONES = { "diagnosis": ("#EAF2FF", "#1769E8", "report"), "risk": ("#EAF2FF", "#1769E8", "alert"), "treatment": ("#EAF2FF", "#1769E8", "meds"), } 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 background, foreground, glyph_name = self._TONES.get( self._tone, self._TONES["diagnosis"] ) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor(background)) painter.drawRoundedRect(QRectF(0, 0, 28, 28), 7, 7) if self._tone == "diagnosis": painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(QPen(QColor(foreground), 1.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) bulb = QPainterPath(QPointF(11, 19)) bulb.lineTo(11, 17) bulb.cubicTo(4, 12, 8, 5, 14, 5) bulb.cubicTo(20, 5, 24, 12, 17, 17) bulb.lineTo(17, 19) bulb.closeSubpath() painter.drawPath(bulb) painter.drawLine(QPointF(11.5, 22), QPointF(16.5, 22)) painter.drawLine(QPointF(13, 24), QPointF(15, 24)) else: glyph = icons.pixmap(glyph_name, foreground, 17) painter.drawPixmap(QRectF(5.5, 5.5, 17, 17), glyph, QRectF(glyph.rect())) painter.end() 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(24, 24) 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("#1769E8")) painter.drawRoundedRect(QRectF(1, 1, 22, 22), 4, 4) painter.setPen(QColor("#FFFFFF")) font = painter.font() font.setPixelSize(11) 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("#5D6B80") 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("#1769E8" if self.isEnabled() else "#8E8F90") 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("#273244" if self.isEnabled() else "#8E8F90") 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(14) 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 self._structured_labels: dict[str, QLabel] = {} 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(16, 14, 18, 16) section_layout.setSpacing(11) section_layout.addWidget( _ReceptionAiSectionIcon(tone, section), 0, Qt.AlignmentFlag.AlignTop, ) copy = QVBoxLayout() copy.setContentsMargins(0, 0, 0, 0) copy.setSpacing(8) 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, ) # PlainText 标签仍然保留以兼容现有的 findChild 测试; # 视觉上隐藏, 由下方的 RichText 结构化视图替代渲染。 value_label.setMaximumHeight(0) value_label.hide() copy.addWidget(value_label) structured_label = QLabel("", section) structured_label.setObjectName(object_name + "Structured") structured_label.setProperty("dialogAiBody", True) structured_label.setProperty("dialogAiStructured", True) structured_label.setTextFormat(Qt.TextFormat.RichText) structured_label.setWordWrap(True) structured_label.setMinimumWidth(0) structured_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) structured_label.setSizePolicy( QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred, ) # 默认占位符 (后续 _render_model 会覆盖) structured_label.setText("—") copy.addWidget(structured_label) section_layout.addLayout(copy, 1) body_layout.addWidget(section) self._structured_labels[object_name] = structured_label 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(16, 14, 18, 16) 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(9) risk_caption = QLabel("风险评估", risk_section) risk_caption.setProperty("dialogAiCaption", True) risk_copy.addWidget(risk_caption) risk_items = _AiFlowHost( risk_section, horizontal_spacing=9, vertical_spacing=9, ) 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", ) source_label = QLabel("", body) source_label.setObjectName("ReceptionAiAnalysisDialogSources") source_label.setTextFormat(Qt.TextFormat.PlainText) source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) source_label.setWordWrap(True) source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;") body_layout.addWidget(source_label) self.source_label = source_label 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 "—" ) # 同步刷新右侧 RichText 结构化视图 (左侧 PlainText 标签只在 findChild 测试中使用) structured_diagnosis = self._structured_labels.get( "ReceptionAiAnalysisDialogDiagnosisText" ) if structured_diagnosis is not None: diagnosis_html = _ai_narrative_structured_html( _analysis_value(payload, "diagnosis_advice") ) structured_diagnosis.setText(diagnosis_html or self.diagnosis_label.text()) structured_treatment = self._structured_labels.get( "ReceptionAiAnalysisDialogTreatmentText" ) if structured_treatment is not None: treatment_html = _ai_narrative_structured_html( _analysis_value(payload, "treatment_advice") ) structured_treatment.setText(treatment_html or self.treatment_label.text()) _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.source_label.setText("报告出处\n" + "\n".join(report_provenance_lines(payload))) self.scroll_area.verticalScrollBar().setValue(0) class _GlucoseDropIcon(QWidget): """Semantic glucose dot matching the approved 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": "#F04452", "warn": "#E88900", "ok": "#1555B6"} color = QColor(colors.get(self._tone, "#BE4B58")) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(color) painter.drawEllipse(QPointF(7, 8), 5.2, 5.2) class _VitalMetricIcon(QWidget): """Compact line icon for a vital-sign tile, on the shared icon grid. The painter this replaces drew "weight" as a padlock and "BMI" as the Venus symbol, at yet another stroke weight (1.35 px on a 22 px box). """ #: Tile key -> shared glyph name. _GLYPHS = { "height": "height", "weight": "weight", "bmi": "bmi", "bp": "blood_pressure", } 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("#EAF2FF")) painter.drawRoundedRect(QRectF(0, 0, 22, 22), 6, 6) glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), "#1769E8", 15) painter.drawPixmap(QRectF(3.5, 3.5, 15, 15), glyph, QRectF(glyph.rect())) painter.end() class _LifestyleIcon(QWidget): """Line glyph for smoking / drinking / exercise, on the shared icon grid.""" _GLYPHS = {"smoke": "smoke", "drink": "drink"} 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) glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), "#5D6B80", 16) painter.drawPixmap(QRectF(0, 0, 16, 16), glyph, QRectF(glyph.rect())) painter.end() 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("#5D6B80"), 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("#1769E8"), 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(104) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False) layout = QVBoxLayout(self) layout.setContentsMargins(20, 17, 16, 17) layout.setSpacing(10) top = QHBoxLayout() top.setSpacing(14) 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), ) ) # The nested layouts are attached later. Parent widgets that are shown # during construction so Windows never promotes them to transient HWNDs. badge = StatusBadge( status_text, _queue_badge_kind(status_number, status_text), parent=self, ) 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), self) 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.AlignVCenter) 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 "", self, ) 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("#EAF2FF")) painter.setPen(QPen(QColor("#E5ECF6"), 1)) else: painter.setBrush(QColor("#FFFFFF")) painter.setPen(QPen(QColor("#E5ECF6"), 1)) painter.drawRoundedRect(rect, 6, 6) if self._selected: painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor("#1769E8")) painter.drawRoundedRect(QRectF(1.2, 1, 3.2, self.height() - 2), 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) self._sizing_daily_columns = False self.matrix.viewport().installEventFilter(self) 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 eventFilter(self, watched: Any, event: Any) -> bool: # noqa: N802 - Qt virtual if ( watched is self.matrix.viewport() and event.type() == QEvent.Type.Resize and event.size().width() != event.oldSize().width() ): self._fit_daily_columns() return super().eventFilter(watched, event) def _fit_daily_columns(self) -> None: count = self.matrix.columnCount() - 1 if count < 1 or self._sizing_daily_columns: return self._sizing_daily_columns = True try: header = self.matrix.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) header.setMinimumSectionSize(96) available = max(0, self.matrix.viewport().width() - 132) width, remainder = divmod(available, count) widths = [max(96, width + (index < remainder)) for index in range(count)] changed = False for column, target in enumerate([132, *widths]): if self.matrix.columnWidth(column) != target: self.matrix.setColumnWidth(column, target) changed = True if changed: self.matrix.resizeRowsToContents() finally: self._sizing_daily_columns = False 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: # A stale range request is intentionally barred from touching the new # patient. Reset the panel-owned flag here so that stale ``finished`` # is not also required to re-enable the controls. self.set_loading(False) 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) self._fit_daily_columns() 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.DemiBold if high else QFont.Weight.Normal) item.setFont(font) item.setForeground(QBrush(QColor("#BE4B58" if high else "#273244"))) item.setBackground( QBrush( QColor( "#FFF1F3" if high else "#F7FAFE" 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.replace("$body_family", body_family()) .replace("$heading_family", heading_family()) .replace("$number_family", number_family()) ) 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._completion_pending = False self._completion_dialog: AppointmentCompleteDialog | None = None self._detail_requests: set[tuple[int, int]] = set() self._detail_cancel_events: dict[tuple[int, int], Event] = {} self._detail_failed_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._ai_analysis_cancel_events: dict[tuple[int, int, int, str], Event] = {} self._ai_analysis_started: set[tuple[int, int, int, str]] = set() self._patient_ai_generation_requests: set[tuple[int, int, int, str]] = set() self._patient_ai_generation_cancel_events: dict[ tuple[int, int, int, str], Event ] = {} self._patient_ai_generation_started: set[tuple[int, int, int, str]] = set() self._patient_ai_list_requests: set[tuple[int, int, int]] = set() self._patient_ai_list_cancel_events: dict[tuple[int, int, int], Event] = {} self._patient_ai_list_started: set[tuple[int, int, int]] = set() self._patient_ai_list_epochs: dict[tuple[int, int, int], int] = {} self._patient_ai_mutation_epochs: dict[int, int] = {} self._patient_ai_mutation_lock = Lock() 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(11, 16, 11, 20) root.setSpacing(0) splitter = QSplitter(Qt.Orientation.Horizontal) splitter.setChildrenCollapsible(False) queue_panel = self._build_queue_panel() detail_panel = self._build_detail_panel() splitter.addWidget(queue_panel) splitter.addWidget(detail_panel) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) splitter.setHandleWidth(10) splitter.setSizes([399, 1072]) root.addWidget(splitter, 1) self.poll_timer = QTimer(self) self.poll_timer.setInterval(5_000) self.poll_timer.timeout.connect(self._poll_queue) self._refresh_cooldown = QTimer(self) self._refresh_cooldown.setSingleShot(True) self._refresh_cooldown.timeout.connect(self._finish_refresh_cooldown) self.refresh_shortcut = QShortcut(QKeySequence("F5"), self) self.refresh_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) self.refresh_shortcut.activated.connect(self._refresh_workspace) def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override super().resizeEvent(event) panel = getattr(self, "queue_panel", None) if panel is not None: width = 340 if self.width() < 1000 else 399 if panel.minimumWidth() != width: panel.setFixedWidth(width) 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") self.queue_panel = panel panel.setFixedWidth(399) layout = QVBoxLayout(panel) layout.setContentsMargins(14, 14, 12, 14) 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.refresh_button = QPushButton("", header) self.refresh_button.setIcon(icons.icon("refresh", "#5D6B80", 18)) self.refresh_button.setObjectName("ReceptionRefreshButton") self.refresh_button.setFixedSize(34, 32) self.refresh_button.setAccessibleName("刷新接诊台") self.refresh_button.setToolTip("刷新队列、当前患者和已保存的 AI 报告(F5)") self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor) self.refresh_button.clicked.connect(self._refresh_workspace) header_layout.addWidget(self.refresh_button) self.queue_filter_panel = QWidget(panel) self.queue_filter_panel.setObjectName("ReceptionQueueFilters") filter_layout = QVBoxLayout(self.queue_filter_panel) filter_layout.setContentsMargins(0, 0, 0, 0) filter_layout.setSpacing(8) self.filter_disclosure = FilterDisclosure(self, [self.queue_filter_panel]) header_layout.addWidget(self.filter_disclosure.button) layout.addWidget(header) self.queue_filter_summary = QLabel(panel) self.queue_filter_summary.setObjectName("ReceptionFilterSummary") layout.addWidget(self.queue_filter_summary) search_row = QHBoxLayout() search_row.setSpacing(8) self.queue_date_button = _ReceptionDateButton(self._queue_date, self.queue_filter_panel) self.queue_date_button.setObjectName("ReceptionDateButton") self.queue_date_button.setFixedSize(124, 32) self.queue_date_button.date_selected.connect(self._queue_date_changed) search_row.addWidget(self.queue_date_button) self.search_edit = QLineEdit(self.queue_filter_panel) self.search_edit.setObjectName("ReceptionQueueSearch") self.search_edit.setFixedHeight(32) self.search_edit.setPlaceholderText("患者姓名 · 回车查询") self.search_edit.setAccessibleName("接诊队列患者姓名") self.search_edit.setClearButtonEnabled(True) self.search_edit.returnPressed.connect(lambda: self.refresh()) self.search_edit.textChanged.connect(self._search_changed) search_row.addWidget(self.search_edit, 1) filter_layout.addLayout(search_row) filter_row = QHBoxLayout() filter_row.setSpacing(6) for status, label in ((None, "全部"), (1, "待接诊"), (2, "问诊中"), (3, "已完成")): button = QPushButton(label, self.queue_filter_panel) button.setFixedHeight(32) 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) filter_layout.addLayout(filter_row) layout.addWidget(self.queue_filter_panel) self._sync_queue_filter_summary() # Preserve the legacy tab integration surface. The original search edit # remains connected to the shell and is now available in the disclosure. 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.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(0) 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(self._refresh_workspace) 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.setVisible(True) 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.Preferred) detail_layout = QVBoxLayout(content) detail_layout.setContentsMargins(0, 0, 0, 0) detail_layout.setSpacing(0) hero = _ReceptionHero(content) hero.setObjectName("ReceptionHero") hero.setMinimumHeight(112) hero_layout = QVBoxLayout(hero) hero_layout.setContentsMargins(16, 22, 16, 22) hero_layout.setSpacing(4) self.detail_banner = MessageBanner(parent=hero) hero_layout.addWidget(self.detail_banner) patient_head = QHBoxLayout() patient_head.setSpacing(12) hero.header_layout = patient_head identity_head = QHBoxLayout() identity_head.setContentsMargins(0, 0, 0, 0) identity_head.setSpacing(16) 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(64, 64) identity_head.addWidget(self.patient_avatar_label) identity = QVBoxLayout() identity.setSpacing(8) 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) self.patient_gender_mark.setFixedSize(0, 0) 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") self.patient_meta_label.setWordWrap(True) identity.addWidget(self.patient_meta_label) identity_head.addLayout(identity, 1) patient_head.addLayout(identity_head, 1) actions = QHBoxLayout() hero.actions_layout = actions actions.setContentsMargins(0, 0, 0, 0) actions.setSpacing(14) actions.addStretch(1) patient_head.addLayout(actions) patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom) self.complete_button = _ReceptionCompleteButton(hero) self.complete_button.clicked.connect(self._complete_appointment) self.complete_button.setVisible(self._can_complete) self.notify_button = QPushButton("通知医助", hero) self.notify_button.setObjectName("ReceptionNotifyButton") self.notify_button.clicked.connect(self._notify_assistant) actions.addWidget(self.notify_button) self.history_button = QPushButton("查看历史", hero) self.history_button.setObjectName("ReceptionHistoryButton") self.history_button.clicked.connect(lambda: self.detail_tabs.setCurrentIndex(4)) actions.addWidget(self.history_button) self.video_button = QPushButton("IM 问诊", hero) self.video_button.setObjectName("ReceptionImButton") self.video_button.setToolTip("打开患者 IM,可发送消息并从会话中发起视频") self.video_button.setProperty("variant", "primary") self.video_button.clicked.connect(self._request_video) actions.addWidget(self.video_button) self.more_button = QPushButton("···", hero) self.more_button.setToolTip("更多操作") self.more_button.setAccessibleName("更多操作") self.more_button.setObjectName("ReceptionMoreButton") self.more_button.setFixedWidth(46) hero.more_control = self.more_button more_menu = QMenu(self.more_button) 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) actions.addWidget(self.more_button) actions.addSpacing(16) hero.complete_gap = actions.itemAt(actions.count() - 1).spacerItem() actions.addWidget(self.complete_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.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(526) 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(label) clinical_layout.addWidget(self.detail_tabs) self.clinical_pages = _ClinicalPageStack(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 = _ClinicalInfoPanel(self.clinical_pages) consultation_page.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum, ) consultation_layout = QHBoxLayout(consultation_page) consultation_layout.setContentsMargins(12, 22, 12, 13) 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(8, 4, 18, 0) history_layout.setSpacing(15) 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 = QVBoxLayout(lifestyle_field) lifestyle_layout.setContentsMargins(0, 0, 0, 0) lifestyle_layout.setSpacing(8) 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) 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, 42) 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.setMinimumHeight(152) vitals_card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) vitals_layout = QVBoxLayout(vitals_card) vitals_layout.setContentsMargins(12, 4, 2, 18) vitals_layout.setSpacing(12) 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.setProperty("firstMetric", column == 0) # Give clinical values the full cell width; placing the icon and # unit beside a value cropped digits in the five-column layout. cell_layout = QVBoxLayout(cell) cell_layout.setContentsMargins(8, 6, 8, 6) cell_layout.setSpacing(3) caption_row = QHBoxLayout() caption_row.setSpacing(5) caption_label = QLabel(caption, cell) caption_label.setProperty("receptionMetricCaption", True) caption_label.setAlignment(Qt.AlignmentFlag.AlignCenter) caption_row.addWidget(caption_label) cell_layout.addLayout(caption_row) value_label = QLabel("—", cell) value_label.setProperty("receptionMetricValue", True) value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) cell_layout.addWidget(value_label) unit_row = QHBoxLayout() unit_row.setSpacing(4) unit_label = QLabel(vital_units[key], cell) unit_label.setProperty("receptionMetricUnit", True) unit_label.setVisible(False) unit_row.setAlignment(Qt.AlignmentFlag.AlignHCenter) unit_row.addWidget(unit_label) if key == "bmi": unit_row.addWidget(self.vital_bmi_state) cell_layout.addLayout(unit_row) cell_layout.addStretch(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, 3, 2, 0) labs_layout.setSpacing(9) 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", "总胆固醇"), ("hdl", "HDL-C"), ("ldl", "LDL-C"), ("triglycerides", "甘油三酯"), ("albumin", "尿微量白蛋白"), ) for index, (key, caption) in enumerate(lab_definitions): cell = QFrame(labs_card) cell.setObjectName("ReceptionLabCell") cell_layout = QVBoxLayout(cell) cell_layout.setContentsMargins(10, 10, 8, 6) cell_layout.setSpacing(6) caption_label = QLabel(caption, cell) caption_label.setProperty("receptionMetricCaption", True) caption_label.setWordWrap(True) cell_layout.addWidget(caption_label) value_label = _LabValueLabel("—", cell) value_label.setProperty("receptionMetricValue", True) value_label.setWordWrap(True) cell_layout.addWidget(value_label) state_label = QLabel("正常", cell) state_label.setProperty("receptionMetricState", True) state_label.setVisible(False) cell_layout.addWidget(state_label, 0, Qt.AlignmentFlag.AlignLeft) cell_layout.addStretch(1) 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.setRowStretch(row, 1) labs_grid.setColumnStretch(column, 1) labs_layout.addLayout(labs_grid, 1) right_layout.addWidget(labs_card, 1) consultation_layout.addWidget(right_clinical, 58) 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) detail_layout.addSpacing(10) ai_panels = _ReceptionAiPanels(content) ai_panels.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) ai_row = QHBoxLayout(ai_panels) ai_row.setContentsMargins(0, 0, 0, 0) ai_row.setSpacing(10) analysis_card = QFrame(ai_panels) 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(18, 12, 16, 18) analysis_layout.setSpacing(8) analysis_header = QHBoxLayout() analysis_header.setContentsMargins(0, 0, 0, 0) analysis_header.setSpacing(6) 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.Minimum, 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) self.ai_analysis_stack = _ReceptionAiStack(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, 10, 12, 12) row.setSpacing(4) row.addWidget( _ReceptionAiSectionIcon(tone, section), 0, Qt.AlignmentFlag.AlignTop, ) copy = QVBoxLayout() copy.setContentsMargins(0, 0, 0, 0) copy.setSpacing(10) 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 ) _diagnosis_section.setMinimumHeight(96) 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") _risk_section.hide() 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" ) # The approved card previews diagnosis only. The expand control opens # the complete cached report, including risks, treatment and history. _treatment_section.hide() self.ai_treatment_label = QLabel("—", analysis_sections) self.ai_treatment_label.setTextFormat(Qt.TextFormat.PlainText) self.ai_treatment_label.setWordWrap(True) 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) self.ai_source_label = QLabel("", analysis_card) self.ai_source_label.setObjectName("ReceptionAiSourceSummary") self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText) self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.ai_source_label.setWordWrap(True) self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;") analysis_layout.addWidget(self.ai_source_label) self.ai_detail_actions_host = QWidget(analysis_card) report_footer = QHBoxLayout(self.ai_detail_actions_host) 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) self.ai_detail_actions_host.hide() more_menu.addSeparator() self.ai_history_action = more_menu.addAction("历史 AI 报告") self.ai_history_action.triggered.connect(self._open_ai_analysis_dialog) self.ai_regenerate_action = more_menu.addAction("重新分析 AI 报告") self.ai_regenerate_action.triggered.connect(self._regenerate_patient_ai_reports) ask_action = more_menu.addAction("向 AI 提问") ask_action.setVisible(self._can_ai_assistant) ask_action.triggered.connect(lambda: self._open_ai_assistant("")) 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, 17) assistant_card = QFrame(ai_panels) 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(18, 12, 16, 18) assistant_layout.setSpacing(8) assistant_header = QHBoxLayout() assistant_header.setContentsMargins(0, 0, 0, 0) assistant_header.setSpacing(6) assistant_header.addWidget(_ReceptionAssistantIcon(assistant_card)) assistant_title = QPushButton("AI 问诊助手", assistant_card) assistant_title.setObjectName("ReceptionAiTitle") assistant_title.setCursor(Qt.CursorShape.PointingHandCursor) assistant_title.setToolTip("打开当前患者的 AI 问诊助手") assistant_title.clicked.connect(lambda: self._open_ai_assistant("")) 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) 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 prompt_host = _AiFlowHost(assistant_card, horizontal_spacing=8, vertical_spacing=8) prompt_host.setObjectName("ReceptionAiPrompts") for label in ( "基于当前病史,下一步检查建议?", "该患者的用药调整建议?", "糖尿病教育要点有哪些?", "并发症筛查建议?", ): prompt_host.flow.addWidget(prompt_button(label)) assistant_layout.addWidget(prompt_host) assistant_layout.addStretch(1) assistant_input_host = QWidget(assistant_card) assistant_input_row = QHBoxLayout(assistant_input_host) 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_input_host.hide() ai_row.addWidget(assistant_card, 23) detail_layout.addWidget(ai_panels) 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_pages.updateGeometry() 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 ) self.ai_source_label.setVisible(success) 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_history_action.setEnabled(self.ai_analysis_history_button.isEnabled()) self.ai_regenerate_action.setEnabled(self.ai_analysis_regenerate_button.isEnabled()) 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" or 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 智能分析" ) # 标题行放不下“第 N 版 | 08-13 15:42”,时间会被截成“08-13 1…”。 # 完整快照时间本来就在下一行的“快照时间:…”里,这里只留版本号。 meta_text = f"第 {version} 版" 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("") source_lines = report_provenance_lines(payload) self.ai_source_label.setText("报告出处:" + ";".join(source_lines[:2])) self.ai_source_label.setToolTip("\n".join(source_lines)) 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) chip.ensurePolished() chip.setMinimumWidth(min(200, chip.fontMetrics().horizontalAdvance(label) + 24)) self.ai_risk_chip_layout.addWidget(chip) chip.show() 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) chip.show() 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) overflow.show() 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 _patient_ai_request_pending( self, patient_id: int, model: str | None = None, ) -> bool: """Return whether a live worker can settle this patient's model state. A history request can only settle the qwen-first loading state. An unrelated OpenAI generation must never keep qwen stuck in loading. Patient-level workers are intentionally shared across A -> B -> A selection generations, so matching is by stable patient id. """ if model in {None, "qwen"}: for request_key in self._patient_ai_list_requests: _generation, _appointment_id, request_patient_id = request_key if not _same_id(request_patient_id, patient_id): continue cancel_event = self._patient_ai_list_cancel_events.get(request_key) if ( request_key in self._patient_ai_list_started or cancel_event is None or not cancel_event.is_set() ): return True expected_model = "qwen" if model is None else model for request_key in self._patient_ai_generation_requests: ( _generation, _request_appointment_id, request_patient_id, request_model, ) = request_key if not ( _same_id(request_patient_id, patient_id) and request_model == expected_model ): continue cancel_event = self._patient_ai_generation_cancel_events.get(request_key) if ( request_key in self._patient_ai_generation_started or cancel_event is None or not cancel_event.is_set() ): return True return False def _current_patient_ai_context( self, patient_id: int, ) -> tuple[int, int, int] | None: """Return the active patient-report context, including attached retries.""" if not self._ai_analysis_patient_flow: return None context = self._selection_context() if context is None or not _same_id(context[3], patient_id): return None return self._ai_analysis_generation, context[1], patient_id def _patient_ai_mutation_epoch(self, patient_id: int) -> int: with self._patient_ai_mutation_lock: return self._patient_ai_mutation_epochs.get(patient_id, 0) def _advance_patient_ai_mutation_epoch(self, patient_id: int) -> None: with self._patient_ai_mutation_lock: self._patient_ai_mutation_epochs[patient_id] = ( self._patient_ai_mutation_epochs.get(patient_id, 0) + 1 ) def _reconcile_stale_patient_ai_list(self, patient_id: int) -> None: """Preserve newer writes and only retry an otherwise orphaned read.""" current_context = self._current_patient_ai_context(patient_id) if current_context is None: return _generation, appointment_id, _patient_id = current_context if ( self._ai_analysis_model_states.get("qwen") != "loading" or "qwen" in self._ai_analysis_payloads or self._patient_ai_request_pending(patient_id, "qwen") ): self._sync_ai_analysis_view() return self._load_patient_ai_reports( patient_id, appointment_id=appointment_id, diagnosis_id=self._ai_analysis_diagnosis_id, force=True, ) def _diagnosis_ai_request_pending( self, request_generation: int, appointment_id: int, diagnosis_id: int, model: str, ) -> bool: return ( request_generation, appointment_id, diagnosis_id, model, ) in self._ai_analysis_requests def _finish_orphaned_ai_loading(self, model: str, message: str) -> None: """Turn an unsupported loading state into a visible retryable error.""" regenerating = self._ai_analysis_regeneration_model == model if regenerating: self._ai_analysis_regenerating = False self._ai_analysis_regeneration_model = None if self._ai_analysis_model_states.get(model) != "loading" and not regenerating: return if model in self._ai_analysis_payloads: self._ai_analysis_model_states[model] = "success" self._ai_analysis_model_messages[model] = "" self._ai_analysis_operation_error = message else: self._ai_analysis_model_states[model] = "error" self._ai_analysis_model_messages[model] = message self._sync_ai_analysis_view() 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, read_only_refresh: bool = False, ) -> None: if not force and _same_id(patient_id, self._ai_analysis_patient_id): qwen_state = self._ai_analysis_model_states["qwen"] if qwen_state == "loading": if self._patient_ai_request_pending( patient_id, ): return elif qwen_state in {"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 报告…", ) # A patient-level read or qwen generation survives a temporary switch # away from the patient. Reattach the current view instead of issuing # a duplicate GET/POST when the doctor returns via A -> B -> A. cancelled_reads = [ request_key for request_key in self._patient_ai_list_requests if _same_id(request_key[2], patient_id) and request_key not in self._patient_ai_list_started and (cancel_event := self._patient_ai_list_cancel_events.get(request_key)) is not None and cancel_event.is_set() ] for cancelled_key in cancelled_reads: self._patient_ai_list_requests.discard(cancelled_key) self._patient_ai_list_cancel_events.pop(cancelled_key, None) self._patient_ai_list_epochs.pop(cancelled_key, None) if not read_only_refresh and self._patient_ai_request_pending(patient_id, "qwen"): self._sync_ai_analysis_view() return request_key = (request_generation, appointment_id, patient_id) self._patient_ai_list_requests.add(request_key) cancel_event = Event() self._patient_ai_list_cancel_events[request_key] = cancel_event self._patient_ai_list_epochs[request_key] = self._patient_ai_mutation_epoch( patient_id ) def request() -> Any: if cancel_event.wait(_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS): return _ASYNC_REQUEST_CANCELLED if not self._patient_ai_context_current( request_generation, appointment_id, patient_id, ) or cancel_event.is_set(): return _ASYNC_REQUEST_CANCELLED automatic_slot = not read_only_refresh and _AI_AUTOMATIC_REQUEST_SLOTS.acquire( blocking=False ) if not read_only_refresh and not automatic_slot: return _ASYNC_REQUEST_DEFERRED try: self._patient_ai_list_started.add(request_key) if not self._patient_ai_context_current( request_generation, appointment_id, patient_id, ) or cancel_event.is_set(): self._patient_ai_list_started.discard(request_key) return _ASYNC_REQUEST_CANCELLED return method(patient_id) finally: if automatic_slot: _AI_AUTOMATIC_REQUEST_SLOTS.release() runner = self._run_refresh_read if read_only_refresh else run_async runner( request, on_success=lambda result: self._apply_patient_ai_report_list( result, request_generation, appointment_id, patient_id, read_only_refresh=read_only_refresh, ), 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, ), priority=self._detail_generation, ) def _apply_patient_ai_report_list( self, result: Any, request_generation: int, appointment_id: int, patient_id: int, *, read_only_refresh: bool = False, ) -> None: request_key = (request_generation, appointment_id, patient_id) authoritative = request_key in self._patient_ai_list_requests request_epoch = self._patient_ai_list_epochs.pop(request_key, None) self._patient_ai_list_requests.discard(request_key) self._patient_ai_list_cancel_events.pop(request_key, None) self._patient_ai_list_started.discard(request_key) current_context = self._current_patient_ai_context(patient_id) if current_context is None or not authoritative: return effective_generation, effective_appointment_id, _patient_id = current_context if request_epoch != self._patient_ai_mutation_epoch(patient_id): # A report POST began after this GET. The older read, including a # cancellation result, must never clear the generated snapshot. self._reconcile_stale_patient_ai_list(patient_id) return if result is _ASYNC_REQUEST_DEFERRED: message = "患者报告查询任务较多,请稍后重试。" if "qwen" in self._ai_analysis_payloads: self._ai_analysis_operation_error = message self._sync_ai_analysis_view() else: self._set_ai_analysis_state("error", message) return if result is _ASYNC_REQUEST_CANCELLED: self._reconcile_stale_patient_ai_list(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 read_only_refresh: self._set_ai_analysis_state( "missing", "暂无已保存的患者报告;刷新不会自动生成,可点击重新分析。" ) 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", effective_generation, effective_appointment_id, patient_id, settle_before_request=True, ) def _patient_ai_report_list_error( self, error: Exception, request_generation: int, appointment_id: int, patient_id: int, ) -> None: request_key = (request_generation, appointment_id, patient_id) authoritative = request_key in self._patient_ai_list_requests request_epoch = self._patient_ai_list_epochs.pop(request_key, None) self._patient_ai_list_requests.discard(request_key) self._patient_ai_list_cancel_events.pop(request_key, None) self._patient_ai_list_started.discard(request_key) if self._current_patient_ai_context(patient_id) is None or not authoritative: return if request_epoch != self._patient_ai_mutation_epoch(patient_id): self._reconcile_stale_patient_ai_list(patient_id) return message = f"患者历史报告查询失败:{friendly_error(error)}" if "qwen" in self._ai_analysis_payloads: self._ai_analysis_operation_error = message self._sync_ai_analysis_view() return self._set_ai_analysis_state( "error", message, ) def _patient_ai_report_list_finished( self, request_generation: int, appointment_id: int, patient_id: int, ) -> None: request_key = (request_generation, appointment_id, patient_id) self._patient_ai_list_requests.discard(request_key) self._patient_ai_list_epochs.pop(request_key, None) self._patient_ai_list_cancel_events.pop(request_key, None) self._patient_ai_list_started.discard(request_key) if self._current_patient_ai_context(patient_id) is not None: if not self._patient_ai_request_pending( patient_id, ): self._finish_orphaned_ai_loading( "qwen", "患者 AI 报告查询已结束但未返回有效结果,请重试。", ) self._sync_ai_analysis_view() def _request_patient_ai_generation( self, model: str, request_generation: int, appointment_id: int, patient_id: int, *, settle_before_request: bool = False, ) -> 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) 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() cancelled_matches: list[tuple[int, int, int, str]] = [] for active_key in self._patient_ai_generation_requests: _generation, _active_appointment_id, active_patient_id, active_model = active_key if not (_same_id(active_patient_id, patient_id) and active_model == model): continue cancel_event = self._patient_ai_generation_cancel_events.get(active_key) if ( active_key in self._patient_ai_generation_started or cancel_event is None or not cancel_event.is_set() ): return cancelled_matches.append(active_key) for cancelled_key in cancelled_matches: self._patient_ai_generation_requests.discard(cancelled_key) self._patient_ai_generation_cancel_events.pop(cancelled_key, None) self._patient_ai_generation_requests.add(request_key) cancel_event = Event() self._patient_ai_generation_cancel_events[request_key] = cancel_event def request() -> Any: if settle_before_request and cancel_event.wait( _AI_AUTOMATIC_GENERATION_SETTLE_SECONDS ): return _ASYNC_REQUEST_CANCELLED if cancel_event.is_set() or self._current_patient_ai_context(patient_id) is None: return _ASYNC_REQUEST_CANCELLED automatic_slot = False if settle_before_request: automatic_slot = _AI_AUTOMATIC_REQUEST_SLOTS.acquire(blocking=False) if not automatic_slot: return _ASYNC_REQUEST_DEFERRED try: self._patient_ai_generation_started.add(request_key) if ( cancel_event.is_set() or self._current_patient_ai_context(patient_id) is None ): self._patient_ai_generation_started.discard(request_key) return _ASYNC_REQUEST_CANCELLED # From this point the write is authoritative and will be # submitted; older history reads cannot replace it. self._advance_patient_ai_mutation_epoch(patient_id) return method(patient_id, model=model) finally: if automatic_slot: _AI_AUTOMATIC_REQUEST_SLOTS.release() run_async( request, on_success=lambda result: self._apply_patient_ai_generation( result, request_generation, appointment_id, patient_id, model, settle_before_request, ), 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, ), pool=_AI_GENERATION_POOL, priority=self._detail_generation, ) def _apply_patient_ai_generation( self, result: Any, request_generation: int, appointment_id: int, patient_id: int, model: str, settle_before_request: bool, ) -> None: request_key = (request_generation, appointment_id, patient_id, model) authoritative = request_key in self._patient_ai_generation_requests self._patient_ai_generation_requests.discard(request_key) self._patient_ai_generation_cancel_events.pop(request_key, None) self._patient_ai_generation_started.discard(request_key) current_context = self._current_patient_ai_context(patient_id) if current_context is None or not authoritative: return effective_generation, effective_appointment_id, _patient_id = current_context if result is _ASYNC_REQUEST_DEFERRED: if self._ai_analysis_regeneration_model == model: self._ai_analysis_regenerating = False self._ai_analysis_regeneration_model = None label = AI_ANALYSIS_MODEL_LABELS.get(model, model) message = f"{label}后台分析任务较多,请稍后点击重新分析。" if model in self._ai_analysis_payloads: self._ai_analysis_model_states[model] = "success" self._ai_analysis_model_messages[model] = "" self._ai_analysis_operation_error = message else: self._ai_analysis_model_states[model] = "error" self._ai_analysis_model_messages[model] = message self._sync_ai_analysis_view() return if result is _ASYNC_REQUEST_CANCELLED: # The old worker reached its preflight while another patient was # selected, then A became current again before signal delivery. # Replace the cancelled single-flight instead of treating the # sentinel as a malformed server response. if ( model not in self._ai_analysis_payloads and not self._patient_ai_request_pending(patient_id, model) and ( self._ai_analysis_model_states.get(model) == "loading" or self._ai_analysis_regeneration_model == model ) ): self._request_patient_ai_generation( model, effective_generation, effective_appointment_id, patient_id, settle_before_request=settle_before_request, ) 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", effective_generation, effective_appointment_id, patient_id, settle_before_request=True, ) 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: request_key = (request_generation, appointment_id, patient_id, model) authoritative = request_key in self._patient_ai_generation_requests self._patient_ai_generation_requests.discard(request_key) self._patient_ai_generation_cancel_events.pop(request_key, None) self._patient_ai_generation_started.discard(request_key) if self._current_patient_ai_context(patient_id) is None or not authoritative: 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: request_key = (request_generation, appointment_id, patient_id, model) self._patient_ai_generation_requests.discard(request_key) self._patient_ai_generation_cancel_events.pop(request_key, None) self._patient_ai_generation_started.discard(request_key) if self._current_patient_ai_context(patient_id) is not None: if not self._patient_ai_request_pending( patient_id, model, ): label = AI_ANALYSIS_MODEL_LABELS.get(model, model) self._finish_orphaned_ai_loading( model, f"{label}报告请求已结束但未返回有效结果,请重试。", ) 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): qwen_state = self._ai_analysis_model_states["qwen"] if qwen_state == "loading": if self._diagnosis_ai_request_pending( self._ai_analysis_generation, appointment_id, diagnosis_id, "qwen", ): return elif qwen_state in {"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, settle_before_request=not force, ) def _request_ai_analysis_model( self, model: str, method: Any, request_generation: int, appointment_id: int, diagnosis_id: int, *, settle_before_request: bool = False, ) -> 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) cancel_event = Event() self._ai_analysis_cancel_events[request_key] = cancel_event self._ai_analysis_model_states[model] = "loading" self._ai_analysis_model_messages[model] = "" self._sync_ai_analysis_view() def request() -> Any: if settle_before_request and cancel_event.wait( _AI_AUTOMATIC_GENERATION_SETTLE_SECONDS ): return _ASYNC_REQUEST_CANCELLED if not self._ai_analysis_context_current( request_generation, appointment_id, diagnosis_id, ) or cancel_event.is_set(): return _ASYNC_REQUEST_CANCELLED automatic_slot = False if settle_before_request: automatic_slot = _AI_AUTOMATIC_REQUEST_SLOTS.acquire(blocking=False) if not automatic_slot: return _ASYNC_REQUEST_DEFERRED try: self._ai_analysis_started.add(request_key) if not self._ai_analysis_context_current( request_generation, appointment_id, diagnosis_id, ) or cancel_event.is_set(): self._ai_analysis_started.discard(request_key) return _ASYNC_REQUEST_CANCELLED if self._ai_analysis_supports_models: return method(diagnosis_id, model=model) return method(diagnosis_id) finally: if automatic_slot: _AI_AUTOMATIC_REQUEST_SLOTS.release() 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, ), pool=_AI_GENERATION_POOL, priority=self._detail_generation, ) def _apply_ai_analysis( self, result: Any, request_generation: int, appointment_id: int, diagnosis_id: int, model: str = "qwen", ) -> None: request_key = (request_generation, appointment_id, diagnosis_id, model) authoritative = request_key in self._ai_analysis_requests self._ai_analysis_requests.discard(request_key) self._ai_analysis_cancel_events.pop(request_key, None) self._ai_analysis_started.discard(request_key) if not self._ai_analysis_context_current( request_generation, appointment_id, diagnosis_id, ) or not authoritative: return if self._ai_analysis_model_states.get(model) != "loading": return if result is _ASYNC_REQUEST_CANCELLED: self._finish_orphaned_ai_loading( model, f"{AI_ANALYSIS_MODEL_LABELS.get(model, model)}分析已取消,请重试。", ) return if result is _ASYNC_REQUEST_DEFERRED: self._ai_analysis_model_states[model] = "error" self._ai_analysis_model_messages[model] = ( f"{AI_ANALYSIS_MODEL_LABELS.get(model, model)}后台分析任务较多,请稍后重试。" ) self._sync_ai_analysis_view() 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, settle_before_request=True, ) def _ai_analysis_error( self, error: Exception, request_generation: int, appointment_id: int, diagnosis_id: int, model: str = "qwen", ) -> None: request_key = (request_generation, appointment_id, diagnosis_id, model) self._ai_analysis_requests.discard(request_key) self._ai_analysis_cancel_events.pop(request_key, None) self._ai_analysis_started.discard(request_key) 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: request_key = (request_generation, appointment_id, diagnosis_id, model) self._ai_analysis_requests.discard(request_key) self._ai_analysis_cancel_events.pop(request_key, None) self._ai_analysis_started.discard(request_key) if not self._ai_analysis_context_current( request_generation, appointment_id, diagnosis_id, ): return if not self._diagnosis_ai_request_pending( request_generation, appointment_id, diagnosis_id, model, ): label = AI_ANALYSIS_MODEL_LABELS.get(model, model) self._finish_orphaned_ai_loading( model, f"{label}分析请求已结束但未返回有效结果,请重试。", ) 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:500;") 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 _sync_queue_filter_summary(self) -> None: status = { None: "全部状态", 1: "待接诊", 2: "问诊中", 3: "已完成", 4: "已过号", }.get(self._queue_filter_status, "全部状态") summary = f"{self._queue_date} · {status}" keyword = self.search_edit.text().strip() self.queue_filter_summary.setText(summary + (" · 姓名已筛选" if keyword else "")) self.queue_filter_summary.setToolTip(summary + (f" · 姓名:{keyword}" if keyword else "")) 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 _run_refresh_read( self, function: Any, *, on_success: Any, on_error: Any, on_finished: Any, priority: int = 0, ) -> None: """Bound GUI waiting; a timed-out worker may finish but cannot apply data. Running HTTP calls are not forcibly terminated. The fixed-size pool bounds concurrency, and expired queued reads never call the backend. """ settled = Event() timer = QTimer(self) timer.setSingleShot(True) timer.destroyed.connect(lambda _object=None: settled.set()) def settle(callback: Any, result: Any) -> None: if settled.is_set(): return settled.set() timer.stop() timer.deleteLater() try: callback(result) finally: on_finished() def request() -> Any: if settled.is_set(): return _ASYNC_REQUEST_CANCELLED return function() timer.timeout.connect( lambda: settle(on_error, TimeoutError("刷新超时,请检查网络后再次点击刷新。")) ) timer.start(_REFRESH_TIMEOUT_MS) run_async( request, on_success=lambda result: settle(on_success, result), on_error=lambda error: settle(on_error, error), on_finished=lambda: settle( on_error, RuntimeError("刷新未返回有效结果,请重试。") ), pool=_RECEPTION_REFRESH_POOL, priority=priority, ) def _cancel_patient_ai_reads(self, patient_id: int | None) -> None: # Reads are normally shared across selection generations. Explicit # refresh revokes their authority, but never forgets an in-flight POST. for key in list(self._patient_ai_list_requests): if patient_id is not None and not _same_id(key[2], patient_id): continue cancel_event = self._patient_ai_list_cancel_events.pop(key, None) if cancel_event is not None: cancel_event.set() self._patient_ai_list_requests.discard(key) self._patient_ai_list_epochs.pop(key, None) self._patient_ai_list_started.discard(key) def _refresh_workspace(self) -> None: """Recover reads without replaying writes or clearing the doctor's draft.""" if self._refresh_cooldown.isActive(): return if self._note_busy or self._completion_pending: show_toast(self, "正在提交,请等待提交结束后再刷新。", "warning") return if self._completion_dialog is not None and self._completion_dialog.isVisible(): show_toast(self, "请先关闭完成问诊窗口,再刷新接诊台。", "warning") return if self._selected_appointment_id is not None and not self.notify_button.isEnabled(): show_toast(self, "正在通知医助,请稍后刷新。", "warning") return self.refresh_button.setEnabled(False) self.refresh_button.setCursor(Qt.CursorShape.ArrowCursor) self._refresh_cooldown.start(_REFRESH_COOLDOWN_MS) context = self._selection_context() self._cancel_patient_ai_reads(context[3] if context is not None else None) # Invalidate a pending daily-range result before the fresh detail arrives. self._daily_generation += 1 self._daily_loading = False self.daily_panel.set_loading(False) record = self._selected_record if record is not None: self._load_detail(record, clear=False, read_only_refresh=True) # A stuck queue must not prevent refreshing the selected patient's detail. self.refresh(workspace_refresh=True) def _finish_refresh_cooldown(self) -> None: self.refresh_button.setEnabled(True) self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor) def refresh(self, silent: bool = False, *, workspace_refresh: bool = False) -> None: """Replace the queue with page one using a GUI-thread query snapshot.""" if silent and self._queue_loading and not workspace_refresh: return selected_date = self._queue_date or date.today().isoformat() page_size = self._queue_page_size if silent or workspace_refresh: 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, } self._sync_queue_filter_summary() query_key = self._query_key(query) if query_key != self._queue_query_key: if not workspace_refresh: 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, workspace_refresh=workspace_refresh ) 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, 120)) 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, workspace_refresh: bool = False, ) -> 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) # 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排, # 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。 # 已有数据时保持旧行可见、静默替换;失败仍然照常报错。 frozen_query = dict(query) page_no = int(frozen_query["page_no"]) query_key = self._query_key(frozen_query) def request() -> Any: if generation != self._queue_generation: return _ASYNC_REQUEST_CANCELLED return invoke( self.repository, "list_appointments", **frozen_query, ) runner = self._run_refresh_read if workspace_refresh else run_async runner( request, on_success=lambda result: self._apply_queue( result, generation, page_no=page_no, append=append, query_key=query_key, silent=silent, workspace_refresh=workspace_refresh, ), 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, workspace_refresh: 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, ) preserve_selection = (workspace_refresh or silent) and selected_id is not None if row_to_select < 0 and records and not preserve_selection: 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) elif row_to_select < 0: self.queue_list.setCurrentRow(-1) self.queue_list.blockSignals(False) self._sync_queue_row_selection() self.queue_summary.setText( f"共 {self._queue_total} 位患者" if len(records) >= self._queue_total else f"已加载 {len(records)} / 共 {self._queue_total} 位患者" ) self.queue_stack.setCurrentIndex(0 if records else 1) self.queue_banner.clear() if row_to_select < 0: # The patient may have left the queue. Keep the detail and unsaved # draft attached to that patient until the doctor selects another. if preserve_selection: self.queue_banner.show_message( "当前患者已不在筛选队列,已保留详情与未保存内容,可手动选择其他患者。", "info", ) else: 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 and not workspace_refresh: self._load_detail(chosen, silent=True, clear=False) else: self._select_record(chosen, silent=True, read_only_refresh=workspace_refresh) 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, read_only_refresh: 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, read_only_refresh=read_only_refresh) def _load_detail( self, record: Any, silent: bool = False, *, clear: bool = True, read_only_refresh: bool = False, ) -> 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 for cancel_event in self._detail_cancel_events.values(): cancel_event.set() cancel_event = Event() request_key = (generation, appointment_id) self._detail_cancel_events[request_key] = cancel_event 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) # 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排, # 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。 # 已有数据时保持旧行可见、静默替换;失败仍然照常报错。 self._detail_loading = True self._detail_requests.add(request_key) runner = self._run_refresh_read if read_only_refresh else run_async runner( lambda: self._fetch_detail_bundle(record, appointment_id, cancel_event), on_success=lambda bundle: self._apply_detail( bundle, generation, appointment_id, read_only_refresh=read_only_refresh ), on_error=lambda error: self._detail_error(error, generation, appointment_id), on_finished=lambda: self._detail_finished(generation, appointment_id), priority=generation, ) def _fetch_detail_bundle( self, record: Any, appointment_id: int, cancel_event: Event | None = None, ) -> dict[str, Any]: if cancel_event is not None and cancel_event.is_set(): return {"cancelled": True} detail = invoke( self.repository, "get_reception", appointment_id=appointment_id, id=appointment_id, ) if cancel_event is not None and cancel_event.is_set(): return {"detail": detail, "cancelled": True} 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 ( (cancel_event is None or not cancel_event.is_set()) and 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 ( (cancel_event is None or not cancel_event.is_set()) and 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 ( (cancel_event is None or not cancel_event.is_set()) and 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) def request() -> dict[str, Any]: if ( generation != self._daily_generation or not _same_id(appointment_id, self._selected_appointment_id) or diagnosis_id != self._daily_diagnosis_id ): return {"cancelled": True} return self._fetch_daily_bundle( diagnosis_id, start, end, fallback_notes=fallback_notes, ) run_async( request, 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, ), priority=self._detail_generation, ) 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, *, read_only_refresh: bool = False, ) -> 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._detail_failed_requests.add((generation, expected_id)) self._render_detail_load_failure( "患者详情校验失败,请重新选择患者后重试。" ) 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 read_only_refresh: self._refresh_saved_ai_reports(expected_id, diagnosis_id, patient_id) elif 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 _refresh_saved_ai_reports( self, appointment_id: int, diagnosis_id: int | None, patient_id: int | None ) -> None: self._cancel_patient_ai_reads(patient_id) if self._can_patient_ai_read and patient_id is not None and callable( getattr(self.repository, "list_patient_ai_reports", None) ): # A generation is a write with an uncertain outcome while running. # Keep its single-flight identity and do not race it with an older GET. if any( self._patient_ai_request_pending(patient_id, model) for model in AI_ANALYSIS_MODELS ): self._ai_analysis_operation_error = "报告生成任务仍在处理中,刷新不会重复生成。" self._sync_ai_analysis_view() return self._load_patient_ai_reports( patient_id, appointment_id=appointment_id, diagnosis_id=diagnosis_id, force=True, read_only_refresh=True, ) elif not self._can_ai_analysis: self._set_ai_analysis_state("permission") elif not self._ai_analysis_payloads and not self._ai_analysis_requests: # The legacy analysis endpoint is a POST despite its get_* name. self._set_ai_analysis_state( "error", "当前数据源无法只读刷新 AI 报告;如需生成分析,请点击重试。" ) 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 visit_id not in (None, ""): meta_parts.append(f"就诊号:{display_text(visit_id)}") meta_parts.append(appointment_type_description(appointment_type_value(appointment))) self.patient_meta_label.setText(" | ".join(meta_parts)) self.patient_meta_label.setToolTip(" · ".join(condition_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( appointment_type_description(appointment_type_value(appointment)) ) 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] status_label.setProperty( "warning", key in {"fasting", "postprandial"} and _glucose_tone(value) == "warn" ) 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.lstrip("↑↓ ") if "偏" in status_text else status_text) status_label.setToolTip(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.lstrip("↑↓ ") if "偏" in status_text else status_text) status_label.setToolTip(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: for cancel_event in self._patient_ai_generation_cancel_events.values(): cancel_event.set() for cancel_event in self._patient_ai_list_cancel_events.values(): cancel_event.set() for cancel_event in self._ai_analysis_cancel_events.values(): cancel_event.set() 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.patient_meta_label.setToolTip("") 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 _render_detail_load_failure(self, message: str) -> None: """Replace every transient detail placeholder with a terminal state.""" if not hasattr(self, "diagnosis_text"): return self.patient_meta_label.setText(message) self.patient_meta_label.setToolTip("") self._set_case_placeholder(message) 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.set_error("患者详情加载失败,暂时无法读取日常记录。") self.followup_text.setText("患者详情加载失败,暂时无法读取跟踪备注。") self.health_summary_label.setText("患者详情加载失败,暂时无法读取健康数据。") for label in self.case_labels.values(): label.setText("—") 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) 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 ) is_text = appointment_type_value(appointment) == "text" self.video_button.setText("图文沟通" if is_text else "IM 问诊") self.video_button.setToolTip( "发送文字、图片和文件;图文问诊不支持音视频通话" if is_text else "打开患者 IM,会话通话能力由本次挂号决定" ) 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 not self._completion_pending 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 expected_id is not None: cancel_event = self._detail_cancel_events.get((generation, expected_id)) if cancel_event is not None: cancel_event.set() if ( generation == self._detail_generation and expected_id is not None and _same_id(expected_id, self._selected_appointment_id) ): request_key = (generation, expected_id) self._detail_failed_requests.add(request_key) cached_detail = self._selected_detail if cached_detail is None: self._render_detail_load_failure( "患者详情加载失败,请重新选择患者或稍后重试。" ) self._stop_ai_analysis_after_detail_failure( "患者病历加载失败,暂时无法生成 AI 智能分析。" ) self.detail_banner.show_message(friendly_error(error), "danger") if cached_detail is None: self._update_action_state(self._selected_record or {}, {}) else: appointment = ( get_value(cached_detail, "appointment", None) or self._selected_record or {} ) diagnosis = get_value(cached_detail, "diagnosis", None) or {} self._update_action_state(appointment, diagnosis) 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: request_key = (generation, appointment_id) self._detail_requests.discard(request_key) self._detail_cancel_events.pop(request_key, None) 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 ) current = self._context_current(generation, appointment_id) failed = request_key in self._detail_failed_requests if ( current and not self._detail_loading and self._selected_detail is None and not failed ): self._render_detail_load_failure( "患者详情请求已结束但未返回有效结果,请重新选择患者后重试。" ) self._stop_ai_analysis_after_detail_failure( "患者病历未返回有效结果,暂时无法生成 AI 智能分析。" ) self.detail_banner.show_message( "患者详情请求未返回有效结果,请重新选择患者后重试。", "danger", ) if not self._detail_loading and current: 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) self._detail_failed_requests.discard(request_key) def _clear_selection(self) -> None: self._detail_generation += 1 for cancel_event in self._detail_cancel_events.values(): cancel_event.set() self._detail_loading = False 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_selected_ai_consult(self) -> bool: """Open AI chat only when the reception selection has a diagnosis.""" if not self._can_ai_assistant or self._detail_loading: return False context = self._selection_context() if context is None or context[2] is None: return False 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="接诊台", ) return True def _open_ai_consult(self) -> None: if self.open_selected_ai_consult(): return if not self._can_ai_assistant: show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger") elif self._detail_loading: show_toast(self, "患者病历仍在加载,请稍候。", "warning") else: show_toast(self, "请先选择一位有诊单的患者。", "warning") 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 self._completion_pending: return if self._completion_dialog is not None: self._completion_dialog.raise_() self._completion_dialog.activateWindow() return 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 dialog = AppointmentCompleteDialog(can_note=self._can_note, parent=self) self._completion_dialog = dialog dialog.submitted.connect( lambda note: self._submit_completion(dialog, generation, appointment_id, note) ) dialog.finished.connect(lambda _result: self._close_completion_dialog(dialog)) dialog.open() def _close_completion_dialog(self, dialog: AppointmentCompleteDialog) -> None: if self._completion_dialog is dialog: self._completion_dialog = None dialog.deleteLater() def _submit_completion( self, dialog: AppointmentCompleteDialog, generation: int, appointment_id: int, note: str, ) -> None: if self._completion_pending or self._completion_dialog is not dialog: return if not self._context_current(generation, appointment_id): dialog.show_error("当前患者已切换或详情已更新,请关闭窗口后重新操作。") return self._completion_pending = True dialog.set_busy(True) self.complete_button.setEnabled(False) run_async( lambda: self._complete_after_revalidation(appointment_id, note), on_success=lambda warning: self._completion_succeeded( dialog, generation, appointment_id, warning ), on_error=lambda error: self._completion_failed(dialog, error), on_finished=self._completion_finished, ) def _completion_succeeded( self, dialog: AppointmentCompleteDialog, generation: int, appointment_id: int, warning: str, ) -> None: if self._completion_dialog is dialog: if warning: dialog.show_completed_warning(warning) else: dialog.set_busy(False) dialog.accept() self._appointment_completed(generation, appointment_id, warning) def _completion_failed(self, dialog: AppointmentCompleteDialog, error: Exception) -> None: if self._completion_dialog is dialog: dialog.show_error(friendly_error(error)) def _completion_finished(self) -> None: self._completion_pending = False if self._selected_appointment_id is not None: self._restore_action_state(self._detail_generation, self._selected_appointment_id) def _complete_after_revalidation(self, appointment_id: int, note: str = "") -> str: if not self._can_complete: raise ValueError("当前账号没有完成接诊权限。") note = note.strip() if note and not self._can_note: raise ValueError("当前账号没有添加医生备注权限。") if len(note) > COMPLETION_NOTE_LIMIT: raise ValueError(f"备注不能超过 {COMPLETION_NOTE_LIMIT} 字。") 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("挂号状态已变化,请刷新队列后重试") invoke( self.repository, "complete_appointment", appointment_id=appointment_id, id=appointment_id, ) if note: # Use the freshly validated diagnosis, never the patient's ID or # mutable selection: notes belong to the diagnosis timeline. diagnosis = get_value(detail, "diagnosis", None) or {} diagnosis_id = _as_int(first_value(diagnosis, "id", "diagnosis_id", default=None)) if diagnosis_id is None or diagnosis_id <= 0: return "问诊已完成,但该预约没有关联诊单,备注未保存" try: invoke( self.repository, "add_doctor_note", diagnosis_id=diagnosis_id, content=note, ) except Exception: return "问诊已完成,但备注未保存" return "" def _appointment_completed( self, generation: int, appointment_id: int, warning: str = "" ) -> None: show_toast(self, warning or "接诊已完成。", "warning" if warning else "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, "appointment_type": appointment_type_value(appointment), "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"]