Files
zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py
T
2026-10-08 15:49:53 +08:00

5129 lines
216 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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