"""Patient diagnosis detail/editor used by the scoped patient workspace.""" from __future__ import annotations import re import sqlite3 from collections.abc import Mapping, Sequence from contextlib import suppress from typing import Any from PySide6.QtCore import QDate, QDateTime, QEvent, QPoint, QRect, Qt, QTimer, QUrl, Signal from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QAbstractItemView, QBoxLayout, QDialog, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QStackedLayout, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from ...core.appointment_modes import appointment_type_description, appointment_type_value from ..diagnosis_drawer import ( DIAGNOSIS_QSS, CaseGrid, ChatPanel, DailyRecordPanel, DiagnosisChoiceButtons, DiagnosisComboBox, DiagnosisDateEdit, DiagnosisLineEdit, DiagnosisNumberEdit, DiagnosisSwitch, DiagnosisTabWidget, DiagnosisTextEdit, ExpandableDiagnosisTextEdit, LoadingOverlay, MessageStrip, NotesTimeline, RecordTable, SaveStateButton, readonly_card, repolish, section_heading, set_status_tag, set_tag_item, ) from ..diagnosis_editors import DailyRecordEditorDialog from ..diagnosis_media import ( ImagePreviewDialog, RecordingPlaybackCell, RecordingPlayerDialog, safe_image_sources, ) from ..infinite_list import InfiniteList from ..theme import main_window_target_rect from ..widgets import ( display_text, first_value, friendly_error, get_value, has_permission, invoke, page_items, page_total, run_async, ) from .issued_prescription_ai import can_open_issued_ai, present_issued_prescription_ai from .local_audio_queue import LocalAudioQueueDialog from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report _PHONE_PERMISSION = "tcm.diagnosis/phonePlain" _PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders" _ORDER_DETAIL_PERMISSION = "tcm.prescriptionOrder/detail" _ORDER_LOGS_PERMISSION = "tcm.prescriptionOrder/logs" _DAILY_PERMISSION = "tcm.diagnosis/dailyRecord" _PRESCRIPTION_PERMISSION = "tcm.diagnosis/chufang" _PRESCRIPTION_EDIT_PERMISSION = "cf.prescription/edit" _OFFSET_PERMISSION = "tcm.diagnosis/setRevisitSlotStartOffset" _VIDEO_PERMISSION = "tcm.diagnosis/huifang" _CHAT_PERMISSION = "tcm.diagnosis/chat" _NOTE_ADD_PERMISSION = "doctor.appointment/addDoctorNote" _NOTE_DELETE_PERMISSION = "doctor.appointment/deleteDoctorNoteImage" _PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$") _ID_CARD_PATTERN = re.compile( r"^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$" ) _DIAGNOSIS_FIELDS = ( ("患者姓名", "patient_name", "患者姓名"), ("手机号", "phone", "11 位手机号"), ("身份证号", "id_card", "18 位身份证号(可选)"), ("性别", "gender", "1=男,0=女"), ("年龄", "age", "患者年龄"), ("诊断日期", "diagnosis_date", "YYYY-MM-DD"), ("诊断类型", "diagnosis_type", "初诊/复诊类型"), ("证型", "syndrome_type", "中医证型"), ("糖尿病类型", "diabetes_type", "糖尿病分型"), ("糖尿病发现年", "diabetes_discovery_year", "发现年份或病程描述"), ("当地医院诊断", "local_hospital_diagnosis", "多项以顿号分隔"), ("当地医院", "local_hospital_name", "当地就诊医院"), ("婚姻状态", "marital_status", "婚姻状态"), ("身高", "height", "cm"), ("体重", "weight", "kg"), ("地区", "region", "所在地区"), ("收缩压", "systolic_pressure", "mmHg"), ("舒张压", "diastolic_pressure", "mmHg"), ("空腹血糖", "fasting_blood_sugar", "mmol/L"), ("主诉", "chief_complaint", "患者此次就诊的主要诉求"), ("现病史", "present_illness", "主要症状、持续时间与变化"), ("症状", "symptoms", "当前主要症状"), ("既往史", "past_history", "既往史;多项可用顿号分隔"), ("外伤史", "trauma_history", "0/1 或具体说明"), ("手术史", "surgery_history", "0/1 或具体说明"), ("过敏史", "allergy_history", "0/1 或具体说明"), ("家族史", "family_history", "0/1 或具体说明"), ("妊娠史", "pregnancy_history", "0/1 或具体说明"), ("食欲", "appetite", "多项以顿号分隔"), ("饮水", "water_intake", "饮水情况"), ("饮食", "diet_condition", "多项以顿号分隔"), ("体重变化", "weight_change", "体重变化"), ("身体感觉", "body_feeling", "多项以顿号分隔"), ("睡眠", "sleep_condition", "多项以顿号分隔"), ("眼部", "eye_condition", "多项以顿号分隔"), ("头部感觉", "head_feeling", "多项以顿号分隔"), ("出汗", "sweat_condition", "多项以顿号分隔"), ("皮肤", "skin_condition", "多项以顿号分隔"), ("小便", "urine_condition", "多项以顿号分隔"), ("大便", "stool_condition", "多项以顿号分隔"), ("肾脏情况", "kidney_condition", "多项以顿号分隔"), ("脂肪肝程度", "fatty_liver_degree", "脂肪肝程度"), ("舌象", "tongue", "舌质、舌苔等观察"), ("舌苔", "tongue_coating", "舌苔记录"), ("脉象", "pulse", "脉象记录"), ("临床诊断", "clinical_diagnosis", "填写临床诊断"), ("治则", "treatment_principle", "治疗原则"), ("处方", "prescription", "处方摘要"), ("处方意见", "prescription_opinion", "辨证与处方意见"), ("医嘱", "doctor_advice", "医生嘱托"), ("当前用药", "current_medications", "患者当前用药"), ("补充备注", "remark", "其他需要记录的信息"), ("状态", "status", "启用或禁用"), ("渠道来源", "create_source", "诊单创建来源"), ("统计端就诊卡", "show_card", "是否在统计端显示"), ) _LIST_FIELDS = { "local_hospital_diagnosis", "appetite", "diet_condition", "body_feeling", "sleep_condition", "eye_condition", "head_feeling", "sweat_condition", "skin_condition", "urine_condition", "stool_condition", "kidney_condition", "past_history", } _INTEGER_FIELDS = { "gender", "age", "marital_status", "systolic_pressure", "diastolic_pressure", "trauma_history", "surgery_history", "allergy_history", "family_history", "pregnancy_history", "status", "show_card", } # fasting_blood_sugar stays free text (supports ranges like "6-9"). _FLOAT_FIELDS = {"height", "weight"} _PATIENT_BASIC_FIELDS = {"patient_name", "phone", "id_card", "gender", "age"} _ORDER_OFFSET_HELP = ( "在实单诊次序号上叠加偏移量。设为 0(默认):第 1 笔实单计为一诊;" "设为 1:第 1 笔实单计为二诊;设为 2:第 1 笔实单计为三诊——" "若有 5 笔实单且偏移 2,则统计上相当于计至七诊(5+2)。" ) _ORDER_DETAIL_QSS = """ QDialog#DiagnosisOrderDetailOverlay { background: transparent; } QFrame#DiagnosisOrderDetailScrim { background: rgba(26, 28, 31, 0.18); border: 0; } QFrame#DiagnosisOrderDetailDrawer { background: #F7F7F7; border: 0; border-left: 1px solid #F0F0F0; } QFrame#DiagnosisOrderDetailHeader { background: #FFFFFF; border: 0; border-bottom: 1px solid #F0F0F0; } QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; } QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; } QLabel#DiagnosisOrderReadonlyBadge { color: #1A1C1F; background: #F7F7F7; border: 1px solid #EDEDEE; border-radius: 4px; padding: 3px 7px; font-size: 11px; font-weight: 600; } QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; } QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; } QFrame[orderAmountCard="true"] { background: #FFFFFF; border: 1px solid #F0F0F0; border-radius: 9px; } QLabel[orderAmountTitle="true"] { color: #606163; font-size: 11px; font-weight: 550; } QLabel[orderAmountValue="true"] { color: #1A1C1F; font-size: 18px; font-weight: 700; } QLabel[orderAmountTone="danger"] { color: #BE4B58; } QLabel[orderAmountTone="success"] { color: #287B65; } QLabel[orderAmountTone="warning"] { color: #A9691D; } QFrame[orderDetailSection="true"] { background: #FFFFFF; border: 1px solid #EDEDEE; border-radius: 10px; } QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; } QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; } QFrame[orderField="true"] { background: #F7F7F7; border: 1px solid #EDEDEE; border-radius: 7px; } QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; } QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; } QLabel[orderEmptyState="true"] { color: #606163; background: #F7F7F7; border: 1px dashed #E4E4E5; border-radius: 5px; padding: 18px 12px; font-size: 12px; } QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; } QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; } QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; } QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; } QFrame#DiagnosisOrderDetailFooter { background: #FFFFFF; border: 0; border-top: 1px solid #F0F0F0; } """ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ...]], ...] = ( ( "基本信息", ( (("诊单ID", "__diagnosis_id", 24, "line"),), ( ("姓名", "patient_name", 12, "line"), ("身份证号", "id_card", 12, "line"), ), (("手机号", "phone", 24, "line"),), ( ("性别", "gender", 12, "gender"), ("年龄", "age", 12, "age_number"), ), ), ), ( "生命体征", ( ( ("婚姻", "marital_status", 12, "marital"), ("身高", "height", 12, "height_number"), ), ( ("体重", "weight", 12, "weight_number"), ("地区", "region", 12, "line"), ), ( ("高压", "systolic_pressure", 12, "systolic_number"), ("低压", "diastolic_pressure", 12, "diastolic_number"), ), ( ("空腹血糖", "fasting_blood_sugar", 12, "line"), ("诊断类型", "diagnosis_type", 12, "diagnosis_type"), ), ( ("状态", "status", 12, "status"), ("渠道", "create_source", 12, "create_source"), ), (("统计端就诊卡", "show_card", 12, "show_card"),), (("在用药物", "current_medications", 24, "expandable_textarea3"),), ), ), ( "主诉", ( ( ("当地诊断日期", "diagnosis_date", 12, "date"), ("糖尿病病史", "diabetes_discovery_year", 12, "line"), ), (("当地医院诊断结果", "local_hospital_diagnosis", 24, "local_diagnosis"),), (("当地医院名称", "local_hospital_name", 24, "line"),), ), ), ( "现病史", ( (("口腔感觉", "appetite", 24, "appetite_choices"),), (("每日饮水量", "water_intake", 24, "water_intake_choices"),), (("近一个月体重变化", "weight_change", 24, "weight_change_choices"),), (("脂肪肝程度", "fatty_liver_degree", 24, "fatty_liver_degree_choices"),), (("饮食情况", "diet_condition", 24, "diet_condition_choices"),), (("肢体感觉", "body_feeling", 24, "body_feeling_choices"),), (("睡眠情况", "sleep_condition", 24, "sleep_condition_choices"),), (("眼睛情况", "eye_condition", 24, "eye_condition_choices"),), (("头部感觉", "head_feeling", 24, "head_feeling_choices"),), (("出汗情况", "sweat_condition", 24, "sweat_condition_choices"),), (("皮肤情况", "skin_condition", 24, "skin_condition_choices"),), (("小便情况", "urine_condition", 24, "urine_condition_choices"),), (("大便情况", "stool_condition", 24, "stool_condition_choices"),), (("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),), (("其他补充", "symptoms", 24, "expandable_textarea3"),), ), ), ("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)), ( "其他病史", ( ( ("外伤史", "trauma_history", 12, "boolean"), ("手术史", "surgery_history", 12, "boolean"), ), ( ("过敏史", "allergy_history", 12, "boolean"), ("家族史", "family_history", 12, "boolean"), ), (("妊娠史", "pregnancy_history", 12, "boolean"),), ), ), ("诊断信息", ((("病史补充", "remark", 24, "textarea2"),),)), ) _CHOICE_DICTIONARIES: dict[str, tuple[str, bool]] = { "appetite": ("appetite", True), "water_intake": ("water_intake", False), "diet_condition": ("diet_condition", True), "weight_change": ("weight_change", False), "body_feeling": ("body_feeling", True), "sleep_condition": ("sleep_condition", True), "eye_condition": ("eye_condition", True), "head_feeling": ("head_feeling", True), "sweat_condition": ("sweat_condition", True), "skin_condition": ("skin_condition", True), "urine_condition": ("urine_condition", True), "stool_condition": ("stool_condition", True), "kidney_condition": ("kidney_condition", True), "fatty_liver_degree": ("fatty_liver_degree", False), "past_history": ("past_history", True), } _TAB_DEFINITIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( ("basic", "病历", ()), ("notes", "医生备注", ()), ("daily", "日常记录", ("tcm.diagnosis/dailyRecord",)), ("prescription", "处方", ("tcm.diagnosis/chufang",)), ("orders", "业务订单", (_PATIENT_ORDERS_PERMISSION,)), ("video", "视频录制回放", ("tcm.diagnosis/huifang",)), ("chat", "聊天", ("tcm.diagnosis/chat",)), ( "assign", "指派医助记录", ("tcm.diagnosis/assign", "tcm.diagnosis/detail"), ), ("appointment", "挂号记录", ("doctor.appointment/lists",)), ) def _mask_phone(value: Any) -> str: text = str(value or "").strip() return f"{text[:3]}****{text[-4:]}" if len(text) == 11 else ("***" if text else "") def _mask_id_card(value: Any) -> str: text = str(value or "").strip() if len(text) == 18: return f"{text[:6]}********{text[-4:]}" if len(text) == 15: return f"{text[:6]}*****{text[-4:]}" return "***" if text else "" def _truthy(value: Any) -> bool: if isinstance(value, str): return value.strip().lower() not in {"", "0", "false", "no", "off"} return bool(value) def _editor_text(value: Any) -> str: if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return "、".join(str(item) for item in value) return "" if value is None else str(value) def _diagnosis_gender_text(value: Any, default: str = "—") -> str: """Render the diagnosis API's documented 1/0 male/female contract.""" normalized = str(value).strip().lower() if value not in (None, "") else "" if normalized in {"1", "m", "male", "男"}: return "男" if normalized in {"0", "2", "f", "female", "女"}: return "女" return display_text(value, default) def _duplicate_found(value: Any) -> bool: for key in ("exists", "duplicate", "data.exists", "data.duplicate"): candidate = get_value(value, key, None) if candidate is not None: if isinstance(candidate, str): return candidate.strip().lower() not in {"", "0", "false", "no", "off"} return bool(candidate) return False def _field_value(key: str, text: str, original: Any) -> Any: value = text.strip() if isinstance(original, Sequence) and not isinstance(original, (str, bytes, bytearray)): return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()] if isinstance(original, bool): return value.lower() not in {"", "0", "false", "no", "off"} if isinstance(original, int) or (original in (None, "") and key in _INTEGER_FIELDS): return _int(value, 0) if value else "" if isinstance(original, float) or (original in (None, "") and key in _FLOAT_FIELDS): try: return float(value) if value else "" except ValueError: return value if key in _LIST_FIELDS: return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()] return value def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any: """Call the first repository method present while keeping keyword tolerance.""" for name in names: if callable(getattr(repository, name, None)): return invoke(repository, name, **kwargs) return invoke(repository, names[0], **kwargs) def _mapping(value: Any) -> Mapping[str, Any]: return value if isinstance(value, Mapping) else {} def _dictionary_choices(value: Any, dictionary_type: str) -> list[tuple[str, Any]]: candidates: Any = value if isinstance(value, Mapping): candidates = value.get(dictionary_type, value.get("lists", value.get("data", []))) if isinstance(candidates, Mapping): candidates = candidates.get(dictionary_type, candidates.get("lists", [])) rows = ( list(candidates) if isinstance(candidates, Sequence) and not isinstance(candidates, (str, bytes, bytearray)) else [] ) choices: list[tuple[str, Any]] = [] for row in rows: label = first_value(row, "name", "label", "text", default="") value = first_value(row, "value", "id", default="") if label not in (None, "") and value not in (None, ""): choices.append((str(label), value)) return choices def _diagnosis_type_value(value: Any) -> str: normalized = str(value or "").strip() canonical = { "1": "first_visit", "2": "follow_up", "3": "consultation", "初诊": "first_visit", "复诊": "follow_up", "会诊": "consultation", }.get(normalized, normalized) return canonical if canonical in {"first_visit", "follow_up", "consultation"} else "" def _int(value: Any, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default _MISSING = object() def _raw_value(source: Any, *keys: str, default: Any = _MISSING) -> Any: """Return the first present field while preserving explicit zero/false values.""" for key in keys: value = get_value(source, key, _MISSING) if value is not _MISSING and value is not None and value != "": return value return default def _detail_rows(source: Any, *keys: str) -> tuple[list[Any], bool]: """Read a returned list field and report whether that field actually existed.""" for key in keys: value = get_value(source, key, _MISSING) if value is _MISSING: continue if value is None: return [], True if isinstance(value, Mapping): for nested_key in ("lists", "items", "rows", "results", "data", "traces"): nested = value.get(nested_key, _MISSING) if isinstance(nested, Sequence) and not isinstance(nested, (str, bytes, bytearray)): return list(nested), True return [], True if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return list(value), True return [], True return [], False def _number(value: Any) -> float | None: if value is _MISSING or value is None or value == "": return None if isinstance(value, bool): return float(value) try: return float(str(value).replace("¥", "").replace("¥", "").replace(",", "").strip()) except (TypeError, ValueError): return None def _money_text(value: Any) -> str: amount = _number(value) return "—" if amount is None else f"¥{amount:,.2f}" def _fulfillment_text(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" labels = { 1: "待双审通过", 2: "待发货", 3: "已完成", 4: "已取消", 5: "已发货", 6: "已签收", 7: "进行中", 8: "暂不制药", 9: "拒收", 10: "退款", 11: "保留药方", 12: "制药缓发", } numeric = _int(value, -1) return labels.get(numeric, display_text(value)) def _pay_status_text(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" labels = {1: "待支付", 2: "已支付", 3: "已取消", 4: "已退款", 5: "待审核"} return labels.get(_int(value, -1), display_text(value)) def _fee_type_text(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" labels = { 1: "挂号费", 2: "问诊费", 3: "药品费用", 4: "首付费用", 5: "尾款费用", 6: "其他费用", 7: "全部费用", 8: "驼奶费用", } return labels.get(_int(value, -1), display_text(value)) def _express_company_text(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" normalized = str(value).strip().lower() return { "sf": "顺丰速运", "jd": "京东快递", "jt": "极兔速递", "jtexpress": "极兔速递", "auto": "自动识别", }.get(normalized, display_text(value)) def _audit_text(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" return {0: "待审核", 1: "审核通过", 2: "已驳回"}.get(_int(value, -1), display_text(value)) def _log_action_text(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" normalized = str(value).strip() labels = { "create": "创建", "edit": "编辑", "audit_rx_approve": "处方审核", "audit_rx_reject": "处方审核", "audit_pay_approve": "支付审核", "audit_pay_reject": "支付审核", "fill_tracking": "填快递单", "ship": "确认发货", "withdraw": "撤销", "link_pay_order": "关联支付单", "completion_request": "完单申请", "auto_complete": "自动完成", "revoke_rx_audit": "撤回处方审核", "revoke_pay_audit": "撤回支付审核", "gancao_submit": "甘草下单", "ej_pharmacy_submit": "洛阳药房下单", "ej_pharmacy_callback": "洛阳药房状态", "patch_rx_patient": "处方患者信息", "patch_rx_usage": "服用参数", "update_amount": "修改订单金额", "complete": "完成订单", "refund": "退款", "manual_log": "手工备注", "assign_assistant": "改派医助", "add_pay_order": "补齐支付单", "set_ship_mode": "发货类型", } return labels.get(normalized, normalized) def _display_collection(value: Any) -> str: if value is _MISSING or value in (None, ""): return "—" if isinstance(value, Mapping): for key in ("name", "label", "text", "path"): item = value.get(key) if item not in (None, ""): return str(item) return "—" if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): parts = [_display_collection(item) for item in value] return " / ".join(part for part in parts if part != "—") or "—" return display_text(value) def _visit_slot_label(offset: int) -> str: slot = max(0, int(offset)) + 1 chinese = ("", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十") return f"{chinese[slot]}诊" if slot <= 10 else f"第{slot}诊" class OrderDetailDrawer(QDialog): """Modal overlay whose readonly content panel occupies 80% of its owner.""" drawer_ratio = 0.8 def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("DiagnosisOrderDetailOverlay") self.setWindowTitle("业务订单详情") self.setModal(True) self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setStyleSheet(DIAGNOSIS_QSS + _ORDER_DETAIL_QSS) root = QHBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) self.scrim = QFrame() self.scrim.setObjectName("DiagnosisOrderDetailScrim") root.addWidget(self.scrim, 1) self.drawer_panel = QFrame() self.drawer_panel.setObjectName("DiagnosisOrderDetailDrawer") self.drawer_panel.setProperty("readonly", True) self.drawer_panel.setProperty("drawerRatio", self.drawer_ratio) self.content_layout = QVBoxLayout(self.drawer_panel) self.content_layout.setContentsMargins(0, 0, 0, 0) self.content_layout.setSpacing(0) root.addWidget(self.drawer_panel, 0) self._owner = parent if self._owner is not None: self._owner.installEventFilter(self) QTimer.singleShot(0, self.sync_to_owner) def sync_to_owner(self) -> None: target = main_window_target_rect(self) if target is not None: self.setGeometry(target) self.drawer_panel.setFixedWidth(max(1, round(target.width() * self.drawer_ratio))) return owner = self._owner if owner is None: self.resize(1152, 720) width = round(self.width() * self.drawer_ratio) else: origin = owner.mapToGlobal(QPoint(0, 0)) self.setGeometry(origin.x(), origin.y(), owner.width(), owner.height()) width = round(owner.width() * self.drawer_ratio) self.drawer_panel.setFixedWidth(max(1, width)) def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 - Qt API owner = getattr(self, "_owner", None) if watched is owner and event.type() in { QEvent.Type.Move, QEvent.Type.Resize, QEvent.Type.Show, QEvent.Type.WindowStateChange, }: QTimer.singleShot(0, self.sync_to_owner) return super().eventFilter(watched, event) def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual self.sync_to_owner() super().showEvent(event) def closeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual if self._owner is not None: with suppress(RuntimeError): self._owner.removeEventFilter(self) super().closeEvent(event) class DiagnosisDialog(QDialog): """Diagnosis readonly page plus edit/view-only right drawer. The public ``open_for`` contract and all repository calls remain compatible with the earlier dialog. ``editable=False`` alone keeps the standalone readonly card flow; list/detail “查看” should call :meth:`open_view_only` so it matches admin ``openViewOnly`` (same 病历 form, chips stay selected). """ saved = Signal() _local_audio_upload_completed = Signal(int, int) def __init__( self, repository: Any, parent: QWidget | None = None, *, permissions: Any = None, embedded: bool = False, ) -> None: super().__init__(parent) self.repository = repository self._embedded = bool(embedded) self.permissions = ( permissions if permissions is not None else getattr(parent, "permissions", None) if parent is not None else None ) self._can_phone_plain = has_permission(self.permissions, _PHONE_PERMISSION, default=False) self._can_patient_orders = has_permission( self.permissions, _PATIENT_ORDERS_PERMISSION, default=False ) self._can_order_detail = has_permission( self.permissions, _ORDER_DETAIL_PERMISSION, default=False ) self._can_order_logs = has_permission( self.permissions, _ORDER_LOGS_PERMISSION, default=False ) self._can_daily_mutate = has_permission( self.permissions, _DAILY_PERMISSION, default=False ) and all( callable(getattr(self.repository, name, None)) for name in ( "add_blood_record", "add_diet_record", "add_exercise_record", "add_tracking_note", "add_diagnosis_todo", "cancel_diagnosis_todo", ) ) self._can_daily_edit = has_permission( self.permissions, _DAILY_PERMISSION, default=False ) and all( callable(getattr(self.repository, name, None)) for name in ( "update_blood_record", "update_diet_record", "update_exercise_record", ) ) self._can_prescribe = has_permission( self.permissions, _PRESCRIPTION_PERMISSION, default=False ) and callable(getattr(self.repository, "create_prescription", None)) self._can_edit_prescription = has_permission( self.permissions, _PRESCRIPTION_EDIT_PERMISSION, default=False ) and callable(getattr(self.repository, "update_prescription", None)) self._can_offset = has_permission( self.permissions, _OFFSET_PERMISSION, default=False ) and callable(getattr(self.repository, "set_revisit_slot_start_offset", None)) self._can_video_upload = has_permission( self.permissions, _VIDEO_PERMISSION, default=False ) and callable(getattr(self.repository, "upload_call_recording", None)) self._can_chat_sync = has_permission( self.permissions, _CHAT_PERMISSION, default=False ) and callable(getattr(self.repository, "sync_im_chat_messages", None)) self._can_note_add = has_permission( self.permissions, _NOTE_ADD_PERMISSION, default=False ) and callable(getattr(self.repository, "add_doctor_note", None)) self._can_note_delete = has_permission( self.permissions, _NOTE_DELETE_PERMISSION, default=False ) and callable(getattr(self.repository, "delete_doctor_note_image", None)) self._diagnosis_id = 0 self._patient_id = 0 self._editable = False self._authoritative_detail_loaded = False self._saving = False self._load_mode = "readonly" self._generation = 0 self._dictionary_requested_generation = -1 self._dictionary_error_message = "" self._save_generation = 0 self._orders_generation = 0 self._order_detail_generation = 0 self._daily_mutation_generation = 0 self._notes_mutation_generation = 0 self._media_generation = 0 self._prescription_detail_generation = 0 self._prescription_detail_target = 0 self._offset_generation = 0 self._tab_generations: dict[str, int] = {key: 0 for key, _label, _codes in _TAB_DEFINITIONS} self._loaded_tabs: set[str] = set() self._loading_tabs: set[str] = set() self._daily_todo_status: int | None = None self._recording_players: list[RecordingPlayerDialog] = [] self._image_preview: ImagePreviewDialog | None = None self._inline_recording_cells: list[RecordingPlaybackCell] = [] self._last_order_detail_dialog: QDialog | None = None self._local_audio_dialog: LocalAudioQueueDialog | None = None self._local_audio_upload_manager: Any = None self._local_audio_upload_listener: Any = None self._video_reload_pending = False self._orders_page = 1 self._orders_page_size = 10 self._orders_total = 0 self._saved_order_offset = 0 self._detail: Any = None self._field_originals: dict[str, Any] = {} self._standalone_readonly = True self._view_only = False self._basic_locked = False self._tab_pages: dict[str, QWidget] = {} self._readonly_sections: dict[str, QWidget] = {} self._form_rows: list[tuple[QBoxLayout, list[QWidget], QWidget | None]] = [] self._form_narrow: bool | None = None self._choice_fields: dict[str, DiagnosisChoiceButtons] = {} self._table_registry: dict[str, list[RecordTable]] = { "daily": [], "prescription": [], "orders": [], "video": [], "assign": [], "appointment": [], } self._notes_timelines: list[NotesTimeline] = [] self._chat_panels: list[ChatPanel] = [] self._daily_panels: list[DailyRecordPanel] = [] self._owner = None self._owner_filter_installed = False self._local_audio_upload_completed.connect( self._local_audio_upload_succeeded, type=Qt.ConnectionType.QueuedConnection, ) self.setObjectName("DiagnosisDialogRoot") if self._embedded: self.setWindowFlags(Qt.WindowType.Widget) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False) self.setMinimumSize(0, 0) self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) else: self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setMinimumSize(760, 520) self.resize(1024, 640) self.setWindowTitle("患者信息详情") self.setStyleSheet(DIAGNOSIS_QSS) self.view_stack = QStackedLayout(self) self.view_stack.setContentsMargins(0, 0, 0, 0) self.view_stack.setStackingMode(QStackedLayout.StackingMode.StackOne) self.readonly_page = self._build_readonly_page() self.drawer_overlay = self._build_drawer_overlay() self.view_stack.addWidget(self.readonly_page) self.view_stack.addWidget(self.drawer_overlay) self.compatibility_host = QWidget(self) self.compatibility_host.hide() self.notes_table = QTableWidget(0, 3, self.compatibility_host) self.notes_table.setHorizontalHeaderLabels(["时间", "医生", "内容"]) self._build_compatibility_editors() self.refresh_permissions() def _build_readonly_page(self) -> QFrame: page = QFrame() page.setObjectName("DiagnosisReadonlyPage") page.setProperty("mode", "readonly") root = QVBoxLayout(page) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) self.readonly_header = QWidget(page) self.readonly_header.setObjectName("DiagnosisReadonlyHeader") header_layout = QVBoxLayout(self.readonly_header) header_layout.setContentsMargins(16, 16, 16, 0) header_layout.setSpacing(0) self.readonly_hero = self._build_readonly_hero() header_layout.addWidget(self.readonly_hero) root.addWidget(self.readonly_header) self.readonly_scroll = QScrollArea() self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll") self.readonly_scroll.setWidgetResizable(True) self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.readonly_scroll.verticalScrollBar().setSingleStep(28) content = QWidget() self.readonly_content_layout = QVBoxLayout(content) self.readonly_content_layout.setContentsMargins(16, 16, 16, 16) self.readonly_content_layout.setSpacing(16) self.readonly_error = QFrame() self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard") self.readonly_error.setProperty("diagnosisReadonlyCard", True) error_layout = QVBoxLayout(self.readonly_error) error_layout.setContentsMargins(18, 40, 18, 40) self.readonly_error_label = QLabel("诊单不存在或无权访问") self.readonly_error_label.setObjectName("DiagnosisEmptyState") self.readonly_error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.readonly_error_label.setWordWrap(True) error_layout.addWidget(self.readonly_error_label) self.readonly_retry_button = QPushButton("重试加载") self.readonly_retry_button.setProperty("variant", "primary") self.readonly_retry_button.setFixedHeight(36) self.readonly_retry_button.clicked.connect(self._retry_detail) error_layout.addWidget(self.readonly_retry_button, 0, Qt.AlignmentFlag.AlignCenter) self.readonly_error.hide() self.readonly_content_layout.addWidget(self.readonly_error) self.readonly_patient_card = self._build_readonly_patient_card() self.readonly_content_layout.addWidget(self.readonly_patient_card) self.case_grid = CaseGrid() self.readonly_content_layout.addWidget(self.case_grid) daily = self._new_daily_panel("DiagnosisReadonlyDailyPanel") self._add_readonly_section("daily", "日常记录", daily) notes = NotesTimeline(editable=False) notes.open_attachment_requested.connect(self._open_safe_resource) notes.preview_images_requested.connect(self._preview_note_images) self._notes_timelines.append(notes) self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes) orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable") self._add_readonly_section("orders", "业务订单", orders) video = self._new_table("video", "DiagnosisReadonlyVideoTable") self._add_readonly_section("video", "视频录制回放记录", video) chat = ChatPanel() chat.sync_requested.connect(self._sync_chat_archive) chat.reload_requested.connect(lambda: self._ensure_tab_loaded("chat", force=True)) chat.open_attachment_requested.connect(self._open_safe_resource) self._chat_panels.append(chat) self._add_readonly_section("chat", "聊天记录", chat) assignments = self._new_table("assign", "DiagnosisReadonlyAssignmentsTable") self._add_readonly_section("assign", "指派医助记录", assignments) appointments = self._new_table("appointment", "DiagnosisReadonlyAppointmentsTable") self._add_readonly_section("appointment", "挂号记录", appointments) self.readonly_content_layout.addStretch(1) self.readonly_scroll.setWidget(content) root.addWidget(self.readonly_scroll) self.readonly_loading = LoadingOverlay(page) return page def _build_readonly_hero(self) -> QFrame: hero = QFrame() hero.setObjectName("DiagnosisReadonlyHero") layout = QGridLayout(hero) layout.setContentsMargins(16, 12, 16, 12) layout.setHorizontalSpacing(12) layout.setVerticalSpacing(8) self.readonly_hero_layout = layout self.readonly_hero_left = QWidget() left_layout = QHBoxLayout(self.readonly_hero_left) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(12) self.readonly_back_button = QPushButton("← 收起资料" if self._embedded else "← 返回") self.readonly_back_button.setObjectName("DiagnosisReadonlyBack") self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor) self.readonly_back_button.setStyleSheet( "QPushButton{height:32px;padding:0 8px;border:0;background:transparent;" "color:#1A1C1F;font-size:13px;font-weight:500;}" "QPushButton:hover,QPushButton:focus{background:#EEF1FA;border-radius:6px;}" ) self.readonly_back_button.clicked.connect(self.reject) left_layout.addWidget(self.readonly_back_button) title = QLabel("患者信息详情") title.setObjectName("DiagnosisReadonlyTitle") left_layout.addWidget(title) left_layout.addStretch(1) self.readonly_hero_right = QWidget() right_layout = QHBoxLayout(self.readonly_hero_right) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(12) self.readonly_hero_name = QLabel("患者") self.readonly_hero_name.setObjectName("DiagnosisReadonlyPatientName") right_layout.addWidget(self.readonly_hero_name) self.readonly_hero_meta = QLabel("—") self.readonly_hero_meta.setObjectName("DiagnosisReadonlyHeroMeta") right_layout.addWidget(self.readonly_hero_meta) self.readonly_status = QLabel("从未打卡") self.readonly_status.setObjectName("DiagnosisReadonlyStatus") self.readonly_status.setProperty("severity", "neutral") right_layout.addWidget(self.readonly_status) self.readonly_close_button = QPushButton("×") self.readonly_close_button.setObjectName("DiagnosisCloseButton") self.readonly_close_button.setToolTip("关闭诊单详情") self.readonly_close_button.setAccessibleName("关闭诊单详情") self.readonly_close_button.setCursor(Qt.CursorShape.PointingHandCursor) self.readonly_close_button.clicked.connect(self.reject) right_layout.addWidget(self.readonly_close_button) layout.addWidget(self.readonly_hero_left, 0, 0) layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight) layout.setColumnStretch(0, 1) return hero def _build_readonly_patient_card(self) -> QFrame: card = QFrame() card.setObjectName("DiagnosisReadonlyPatientCard") card.setProperty("diagnosisReadonlyCard", True) layout = QVBoxLayout(card) layout.setContentsMargins(18, 18, 18, 18) layout.setSpacing(14) heading_row = QHBoxLayout() heading_row.setContentsMargins(0, 0, 0, 0) heading_row.setSpacing(12) heading = QLabel("患者信息") heading.setObjectName("DiagnosisReadonlyCardTitle") heading_row.addWidget(heading, 1) self.readonly_ai_button = QPushButton("AI 报告", card) self.readonly_ai_button.setProperty("variant", "secondary") self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor) self.readonly_ai_button.clicked.connect(self._open_ai_report) heading_row.addWidget(self.readonly_ai_button, 0) self.readonly_prescription_ai_button = QPushButton("处方 AI 对照", card) self.readonly_prescription_ai_button.setProperty("variant", "secondary") self.readonly_prescription_ai_button.setCursor(Qt.CursorShape.PointingHandCursor) self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None))) self.readonly_prescription_ai_button.clicked.connect(self._open_prescription_ai_report) heading_row.addWidget(self.readonly_prescription_ai_button, 0) layout.addLayout(heading_row) patient_hero = QFrame() patient_hero.setObjectName("DiagnosisReadonlyPatientHero") hero_layout = QVBoxLayout(patient_hero) hero_layout.setContentsMargins(18, 16, 18, 16) hero_layout.setSpacing(3) self.readonly_patient_title = QLabel("患者") self.readonly_patient_title.setObjectName("DiagnosisReadonlyPatientTitle") hero_layout.addWidget(self.readonly_patient_title) self.summary_fields: dict[str, QLabel] = {} for parts in ( (("", "phone"), (" · ", "gender_age")), (("", "body"), (" · ", "region")), (("预约:", "appointment"),), (("", "staff"),), (("状态:", "appointment_status"), (" · ", "prescription_status")), (("备注:", "remark"),), ): row = QWidget() row_layout = QHBoxLayout(row) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(0) for prefix, key in parts: if prefix: prefix_label = QLabel(prefix) prefix_label.setProperty("diagnosisPatientMeta", True) prefix_label.setSizePolicy( QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred ) prefix_label.setMinimumWidth(prefix_label.sizeHint().width()) row_layout.addWidget(prefix_label) value = QLabel("—") value.setProperty("diagnosisPatientMeta", True) value.setWordWrap(False) value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) value.setSizePolicy( QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred ) row_layout.addWidget(value) self.summary_fields[key] = value row_layout.addStretch(1) hero_layout.addWidget(row) layout.addWidget(patient_hero) self.summary_fields["patient"] = self.readonly_patient_title for key in ("id_card", "pressure", "blood_sugar"): self.summary_fields[key] = QLabel("—", card) self.summary_fields[key].hide() return card def _add_readonly_section(self, key: str, title: str, body: QWidget) -> None: card = readonly_card(title, f"DiagnosisReadonlySection_{key}", body) self._readonly_sections[key] = card self.readonly_content_layout.addWidget(card) def _build_drawer_overlay(self) -> QFrame: overlay = QFrame() overlay.setObjectName("DiagnosisDrawerOverlay") layout = QHBoxLayout(overlay) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addStretch(1) self.drawer_panel = QFrame() self.drawer_panel.setObjectName("DiagnosisDrawer") self.drawer_panel.setProperty("mode", "edit") self.drawer_panel.setFixedWidth(614) panel_layout = QVBoxLayout(self.drawer_panel) panel_layout.setContentsMargins(0, 0, 0, 0) panel_layout.setSpacing(0) panel_layout.addWidget(self._build_drawer_header()) self.drawer_banner = MessageStrip() self.drawer_banner.setContentsMargins(16, 8, 16, 0) self.drawer_banner.action_requested.connect(self._retry_current) panel_layout.addWidget(self.drawer_banner) self.tabs = DiagnosisTabWidget() self.tabs.setObjectName("DiagnosisDrawerTabs") self._build_drawer_tabs() self.tabs.currentChanged.connect(self._tab_changed) panel_layout.addWidget(self.tabs, 1) panel_layout.addWidget(self._build_drawer_footer()) self.drawer_loading = LoadingOverlay(self.drawer_panel) layout.addWidget(self.drawer_panel) return overlay def _build_drawer_header(self) -> QFrame: header = QFrame() header.setObjectName("DiagnosisDrawerHeader") layout = QHBoxLayout(header) layout.setContentsMargins(16, 13, 16, 13) layout.setSpacing(10) identity = QVBoxLayout() identity.setSpacing(4) title_row = QHBoxLayout() title_row.setSpacing(10) self.title_label = QLabel("编辑诊单") self.title_label.setObjectName("DiagnosisDrawerTitle") title_row.addWidget(self.title_label) self.mode_label = QLabel("编辑") self.mode_label.setObjectName("DiagnosisDrawerModeBadge") self.mode_label.setProperty("kind", "warning") title_row.addWidget(self.mode_label) title_row.addStretch(1) identity.addLayout(title_row) self.meta_label = QLabel("正在准备诊单…") self.meta_label.setObjectName("DiagnosisDrawerMeta") identity.addWidget(self.meta_label) layout.addLayout(identity, 1) close = QPushButton("×") close.setObjectName("DiagnosisCloseButton") close.setToolTip("关闭") close.clicked.connect(self.reject) layout.addWidget(close, 0, Qt.AlignmentFlag.AlignTop) return header def _build_drawer_footer(self) -> QFrame: footer = QFrame() footer.setObjectName("DiagnosisDrawerFooter") layout = QHBoxLayout(footer) layout.setContentsMargins(20, 14, 20, 18) layout.setSpacing(12) layout.addStretch(1) self.close_button = QPushButton("关闭") self.close_button.setObjectName("DiagnosisFooterCloseButton") self.close_button.clicked.connect(self.reject) layout.addWidget(self.close_button) self.save_button = SaveStateButton("保存病历") self.save_button.setObjectName("DiagnosisSaveButton") self.save_button.clicked.connect(self._save) layout.addWidget(self.save_button) return footer def _build_drawer_tabs(self) -> None: self._tab_pages["basic"] = self._build_basic_tab() self._tab_pages["notes"] = self._build_notes_tab() self._tab_pages["daily"] = self._wrap_tab( "DiagnosisTabDaily", self._new_daily_panel("DiagnosisDailyPanel") ) self._tab_pages["prescription"] = self._build_prescription_tab() self._tab_pages["orders"] = self._build_orders_tab() self._tab_pages["video"] = self._build_video_tab() chat = ChatPanel() chat.sync_requested.connect(self._sync_chat_archive) chat.reload_requested.connect(lambda: self._ensure_tab_loaded("chat", force=True)) chat.open_attachment_requested.connect(self._open_safe_resource) self._chat_panels.append(chat) self._tab_pages["chat"] = self._wrap_tab("DiagnosisTabChat", chat) self.assign_table = self._new_table("assign", "DiagnosisTableAssignments") self._tab_pages["assign"] = self._wrap_tab("DiagnosisTabAssign", self.assign_table) self.appointment_table = self._new_table("appointment", "DiagnosisTableAppointments") self._tab_pages["appointment"] = self._wrap_tab( "DiagnosisTabAppointment", self.appointment_table ) def _build_basic_tab(self) -> QScrollArea: scroll = QScrollArea() scroll.setObjectName("DiagnosisDrawerBody") scroll.setProperty("diagnosisTabKey", "basic") scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) scroll.verticalScrollBar().setSingleStep(28) scroll.verticalScrollBar().setPageStep(120) content = QWidget() content.setObjectName("DiagnosisTabBasic") content.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) content.setMinimumWidth(0) content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) layout = QVBoxLayout(content) layout.setContentsMargins(20, 16, 20, 20) layout.setSpacing(8) self.privacy_banner = QFrame() self.privacy_banner.setObjectName("DiagnosisPrivacyBanner") privacy_layout = QHBoxLayout(self.privacy_banner) privacy_layout.setContentsMargins(12, 9, 12, 9) self.privacy_label = QLabel("存在未完成的业务订单,患者基本信息不可修改") self.privacy_label.setWordWrap(True) self.privacy_label.setObjectName("DiagnosisPrivacyText") privacy_layout.addWidget(self.privacy_label) self.privacy_banner.hide() layout.addWidget(self.privacy_banner) self.form_host = QFrame() self.form_host.setObjectName("DiagnosisFormHost") self.form_host.setProperty("locked", False) self.form_host.setMinimumWidth(0) form_layout = QVBoxLayout(self.form_host) form_layout.setContentsMargins(0, 0, 0, 8) form_layout.setSpacing(12) self.edit_fields: dict[str, Any] = {} self.display_fields: dict[str, Any] = {} for section_key, (title, rows) in enumerate(_FORM_SECTIONS): form_layout.addWidget(section_heading(title, f"DiagnosisSection_{section_key}")) for row_index, row_fields in enumerate(rows): row_host = QWidget() row_host.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred ) row_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, row_host) row_layout.setContentsMargins(0, 0, 0, 6 if row_index == len(rows) - 1 else 10) row_layout.setSpacing(16) fields: list[QWidget] = [] occupied = 0 for label, key, span, field_kind in row_fields: editor = self._ensure_editor(key, field_kind) if isinstance(editor, DiagnosisNumberEdit): display = editor.wrap_with_unit() elif isinstance(editor, ExpandableDiagnosisTextEdit): display = editor.wrap_with_expander(label) else: display = editor container = self._field_container(label, display, span, editor=editor) row_layout.addWidget(container, span) fields.append(container) occupied += span remainder: QWidget | None = None if occupied < 24: remainder = QWidget() remainder.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred ) row_layout.addWidget(remainder, 24 - occupied) self._form_rows.append((row_layout, fields, remainder)) form_layout.addWidget(row_host) layout.addWidget(self.form_host) layout.addStretch(1) scroll.setWidget(content) return scroll def _field_container( self, caption: str, display: QWidget, span: int, *, editor: QWidget | None = None, ) -> QWidget: # `display` may wrap a number edit with an external unit label; stacked # detection must use the semantic editor when provided. semantic = editor if editor is not None else display container = QWidget() container.setMinimumWidth(0) container.setProperty("diagnosisFieldSpan", span) policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) policy.setHorizontalStretch(max(1, span)) # Choice/flow editors need height-for-width; propagate so the form row grows. if hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth(): policy.setHeightForWidth(True) container.setSizePolicy(policy) # Multi-option chips sit under the label so wrapped rows stay visible. stacked = isinstance(semantic, DiagnosisChoiceButtons) or ( hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth() ) layout = QBoxLayout( QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight, container, ) layout.setContentsMargins(0, 2, 0, 2) layout.setSpacing(6 if stacked else 8) label = QLabel(caption) label.setObjectName("DiagnosisFieldLabel") label.setProperty("diagnosisFieldLabel", True) if stacked: label.setMinimumWidth(0) label.setMaximumWidth(16777215) label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) else: label.setFixedWidth(100) label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) label.setWordWrap(False) layout.addWidget(label, 0, Qt.AlignmentFlag.AlignTop) display.setMinimumWidth(0) editor_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) if hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth(): editor_policy.setHeightForWidth(True) if not isinstance(semantic, DiagnosisSwitch): display.setSizePolicy(editor_policy) layout.addWidget(display, 1) container.setProperty("diagnosisFieldStacked", stacked) return container def _ensure_editor(self, key: str, field_kind: str) -> QWidget: if key in self.edit_fields: return self.edit_fields[key] if key in self.display_fields: return self.display_fields[key] if field_kind == "gender": editor: Any = DiagnosisChoiceButtons( (("男", 1), ("女", 0)), multiple=False, columns=2 ) elif field_kind == "marital": editor = DiagnosisComboBox((("未婚", 0), ("已婚", 1), ("离异", 2))) elif field_kind == "diagnosis_type": editor = DiagnosisComboBox( (("初诊", "first_visit"), ("复诊", "follow_up"), ("会诊", "consultation")), allow_custom=False, ) elif field_kind == "status": editor = DiagnosisChoiceButtons( (("启用", 1), ("禁用", 0)), multiple=False, columns=2 ) elif field_kind == "show_card": editor = DiagnosisSwitch() elif field_kind == "create_source": editor = DiagnosisComboBox( ( ("未设置", ""), ("后台建档", "admin"), ("小程序完整建档", "mnp"), ("小程序手机号快捷建档", "mnp_daily"), ) ) elif field_kind == "age_number": editor = DiagnosisNumberEdit(maximum=150) elif field_kind == "height_number": editor = DiagnosisNumberEdit(maximum=300, decimals=1, step=0.5, suffix="cm") elif field_kind == "weight_number": editor = DiagnosisNumberEdit(maximum=500, decimals=1, step=0.1, suffix="kg") elif field_kind == "systolic_number": editor = DiagnosisNumberEdit(maximum=250, suffix="mmHg") elif field_kind == "diastolic_number": editor = DiagnosisNumberEdit(maximum=150, suffix="mmHg") elif field_kind == "date": editor = DiagnosisDateEdit() elif field_kind == "local_diagnosis": editor = DiagnosisChoiceButtons( (("糖尿病", "糖尿病"), ("消渴病", "消渴病"), ("糖尿病前期", "糖尿病前期")), multiple=True, columns=3, ) self._choice_fields[key] = editor elif field_kind == "boolean": editor = DiagnosisChoiceButtons( (("有", 1), ("无", 0)), multiple=False, columns=2 ) elif field_kind.endswith("_choices"): multiple = _CHOICE_DICTIONARIES.get(key, (key, True))[1] editor = DiagnosisChoiceButtons( (), multiple=multiple, columns=4 if multiple else 5, ) self._choice_fields[key] = editor elif field_kind.startswith(("textarea", "expandable_textarea")): rows = int(field_kind[-1]) editor = ( ExpandableDiagnosisTextEdit(rows) if field_kind.startswith("expandable_") else DiagnosisTextEdit(rows) ) else: editor = DiagnosisLineEdit() editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}") if key.startswith("__"): editor.setReadOnly(True) self.display_fields[key] = editor else: placeholder = next( (item[2] for item in _DIAGNOSIS_FIELDS if item[1] == key), "", ) if hasattr(editor, "setPlaceholderText"): editor.setPlaceholderText(placeholder) self.edit_fields[key] = editor return editor def _build_compatibility_editors(self) -> None: for _caption, key, placeholder in _DIAGNOSIS_FIELDS: if key in self.edit_fields: continue editor = DiagnosisTextEdit(2, self.compatibility_host) editor.setObjectName(f"DiagnosisField_{key}") editor.setPlaceholderText(placeholder) editor.hide() self.edit_fields[key] = editor def _build_notes_tab(self) -> QWidget: timeline = NotesTimeline(editable=True) timeline.add_requested.connect(self._add_doctor_note) timeline.upload_requested.connect(self._upload_doctor_note_material) timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment) timeline.open_attachment_requested.connect(self._open_safe_resource) timeline.preview_images_requested.connect(self._preview_note_images) self.drawer_notes_timeline = timeline self._notes_timelines.append(timeline) return self._wrap_tab("DiagnosisTabNotes", timeline) def _build_prescription_tab(self) -> QWidget: page = QWidget() page.setObjectName("DiagnosisTabPrescription") layout = QVBoxLayout(page) layout.setContentsMargins(16, 14, 16, 18) layout.setSpacing(12) toolbar = QHBoxLayout() title = QLabel("处方记录") title.setObjectName("DiagnosisDailySectionTitle") toolbar.addWidget(title) hint = QLabel("开方后进入真实审核与订单流程") hint.setObjectName("DiagnosisDialogGuidance") toolbar.addWidget(hint) toolbar.addStretch(1) self.prescribe_button = QPushButton("开方") self.prescribe_button.setProperty("variant", "primary") self.prescribe_button.clicked.connect(self._open_new_prescription) toolbar.addWidget(self.prescribe_button) layout.addLayout(toolbar) layout.addWidget(self._new_table("prescription", "DiagnosisPrescriptionTable"), 1) return page def _build_video_tab(self) -> QWidget: page = QWidget() page.setObjectName("DiagnosisTabVideo") layout = QVBoxLayout(page) layout.setContentsMargins(16, 14, 16, 18) layout.setSpacing(12) toolbar = QHBoxLayout() hint = QLabel("每条通话记录可包含多个回放地址;录音转写完成后可查看本次面诊对话文字。") hint.setObjectName("DiagnosisDialogGuidance") hint.setWordWrap(True) toolbar.addWidget(hint, 1) toolbar.addStretch(1) self.local_audio_queue_button = QPushButton("本机录音文件") self.local_audio_queue_button.setObjectName("DiagnosisLocalAudioQueueButton") self.local_audio_queue_button.setAccessibleName("查看本机录音文件及上传状态") self.local_audio_queue_button.clicked.connect(self._open_local_audio_queue) toolbar.addWidget(self.local_audio_queue_button) self.video_upload_button = QPushButton("上传本地回放") self.video_upload_button.setProperty("variant", "primary") self.video_upload_button.clicked.connect( lambda _checked=False: self._upload_call_recording(None) ) toolbar.addWidget(self.video_upload_button) layout.addLayout(toolbar) layout.addWidget(self._new_table("video", "DiagnosisVideoTable"), 1) return page def _build_orders_tab(self) -> QWidget: page = QWidget() page.setObjectName("DiagnosisTabOrders") layout = QVBoxLayout(page) layout.setContentsMargins(16, 14, 16, 18) layout.setSpacing(12) toolbar = QFrame() toolbar.setObjectName("DiagnosisOrderOffsetBar") toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(14, 10, 14, 10) label = QLabel("复诊统计起始偏移") label.setObjectName("DiagnosisOrderOffsetLabel") label.setToolTip(_ORDER_OFFSET_HELP) toolbar_layout.addWidget(label) help_button = QPushButton("?") help_button.setObjectName("DiagnosisOrderOffsetHelp") help_button.setAccessibleName("查看复诊统计起始偏移说明") help_button.setToolTip(_ORDER_OFFSET_HELP) help_button.setFixedSize(22, 22) help_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) toolbar_layout.addWidget(help_button) self.order_offset_help = help_button self.order_offset = QSpinBox() self.order_offset.setRange(0, 20) self.order_offset.setSingleStep(1) self.order_offset.setFixedWidth(120) self.order_offset.setEnabled(False) self.order_offset.setToolTip(_ORDER_OFFSET_HELP) self.order_offset.valueChanged.connect(self._order_offset_changed) toolbar_layout.addWidget(self.order_offset) self.order_offset_preview = QLabel("第 1 笔实单计为一诊") self.order_offset_preview.setObjectName("DiagnosisOrderOffsetPreview") self.order_offset_preview.setToolTip(_ORDER_OFFSET_HELP) toolbar_layout.addWidget(self.order_offset_preview) offset_save = QPushButton("保存") offset_save.setProperty("variant", "primary") self.order_offset_save = offset_save offset_save.clicked.connect(self._save_order_offset) toolbar_layout.addWidget(offset_save) toolbar_layout.addStretch(1) layout.addWidget(toolbar) self.orders_table = self._new_table("orders", "DiagnosisTableOrders") layout.addWidget(self.orders_table, 1) self.orders_list = InfiniteList(self._orders_page_size, page) for table in self._table_registry["orders"]: self.orders_list.bind(table) self._orders_footer_layout = layout layout.addWidget(self.orders_list) return page def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea: scroll = QScrollArea() scroll.setObjectName(object_name) scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) content = QWidget() layout = QVBoxLayout(content) layout.setContentsMargins(16, 14, 16, 18) layout.setSpacing(12) layout.addWidget(body) layout.addStretch(1) scroll.setWidget(content) return scroll def _new_daily_panel(self, object_name: str) -> DailyRecordPanel: panel = DailyRecordPanel() panel.setObjectName(object_name) panel.range_requested.connect( lambda start, end: self._load_daily_range(start, end, force=True) ) panel.refresh_requested.connect(lambda: self._ensure_tab_loaded("daily", force=True)) panel.todo_status_requested.connect(self._daily_todo_filter_changed) panel.daily_add_requested.connect(self._add_daily_record) panel.daily_edit_requested.connect(self._edit_daily_record) panel.todo_add_requested.connect(self._add_diagnosis_todo) panel.todo_cancel_requested.connect(self._cancel_diagnosis_todo) self._daily_panels.append(panel) return panel def _new_table(self, kind: str, object_name: str) -> RecordTable: definitions: dict[str, tuple[tuple[str, int], ...]] = { "daily": (("日期", 120), ("指标", 150), ("记录", 180), ("记录人", 110)), "prescription": ( ("就诊日期", 120), ("就诊编号", 120), ("诊断", 180), ("处方摘要", 220), ("医生", 90), ("状态", 140), ("操作", 120), ), "orders": ( ("订单编号", 200), ("全局诊次", 96), ("计入统计", 96), ("金额", 120), ("医生", 110), ("医助", 110), ("履约状态", 110), ("创建时间", 170), ("操作", 100), ), "video": ( ("录制回放", 440), ("房间号", 180), ("开始时间", 170), ("结束时间", 170), ("通话类型", 100), ("时长", 110), ("状态", 90), ("录制 / 文字", 120), ("操作", 130), ), "assign": ( ("操作时间", 175), ("原医助", 120), ("新医助", 120), ("继承", 72), ("快照·业务单创建人", 150), ("快照·创建时间", 190), ("操作人", 110), ("操作账号", 120), ("IP", 130), ), "appointment": ( ("ID", 72), ("状态", 100), ("患者(挂号人)", 150), ("挂号医生", 110), ("挂号助理", 110), ("预约日期", 130), ("时段", 80), ("类型", 100), ("渠道", 110), ("确认诊单", 92), ("开方", 80), ("备注", 120), ("创建时间", 165), ), } table = RecordTable(object_name, definitions[kind]) table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self._table_registry[kind].append(table) return table def _tab_allowed(self, codes: tuple[str, ...]) -> bool: return not codes or has_permission(self.permissions, codes, default=True) def refresh_permissions(self, permissions: Any | None = None) -> None: if permissions is not None: self.permissions = permissions self._can_phone_plain = has_permission(self.permissions, _PHONE_PERMISSION, default=False) self._can_patient_orders = has_permission( self.permissions, _PATIENT_ORDERS_PERMISSION, default=False ) self._can_order_detail = has_permission( self.permissions, _ORDER_DETAIL_PERMISSION, default=False ) self._can_order_logs = has_permission( self.permissions, _ORDER_LOGS_PERMISSION, default=False ) self._can_daily_mutate = has_permission( self.permissions, _DAILY_PERMISSION, default=False ) and all( callable(getattr(self.repository, name, None)) for name in ( "add_blood_record", "add_diet_record", "add_exercise_record", "add_tracking_note", "add_diagnosis_todo", "cancel_diagnosis_todo", ) ) self._can_daily_edit = has_permission( self.permissions, _DAILY_PERMISSION, default=False ) and all( callable(getattr(self.repository, name, None)) for name in ( "update_blood_record", "update_diet_record", "update_exercise_record", ) ) self._can_prescribe = has_permission( self.permissions, _PRESCRIPTION_PERMISSION, default=False ) and callable(getattr(self.repository, "create_prescription", None)) self._can_edit_prescription = has_permission( self.permissions, _PRESCRIPTION_EDIT_PERMISSION, default=False ) and callable(getattr(self.repository, "update_prescription", None)) self._can_offset = has_permission( self.permissions, _OFFSET_PERMISSION, default=False ) and callable(getattr(self.repository, "set_revisit_slot_start_offset", None)) self._can_video_upload = has_permission( self.permissions, _VIDEO_PERMISSION, default=False ) and callable(getattr(self.repository, "upload_call_recording", None)) self._can_chat_sync = has_permission( self.permissions, _CHAT_PERMISSION, default=False ) and callable(getattr(self.repository, "sync_im_chat_messages", None)) self._can_note_add = has_permission( self.permissions, _NOTE_ADD_PERMISSION, default=False ) and callable(getattr(self.repository, "add_doctor_note", None)) self._can_note_delete = has_permission( self.permissions, _NOTE_DELETE_PERMISSION, default=False ) and callable(getattr(self.repository, "delete_doctor_note_image", None)) if hasattr(self, "readonly_ai_button"): self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions)) self.readonly_ai_button.setEnabled(self._diagnosis_id > 0) if hasattr(self, "readonly_prescription_ai_button"): self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None))) self.readonly_prescription_ai_button.setEnabled(self._diagnosis_id > 0) previous_key = self._current_tab_key() allowed_tabs = [ (key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes) ] if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [ key for key, _label in allowed_tabs ]: blocked = self.tabs.blockSignals(True) self.tabs.clear() target = 0 for key, label in allowed_tabs: index = self.tabs.addTab(self._tab_pages[key], label) self.tabs.tabBar().setTabData(index, key) if key == previous_key: target = index self.tabs.setCurrentIndex(target) self.tabs.blockSignals(blocked) section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS} for key, section in self._readonly_sections.items(): section.setVisible(self._tab_allowed(section_codes.get(key, ()))) for panel in self._daily_panels: panel.set_editable( self._editable and self._can_daily_mutate, record_editable=self._editable and self._can_daily_edit, ) for timeline in self._notes_timelines: timeline.set_editable( self._editable and timeline is self.drawer_notes_timeline and self._can_note_add, can_delete=( self._editable and timeline is self.drawer_notes_timeline and self._can_note_delete ), ) self.prescribe_button.setVisible(self._editable and self._can_prescribe) self.video_upload_button.setVisible(self._editable and self._can_video_upload) self.local_audio_queue_button.setVisible(self._can_video_upload) self.local_audio_queue_button.setEnabled( self._diagnosis_id > 0 and self._can_video_upload ) offset_visible = self._editable and self._can_offset self.order_offset.setVisible(offset_visible) self.order_offset_save.setVisible(offset_visible) self._sync_order_offset_actions() for panel in self._chat_panels: panel.sync_button.setVisible(self._can_chat_sync) self._sync_save_button() if self._current_tab_key() != previous_key: self._tab_changed(self.tabs.currentIndex()) def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None: self.drawer_banner.show_message(text, kind, action_text) if self._standalone_readonly and kind == "danger": self.readonly_error_label.setText(text) self.readonly_error.show() def _clear_message(self) -> None: if self._dictionary_error_message and self._authoritative_detail_loaded: self._show_message(self._dictionary_error_message, "warning", action_text="重试") return self.drawer_banner.clear() self.readonly_error.hide() def _set_loading(self, loading: bool) -> None: overlay = self.readonly_loading if self._standalone_readonly else self.drawer_loading overlay.setVisible(loading) if loading: overlay.sync_geometry() def _reflow_form(self, viewport_width: int) -> None: """Adapt the diagnosis form to the live drawer content width. Wide/medium drawers keep a stable two-column grid. Only phone-narrow drawers stack fields and flip labels above editors. """ content_w = max(240, int(viewport_width) - 56) mode = "grid" if content_w >= 520 else "stack" narrow = mode == "stack" if narrow == self._form_narrow: return self._form_narrow = narrow for row_layout, fields, remainder in self._form_rows: row_layout.setDirection( QBoxLayout.Direction.TopToBottom if narrow else QBoxLayout.Direction.LeftToRight ) row_layout.setSpacing(12 if narrow else 16) if remainder is not None: remainder.setVisible(not narrow) for container in fields: self._orient_field_container(container, stacked=narrow) def _orient_field_container(self, container: QWidget, *, stacked: bool) -> None: # Choice fields stay label-above permanently so wrapped chips never clip. if bool(container.property("diagnosisFieldStacked")): return layout = container.layout() if not isinstance(layout, QBoxLayout): return label = container.findChild(QLabel, "DiagnosisFieldLabel") layout.setDirection( QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight ) layout.setSpacing(6 if stacked else 8) if label is None: return if stacked: label.setMinimumWidth(0) label.setMaximumWidth(16777215) label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) else: label.setFixedWidth(100) label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) def _sync_host_geometry(self) -> None: if self._embedded: return target = main_window_target_rect(self) if target is not None: self.setGeometry(target) return owner = self._owner if owner is None: if self.width() < 760 or self.height() < 520: self.resize(1024, 640) return if owner is self: return origin = owner.mapToGlobal(QPoint(0, 0)) self.setGeometry(QRect(origin, owner.size())) def _update_drawer_geometry(self) -> None: if not hasattr(self, "drawer_panel"): return drawer_width = self.width() if self.width() <= 768 else max(456, round(self.width() * 0.60)) self.drawer_panel.setFixedWidth(drawer_width) # Reflow against the actual drawer content width, not the fullscreen overlay. self._reflow_form(drawer_width) self._sync_choice_field_heights() def _reflow_readonly_hero(self) -> None: if not hasattr(self, "readonly_hero_layout"): return layout = self.readonly_hero_layout layout.removeWidget(self.readonly_hero_left) layout.removeWidget(self.readonly_hero_right) if self.width() < 900: layout.addWidget(self.readonly_hero_left, 0, 0) layout.addWidget(self.readonly_hero_right, 1, 0, Qt.AlignmentFlag.AlignLeft) else: layout.addWidget(self.readonly_hero_left, 0, 0) layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight) def _install_owner_filter(self) -> None: if self._embedded: return if self._owner is not None and not self._owner_filter_installed: self._owner.installEventFilter(self) self._owner_filter_installed = True def _rebind_owner(self) -> None: """Resolve the live Shell window for every open/show cycle.""" if self._embedded: self._owner = None self._owner_filter_installed = False return parent = self.parentWidget() candidate = parent.window() if parent is not None else None if candidate is self: candidate = None if candidate is not self._owner: if self._owner is not None and self._owner_filter_installed: with suppress(RuntimeError): self._owner.removeEventFilter(self) self._owner = candidate self._owner_filter_installed = False self._install_owner_filter() def resizeEvent(self, event: Any) -> None: self._update_drawer_geometry() self._reflow_readonly_hero() if hasattr(self, "readonly_loading"): self.readonly_loading.sync_geometry() if hasattr(self, "drawer_loading"): self.drawer_loading.sync_geometry() super().resizeEvent(event) def showEvent(self, event: Any) -> None: if not self._embedded: self._rebind_owner() self._sync_host_geometry() self._update_drawer_geometry() self._reflow_readonly_hero() super().showEvent(event) self._queue_owner_sync() def _queue_owner_sync(self) -> None: generation = self._generation QTimer.singleShot(0, lambda: self._sync_owner_if_current(generation)) def _sync_owner_if_current(self, generation: int) -> None: if generation == self._generation and self.isVisible(): self._sync_host_geometry() def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API if watched is self._owner and event.type() in { QEvent.Type.Resize, QEvent.Type.Move, QEvent.Type.Show, }: self._sync_host_geometry() self._update_drawer_geometry() self._queue_owner_sync() elif watched is self._owner and event.type() == QEvent.Type.Close: self._invalidate_requests() QTimer.singleShot(0, self.reject) return super().eventFilter(watched, event) def reject(self) -> None: self._invalidate_requests() super().reject() def done(self, result: int) -> None: self._invalidate_requests() if self._owner is not None and self._owner_filter_installed: with suppress(RuntimeError): self._owner.removeEventFilter(self) self._owner_filter_installed = False super().done(result) def _open_ai_report(self) -> None: if not can_open_diagnosis_ai_report(self.permissions): return if self._diagnosis_id <= 0: return detail = self._detail if isinstance(self._detail, Mapping) else {} diagnosis = get_value(detail, "diagnosis", None) row = dict(diagnosis) if isinstance(diagnosis, Mapping) else dict(detail) row.update( { "id": self._diagnosis_id, "diagnosis_id": self._diagnosis_id, "patient_name": first_value( row, "patient_name", "name", default=self.readonly_patient_title.text() if hasattr(self, "readonly_patient_title") else "患者", ), } ) present_diagnosis_ai_report(self.repository, self.permissions, self, row) def _open_prescription_ai_report(self) -> None: if self._diagnosis_id > 0: present_issued_prescription_ai(self.repository, self.permissions, self, diagnosis_id=self._diagnosis_id) def open_view_only( self, diagnosis_id: int, *, seed: Any = None, modeless: bool = False, ) -> None: self.open_for( diagnosis_id, editable=False, seed=seed, view_only=True, modeless=modeless, ) def open_for( self, diagnosis_id: int, *, editable: bool = False, seed: Any = None, authoritative_detail: Any = None, view_only: bool = False, modeless: bool = False, auto_show: bool = True, ) -> None: """Prepare a diagnosis view, optionally showing it immediately.""" self._rebind_owner() for player in list(self._recording_players): with suppress(RuntimeError): player.close() self._recording_players.clear() self._stop_inline_recordings() if self._last_order_detail_dialog is not None: with suppress(RuntimeError): self._last_order_detail_dialog.close() self._last_order_detail_dialog = None if self._local_audio_dialog is not None: with suppress(RuntimeError): self._local_audio_dialog.close() self._local_audio_dialog = None self._diagnosis_id = int(diagnosis_id) self._patient_id = 0 self._editable = bool( editable and has_permission(self.permissions, "tcm.diagnosis/edit", default=True) ) self._view_only = bool(view_only and not self._editable) self._standalone_readonly = not self._editable and not self._view_only self._load_mode = ( "readonly" if self._standalone_readonly else "edit" if self._editable else "view_only" ) self._authoritative_detail_loaded = False self._saving = False self._dictionary_error_message = "" self._generation += 1 self._save_generation += 1 self._orders_generation += 1 self._order_detail_generation += 1 self._daily_mutation_generation += 1 self._notes_mutation_generation += 1 self._media_generation += 1 self._prescription_detail_generation += 1 self._offset_generation += 1 for key in self._tab_generations: self._tab_generations[key] += 1 self._loaded_tabs.clear() self._loading_tabs.clear() self._video_reload_pending = False self._daily_todo_status = None self._orders_page = 1 self._orders_total = 0 self.orders_list.reset() footer_layout = ( self._readonly_sections["orders"].layout() if self._standalone_readonly else self._orders_footer_layout ) footer_layout.addWidget(self.orders_list) self._detail = authoritative_detail if authoritative_detail is not None else seed self.save_button.set_state("idle") self.refresh_permissions() self.view_stack.setCurrentWidget( self.readonly_page if self._standalone_readonly else self.drawer_overlay ) self.setModal(False if self._embedded else not modeless and not self._standalone_readonly) self.setWindowTitle( "患者信息详情" if self._standalone_readonly else "编辑诊单" if self._editable else "诊单详情" ) self.title_label.setText("编辑诊单" if self._editable else "诊单详情") self.mode_label.setText("编辑" if self._editable else "只读") self.mode_label.setProperty("kind", "warning" if self._editable else "info") repolish(self.mode_label) self.drawer_panel.setProperty("mode", "edit" if self._editable else "viewOnly") repolish(self.drawer_panel) self.close_button.setText("取消" if self._editable else "关闭") for timeline in self._notes_timelines: timeline.set_editable( self._editable and timeline is self.drawer_notes_timeline and self._can_note_add, can_delete=( self._editable and timeline is self.drawer_notes_timeline and self._can_note_delete ), ) self._clear_tables() self._clear_message() if self._detail is not None: self._render(self._detail, [], []) self._sync_form_interactivity() self._sync_save_button() self._sync_host_geometry() if auto_show: self.show() if not self._embedded: self.raise_() if authoritative_detail is not None: diagnosis = get_value(authoritative_detail, "diagnosis", None) or authoritative_detail patient = get_value(authoritative_detail, "patient", None) or {} self._patient_id = _int( first_value( diagnosis, "patient_id", "source_patient_id", default=first_value(patient, "patient_id", "id", default=0), ), 0, ) self._authoritative_detail_loaded = True self._show_authoritative_content(True) self._clear_message() self._set_loading(False) if self._standalone_readonly: self._load_visible_readonly_sections() else: self._ensure_tab_loaded(self._current_tab_key()) return self._show_message("正在加载权威诊单详情…", "info") self._set_loading(True) self._start_detail_load() def _start_detail_load(self) -> None: self._generation += 1 generation = self._generation diagnosis_id_snapshot = self._diagnosis_id mode_snapshot = self._load_mode run_async( lambda: self._load_bundle(diagnosis_id_snapshot, mode_snapshot), on_success=lambda result: self._apply_bundle(result, generation), on_error=lambda error: self._load_error(error, generation), on_finished=lambda: self._load_finished(generation), ) def _load_bundle(self, diagnosis_id: int, mode: str) -> dict[str, Any]: readonly = mode == "readonly" detail_names = ( ("diagnosis_readonly_detail", "patient_detail", "get_diagnosis_detail") if readonly else ("get_diagnosis_detail",) ) detail = _invoke_first( self.repository, detail_names, diagnosis_id=diagnosis_id, id=diagnosis_id, readonly=readonly, ) if detail in (None, "", [], {}): raise LookupError("诊单不存在或无权访问") diagnosis = get_value(detail, "diagnosis", None) or detail or {} patient = get_value(detail, "patient", None) or {} patient_id = _int( first_value( diagnosis, "patient_id", "source_patient_id", default=first_value(patient, "patient_id", "id", default=0), ), 0, ) return {"detail": detail, "patient_id": patient_id} def _load_dictionary_choices(self) -> dict[str, Any]: """Load optional editor labels without extending the authoritative-detail gate.""" dictionaries: dict[str, list[tuple[str, Any]]] = {} dictionary_errors: list[str] = [] dictionary_types = sorted( { "diagnosis_type", *(item[0] for item in _CHOICE_DICTIONARIES.values()), } ) if callable(getattr(self.repository, "get_dictionaries", None)): raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types) dictionaries = { key: _dictionary_choices(get_value(raw, key, []), key) for key in dictionary_types } elif callable(getattr(self.repository, "get_dictionary", None)): for dictionary_type in dictionary_types: try: raw = invoke( self.repository, "get_dictionary", dictionary_type=dictionary_type, ) dictionaries[dictionary_type] = _dictionary_choices(raw, dictionary_type) except Exception as error: dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}") return { "dictionaries": dictionaries, "dictionary_errors": dictionary_errors, } def _ensure_dictionary_loaded(self, *, force: bool = False) -> None: if self._standalone_readonly or not self._authoritative_detail_loaded: return generation = self._generation if not force and self._dictionary_requested_generation == generation: return self._dictionary_requested_generation = generation clear_error = self.drawer_banner.label.text() == self._dictionary_error_message self._dictionary_error_message = "" if clear_error: self._clear_message() run_async( self._load_dictionary_choices, on_success=lambda result: self._dictionary_choices_loaded(result, generation), on_error=lambda error: self._dictionary_choices_loaded( {"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation ), ) def _dictionary_choices_loaded(self, result: Any, generation: int) -> None: if generation != self._generation or not self._authoritative_detail_loaded: return # A clinician may already have edited fields while the optional labels load. # Update only choices; re-rendering the detail would overwrite that draft. self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True) errors = get_value(result, "dictionary_errors", []) or [] if errors: self._dictionary_requested_generation = -1 self._dictionary_error_message = ";".join(str(item) for item in errors) self._show_message(self._dictionary_error_message, "warning", action_text="重试") def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any: filters: dict[str, Any] = { "context_diagnosis_id": diagnosis_id, "scene": "diagnosis_edit", } if patient_id > 0: filters["patient_id"] = patient_id return _invoke_first( self.repository, ("list_prescription_orders",), page_no=page, page_size=self._orders_page_size, **filters, ) def _apply_bundle(self, result: Any, generation: int) -> None: if generation != self._generation: return detail = get_value(result, "detail", None) self._detail = detail self._patient_id = _int(get_value(result, "patient_id", 0), 0) self._authoritative_detail_loaded = True self._show_authoritative_content(True) self._render(detail, [], []) self._sync_form_interactivity() self._sync_save_button() self._set_loading(False) errors = get_value(result, "dictionary_errors", []) or [] if errors: self._show_message(";".join(str(item) for item in errors), "warning") else: self._clear_message() if self._standalone_readonly: self._load_visible_readonly_sections() else: self._ensure_tab_loaded(self._current_tab_key()) def _load_error(self, error: Exception, generation: int) -> None: if generation == self._generation: self._authoritative_detail_loaded = False self._detail = None self._clear_authoritative_content() self._sync_form_interactivity() self._sync_save_button() message = friendly_error(error) self._show_message(message, "danger", action_text="重试") self.readonly_error_label.setText(message) self.readonly_error.show() def _load_finished(self, generation: int) -> None: if generation == self._generation: self._set_loading(False) def _apply_dictionary_choices( self, dictionaries: Mapping[str, Any], *, preserve: bool = False ) -> None: diagnosis_type_editor = self.edit_fields.get("diagnosis_type") if isinstance(diagnosis_type_editor, DiagnosisComboBox): labels = { normalized: str(label) for label, value in dictionaries.get("diagnosis_type", []) if (normalized := _diagnosis_type_value(value)) } diagnosis_type_editor.set_choices( tuple( (labels.get(value, fallback), value) for fallback, value in ( ("初诊", "first_visit"), ("复诊", "follow_up"), ("会诊", "consultation"), ) ), preserve=preserve, ) for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items(): if dictionary_type not in dictionaries: continue editor = self._choice_fields.get(key) choices = list(dictionaries.get(dictionary_type, [])) if not _multiple: choices.insert(0, ("无", "")) if editor is not None: editor.set_choices(choices, preserve=preserve) # Defer until the drawer has a real width so wrapped chips get height. QTimer.singleShot(0, self._sync_choice_field_heights) def _sync_choice_field_heights(self) -> None: for editor in self._choice_fields.values(): if hasattr(editor, "_sync_flow_height"): editor._sync_flow_height() if hasattr(self, "form_host"): self.form_host.updateGeometry() self.form_host.adjustSize() def _show_authoritative_content(self, visible: bool) -> None: self.readonly_patient_card.setVisible(visible) self.case_grid.setVisible(visible) for section in self._readonly_sections.values(): section.setVisible(visible) if visible: self.readonly_error.hide() self.refresh_permissions() def _clear_authoritative_content(self) -> None: self._field_originals.clear() for field in self.edit_fields.values(): field.setPlainText("") field.setProperty("invalid", False) field.setReadOnly(True) repolish(field) for field in self.display_fields.values(): field.setPlainText("") self.meta_label.setText(f"诊单 #{self._diagnosis_id} · 详情加载失败") self.case_grid.set_data({}, {}) for label in self.summary_fields.values(): label.setText("—") self._clear_tables() self._show_authoritative_content(False) def _sync_form_interactivity(self) -> None: for key, field in self.edit_fields.items(): locked = ( not self._authoritative_detail_loaded or not self._editable or (key in _PATIENT_BASIC_FIELDS and self._basic_locked) or (key in {"phone", "id_card"} and not self._can_phone_plain) ) field.setReadOnly(locked) repolish(field) def _current_tab_key(self) -> str: index = self.tabs.currentIndex() return str(self.tabs.tabBar().tabData(index) or "basic") if index >= 0 else "basic" def _sync_save_button(self) -> None: visible = self._editable and self._current_tab_key() == "basic" self.save_button.setVisible(visible) self.save_button.setEnabled( visible and self._authoritative_detail_loaded and not self._saving ) if self._saving: self.save_button.set_state("loading") elif self.save_button.state == "loading": self.save_button.set_state("idle") def _retry_detail(self) -> None: if self._diagnosis_id <= 0: return self._authoritative_detail_loaded = False self.readonly_error.hide() self._clear_message() self._show_message("正在重新加载权威诊单详情…", "info") self._set_loading(True) self._sync_form_interactivity() self._sync_save_button() self._start_detail_load() def _retry_current(self) -> None: if not self._authoritative_detail_loaded: self._retry_detail() return if (self._dictionary_error_message and self.drawer_banner.label.text() == self._dictionary_error_message): self._ensure_dictionary_loaded(force=True) return self._ensure_tab_loaded(self._current_tab_key(), force=True) def _invalidate_requests(self) -> None: self._close_image_preview() self._stop_watching_local_audio_uploads() self._video_reload_pending = False self._generation += 1 self._save_generation += 1 self._orders_generation += 1 self.orders_list.invalidate() self._order_detail_generation += 1 self._daily_mutation_generation += 1 self._notes_mutation_generation += 1 self._media_generation += 1 self._prescription_detail_generation += 1 self._offset_generation += 1 for key in self._tab_generations: self._tab_generations[key] += 1 self._loading_tabs.clear() self._saving = False self.save_button.set_state("idle") for player in list(getattr(self, "_recording_players", [])): with suppress(RuntimeError): player.close() self._recording_players.clear() self._stop_inline_recordings() order_dialog = getattr(self, "_last_order_detail_dialog", None) if order_dialog is not None: with suppress(RuntimeError): order_dialog.close() self._last_order_detail_dialog = None local_audio_dialog = getattr(self, "_local_audio_dialog", None) if local_audio_dialog is not None: with suppress(RuntimeError): local_audio_dialog.close() self._local_audio_dialog = None if hasattr(self, "readonly_loading"): self.readonly_loading.hide() if hasattr(self, "drawer_loading"): self.drawer_loading.hide() for panel in getattr(self, "_daily_panels", []): panel.set_loading(False) def _render(self, detail: Any, appointments: Sequence[Any], assignments: Sequence[Any]) -> None: diagnosis = get_value(detail, "diagnosis", None) or detail or {} patient = get_value(detail, "patient", None) or {} appointment = get_value(detail, "appointment", None) or {} patient_name = first_value( diagnosis, "patient_name", "name", default=first_value(patient, "patient_name", "name", default="未命名患者"), ) gender = _diagnosis_gender_text( first_value( diagnosis, "gender_desc", "gender", default=first_value(patient, "gender_desc", "gender"), ) ) age = display_text(first_value(diagnosis, "age", default=first_value(patient, "age"))) self.meta_label.setText(f"{patient_name} · 诊单 #{self._diagnosis_id}") self.readonly_hero_name.setText(display_text(patient_name)) self.readonly_hero_meta.setText(f"{gender} · {age}岁") self.readonly_patient_title.setText(display_text(patient_name)) self.summary_fields["patient"].setText(display_text(patient_name)) raw_phone = first_value( diagnosis, "phone", "patient_phone", default=first_value( patient, "phone", "patient_phone", default=first_value(appointment, "patient_phone"), ), ) rendered_phone = raw_phone if self._can_phone_plain else _mask_phone(raw_phone) self.summary_fields["phone"].setText(display_text(rendered_phone)) id_card = first_value( diagnosis, "id_card", default=first_value(patient, "id_card", default=""), ) self.summary_fields["id_card"].setText( display_text(id_card if self._can_phone_plain else _mask_id_card(id_card)) ) self.summary_fields["gender_age"].setText(f"{gender} / {age}岁") self.summary_fields["body"].setText( f"{display_text(first_value(diagnosis, 'height', default=first_value(patient, 'height')))} cm / " f"{display_text(first_value(diagnosis, 'weight', default=first_value(patient, 'weight')))} kg" ) self.summary_fields["region"].setText( display_text(first_value(diagnosis, "region", default=first_value(patient, "region"))) ) appointment_text = " ".join( part for part in ( display_text(first_value(appointment, "appointment_date"), ""), display_text( first_value(appointment, "appointment_time", "appointment_time_text"), "" ), display_text(first_value(appointment, "period", "period_text"), ""), ) if part ) self.summary_fields["appointment"].setText(appointment_text or "暂无预约") self.summary_fields["staff"].setText( f"医生:{display_text(first_value(appointment, 'doctor_name'))} " f"客服:{display_text(first_value(appointment, 'assistant_name'))}" ) self.summary_fields["appointment_status"].setText( display_text( first_value( appointment, "status_desc", "status_text", default=first_value(diagnosis, "appointment_status_desc", "appointment_status"), ) ) ) has_prescription = _truthy( first_value(diagnosis, "has_prescription", "is_prescribed", default=False) ) self.summary_fields["prescription_status"].setText( "已开方" if has_prescription else "未开方" ) self.summary_fields["remark"].setText( display_text( first_value(appointment, "remark", default=first_value(diagnosis, "remark")) ) ) self.summary_fields["pressure"].setText( f"{display_text(first_value(diagnosis, 'systolic_pressure'))} / " f"{display_text(first_value(diagnosis, 'diastolic_pressure'))} mmHg" ) self.summary_fields["blood_sugar"].setText( f"{display_text(first_value(diagnosis, 'fasting_blood_sugar'))} mmol/L" ) offset = max( 0, min( 20, _int( first_value( diagnosis, "revisit_slot_start_offset", "revisitSlotStartOffset", default=0, ), 0, ), ), ) blocked = self.order_offset.blockSignals(True) self.order_offset.setValue(offset) self.order_offset.blockSignals(blocked) self._saved_order_offset = offset self._order_offset_changed(offset) self._render_unserved(detail, diagnosis) self.case_grid.set_data(diagnosis, patient) if not self._can_phone_plain: for label in self.case_grid.value_labels.get("phone", []): label.setText(display_text(_mask_phone(raw_phone))) for label in self.case_grid.value_labels.get("id_card", []): label.setText(display_text(_mask_id_card(id_card))) self.display_fields["__diagnosis_id"].setPlainText(str(self._diagnosis_id)) basic_locked = _truthy( first_value(diagnosis, "patient_basic_locked", default=False) ) or not _truthy(first_value(diagnosis, "can_edit_patient_basic", default=True)) self._basic_locked = basic_locked self.form_host.setProperty("locked", basic_locked) repolish(self.form_host) self.privacy_banner.setVisible((self._editable or self._view_only) and basic_locked) latest_order = first_value(diagnosis, "latest_prescription_order", default=None) if latest_order: order_id = first_value(latest_order, "id", "order_no") order_status = first_value( latest_order, "fulfillment_status_text", "status_text", "status" ) self.privacy_label.setText( f"最近业务订单 #{display_text(order_id)} 状态为「{display_text(order_status)}」," "患者基本信息不可修改" ) else: self.privacy_label.setText("存在未完成的业务订单,患者基本信息不可修改") self._field_originals.clear() for key, field in self.edit_fields.items(): raw = first_value( diagnosis, key, default=first_value( patient, key, default=first_value( diagnosis, "patient_phone" if key == "phone" else key, default="", ), ), ) if key == "appetite" and raw in (None, "", [], ()): raw = first_value(diagnosis, "oral_condition", "mouth_condition", default="") if key == "diagnosis_type": raw = _diagnosis_type_value(raw) or _diagnosis_type_value( first_value(diagnosis, "consultation_type", default="") ) if key == "gender": gender_token = str(raw).strip().lower() if raw not in (None, "") else "" if gender_token in {"2", "f", "female", "女"}: raw = 0 elif gender_token in {"1", "m", "male", "男"}: raw = 1 self._field_originals[key] = raw if key == "phone" and not self._can_phone_plain: rendered = _mask_phone(raw) elif key == "id_card" and not self._can_phone_plain: rendered = _mask_id_card(raw) else: rendered = _editor_text(raw) field.setPlainText(rendered) field.setProperty("invalid", False) field.setReadOnly( not self._editable or (key in _PATIENT_BASIC_FIELDS and basic_locked) or (key in {"phone", "id_card"} and not self._can_phone_plain) ) repolish(field) self._fill_notes([]) latest_appointments = list(appointments) if not latest_appointments and appointment: latest_appointments = [appointment] self._fill_appointments(latest_appointments) self._fill_assignments(assignments) def _render_unserved(self, detail: Any, diagnosis: Any) -> None: value = get_value(detail, "unserved_days", None) if value is None: value = first_value(diagnosis, "unserved_days", default=None) if value in (None, ""): self.readonly_status.setText("从未打卡") severity = "neutral" else: days = _int(value, 0) self.readonly_status.setText(f"未服务 {days} 天") severity = "success" if days <= 2 else "warning" if days <= 6 else "danger" self.readonly_status.setProperty("severity", severity) repolish(self.readonly_status) @staticmethod def _status_kind(value: Any) -> str: text = str(value or "") if any(token in text for token in ("失败", "驳回", "作废", "取消", "异常")): return "danger" if any(token in text for token in ("待", "未", "过号", "爽约")): return "warning" if any(token in text for token in ("已", "完成", "成功", "确认")): return "success" return "info" def _semanticize(self, table: RecordTable, column: int) -> None: for row in range(table.rowCount()): item = table.item(row, column) if item is not None: set_status_tag( table, row, column, item.text(), self._status_kind(item.text()), ) def _tab_changed(self, _index: int) -> None: self._sync_save_button() if self._authoritative_detail_loaded and not self._standalone_readonly: key = self._current_tab_key() if key == "video": self._reload_video_records(self._diagnosis_id) else: self._ensure_tab_loaded(key) def _load_visible_readonly_sections(self) -> None: permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS} for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"): if self._tab_allowed(permission_map.get(key, ())): self._ensure_tab_loaded(key) def _set_tab_loading(self, key: str) -> None: if key == "notes": for timeline in self._notes_timelines: timeline.set_notes([]) timeline.set_state("正在加载医生备注…") elif key == "daily": for panel in self._daily_panels: panel.set_loading(True) elif key == "chat": for panel in self._chat_panels: panel.set_unavailable("正在加载聊天归档…") elif key in self._table_registry: for table in self._table_registry[key]: table.set_matrix([]) table.set_empty_text("正在加载…") def _set_tab_error(self, key: str, text: str) -> None: message = f"加载失败:{text}" if key == "notes": for timeline in self._notes_timelines: timeline.set_state(message) elif key == "daily": for panel in self._daily_panels: panel.set_error(message) elif key == "chat": for panel in self._chat_panels: panel.set_unavailable(message) elif key in self._table_registry: for table in self._table_registry[key]: table.set_empty_text(message) def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None: if not self._authoritative_detail_loaded or self._diagnosis_id <= 0: return if key == "basic": self._ensure_dictionary_loaded(force=force) return if key == "daily": source = next( (panel for panel in self._daily_panels if panel.isVisibleTo(self)), self._daily_panels[0], ) start, end = source.current_range() self._load_daily_range(start, end, force=force) return if not force and (key in self._loaded_tabs or key in self._loading_tabs): return if key == "orders": self._load_orders() return self._tab_generations[key] += 1 generation = self._tab_generations[key] diagnosis_id = self._diagnosis_id self._loading_tabs.add(key) self._set_tab_loading(key) run_async( lambda: self._query_tab(key, diagnosis_id), on_success=lambda result: self._apply_tab_result(key, result, diagnosis_id, generation), on_error=lambda error: self._tab_load_error(key, error, diagnosis_id, generation), ) def _query_tab(self, key: str, diagnosis_id: int) -> Any: if key == "notes": return _invoke_first(self.repository, ("get_doctor_notes",), diagnosis_id=diagnosis_id) if key == "prescription": return _invoke_first( self.repository, ("list_prescriptions_by_diagnosis",), diagnosis_id=diagnosis_id, ) if key == "orders": return self._query_orders(diagnosis_id, self._patient_id, 1) if key == "assign": return _invoke_first( self.repository, ("assign_history",), diagnosis_id=diagnosis_id, page_no=1, page_size=50, ) if key == "appointment": return _invoke_first( self.repository, ("appointment_history",), diagnosis_id=diagnosis_id, page_no=1, page_size=500, ) if key == "video": names = tuple( name for name in ("list_call_records", "get_call_records") if callable(getattr(self.repository, name, None)) ) if not names: return {"unsupported": "桌面端尚未接入视频回放列表接口。"} return _invoke_first(self.repository, names, diagnosis_id=diagnosis_id) if key == "chat": names = tuple( name for name in ("list_im_chat_messages", "get_im_chat_messages", "list_chat_records") if callable(getattr(self.repository, name, None)) ) if not names: return {"unsupported": "桌面端尚未接入 IM 聊天归档接口。"} return _invoke_first( self.repository, names, diagnosis_id=diagnosis_id, only_archived=True, ) return [] def _apply_tab_result( self, key: str, result: Any, diagnosis_id: int, generation: int, ) -> None: if ( generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id or not self._authoritative_detail_loaded ): return self._loading_tabs.discard(key) self._loaded_tabs.add(key) unsupported = get_value(result, "unsupported", "") if unsupported: if key == "chat": for panel in self._chat_panels: panel.set_unavailable(str(unsupported)) else: for table in self._table_registry.get(key, []): table.set_matrix([]) table.set_empty_text(str(unsupported)) elif key == "notes": self._fill_notes(page_items(result)) elif key == "prescription": self._fill_prescriptions(page_items(result)) elif key == "orders": rows = page_items(result) self._orders_page = self.orders_list.page self._orders_total = page_total(result, len(rows)) self._fill_orders(rows) elif key == "assign": self._fill_assignments(page_items(result)) elif key == "appointment": self._fill_appointments(page_items(result)) elif key == "video": self._fill_video(page_items(result)) elif key == "chat": rows = page_items(result) for panel in self._chat_panels: panel.set_messages(rows) if not self._standalone_readonly and self._current_tab_key() == key: self._clear_message() if key == "video": self._flush_video_reload_if_pending(diagnosis_id) def _tab_load_error( self, key: str, error: Exception, diagnosis_id: int, generation: int, ) -> None: if generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id: return self._loading_tabs.discard(key) text = friendly_error(error) self._set_tab_error(key, text) if not self._standalone_readonly and self._current_tab_key() == key: self._show_message(text, "danger", action_text="重试") if key == "video": self._flush_video_reload_if_pending(diagnosis_id) def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None: key = "daily" if not self._authoritative_detail_loaded or self._diagnosis_id <= 0: return if not force and (key in self._loaded_tabs or key in self._loading_tabs): return self._tab_generations[key] += 1 generation = self._tab_generations[key] diagnosis_id = self._diagnosis_id status = self._daily_todo_status self._loading_tabs.add(key) for panel in self._daily_panels: panel.set_range(start, end) self._set_tab_loading(key) def load() -> dict[str, Any]: window = _invoke_first( self.repository, ("get_tracking_window",), diagnosis_id=diagnosis_id, start_date=start, end_date=end, ) notes = _invoke_first( self.repository, ("list_tracking_notes",), diagnosis_id=diagnosis_id, ) todos: Any = [] if callable(getattr(self.repository, "list_diagnosis_todos", None)): todos = invoke( self.repository, "list_diagnosis_todos", diagnosis_id=diagnosis_id, page_no=1, page_size=15, status=status, ) return {"window": window, "notes": notes, "todos": todos} run_async( load, on_success=lambda result: self._apply_daily_result(result, diagnosis_id, generation), on_error=lambda error: self._tab_load_error(key, error, diagnosis_id, generation), ) def _apply_daily_result(self, result: Any, diagnosis_id: int, generation: int) -> None: key = "daily" if generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id: return self._loading_tabs.discard(key) self._loaded_tabs.add(key) window = get_value(result, "window", {}) or {} notes = page_items(get_value(result, "notes", []) or []) todo_result = get_value(result, "todos", []) or [] todos = page_items(todo_result) total = page_total(todo_result, len(todos)) for panel in self._daily_panels: panel.set_data(window, notes) panel.set_todos(todos, total) if not self._standalone_readonly and self._current_tab_key() == key: self._clear_message() def _daily_todo_filter_changed(self, status: Any) -> None: self._daily_todo_status = None if status is None else _int(status) source = next( (panel for panel in self._daily_panels if panel.isVisibleTo(self)), self._daily_panels[0], ) start, end = source.current_range() self._load_daily_range(start, end, force=True) def _add_daily_record(self, kind: str) -> None: if not self._editable or not self._can_daily_mutate: self._show_message("当前账号无日常记录写入权限或接口不可用。", "warning") return if self._patient_id <= 0: self._show_message("诊单缺少患者 ID,无法新增日常记录。", "danger") return record_date = QDate.currentDate().toString("yyyy-MM-dd") base: dict[str, Any] = { "diagnosis_id": self._diagnosis_id, "patient_id": self._patient_id, "record_date": record_date, } if kind in {"blood", "diet", "exercise"}: editor = DailyRecordEditorDialog(kind, base, parent=self) if isinstance(editor, QWidget): editor.setStyleSheet(DIAGNOSIS_QSS) if editor.exec() == QDialog.DialogCode.Accepted: self._submit_daily_record(kind, editor.payload()) return if kind == "tracking": content, accepted = QInputDialog.getMultiLineText( self, "新增跟踪备注", "备注内容(最多 1000 字)" ) if accepted: self._submit_daily_record(kind, {"content": content}) def _edit_daily_record(self, kind: str, record: Any) -> None: if not self._editable or not self._can_daily_edit: self._show_message("当前账号无日常记录编辑权限或接口不可用。", "warning") return if kind not in {"blood", "diet", "exercise"}: return source = dict(record) if isinstance(record, Mapping) else {} record_id = _int(first_value(source, "id"), 0) if record_id <= 0: self._show_message("该记录缺少 ID,无法安全编辑。", "warning") return source_diagnosis_id = _int(first_value(source, "diagnosis_id"), 0) source_patient_id = _int(first_value(source, "patient_id"), 0) if source_diagnosis_id not in {0, self._diagnosis_id} or source_patient_id not in { 0, self._patient_id, }: self._show_message("记录归属与当前诊单不一致,已拒绝编辑。", "danger") return source.update( { "id": record_id, "diagnosis_id": self._diagnosis_id, "patient_id": self._patient_id, } ) editor = DailyRecordEditorDialog(kind, source, parent=self) if isinstance(editor, QWidget): editor.setStyleSheet(DIAGNOSIS_QSS) if editor.exec() == QDialog.DialogCode.Accepted: self._submit_daily_record(kind, editor.payload()) def _submit_daily_record(self, kind: str, payload: Mapping[str, Any]) -> None: add_operations = { "blood": "add_blood_record", "diet": "add_diet_record", "exercise": "add_exercise_record", "tracking": "add_tracking_note", } update_operations = { "blood": "update_blood_record", "diet": "update_diet_record", "exercise": "update_exercise_record", } frozen = dict(payload) if kind != "tracking": payload_diagnosis_id = _int(frozen.get("diagnosis_id"), 0) payload_patient_id = _int(frozen.get("patient_id"), 0) if payload_diagnosis_id != self._diagnosis_id or payload_patient_id != self._patient_id: self._show_message("日常记录归属与当前诊单不一致,已拒绝提交。", "danger") return record_id = _int(frozen.get("id"), 0) updating = record_id > 0 and kind in update_operations if updating and (not self._editable or not self._can_daily_edit): self._show_message("当前账号无日常记录编辑权限或接口不可用。", "warning") return if not updating and (not self._editable or not self._can_daily_mutate): self._show_message("当前账号无日常记录写入权限或接口不可用。", "warning") return method = update_operations.get(kind) if updating else add_operations.get(kind) if method is None or not callable(getattr(self.repository, method, None)): self._show_message("该日常记录接口不可用。", "danger") return diagnosis_id = self._diagnosis_id def operation() -> Any: if kind == "tracking": return invoke( self.repository, method, diagnosis_id=diagnosis_id, content=str(frozen.get("content") or ""), ) if updating: return invoke(self.repository, method, record=frozen) return invoke(self.repository, method, payload=frozen) self._run_daily_mutation(operation, "日常记录已更新。" if updating else "日常记录已保存。") def _add_diagnosis_todo(self) -> None: if not self._editable or not self._can_daily_mutate: return content, accepted = QInputDialog.getMultiLineText(self, "新增待办", "待办内容(1–500 字)") if not accepted: return default_time = QDateTime.currentDateTime().addSecs(3600).toString("yyyy-MM-dd HH:mm") time_text, accepted = QInputDialog.getText( self, "新增待办", "提醒时间(YYYY-MM-DD HH:mm)", text=default_time ) if not accepted: return remind_at = QDateTime.fromString(time_text.strip(), "yyyy-MM-dd HH:mm") if not remind_at.isValid(): self._show_message("提醒时间格式不正确。", "warning") return self._run_daily_mutation( lambda: invoke( self.repository, "add_diagnosis_todo", diagnosis_id=self._diagnosis_id, content=content, remind_time=remind_at.toSecsSinceEpoch(), ), "待办已新增。", ) def _cancel_diagnosis_todo(self, todo_id: int) -> None: if not self._editable or not self._can_daily_mutate or todo_id <= 0: return answer = QMessageBox.question( self, "取消待办", "确定取消这条待办吗?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel, ) if answer != QMessageBox.StandardButton.Yes: return self._run_daily_mutation( lambda: invoke(self.repository, "cancel_diagnosis_todo", todo_id=todo_id), "待办已取消。", ) def _run_daily_mutation(self, operation: Any, success_text: str) -> None: self._daily_mutation_generation += 1 generation = self._daily_mutation_generation diagnosis_id = self._diagnosis_id self._show_message("正在提交…", "info") run_async( operation, on_success=lambda _result: self._daily_mutation_success( success_text, diagnosis_id, generation ), on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "daily"), ) def _daily_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None: if generation != self._daily_mutation_generation or diagnosis_id != self._diagnosis_id: return self._show_message(text, "success") self._loaded_tabs.discard("daily") source = next( (panel for panel in self._daily_panels if panel.isVisibleTo(self)), self._daily_panels[0], ) self._load_daily_range(*source.current_range(), force=True) def _mutation_error( self, error: Exception, diagnosis_id: int, generation: int, kind: str, ) -> None: current = { "daily": self._daily_mutation_generation, "notes": self._notes_mutation_generation, "media": self._media_generation, "offset": self._offset_generation, }[kind] if generation == current and diagnosis_id == self._diagnosis_id: self._show_message(friendly_error(error), "danger") def _add_doctor_note(self) -> None: if not self._editable or not self._can_note_add: return content, accepted = QInputDialog.getMultiLineText( self, "新增 / 追加诊疗笔记", "笔记内容(最多 500 字)" ) if not accepted: return self._run_notes_mutation( lambda: invoke( self.repository, "add_doctor_note", diagnosis_id=self._diagnosis_id, content=content, ), "诊疗笔记已保存。", ) def _upload_doctor_note_material(self, image_type: str) -> None: if ( image_type not in {"tongue_images", "report_files"} or not self._editable or not self._can_note_add ): return if not all( callable(getattr(self.repository, name, None)) for name in ("upload_material", "add_doctor_note") ): self._show_message("附件上传接口不可用。", "warning") return caption = "选择舌象图片" if image_type == "tongue_images" else "选择检查报告" file_filter = ( "图片 (*.png *.jpg *.jpeg *.webp)" if image_type == "tongue_images" else "报告文件 (*.pdf *.png *.jpg *.jpeg *.doc *.docx);;所有文件 (*)" ) path, _selected = QFileDialog.getOpenFileName(self, caption, "", file_filter) if not path: return diagnosis_id = self._diagnosis_id def operation() -> Any: reference = invoke( self.repository, "upload_material", path=path, material_type=image_type, ) kwargs: dict[str, Any] = { "diagnosis_id": diagnosis_id, "content": "", image_type: [str(reference)], } return invoke(self.repository, "add_doctor_note", **kwargs) self._run_notes_mutation(operation, "附件已上传并写入诊疗笔记。") def _delete_doctor_note_attachment( self, note_id: int, image_type: str, image_path: str ) -> None: if ( not self._editable or not self._can_note_delete or note_id <= 0 or image_type not in {"tongue_images", "report_files"} or not callable(getattr(self.repository, "delete_doctor_note_image", None)) ): return answer = QMessageBox.warning( self, "删除附件", "只删除当前附件,诊疗笔记正文会保留。确定继续吗?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel, ) if answer != QMessageBox.StandardButton.Yes: return self._run_notes_mutation( lambda: invoke( self.repository, "delete_doctor_note_image", note_id=note_id, image_type=image_type, image_path=image_path, ), "附件已删除。", ) def _run_notes_mutation(self, operation: Any, success_text: str) -> None: self._notes_mutation_generation += 1 generation = self._notes_mutation_generation diagnosis_id = self._diagnosis_id self._show_message("正在更新诊疗笔记…", "info") run_async( operation, on_success=lambda _result: self._notes_mutation_success( success_text, diagnosis_id, generation ), on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "notes"), ) def _notes_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None: if generation != self._notes_mutation_generation or diagnosis_id != self._diagnosis_id: return self._show_message(text, "success") self._loaded_tabs.discard("notes") self._ensure_tab_loaded("notes", force=True) def _open_new_prescription(self) -> None: if not self._editable or not self._can_prescribe: self._show_message("当前账号无开方权限或接口不可用。", "warning") return from .prescription import ( PrescriptionEditorDialog, build_prescription_clinical_diagnosis, build_prescription_visit_no, ) detail = self._detail or {} diagnosis = get_value(detail, "diagnosis", None) or detail appointment = get_value(detail, "appointment", None) or {} case_record = first_value(diagnosis, "case_record", default={}) or {} diagnosis_id = self._diagnosis_id appointment_id = _int( first_value(appointment, "id", "appointment_id", default=0) or first_value(diagnosis, "appointment_id", default=0), 0, ) seed = { "diagnosis_id": diagnosis_id, "appointment_id": appointment_id, "case_record": dict(case_record) if isinstance(case_record, Mapping) else {}, "patient_name": first_value(diagnosis, "patient_name", "name"), "gender": _int(first_value(diagnosis, "gender"), 0), "age": _int(first_value(diagnosis, "age"), 0), "phone": first_value(diagnosis, "phone", default=""), "visit_no": build_prescription_visit_no( diagnosis_id=diagnosis_id, appointment_id=appointment_id ), "tongue": first_value(diagnosis, "tongue", "tongue_coating"), "tongue_image": first_value(diagnosis, "tongue_image"), "pulse": first_value(diagnosis, "pulse"), "pulse_condition": first_value(diagnosis, "pulse_condition"), "clinical_diagnosis": build_prescription_clinical_diagnosis(diagnosis, case_record), "doctor_name": first_value( appointment, "doctor_name", default=first_value(diagnosis, "doctor_name") ), } dialog = PrescriptionEditorDialog( self.repository, seed, mode="add", current_user=getattr(self.parentWidget(), "current_user", None), parent=self, ) dialog.diagnosis_requested.connect(self._open_current_diagnosis_detail) dialog.setObjectName("DiagnosisPrescriptionEditor") dialog.setStyleSheet(DIAGNOSIS_QSS) if dialog.exec() != QDialog.DialogCode.Accepted: return payload = dialog.payload() payload.update( { "diagnosis_id": diagnosis_id, "appointment_id": appointment_id, "case_record": seed["case_record"], } ) self._run_media_mutation( lambda: invoke(self.repository, "create_prescription", prescription=payload), "处方已开具并提交审核。", "prescription", ) def _upload_call_recording(self, call_record_id: int | None = None) -> None: if not self._editable or not self._can_video_upload: return if call_record_id is not None and call_record_id <= 0: self._show_message("通话记录 ID 无效,无法追加回放。", "warning") return path, _selected = QFileDialog.getOpenFileName( self, "上传本地视频回放", "", "视频 (*.mp4 *.mov *.m4v *.webm);;所有文件 (*)", ) if not path: return self._run_media_mutation( lambda: invoke( self.repository, "upload_call_recording", path=path, diagnosis_id=self._diagnosis_id, call_record_id=call_record_id, ), "视频回放已追加到指定通话记录。" if call_record_id is not None else "视频回放已上传并创建通话记录。", "video", ) def _open_local_audio_queue(self) -> None: if self._diagnosis_id <= 0 or not self._can_video_upload: self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning") return current = self._local_audio_dialog if current is not None and current.isVisible(): current.raise_() current.activateWindow() return try: dialog = LocalAudioQueueDialog( self.repository, self._diagnosis_id, self, ) except (OSError, RuntimeError, sqlite3.Error) as error: self._show_message(f"无法打开本机录音文件:{error}", "danger") return self._local_audio_dialog = dialog opened_diagnosis_id = self._diagnosis_id self._watch_local_audio_uploads(dialog.manager) def clear_dialog(_result: int) -> None: if self._local_audio_dialog is dialog: self._local_audio_dialog = None dialog.finished.connect(clear_dialog) dialog.open() # Opening the queue commonly follows a background upload that completed # before this dialog existed, so also refresh the server-backed rows now. self._reload_video_records(opened_diagnosis_id) def _local_audio_upload_succeeded( self, diagnosis_id: int, _call_record_id: int, ) -> None: if self._local_audio_upload_manager is None: return self._reload_video_records(diagnosis_id) def _reload_video_records(self, diagnosis_id: int) -> None: if int(diagnosis_id) != self._diagnosis_id: return if "video" in self._loading_tabs: self._video_reload_pending = True return self._video_reload_pending = False self._loaded_tabs.discard("video") self._ensure_tab_loaded("video", force=True) def _watch_local_audio_uploads(self, manager: Any) -> None: if self._local_audio_upload_manager is manager: return self._stop_watching_local_audio_uploads() def notify(record: Any) -> None: with suppress(RuntimeError): self._local_audio_upload_completed.emit( int(record.diagnosis_id), int(record.call_record_id or 0), ) manager.add_upload_listener(notify) self._local_audio_upload_manager = manager self._local_audio_upload_listener = notify def _stop_watching_local_audio_uploads(self) -> None: manager = self._local_audio_upload_manager listener = self._local_audio_upload_listener self._local_audio_upload_manager = None self._local_audio_upload_listener = None if manager is not None and listener is not None: with suppress(Exception): manager.remove_upload_listener(listener) def _flush_video_reload_if_pending(self, diagnosis_id: int) -> None: if not self._video_reload_pending or diagnosis_id != self._diagnosis_id: return self._video_reload_pending = False QTimer.singleShot( 0, lambda expected_id=diagnosis_id: self._reload_video_records(expected_id), ) def _sync_chat_archive(self) -> None: if not self._can_chat_sync or self._diagnosis_id <= 0: self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning") return self._run_media_mutation( lambda: invoke( self.repository, "sync_im_chat_messages", diagnosis_id=self._diagnosis_id, ), "已提交聊天归档同步,请稍后重新加载。", "chat", ) def _run_media_mutation(self, operation: Any, success_text: str, reload_key: str) -> None: self._media_generation += 1 generation = self._media_generation diagnosis_id = self._diagnosis_id self._show_message("正在提交…", "info") run_async( operation, on_success=lambda _result: self._media_mutation_success( success_text, reload_key, diagnosis_id, generation ), on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "media"), ) def _media_mutation_success( self, text: str, reload_key: str, diagnosis_id: int, generation: int, ) -> None: if generation != self._media_generation or diagnosis_id != self._diagnosis_id: return self._show_message(text, "success") self._loaded_tabs.discard(reload_key) if reload_key == "chat": QTimer.singleShot( 800, lambda: ( self._ensure_tab_loaded("chat", force=True) if generation == self._media_generation else None ), ) else: self._ensure_tab_loaded(reload_key, force=True) def _order_offset_changed(self, value: int) -> None: self.order_offset_preview.setText(f"第 1 笔实单计为{_visit_slot_label(value)}") self._sync_order_offset_actions() def _sync_order_offset_actions(self) -> None: if not hasattr(self, "order_offset_save"): return can_edit = self._editable and self._can_offset dirty = self.order_offset.value() != self._saved_order_offset saving = self.order_offset_save.text() == "保存中…" self.order_offset.setEnabled(can_edit and not saving) self.order_offset_save.setEnabled(can_edit and dirty and not saving) def _save_order_offset(self) -> None: if not self._editable or not self._can_offset or self._diagnosis_id <= 0: return self._offset_generation += 1 generation = self._offset_generation diagnosis_id = self._diagnosis_id offset = self.order_offset.value() self.order_offset.setEnabled(False) self.order_offset_save.setEnabled(False) self.order_offset_save.setText("保存中…") self._show_message("正在保存复诊统计起始偏移…", "info") run_async( lambda: invoke( self.repository, "set_revisit_slot_start_offset", diagnosis_id=diagnosis_id, offset=offset, ), on_success=lambda _result: self._offset_success(diagnosis_id, offset, generation), on_error=lambda error: self._offset_error(error, diagnosis_id, generation), ) def _offset_success(self, diagnosis_id: int, offset: int, generation: int) -> None: if generation != self._offset_generation or diagnosis_id != self._diagnosis_id: return self._saved_order_offset = offset self.order_offset_save.setText("保存") self._sync_order_offset_actions() self._show_message("保存成功", "success") self._ensure_tab_loaded("orders", force=True) def _offset_error(self, error: Exception, diagnosis_id: int, generation: int) -> None: if generation != self._offset_generation or diagnosis_id != self._diagnosis_id: return self.order_offset_save.setText("保存") self._sync_order_offset_actions() self._mutation_error(error, diagnosis_id, generation, "offset") def _close_image_preview(self) -> None: preview, self._image_preview = self._image_preview, None if preview is not None: with suppress(RuntimeError): preview.close() def _image_preview_finished(self, _result: int) -> None: if self.sender() is self._image_preview: self._image_preview = None def _preview_note_images(self, sources: Sequence[str], index: int) -> None: """Keep the diagnosis open while viewing the clicked note's image group.""" if not safe_image_sources(sources): QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。") return self._close_image_preview() preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self) self._image_preview = preview preview.finished.connect(self._image_preview_finished) preview.open() def _open_safe_resource(self, target: str) -> None: url = QUrl(str(target).strip()) if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host(): QMessageBox.warning(self, "无法打开", "仅允许打开 HTTP(S) 服务端附件或回放。") return if not QDesktopServices.openUrl(url): QMessageBox.warning(self, "无法打开", "系统未能打开该安全外部链接。") def _clear_tables(self) -> None: self.notes_table.setRowCount(0) empty_text = { "daily": "切换到日常记录后加载数据。", "prescription": "切换到处方后加载病历记录。", "orders": "切换到业务订单后加载数据。", "video": "切换到视频录制回放后检查桌面端接口。", "assign": "切换到指派记录后加载数据。", "appointment": "切换到挂号记录后加载数据。", } for kind, tables in self._table_registry.items(): for table in tables: table.set_matrix([]) table.set_empty_text(empty_text.get(kind, "暂无数据")) for timeline in self._notes_timelines: timeline.set_state("暂无医生备注") timeline.set_notes([]) for panel in self._chat_panels: panel.set_unavailable("切换到聊天记录后加载归档数据。") for panel in self._daily_panels: panel.clear() self.orders_list.update_state(1, 0) @staticmethod def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None: for column, value in enumerate(values): item = QTableWidgetItem(display_text(value)) item.setToolTip(item.text() if len(item.text()) > 18 else "") table.setItem(row, column, item) def _fill_notes(self, rows: Sequence[Any]) -> None: self.notes_table.setRowCount(len(rows)) for index, row in enumerate(rows): self._set_row( self.notes_table, index, ( first_value(row, "create_time", "created_at", "time"), first_value(row, "doctor_name", "creator_name", default="医生"), first_value(row, "content", "note", "remark"), ), ) for timeline in self._notes_timelines: timeline.empty.setText("暂无医生备注") timeline.set_notes(rows) def _fill_appointments(self, rows: Sequence[Any]) -> None: matrix = [ ( first_value(row, "id", "appointment_id"), first_value(row, "status_desc", "status_text", "status"), "\n".join( part for part in ( display_text(first_value(row, "patient_name"), ""), display_text( first_value(row, "patient_phone", "phone") if self._can_phone_plain else _mask_phone(first_value(row, "patient_phone", "phone")), "", ), ) if part ), first_value(row, "doctor_name"), first_value(row, "assistant_name"), first_value(row, "appointment_date"), first_value(row, "appointment_time", "period"), appointment_type_description(appointment_type_value(row)), " / ".join( part for part in ( display_text(first_value(row, "channel_name", "channel_source"), ""), display_text(first_value(row, "channel_source_detail"), ""), ) if part ), "已确认" if _truthy(first_value(row, "is_confirmed", "confirmed", default=False)) else "未确认", "已开方" if _truthy(first_value(row, "has_prescription", "is_prescribed", default=False)) else "未开方", first_value(row, "remark"), first_value(row, "create_time", "created_at"), ) for row in rows ] for table in self._table_registry["appointment"]: table.set_empty_text("暂无挂号记录。") table.set_matrix(matrix) self._semanticize(table, 1) self._semanticize(table, 9) self._semanticize(table, 10) def _fill_assignments(self, rows: Sequence[Any]) -> None: matrix = [ ( first_value(row, "create_time_text", "create_time"), first_value(row, "from_assistant_name", default="—"), first_value(row, "to_assistant_name", "assistant_name", default="—"), "是" if _truthy(first_value(row, "is_inherit", default=False)) else "否", first_value(row, "snapshot_order_creator_name", "order_creator_name"), first_value(row, "snapshot_order_create_time", "order_create_time"), first_value(row, "operator_name"), first_value(row, "operator_account"), first_value(row, "ip"), ) for row in rows ] for table in self._table_registry["assign"]: table.set_empty_text("暂无指派记录。") table.set_matrix(matrix) for row_index in range(table.rowCount()): item = table.item(row_index, 3) if item is not None: set_tag_item(item, "success" if item.text() == "是" else "neutral") @staticmethod def _action_button(text: str, tooltip: str = "") -> QPushButton: button = QPushButton(text) button.setCursor(Qt.CursorShape.PointingHandCursor) button.setToolTip(tooltip) button.setProperty("variant", "link") return button def _fill_prescriptions(self, rows: Sequence[Any]) -> None: matrix: list[tuple[Any, ...]] = [] for row in rows: herbs = first_value(row, "herbs", default=[]) or [] summary = first_value(row, "prescription_summary", "summary", default="") if ( not summary and isinstance(herbs, Sequence) and not isinstance(herbs, (str, bytes, bytearray)) ): summary = "、".join( f"{display_text(first_value(herb, 'name'), '')}" f"{display_text(first_value(herb, 'dosage'), '')}克" for herb in list(herbs)[:3] ) if len(herbs) > 3: summary += "…" voided = _truthy(first_value(row, "void_status", default=False)) matrix.append( ( first_value(row, "prescription_date", "visit_date", "create_time"), first_value(row, "visit_no", "sn", "id"), first_value(row, "clinical_diagnosis", "diagnosis"), summary, first_value(row, "doctor_name"), "已作废" if voided else first_value(row, "status_text", "audit_status_text", default="正常"), "", ) ) for table in self._table_registry["prescription"]: table.set_empty_text("暂无病历记录,开方后会自动显示。") table.set_matrix(matrix) self._semanticize(table, 5) for row_index, row in enumerate(rows): actions = QWidget() actions_layout = QHBoxLayout(actions) actions_layout.setContentsMargins(0, 0, 0, 0) actions_layout.setSpacing(6) button = self._action_button("查看", "打开真实处方详情") button.clicked.connect( lambda _checked=False, source=row: self._view_prescription(source) ) actions_layout.addWidget(button) approved = _int(first_value(row, "audit_status", "status", default=-1), -1) == 1 voided = _truthy(first_value(row, "void_status", "is_void", default=False)) can_edit = ( self._editable and self._can_edit_prescription and (not approved or voided) ) if can_edit: edit_button = self._action_button("编辑", "使用完整处方表单编辑") edit_button.clicked.connect( lambda _checked=False, source=row: self._edit_prescription(source) ) actions_layout.addWidget(edit_button) actions_layout.addStretch(1) table.setCellWidget(row_index, 6, actions) def _view_prescription(self, prescription: Any) -> None: prescription_id = _int(first_value(prescription, "id", "prescription_id", default=0), 0) method = getattr(self.repository, "get_prescription", None) if prescription_id <= 0 or not callable(method): self._show_message("无法加载完整处方详情。", "warning") return self._prescription_detail_generation += 1 generation = self._prescription_detail_generation self._prescription_detail_target = prescription_id self._show_message("正在加载完整处方详情…", "info") run_async( lambda: method(prescription_id), on_success=lambda detail: self._prescription_detail_loaded( detail, prescription_id, generation, edit=False ), on_error=lambda error: self._prescription_detail_error( error, prescription_id, generation ), ) def _edit_prescription(self, prescription: Any) -> None: if ( not self._editable or not self._can_edit_prescription ): self._show_message("当前账号无处方编辑权限或接口不可用。", "warning") return prescription_id = _int(first_value(prescription, "id", "prescription_id", default=0), 0) if prescription_id <= 0: self._show_message("处方编号不完整,无法编辑。", "warning") return approved = _int(first_value(prescription, "audit_status", "status", default=-1), -1) == 1 voided = _truthy(first_value(prescription, "void_status", "is_void", default=False)) if approved and not voided: self._show_message("审核通过且未作废的处方只能查看。", "warning") return method = getattr(self.repository, "get_prescription", None) if not callable(method): self._show_message("无法加载完整处方详情。", "warning") return self._prescription_detail_generation += 1 generation = self._prescription_detail_generation self._prescription_detail_target = prescription_id self._show_message("正在准备编辑处方…", "info") run_async( lambda: method(prescription_id), on_success=lambda detail: self._prescription_detail_loaded( detail, prescription_id, generation, edit=True ), on_error=lambda error: self._prescription_detail_error( error, prescription_id, generation ), ) def _prescription_detail_loaded( self, prescription: Any, prescription_id: int, generation: int, *, edit: bool, ) -> None: if ( generation != self._prescription_detail_generation or prescription_id != self._prescription_detail_target ): return actual_id = _int(first_value(prescription, "id", "prescription_id", default=0), 0) if actual_id != prescription_id: self._show_message("服务端处方与当前行不一致,已停止打开。", "danger") return self._clear_message() if edit: self._open_prescription_edit_dialog(prescription, prescription_id) return from .prescription import PrescriptionDetailDialog dialog = PrescriptionDetailDialog( prescription, can_open_diagnosis=False, can_open_orders=False, parent=self, ) dialog.exec() def _prescription_detail_error( self, error: Exception, prescription_id: int, generation: int ) -> None: if ( generation == self._prescription_detail_generation and prescription_id == self._prescription_detail_target ): self._show_message(friendly_error(error), "danger") def _open_prescription_edit_dialog(self, prescription: Any, prescription_id: int) -> None: from .prescription import PrescriptionEditorDialog dialog = PrescriptionEditorDialog( self.repository, prescription, mode="edit", current_user=getattr(self.parentWidget(), "current_user", None), parent=self, ) dialog.diagnosis_requested.connect(self._open_current_diagnosis_detail) dialog.setObjectName("DiagnosisPrescriptionEditor") dialog.setStyleSheet(DIAGNOSIS_QSS) if dialog.exec() != QDialog.DialogCode.Accepted: return payload = dialog.payload() self._run_media_mutation( lambda: invoke( self.repository, "update_prescription", prescription=prescription_id, changes=payload, ), "处方已保存并重新进入待审核。", "prescription", ) def _open_current_diagnosis_detail(self, diagnosis_id: int) -> None: if diagnosis_id <= 0 or diagnosis_id != self._diagnosis_id or not self._detail: self._show_message("当前处方关联的诊单详情不可用。", "warning") return from .prescription import DiagnosisDetailDialog DiagnosisDetailDialog( self._detail, self, repository=self.repository, permissions=self.permissions, ).exec() def _stop_inline_recordings(self) -> None: for cell in list(getattr(self, "_inline_recording_cells", [])): with suppress(RuntimeError): cell.stop() self._inline_recording_cells.clear() @staticmethod def _call_transcript_text(row: Any) -> str: raw = first_value( row, "transcript_text", "call_transcript", "conversation_text", "transcript", default="", ) if isinstance(raw, Mapping): raw = first_value(raw, "text", "full_text", "content", default="") text = str(raw or "").strip() if text: return text segments = first_value(row, "transcript_segments", "segments", default=[]) or [] if isinstance(segments, Sequence) and not isinstance( segments, (str, bytes, bytearray) ): labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"} lines: list[str] = [] for segment in segments: content = str(first_value(segment, "text", "sourceText", default="") or "").strip() if not content: continue role = str( first_value(segment, "speaker_role", "speakerRole", default="unknown") or "unknown" ) lines.append(f"{labels.get(role, '未知说话人')}:{content}") return "\n".join(lines) return "" def _view_call_transcript(self, call_record_id: int, transcript: str) -> None: dialog = QDialog(self) dialog.setObjectName("DiagnosisCallTranscriptDialog") dialog.setWindowTitle(f"面诊对话文字 · 通话记录 #{call_record_id}") dialog.resize(680, 520) layout = QVBoxLayout(dialog) layout.setContentsMargins(20, 18, 20, 18) layout.setSpacing(12) title = QLabel("录音转写对话") title.setObjectName("DiagnosisDailySectionTitle") layout.addWidget(title) hint = QLabel("以下内容由语音自动转写,仅作为面诊记录辅助,请由医生核对。") hint.setObjectName("DiagnosisDialogGuidance") hint.setWordWrap(True) layout.addWidget(hint) content = QPlainTextEdit(dialog) content.setObjectName("DiagnosisCallTranscriptText") content.setReadOnly(True) content.setPlainText(transcript) layout.addWidget(content, 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 _fill_video(self, rows: Sequence[Any]) -> None: self._stop_inline_recordings() matrix: list[tuple[Any, ...]] = [] row_urls: list[list[str]] = [] row_audio_urls: list[list[str]] = [] record_ids: list[int] = [] transcripts: list[str] = [] for row in rows: raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or [] if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence): raw_urls = [raw_urls] direct = str(first_value(row, "recording_url", "url", default="") or "").strip() normalized: list[str] = [] for candidate in ([direct] if direct else []) + list(raw_urls): url = str(candidate or "").strip() if url and url not in normalized: normalized.append(url) row_urls.append(normalized) raw_audio_urls = first_value( row, "local_audio_urls_list", "local_audio_urls", default=[], ) or [] if isinstance(raw_audio_urls, str) or not isinstance( raw_audio_urls, Sequence ): raw_audio_urls = [raw_audio_urls] normalized_audio: list[str] = [] for candidate in raw_audio_urls: url = str(candidate or "").strip() if url and url not in normalized_audio: normalized_audio.append(url) row_audio_urls.append(normalized_audio) record_ids.append(_int(first_value(row, "id", "call_record_id"), 0)) transcript = self._call_transcript_text(row) transcripts.append(transcript) call_type = _raw_value(row, "call_type_text") if call_type is _MISSING: raw_call_type = _raw_value(row, "call_type") if raw_call_type is _MISSING: call_type = "—" else: call_type = "语音" if _int(raw_call_type, 2) == 1 else "视频" status = _raw_value(row, "status_text") if status is _MISSING: raw_status = _raw_value(row, "status") status = { 1: "进行中", 2: "已结束", 3: "未接听", 4: "已取消", }.get(_int(raw_status, -1), "—") recording_status = first_value( row, "recording_status_text", "record_status_text", "record_status" ) local_audio_status = first_value( row, "local_audio_status_text", default="本机录音已保存" if normalized_audio else "无本机录音", ) transcript_status = first_value( row, "transcription_status_text", "transcript_status_text", default="文字已生成" if transcript else "未生成文字", ) room_id = str( first_value( row, "room_id_text", "room_id", "room_no", default="", ) or "" ).strip() matrix.append( ( "" if normalized else "暂无录制回放", room_id or "历史记录未保存", first_value(row, "start_time_text", "start_time"), first_value(row, "end_time_text", "end_time"), call_type, first_value(row, "duration_text", "duration"), status, ( f"云端视频:{display_text(recording_status)}\n" f"本机录音:{display_text(local_audio_status)}\n" f"转写文字:{display_text(transcript_status)}" ), "", ) ) for table in self._table_registry["video"]: table.set_empty_text("暂无通话记录。") table.set_matrix(matrix) self._semanticize(table, 6) playback_rows: list[tuple[int, RecordingPlaybackCell]] = [] for row_index, urls in enumerate(row_urls): if urls: playback = RecordingPlaybackCell( urls, record_id=record_ids[row_index], render_owner=self, owner_generation=self._tab_generations["video"], ) playback.fallback_requested.connect(self._open_recording_player) self._inline_recording_cells.append(playback) playback_rows.append((row_index, playback)) table.setCellWidget(row_index, 0, playback) item = table.item(row_index, 0) if item is not None: item.setText("") call_record_id = record_ids[row_index] actions: list[QPushButton] = [] audio_urls = row_audio_urls[row_index] if audio_urls and call_record_id > 0: play_audio = self._action_button( "播放录音", f"播放通话记录 #{call_record_id} 的本机混音录音" ) play_audio.setObjectName("DiagnosisLocalAudioPlayback") play_audio.setProperty("callRecordId", call_record_id) play_audio.clicked.connect( lambda _checked=False, target=audio_urls[0]: self._open_recording_player( target ) ) actions.append(play_audio) transcript = transcripts[row_index] if transcript and call_record_id > 0: view_transcript = self._action_button( "查看文字", f"查看通话记录 #{call_record_id} 的录音转写" ) view_transcript.setObjectName("DiagnosisVideoTranscriptView") view_transcript.setProperty("callRecordId", call_record_id) view_transcript.clicked.connect( lambda _checked=False, selected=call_record_id, text=transcript: self._view_call_transcript( selected, text ) ) actions.append(view_transcript) if self._editable and self._can_video_upload and call_record_id > 0: upload = self._action_button( "追加回放", f"上传并绑定通话记录 #{call_record_id}" ) upload.setProperty("variant", "secondary") upload.setObjectName("DiagnosisVideoRowUpload") upload.setProperty("callRecordId", call_record_id) upload.setMinimumHeight(28) upload.setMaximumHeight(30) upload.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed, ) upload.clicked.connect( lambda _checked=False, selected=call_record_id: self._upload_call_recording( selected ) ) actions.append(upload) if actions: action_host = QWidget() action_host.setObjectName("DiagnosisVideoRowUploadCell") action_layout = QVBoxLayout(action_host) action_layout.setContentsMargins(0, 0, 0, 0) action_layout.setSpacing(2) for action in actions: action_layout.addWidget(action, 0, Qt.AlignmentFlag.AlignCenter) table.setCellWidget(row_index, 8, action_host) upload_item = table.item(row_index, 8) if upload_item is not None: upload_item.setText("") else: table.setItem(row_index, 8, QTableWidgetItem("—")) table.resizeRowsToContents() # A fixed-height action button in another column makes Qt's content # resize pass collapse the whole section to roughly 30 px. Restore # the playback cell's measured layout height after that final pass; # retaining the item hint also documents the row's semantic size for # delegates and future table-layout changes. for row_index, playback in playback_rows: required_height = playback.required_table_row_height() item = table.item(row_index, 0) if item is not None: hint = playback.sizeHint() hint.setHeight(required_height) item.setSizeHint(hint) table.setRowHeight(row_index, required_height) def _open_recording_player(self, target: str) -> None: player = RecordingPlayerDialog(target, parent=self) self._recording_players.append(player) def release(*_args: Any) -> None: with suppress(ValueError): self._recording_players.remove(player) player.destroyed.connect(release) player.show() def _fill_orders(self, rows: Sequence[Any]) -> None: matrix: list[tuple[Any, ...]] = [] for row in rows: visit_sequence = _raw_value( row, "global_visit_seq", "global_visit_sequence", "revisit_slot", "visit_sequence", ) counts = _raw_value( row, "counts_for_revisit_rate", "statistics_count", "quantity", "count", ) fulfillment = _raw_value(row, "fulfillment_status_text", "status_text") if fulfillment is _MISSING: fulfillment = _fulfillment_text(_raw_value(row, "fulfillment_status", "status")) visit_text = display_text(visit_sequence) if visit_sequence is not _MISSING and not visit_text.endswith("诊"): visit_text = f"{visit_text}诊" matrix.append( ( first_value(row, "order_no", "sn", "id"), "—" if visit_sequence is _MISSING else visit_text, "—" if counts is _MISSING else ("是" if _truthy(counts) else "否"), _money_text(_raw_value(row, "amount")), first_value(row, "doctor_name"), first_value(row, "assistant_name"), fulfillment, first_value(row, "create_time", "created_at"), "", ) ) for table in self._table_registry["orders"]: table.set_empty_text("暂无业务订单。") table.set_matrix(matrix, danger_columns=(3,), right_columns=(3,)) self._semanticize(table, 6) for row_index, values in enumerate(matrix): counts_text = str(values[2]) if counts_text in {"是", "否"}: set_status_tag( table, row_index, 2, counts_text, "success" if counts_text == "是" else "neutral", ) for row_index, row in enumerate(rows): order_id = _int(first_value(row, "id", "order_id"), 0) item = table.item(row_index, 0) if item is not None: item.setData(Qt.ItemDataRole.UserRole, row) if ( self._can_order_detail and order_id > 0 and callable(getattr(self.repository, "get_prescription_order", None)) ): button = self._action_button("查看详情", "加载真实业务订单详情") button.clicked.connect( lambda _checked=False, selected=order_id: self._view_order(selected) ) table.setCellWidget(row_index, 8, button) elif not self._can_order_detail: table.setItem(row_index, 8, QTableWidgetItem("无详情权限")) else: table.setItem(row_index, 8, QTableWidgetItem("详情不可用")) def _view_order(self, order_id: int) -> None: if not self._can_order_detail: self._show_message("当前账号无业务订单详情权限。", "warning") return if order_id <= 0 or not callable(getattr(self.repository, "get_prescription_order", None)): self._show_message("当前订单没有可用的详情接口。", "warning") return self._order_detail_generation += 1 generation = self._order_detail_generation diagnosis_id = self._diagnosis_id self._show_message("正在加载订单详情…", "info") run_async( lambda: invoke( self.repository, "get_prescription_order", order_id=order_id, id=order_id, ), on_success=lambda result: self._order_detail_success( result, diagnosis_id, order_id, generation ), on_error=lambda error: self._order_detail_error(error, diagnosis_id, generation), ) def _order_detail_success( self, detail: Any, diagnosis_id: int, order_id: int, generation: int, ) -> None: if generation != self._order_detail_generation or diagnosis_id != self._diagnosis_id: return self._clear_message() payload = detail if isinstance(detail, Mapping): for key in ("data", "detail"): nested = detail.get(key) if isinstance(nested, Mapping) and any( field in nested for field in ("id", "order_no", "prescription_id") ): payload = nested break dialog = self._build_order_detail_dialog(payload, order_id) self._last_order_detail_dialog = dialog def clear_dialog(_result: int) -> None: if self._last_order_detail_dialog is dialog: self._last_order_detail_dialog = None dialog.finished.connect(clear_dialog) dialog.open() def _build_order_detail_dialog( self, detail: Any, order_id: int, *, host: QWidget | None = None, ) -> QDialog: owner = host if isinstance(host, QWidget) else self if isinstance(self, QWidget) else None dialog = OrderDetailDrawer(owner) header = QFrame() header.setObjectName("DiagnosisOrderDetailHeader") header_layout = QHBoxLayout(header) header_layout.setContentsMargins(20, 15, 18, 15) header_layout.setSpacing(12) title_stack = QVBoxLayout() title_stack.setSpacing(4) heading = QLabel("业务订单详情") heading.setObjectName("DiagnosisOrderDetailTitle") title_stack.addWidget(heading) order_number_value = _raw_value(detail, "order_no", "sn") order_number = QLabel( f"订单编号:{display_text(order_number_value)}" if order_number_value is not _MISSING else f"订单ID:#{order_id}" ) order_number.setObjectName("DiagnosisOrderDetailMeta") order_number.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) title_stack.addWidget(order_number) references: list[str] = [] gancao_no = _raw_value(detail, "gancao_reciperl_order_no") if gancao_no is not _MISSING: references.append(f"甘草 {display_text(gancao_no)}") pharmacy_no = _raw_value(detail, "ej_pharmacy_order_no") if pharmacy_no is not _MISSING: references.append(f"洛阳 {display_text(pharmacy_no)}") if references: reference = QLabel(" · ".join(references)) reference.setObjectName("DiagnosisOrderDetailMeta") reference.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) title_stack.addWidget(reference) header_layout.addLayout(title_stack, 1) readonly_badge = QLabel("只读") readonly_badge.setObjectName("DiagnosisOrderReadonlyBadge") header_layout.addWidget(readonly_badge, 0, Qt.AlignmentFlag.AlignVCenter) status_value = _raw_value(detail, "fulfillment_status_text", "status_text") status_text = ( display_text(status_value) if status_value is not _MISSING else _fulfillment_text(_raw_value(detail, "fulfillment_status", "status")) ) if status_text == "—": status_text = "状态未返回" status = QLabel(status_text) status.setObjectName("DiagnosisOrderStatus") status.setProperty("diagnosisTag", True) status.setProperty("kind", self._status_kind(status_text)) status.setAlignment(Qt.AlignmentFlag.AlignCenter) header_layout.addWidget(status, 0, Qt.AlignmentFlag.AlignVCenter) close_header = QPushButton("关闭") close_header.setObjectName("DiagnosisOrderDetailClose") close_header.setProperty("variant", "secondary") close_header.clicked.connect(dialog.reject) header_layout.addWidget(close_header, 0, Qt.AlignmentFlag.AlignVCenter) dialog.content_layout.addWidget(header) scroll = QScrollArea() scroll.setObjectName("DiagnosisOrderDetailScroll") scroll.setWidgetResizable(True) content = QWidget() body = QVBoxLayout(content) body.setContentsMargins(18, 16, 18, 22) body.setSpacing(14) self._add_order_amount_overview(body, detail) self._add_order_prescription_section(body, detail) self._add_order_payment_section(body, detail) self._add_order_fulfillment_section(body, detail) self._add_order_logistics_section(body, detail) self._add_order_logs_section(body, detail) body.addStretch(1) scroll.setWidget(content) dialog.content_layout.addWidget(scroll, 1) footer = QFrame() footer.setObjectName("DiagnosisOrderDetailFooter") actions = QHBoxLayout(footer) actions.setContentsMargins(18, 10, 18, 10) source_note = QLabel("只读 · 数据来自业务订单详情接口") source_note.setObjectName("DiagnosisOrderDetailMeta") actions.addWidget(source_note) actions.addStretch(1) close = QPushButton("关闭") close.setProperty("variant", "secondary") close.clicked.connect(dialog.reject) actions.addWidget(close) dialog.content_layout.addWidget(footer) dialog.sync_to_owner() return dialog def _new_order_section( self, title: str, hint: str = "", ) -> tuple[QFrame, QVBoxLayout]: section = QFrame() section.setProperty("orderDetailSection", True) section.setObjectName(f"DiagnosisOrderSection{title}") layout = QVBoxLayout(section) layout.setContentsMargins(15, 14, 15, 16) layout.setSpacing(11) heading = QHBoxLayout() heading.setSpacing(10) title_label = QLabel(title) title_label.setProperty("orderSectionTitle", True) heading.addWidget(title_label) if hint: hint_label = QLabel(hint) hint_label.setProperty("orderSectionHint", True) hint_label.setWordWrap(True) heading.addWidget(hint_label, 1) else: heading.addStretch(1) layout.addLayout(heading) return section, layout def _add_order_empty(self, layout: QVBoxLayout, text: str) -> QLabel: empty = QLabel(text) empty.setProperty("orderEmptyState", True) empty.setAlignment(Qt.AlignmentFlag.AlignCenter) empty.setWordWrap(True) empty.setMinimumHeight(62) layout.addWidget(empty) return empty def _add_order_field_grid( self, layout: QVBoxLayout, fields: Sequence[tuple[str, Any]], *, columns: int = 3, ) -> None: host = QWidget() grid = QGridLayout(host) grid.setContentsMargins(0, 0, 0, 0) grid.setHorizontalSpacing(8) grid.setVerticalSpacing(8) for column in range(columns): grid.setColumnStretch(column, 1) for index, (caption, raw_value) in enumerate(fields): card = QFrame() card.setProperty("orderField", True) card_layout = QVBoxLayout(card) card_layout.setContentsMargins(10, 8, 10, 9) card_layout.setSpacing(4) label = QLabel(caption) label.setProperty("orderFieldLabel", True) card_layout.addWidget(label) value = QLabel(display_text(raw_value)) value.setProperty("orderFieldValue", True) value.setWordWrap(True) value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) card_layout.addWidget(value, 1) grid.addWidget(card, index // columns, index % columns) layout.addWidget(host) def _add_order_amount_overview(self, parent: QVBoxLayout, detail: Any) -> None: section, layout = self._new_order_section( "金额概览", "需代收优先使用后端快照;缺少快照时仅在总额与已付总额都返回后计算。", ) linked_rows, linked_present = _detail_rows(detail, "linked_pay_orders", "pay_orders") total = _number(_raw_value(detail, "amount", "effective_amount")) paid = _number(_raw_value(detail, "linked_pay_paid_total", "paid_total")) if paid is None and linked_present: amounts = [ _number(_raw_value(row, "amount", "pay_amount", "paid_amount")) for row in linked_rows ] if not linked_rows: paid = 0.0 elif all(amount is not None for amount in amounts): paid = sum(amount for amount in amounts if amount is not None) refund = _number(_raw_value(detail, "refund_amount")) agency = _number(_raw_value(detail, "agency_collect_amount")) if agency is None and total is not None and paid is not None: agency = round((total - paid) * 100) / 100 cards = ( ("总金额", _money_text(total), "danger"), ("已付总额", _money_text(paid), "success"), ("退款金额", _money_text(refund), "danger" if refund and refund > 0 else "neutral"), ("需代收", _money_text(agency), "warning" if agency and agency > 0 else "neutral"), ("已付笔数", f"{len(linked_rows)} 笔" if linked_present else "—", "info"), ) host = QWidget() grid = QGridLayout(host) grid.setContentsMargins(0, 0, 0, 0) grid.setHorizontalSpacing(8) grid.setVerticalSpacing(8) for index, (caption, value, tone) in enumerate(cards): card = QFrame() card.setProperty("orderAmountCard", True) card_layout = QVBoxLayout(card) card_layout.setContentsMargins(11, 10, 11, 11) card_layout.setSpacing(4) title = QLabel(caption) title.setProperty("orderAmountTitle", True) card_layout.addWidget(title) amount = QLabel(value) amount.setProperty("orderAmountValue", True) amount.setProperty("orderAmountTone", tone) amount.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) card_layout.addWidget(amount) grid.addWidget(card, index // 5, index % 5) grid.setColumnStretch(index % 5, 1) layout.addWidget(host) parent.addWidget(section) def _add_order_prescription_section(self, parent: QVBoxLayout, detail: Any) -> None: prescription = _raw_value(detail, "prescription") doctor = _raw_value(detail, "doctor_name") hint = "" if doctor is _MISSING else f"开方人:{display_text(doctor)}" section, layout = self._new_order_section("处方详情", hint) error = _raw_value(detail, "prescription_detail_error") if error is not _MISSING: warning = QLabel(display_text(error)) warning.setProperty("orderEmptyState", True) warning.setProperty("kind", "warning") warning.setWordWrap(True) layout.addWidget(warning) if prescription is _MISSING or prescription is None or prescription == {}: self._add_order_empty(layout, "无处方数据(详情接口未返回 prescription)") parent.addWidget(section) return gender = _raw_value(prescription, "gender_desc") if gender is _MISSING: raw_gender = _raw_value(prescription, "gender") gender = _diagnosis_gender_text(raw_gender) if raw_gender is not _MISSING else "—" age = _raw_value(prescription, "age") gender_age_parts = [display_text(gender)] if age is not _MISSING: gender_age_parts.append(f"{display_text(age)}岁") dose_count = _raw_value(detail, "dose_count") if dose_count is _MISSING: dose_count = _raw_value(prescription, "dose_count") dose_unit = _raw_value(detail, "dose_unit") if dose_unit is _MISSING: dose_unit = _raw_value(prescription, "dose_unit") usage_method = _raw_value(prescription, "usage_instruction", "usage_method") dose_parts: list[str] = [] if dose_count is not _MISSING: dose_parts.append( f"{display_text(dose_count)}{display_text(dose_unit, '') if dose_unit is not _MISSING else ''}" ) if usage_method is not _MISSING: dose_parts.append(display_text(usage_method)) dosage_amount = _raw_value(prescription, "dosage_amount") dosage_parts: list[str] = [] if dosage_amount is not _MISSING: dosage_unit = _raw_value(prescription, "dosage_unit") dosage_parts.append( f"{display_text(dosage_amount)}{display_text(dosage_unit, '') if dosage_unit is not _MISSING else ''}" ) bag_count = _raw_value(prescription, "dosage_bag_count") if bag_count is not _MISSING: dosage_parts.append(f"{display_text(bag_count)}袋") decoction = _raw_value(prescription, "need_decoction") if decoction is not _MISSING: dosage_parts.append("代煎" if _truthy(decoction) else "不代煎") usage_parts: list[str] = [] times = _raw_value(prescription, "times_per_day") if times is not _MISSING: usage_parts.append(f"每天 {display_text(times)} 次") usage_days = _raw_value(prescription, "usage_days") if usage_days is not _MISSING: usage_parts.append(f"处方开立 {display_text(usage_days)} 天") medication_days = _raw_value(detail, "medication_days") if medication_days is not _MISSING: usage_parts.append(f"订单设置 {display_text(medication_days)} 天") dietary = _display_collection(_raw_value(prescription, "dietary_taboo")) void_status = _raw_value(prescription, "void_status") prescription_status = ( "—" if void_status is _MISSING else ("已作废" if _truthy(void_status) else "正常") ) fields = ( ("处方编号", _raw_value(prescription, "sn", "id", default="—")), ("患者", _raw_value(prescription, "patient_name", default="—")), ("性别 / 年龄", " · ".join(gender_age_parts)), ("手机", _raw_value(prescription, "phone", default="—")), ("处方日期", _raw_value(prescription, "prescription_date", default="—")), ("医师", _raw_value(prescription, "doctor_name", default="—")), ("类型", _raw_value(prescription, "prescription_type", default="—")), ("临床诊断", _raw_value(prescription, "clinical_diagnosis", default="—")), ("剂数 / 用法", " · ".join(dose_parts) or "—"), ("处方金额", _money_text(_raw_value(prescription, "amount"))), ("消费者审核", _audit_text(_raw_value(prescription, "audit_status"))), ("用量", " · ".join(dosage_parts) or "—"), ("服用方式", " · ".join(usage_parts) or "—"), ("忌口", dietary), ("状态", prescription_status), ) self._add_order_field_grid(layout, fields, columns=3) parent.addWidget(section) def _build_order_payment_table( self, rows: Sequence[Any], *, present: bool, object_name: str, absent_text: str, empty_text: str, ) -> RecordTable: table = RecordTable( object_name, ( ("ID", 72), ("单号", 150), ("类型", 92), ("金额", 96), ("状态", 94), ("创建人", 88), ("创建时间", 150), ), ) matrix: list[tuple[Any, ...]] = [] for row in rows: status = _raw_value(row, "status_desc", "status_text") if status is _MISSING: status = _pay_status_text(_raw_value(row, "status")) order_type = _raw_value(row, "order_type_desc", "fee_type_text") if order_type is _MISSING: order_type = _fee_type_text(_raw_value(row, "order_type", "fee_type")) matrix.append( ( _raw_value(row, "id", default="—"), _raw_value(row, "order_no", "sn", default="—"), order_type, _money_text(_raw_value(row, "amount", "pay_amount", "paid_amount")), status, _raw_value(row, "creator_name", "admin_name", default="—"), _raw_value(row, "create_time", "created_at", default="—"), ) ) table.set_matrix(matrix, danger_columns=(3,), right_columns=(3,)) table.set_empty_text(empty_text if present else absent_text) table.setMinimumHeight(145) table.setMaximumHeight(min(280, max(145, 78 + len(matrix) * 38))) for row_index, values in enumerate(matrix): set_status_tag( table, row_index, 4, str(values[4]), self._status_kind(values[4]), ) return table def _add_order_payment_section(self, parent: QVBoxLayout, detail: Any) -> None: section, layout = self._new_order_section( "收款记录", "只读模式不展示收款方式;仅呈现详情接口返回的关联关系。", ) linked, linked_present = _detail_rows(detail, "linked_pay_orders", "pay_orders") linked_title = QLabel("关联收款记录") linked_title.setProperty("orderFieldValue", True) layout.addWidget(linked_title) layout.addWidget( self._build_order_payment_table( linked, present=linked_present, object_name="DiagnosisOrderLinkedPayments", absent_text="详情接口未返回关联收款记录字段", empty_text="未关联收款单", ) ) unlinked, unlinked_present = _detail_rows( detail, "unlinked_pay_orders", "available_pay_orders", ) unlinked_title = QLabel("未关联的收款记录") unlinked_title.setProperty("orderFieldValue", True) layout.addWidget(unlinked_title) layout.addWidget( self._build_order_payment_table( unlinked, present=unlinked_present, object_name="DiagnosisOrderUnlinkedPayments", absent_text="详情接口未返回未关联收款记录字段", empty_text="无未关联的收款单", ) ) parent.addWidget(section) def _add_order_fulfillment_section(self, parent: QVBoxLayout, detail: Any) -> None: section, layout = self._new_order_section("履约与收货信息") address_parts = [ _raw_value(detail, key) for key in ( "shipping_province", "shipping_city", "shipping_district", "shipping_address", ) ] full_address = " ".join( display_text(part) for part in address_parts if part is not _MISSING ) if not full_address: full_address = display_text( _raw_value(detail, "recipient_address", "address", default="—") ) creator_parts = [ _raw_value(detail, "creator_name"), _raw_value(detail, "creator_account"), _raw_value(detail, "creator_mobile"), ] creator = ( " · ".join(display_text(part) for part in creator_parts if part is not _MISSING) or "—" ) follow_up = _raw_value(detail, "is_follow_up") fulfillment_status = _raw_value(detail, "fulfillment_status_text", "status_text") if fulfillment_status is _MISSING: fulfillment_status = _fulfillment_text( _raw_value(detail, "fulfillment_status", "status") ) medication_days = _raw_value(detail, "medication_days") fields: list[tuple[str, Any]] = [ ("诊单ID", _raw_value(detail, "diagnosis_id", default="—")), ("创建时间", _raw_value(detail, "create_time_text", "create_time", default="—")), ("业务订单创建人", creator), ( "创建人所属部门", _display_collection( _raw_value( detail, "creator_department_paths", "creator_department_path", "creator_dept_path", "creator_department_name", ) ), ), ("收货人", _raw_value(detail, "recipient_name", "patient_name", default="—")), ("收货手机", _raw_value(detail, "recipient_phone", "phone", default="—")), ("收货地址", full_address), ( "复诊", "—" if follow_up is _MISSING else ("是" if _truthy(follow_up) else "否"), ), ( "用药疗程", "—" if medication_days is _MISSING else f"{display_text(medication_days)} 天", ), ("上次医护", _raw_value(detail, "prev_staff", default="—")), ("服务渠道", _raw_value(detail, "service_channel", default="—")), ("服务套餐", _display_collection(_raw_value(detail, "service_package"))), ("费用类别", _fee_type_text(_raw_value(detail, "fee_type"))), ("履约状态", fulfillment_status), ("处方审核", _audit_text(_raw_value(detail, "prescription_audit_status"))), ("支付审核", _audit_text(_raw_value(detail, "payment_slip_audit_status"))), ("快递单号", _raw_value(detail, "tracking_number", "express_no", default="—")), ("快递公司", _express_company_text(_raw_value(detail, "express_company"))), ("医助备注", _raw_value(detail, "remark_assistant", "remark", default="—")), ( "处方审核意见", _raw_value(detail, "prescription_audit_remark", default="—"), ), ( "支付审核意见", _raw_value(detail, "payment_slip_audit_remark", default="—"), ), ] if has_permission( self.permissions, "tcm.prescriptionOrder/editRemarkExtra", default=False, ): fields.append(("药房备注", _raw_value(detail, "remark_extra", default="—"))) self._add_order_field_grid(layout, fields, columns=3) parent.addWidget(section) def _add_order_timeline_item( self, layout: QVBoxLayout, *, timestamp: Any, title: Any, body: Any, ) -> None: item = QFrame() item.setObjectName("DiagnosisOrderTimelineItem") item_layout = QGridLayout(item) item_layout.setContentsMargins(12, 5, 8, 8) item_layout.setHorizontalSpacing(12) item_layout.setVerticalSpacing(3) time_label = QLabel(display_text(timestamp)) time_label.setObjectName("DiagnosisOrderTimelineTime") time_label.setMinimumWidth(132) time_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) item_layout.addWidget(time_label, 0, 0, 2, 1, Qt.AlignmentFlag.AlignTop) title_label = QLabel(display_text(title)) title_label.setObjectName("DiagnosisOrderTimelineTitle") title_label.setWordWrap(True) item_layout.addWidget(title_label, 0, 1) body_label = QLabel(display_text(body)) body_label.setObjectName("DiagnosisOrderTimelineBody") body_label.setWordWrap(True) body_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) item_layout.addWidget(body_label, 1, 1) item_layout.setColumnStretch(1, 1) layout.addWidget(item) def _add_order_logistics_section(self, parent: QVBoxLayout, detail: Any) -> None: tracking = _raw_value(detail, "tracking_number", "express_no") hint = "" if tracking is _MISSING else display_text(tracking) section, layout = self._new_order_section("物流轨迹", hint) if tracking is _MISSING: self._add_order_empty(layout, "订单详情未返回快递单号,暂无物流轨迹") parent.addWidget(section) return payload = _raw_value( detail, "logistics_trace", "logistics_trace_payload", "tracking_trace", "logistics", ) traces, traces_present = _detail_rows(detail, "logistics_traces", "tracking_traces") if payload is not _MISSING and isinstance(payload, Mapping): nested = payload.get("data") if isinstance(nested, Mapping): payload = nested payload_traces, payload_present = _detail_rows( payload, "traces", "trace", "items", "lists" ) if payload_present: traces, traces_present = payload_traces, True state = _raw_value(payload, "state_text", "status_text", "state") carrier = _raw_value(payload, "carrier_label", "express_company") metadata: list[tuple[str, Any]] = [] if state is not _MISSING: metadata.append(("物流状态", state)) if carrier is not _MISSING: metadata.append(("承运商", _express_company_text(carrier))) if metadata: self._add_order_field_grid(layout, metadata, columns=2) if not traces_present: self._add_order_empty(layout, "详情接口未返回物流轨迹数据") elif not traces: self._add_order_empty(layout, "暂无轨迹节点记录") else: for trace in traces: self._add_order_timeline_item( layout, timestamp=_raw_value(trace, "time", "create_time", "timestamp", default="—"), title=_raw_value(trace, "state_text", "status", "location", default="物流节点"), body=_raw_value(trace, "context", "description", "content", default="—"), ) parent.addWidget(section) def _add_order_logs_section(self, parent: QVBoxLayout, detail: Any) -> None: section, layout = self._new_order_section("操作日志") if not self._can_order_logs: empty = self._add_order_empty(layout, "当前账号无操作日志查看权限") empty.setProperty("permissionDenied", True) parent.addWidget(section) return rows, present = _detail_rows(detail, "logs", "operation_logs", "order_logs") if not present: self._add_order_empty(layout, "详情接口未返回操作日志数据") elif not rows: self._add_order_empty(layout, "暂无操作日志") else: for log in rows: actor = _raw_value(log, "admin_name", "operator_name", "creator_name") action = _raw_value(log, "action_text") if action is _MISSING: action = _log_action_text(_raw_value(log, "action")) title = ( " · ".join( display_text(part) for part in (actor, action) if part is not _MISSING ) or "操作记录" ) self._add_order_timeline_item( layout, timestamp=_raw_value(log, "create_time", "created_at", default="—"), title=title, body=_raw_value(log, "summary", "content", "remark", default="—"), ) parent.addWidget(section) def _order_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None: if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id: self._show_message(friendly_error(error), "danger") def _load_orders(self) -> None: if not self._can_patient_orders or self._diagnosis_id <= 0: return self._tab_generations["orders"] += 1 generation = self._tab_generations["orders"] diagnosis_id = self._diagnosis_id patient_id = self._patient_id self._loading_tabs.add("orders") if not self.orders_list.rows: self._set_tab_loading("orders") self.orders_list.reload( lambda page: self._query_orders(diagnosis_id, patient_id, page), apply=lambda result: self._apply_orders_result( result, diagnosis_id, generation ), on_error=lambda error: self._tab_load_error( "orders", error, diagnosis_id, generation ), runner=run_async, query_key=(diagnosis_id, patient_id), ) def _apply_orders_result( self, result: Any, diagnosis_id: int, generation: int, ) -> None: if ( generation != self._tab_generations["orders"] or diagnosis_id != self._diagnosis_id or not self._authoritative_detail_loaded ): return if self.orders_list.page == 0: self._fill_orders([]) return self._apply_tab_result("orders", result, diagnosis_id, generation) def _save(self) -> None: if ( not self._editable or self._diagnosis_id <= 0 or not self._authoritative_detail_loaded or self._current_tab_key() != "basic" ): if self._editable and not self._authoritative_detail_loaded: self._show_message( "权威详情尚未加载成功,禁止保存。请先重试。", "danger", action_text="重试" ) return diagnosis_id = self._diagnosis_id changes = { key: _field_value(key, field.toPlainText(), self._field_originals.get(key)) for key, field in self.edit_fields.items() if key not in {"phone", "id_card"} or self._can_phone_plain } for key in ( "patient_name", "phone", "id_card", "gender", "age", "fasting_blood_sugar", "diagnosis_type", "local_hospital_name", ): if key in self.edit_fields: self.edit_fields[key].setProperty("invalid", False) repolish(self.edit_fields[key]) patient_name = str(changes.get("patient_name") or "").strip() if not patient_name: self._show_message("请输入患者姓名。", "warning") self.edit_fields["patient_name"].setProperty("invalid", True) repolish(self.edit_fields["patient_name"]) self.edit_fields["patient_name"].setFocus() return phone = str(changes.get("phone") or "").strip() if self._can_phone_plain and not _PHONE_PATTERN.fullmatch(phone): self._show_message("手机号格式不正确。", "warning") self.edit_fields["phone"].setProperty("invalid", True) repolish(self.edit_fields["phone"]) self.edit_fields["phone"].setFocus() return id_card = str(changes.get("id_card") or "").strip() if self._can_phone_plain and id_card and not _ID_CARD_PATTERN.fullmatch(id_card): self._show_message("身份证号格式不正确。", "warning") self.edit_fields["id_card"].setProperty("invalid", True) repolish(self.edit_fields["id_card"]) self.edit_fields["id_card"].setFocus() return gender_text = str(self.edit_fields["gender"].toPlainText() or "").strip() if gender_text == "": self._show_message("请选择性别。", "warning") self.edit_fields["gender"].setProperty("invalid", True) repolish(self.edit_fields["gender"]) return age_text = str(self.edit_fields["age"].toPlainText() or "").strip() if age_text == "": self._show_message("请输入年龄。", "warning") self.edit_fields["age"].setProperty("invalid", True) repolish(self.edit_fields["age"]) self.edit_fields["age"].setFocus() return fasting = str(changes.get("fasting_blood_sugar") or "").strip() if not fasting: self._show_message("请输入空腹血糖。", "warning") self.edit_fields["fasting_blood_sugar"].setProperty("invalid", True) repolish(self.edit_fields["fasting_blood_sugar"]) self.edit_fields["fasting_blood_sugar"].setFocus() return hospital = str(changes.get("local_hospital_name") or "").strip() if not hospital: self._show_message("请输入当地就诊医院名称。", "warning") self.edit_fields["local_hospital_name"].setProperty("invalid", True) repolish(self.edit_fields["local_hospital_name"]) self.edit_fields["local_hospital_name"].setFocus() return diagnosis_type_raw = str(changes.get("diagnosis_type") or "").strip() diagnosis_type = _diagnosis_type_value(diagnosis_type_raw) if not diagnosis_type: self._show_message("请选择诊断类型。", "warning") self.edit_fields["diagnosis_type"].setProperty("invalid", True) repolish(self.edit_fields["diagnosis_type"]) self.edit_fields["diagnosis_type"].setFocus() return changes["diagnosis_type"] = diagnosis_type self._save_generation += 1 generation = self._save_generation self._saving = True self._sync_save_button() self._show_message("正在保存病历…", "info") run_async( lambda: self._validate_and_save(diagnosis_id, changes), on_success=lambda _result: self._save_success(generation), on_error=lambda error: self._save_error(error, generation), on_finished=lambda: self._save_finished(generation), ) def _validate_and_save(self, diagnosis_id: int, changes: Mapping[str, Any]) -> Any: if self._can_phone_plain: phone = str(changes.get("phone") or "").strip() phone_result = _invoke_first( self.repository, ("check_diagnosis_phone",), payload={"phone": phone, "id": diagnosis_id}, ) if _duplicate_found(phone_result): raise ValueError( str( first_value( phone_result, "message", "data.message", default="手机号已存在。" ) ) ) id_card = str(changes.get("id_card") or "").strip() if id_card: id_card_result = _invoke_first( self.repository, ("check_diagnosis_id_card",), payload={"id_card": id_card, "id": diagnosis_id}, ) if _duplicate_found(id_card_result): raise ValueError( str( first_value( id_card_result, "message", "data.message", default="身份证号已存在。", ) ) ) return _invoke_first( self.repository, ("update_diagnosis",), diagnosis=diagnosis_id, changes=dict(changes), ) def _save_success(self, generation: int) -> None: if generation != self._save_generation: return self.save_button.set_state("success") self._show_message("病历已保存。", "success") self.saved.emit() def _save_error(self, error: Exception, generation: int) -> None: if generation == self._save_generation: self.save_button.set_state("error") self._show_message(friendly_error(error), "danger") def _save_finished(self, generation: int) -> None: if generation == self._save_generation: self._saving = False self._sync_save_button() __all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"] def _unwrap_order_detail(detail: Any) -> Any: payload = detail if isinstance(detail, Mapping): for key in ("data", "detail"): nested = detail.get(key) if isinstance(nested, Mapping) and any( field in nested for field in ("id", "order_no", "prescription_id") ): payload = nested break return payload def present_order_detail( parent: QWidget | None, detail: Any, *, order_id: int = 0, permissions: Any = None, exec_: bool = True, ) -> OrderDetailDrawer: """Open the shared prescription-order detail drawer (admin drawer parity). Used by diagnosis「业务订单」、处方「查看关联订单」and 我的患者「订单管理」. """ from types import MethodType payload = _unwrap_order_detail(detail) resolved_id = int(order_id or 0) if resolved_id <= 0: raw_id = first_value(payload, "id", "order_id", default=0) try: resolved_id = int(raw_id or 0) except (TypeError, ValueError): resolved_id = 0 class _OrderDetailShim: pass shim = _OrderDetailShim() shim.permissions = permissions shim._can_order_logs = has_permission(permissions, _ORDER_LOGS_PERMISSION, default=False) for name in ( "_build_order_detail_dialog", "_new_order_section", "_add_order_empty", "_add_order_field_grid", "_add_order_amount_overview", "_add_order_prescription_section", "_build_order_payment_table", "_add_order_payment_section", "_add_order_fulfillment_section", "_add_order_timeline_item", "_add_order_logistics_section", "_add_order_logs_section", ): setattr(shim, name, MethodType(getattr(DiagnosisDialog, name), shim)) shim._status_kind = DiagnosisDialog._status_kind dialog = shim._build_order_detail_dialog(payload, resolved_id, host=parent) if exec_: dialog.exec() else: dialog.open() return dialog