Files
zyt/app/src/doctor_workstation/ui/pages/reception.py
T
2026-08-18 14:08:38 +08:00

8019 lines
311 KiB
Python
Raw Blame History

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