2378 lines
93 KiB
Python
2378 lines
93 KiB
Python
"""Appointment queue page matching ``admin/src/views/tcm/appointment/list.vue``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from copy import deepcopy
|
|
from datetime import date, timedelta
|
|
from html import escape
|
|
from types import MappingProxyType
|
|
from typing import Any
|
|
|
|
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
|
|
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QButtonGroup,
|
|
QCheckBox,
|
|
QComboBox,
|
|
QDateEdit,
|
|
QDialog,
|
|
QDialogButtonBox,
|
|
QFrame,
|
|
QHBoxLayout,
|
|
QHeaderView,
|
|
QLabel,
|
|
QLineEdit,
|
|
QMenu,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QTabBar,
|
|
QTextEdit,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from ..dialogs import DiagnosisDialog
|
|
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
|
from ..dialogs.prescription import (
|
|
PrescriptionDetailDialog,
|
|
PrescriptionEditorDialog,
|
|
build_prescription_clinical_diagnosis,
|
|
build_prescription_visit_no,
|
|
)
|
|
from ..dialogs.prescription_ai import (
|
|
can_open_diagnosis_ai_report,
|
|
present_diagnosis_ai_report,
|
|
)
|
|
from ..theme import mark_business_dialog
|
|
from ..widgets import (
|
|
MessageBanner,
|
|
PageHeader,
|
|
Pager,
|
|
SortableTable,
|
|
TableColumn,
|
|
display_text,
|
|
first_value,
|
|
friendly_error,
|
|
gender_text,
|
|
get_value,
|
|
invoke,
|
|
page_items,
|
|
page_total,
|
|
run_async,
|
|
show_toast,
|
|
)
|
|
|
|
STATUS_TABS: tuple[tuple[str, str | int], ...] = (
|
|
("待接诊", 1),
|
|
("全部", "all"),
|
|
("已过号", 4),
|
|
("已取消", 2),
|
|
("已完成", 3),
|
|
)
|
|
DATE_PRESETS: tuple[tuple[str, str], ...] = (
|
|
("昨天挂号", "yesterday"),
|
|
("前天挂号", "day_before"),
|
|
("当天挂号", "today"),
|
|
("明天挂号", "tomorrow"),
|
|
("后天挂号", "day_after"),
|
|
("不限", ""),
|
|
)
|
|
LIST_POLL_MS = 20_000
|
|
NOTE_LIMIT = 500
|
|
TABLE_CELL_VERTICAL_PADDING = 10
|
|
|
|
_SEMANTIC_COLORS = {
|
|
"success": "#159C79",
|
|
"warning": "#C17A16",
|
|
"danger": "#EC5266",
|
|
"info": "#4776EE",
|
|
"muted": "#7481A3",
|
|
}
|
|
|
|
APPOINTMENTS_LIGHT_QSS = """
|
|
/* 页头此前被压到只剩面包屑(26px),与其余列表页的“面包屑+标题+副标题”
|
|
骨架不一致。这里给它与 PageHeader 自然高度相符的空间。 */
|
|
#AppointmentsPage QWidget#PageHeader {
|
|
min-height: 62px;
|
|
max-height: 62px;
|
|
}
|
|
#AppointmentsPage QFrame#AppointmentFilterPanel {
|
|
min-height: 80px;
|
|
max-height: 80px;
|
|
background-color: #FFFFFF;
|
|
border: 1px solid #E2E7F4;
|
|
border-radius: 10px;
|
|
}
|
|
#AppointmentsPage QFrame#AppointmentMainCard {
|
|
background-color: #FFFFFF;
|
|
border: 1px solid #E2E7F4;
|
|
border-radius: 10px;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentStat="true"] {
|
|
min-height: 30px;
|
|
max-height: 30px;
|
|
padding: 0 7px;
|
|
color: #59698E;
|
|
background-color: #F8F9FD;
|
|
border: 0;
|
|
border-radius: 8px;
|
|
font-size: 12px;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentStat="true"]:hover {
|
|
color: #5265F6;
|
|
background-color: #F0F2FF;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
|
|
color: #FFFFFF;
|
|
background-color: #5265F6;
|
|
border-color: #5265F6;
|
|
font-weight: 600;
|
|
}
|
|
/* 这一排全是筛选片,此前按业务语义分别染成紫/绿/琥珀,一行出现四种底色,
|
|
而且颜色和“是否选中”这一真正需要区分的状态互相打架。筛选片一律保持中性,
|
|
只有“待分配医助”在确有待办时才提示为琥珀色。 */
|
|
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"] {
|
|
color: #C17A16;
|
|
background-color: #FFF8ED;
|
|
border-color: #F0DCB6;
|
|
}
|
|
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
|
|
min-height: 30px;
|
|
max-height: 30px;
|
|
background-color: #FFFFFF;
|
|
border: 1px solid #DDE3F0;
|
|
border-radius: 7px;
|
|
}
|
|
#AppointmentsPage QLabel#FilterRowLabel {
|
|
color: #405074;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
#AppointmentsPage QLabel#FilterDivider {
|
|
color: #E2E7F4;
|
|
padding: 0 2px;
|
|
}
|
|
#AppointmentsPage QTableWidget#AppointmentTable::item {
|
|
padding: 5px 7px;
|
|
}
|
|
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
|
|
min-height: 34px;
|
|
background-color: #F8FAFF;
|
|
}
|
|
#AppointmentsPage QTableWidget#AppointmentTable {
|
|
gridline-color: #E9EDF5;
|
|
selection-background-color: #FFFFFF;
|
|
selection-color: #15224A;
|
|
}
|
|
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
|
|
width: 13px;
|
|
height: 13px;
|
|
background-color: #FFFFFF;
|
|
border: 1px solid #CBD3E7;
|
|
border-radius: 2px;
|
|
}
|
|
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked {
|
|
background-color: #5265F6;
|
|
border-color: #5265F6;
|
|
}
|
|
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
|
|
min-height: 18px;
|
|
max-height: 18px;
|
|
padding: 0 6px;
|
|
color: #5265F6;
|
|
background-color: #EEF1FF;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] {
|
|
color: #B97715;
|
|
background-color: #FFF4DF;
|
|
}
|
|
#AppointmentsPage QLabel[tableAppointmentMeta="true"] {
|
|
color: #59698E;
|
|
font-size: 10px;
|
|
}
|
|
#AppointmentsPage QPushButton[tableCancelAction="true"] {
|
|
min-height: 18px;
|
|
max-height: 18px;
|
|
padding: 0 5px;
|
|
color: #EC5266;
|
|
background-color: #FFF4F6;
|
|
border: 0;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
}
|
|
#AppointmentsPage QWidget[appointmentImHost="true"] {
|
|
background-color: transparent;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentImAction="true"] {
|
|
min-width: 74px;
|
|
min-height: 26px;
|
|
max-height: 26px;
|
|
padding: 0 9px;
|
|
color: #5265F6;
|
|
background-color: #F0F2FF;
|
|
border: 1px solid #D7DEFF;
|
|
border-radius: 7px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover {
|
|
color: #FFFFFF;
|
|
background-color: #5265F6;
|
|
border-color: #5265F6;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus {
|
|
border-color: #8D9BFF;
|
|
}
|
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled {
|
|
color: #98A3BC;
|
|
background-color: #F7F8FC;
|
|
border-color: #E4E8F2;
|
|
}
|
|
#AppointmentsPage QWidget[appointmentInfoHost="true"] {
|
|
background-color: #FFFFFF;
|
|
}
|
|
#AppointmentsPage QPushButton[compactAction="true"] {
|
|
min-height: 28px;
|
|
max-height: 28px;
|
|
padding: 0 8px;
|
|
border-radius: 7px;
|
|
font-size: 11px;
|
|
}
|
|
#AppointmentsPage QPushButton[variant="chip"] {
|
|
min-height: 34px;
|
|
max-height: 34px;
|
|
padding: 0 12px;
|
|
color: #7481A3;
|
|
background-color: #F8FAFF;
|
|
border: 1px solid #E2E7F4;
|
|
border-radius: 8px;
|
|
}
|
|
#AppointmentsPage QPushButton[variant="chip"]:hover {
|
|
color: #15224A;
|
|
background-color: #F0F3FC;
|
|
border-color: #5265F6;
|
|
}
|
|
#AppointmentsPage QPushButton[variant="chip"]:checked {
|
|
color: #FFFFFF;
|
|
background-color: #5265F6;
|
|
border-color: #5265F6;
|
|
}
|
|
#AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; }
|
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
|
|
min-width: 62px;
|
|
min-height: 28px;
|
|
padding: 0 7px;
|
|
color: #7481A3;
|
|
background-color: transparent;
|
|
border: 0;
|
|
border-bottom: 2px solid transparent;
|
|
}
|
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover {
|
|
color: #15224A;
|
|
background-color: #F0F3FC;
|
|
}
|
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
|
|
color: #3C4FD9;
|
|
background-color: #EEF1FF;
|
|
border-bottom-color: #5265F6;
|
|
}
|
|
#AppointmentsPage QPushButton[filterChoice="true"] {
|
|
min-height: 28px;
|
|
max-height: 28px;
|
|
padding: 0 10px;
|
|
color: #405074;
|
|
background-color: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 7px;
|
|
}
|
|
#AppointmentsPage QPushButton[filterChoice="true"]:checked {
|
|
color: #5265F6;
|
|
background-color: #F0F1FF;
|
|
border-color: #E0E4FF;
|
|
}
|
|
"""
|
|
|
|
|
|
def _as_int(value: Any, default: int = 0) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _as_bool(value: Any) -> bool:
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return bool(value)
|
|
|
|
|
|
def _canonical_allowed(permissions: Any, code: str, *, default: bool = True) -> bool:
|
|
if permissions is None:
|
|
return default
|
|
for method_name in ("allows", "has", "can", "contains", "has_permission"):
|
|
method = getattr(permissions, method_name, None)
|
|
if callable(method):
|
|
try:
|
|
return bool(method(code))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
raw = permissions
|
|
for attr in ("codes", "permissions", "values"):
|
|
candidate = getattr(permissions, attr, None)
|
|
if candidate is not None and not callable(candidate):
|
|
raw = candidate
|
|
break
|
|
if isinstance(raw, Mapping):
|
|
nested = first_value(raw, "codes", "permissions", "values", default=None)
|
|
if nested is not None:
|
|
raw = nested
|
|
if isinstance(raw, Mapping):
|
|
available = {str(key) for key, enabled in raw.items() if enabled}
|
|
elif isinstance(raw, str):
|
|
available = {raw}
|
|
else:
|
|
try:
|
|
available = {str(item) for item in raw}
|
|
except TypeError:
|
|
return False
|
|
if "*" in available or code in available:
|
|
return True
|
|
return any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available)
|
|
|
|
|
|
def _supports_native_video(repository: Any) -> bool:
|
|
return all(
|
|
callable(getattr(repository, method_name, None))
|
|
for method_name in ("get_call_ticket", "start_call", "bind_call_room", "end_call")
|
|
)
|
|
|
|
|
|
def _is_admin_user(user: Any) -> bool:
|
|
"""Match admin: neither doctor (1) nor assistant (2)."""
|
|
|
|
role_id = first_value(user, "role_id", "role_ids", default=None)
|
|
if isinstance(role_id, Sequence) and not isinstance(role_id, (str, bytes, bytearray)):
|
|
values = {_as_int(item, -1) for item in role_id}
|
|
return 1 not in values and 2 not in values
|
|
value = _as_int(role_id, -1)
|
|
return value not in {1, 2}
|
|
|
|
|
|
def _mask_phone(value: Any, *, can_plain: bool) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return "—"
|
|
if can_plain or "*" in text:
|
|
return text
|
|
digits = "".join(character for character in text if character.isdigit())
|
|
if len(digits) >= 7:
|
|
return f"{digits[:3]}****{digits[-4:]}"
|
|
if len(text) > 4:
|
|
return f"{text[:2]}***{text[-2:]}"
|
|
return "****"
|
|
|
|
|
|
def _diagnosis_id(row: Any) -> int:
|
|
"""Admin appointment rows often store diagnosis id in ``patient_id``."""
|
|
|
|
explicit = _as_int(first_value(row, "diagnosis_id", default=0))
|
|
if explicit > 0:
|
|
return explicit
|
|
return _as_int(first_value(row, "patient_id", default=0))
|
|
|
|
|
|
def _video_patient_id(row: Any) -> int:
|
|
"""Return only the appointment row's explicitly separated patient owner."""
|
|
|
|
return _as_int(first_value(row, "source_patient_id", default=0))
|
|
|
|
|
|
def _appointment_id(row: Any) -> int:
|
|
return _as_int(first_value(row, "id", "appointment_id", default=0))
|
|
|
|
|
|
def _status_value(row: Any) -> int:
|
|
return _as_int(first_value(row, "status", default=0))
|
|
|
|
|
|
_IM_CONSULT_ACTIVE_STATUSES = frozenset({1, 4})
|
|
|
|
|
|
def _im_consult_status_error(row: Any) -> str:
|
|
"""Return why a queue row cannot open the doctor's IM consultation."""
|
|
|
|
status = _status_value(row)
|
|
if status in _IM_CONSULT_ACTIVE_STATUSES:
|
|
return ""
|
|
if status == 2:
|
|
return "已取消的挂号不可进入 IM 问诊"
|
|
if status == 3:
|
|
return "已完成的挂号不可再进入 IM 问诊"
|
|
return "当前挂号状态不可进入 IM 问诊"
|
|
|
|
|
|
def prescription_action_label(row: Any) -> str:
|
|
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
|
|
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
|
|
explicit = first_value(row, "current_has_prescription", default=None)
|
|
if explicit is None and _as_int(first_value(row, "current_prescription_id", default=0)) <= 0:
|
|
explicit = first_value(row, "has_prescription", default=None)
|
|
has_prescription = (
|
|
_as_bool(explicit)
|
|
if explicit is not None
|
|
else _as_int(
|
|
first_value(row, "current_prescription_id", "prescription_id", default=0),
|
|
0,
|
|
)
|
|
> 0
|
|
or audit in {0, 1, 2}
|
|
)
|
|
if not has_prescription:
|
|
return "开方"
|
|
return "查看" if audit == 1 and voided != 1 else "编辑处方"
|
|
|
|
|
|
def _patient_cell(_value: Any, row: Any, *, can_plain: bool) -> str:
|
|
phone = _mask_phone(
|
|
first_value(row, "patient_phone", "phone", default=""),
|
|
can_plain=can_plain,
|
|
)
|
|
extras = " ".join(
|
|
part
|
|
for part in (
|
|
gender_text(first_value(row, "gender", "gender_desc", default=None)),
|
|
(
|
|
f"{first_value(row, 'age')}岁"
|
|
if first_value(row, "age", default=None) not in (None, "")
|
|
else ""
|
|
),
|
|
(
|
|
f"{first_value(row, 'height')}cm"
|
|
if first_value(row, "height", default=None) not in (None, "")
|
|
else ""
|
|
),
|
|
(
|
|
f"{first_value(row, 'weight')}kg"
|
|
if first_value(row, "weight", default=None) not in (None, "")
|
|
else ""
|
|
),
|
|
)
|
|
if part and part != "—"
|
|
)
|
|
return f"{display_text(first_value(row, 'patient_name'), '—')}\n{phone}" + (
|
|
f"\n{extras}" if extras else ""
|
|
)
|
|
|
|
|
|
def _datetime_cell(_value: Any, row: Any) -> str:
|
|
return (
|
|
f"{display_text(first_value(row, 'appointment_date'), '—')}\n"
|
|
f"{display_text(first_value(row, 'appointment_time'), '—')}"
|
|
)
|
|
|
|
|
|
def _confirmed_cell(_value: Any, row: Any) -> str:
|
|
return (
|
|
"已确认" if _as_bool(first_value(row, "diagnosis_confirmed", default=False)) else "未确认"
|
|
)
|
|
|
|
|
|
def _prescription_cell(_value: Any, row: Any) -> str:
|
|
has_rx = _as_bool(first_value(row, "has_prescription", default=False))
|
|
label = "已开方" if has_rx else "未开方"
|
|
if has_rx and _as_int(first_value(row, "prescription_is_system_auto"), 0) == 1:
|
|
return f"{label}\n系统代开"
|
|
return label
|
|
|
|
|
|
def _status_cell(_value: Any, row: Any) -> str:
|
|
return display_text(
|
|
first_value(row, "status_desc"), str(first_value(row, "status", default="—"))
|
|
)
|
|
|
|
|
|
def _gender_age_cell(_value: Any, row: Any) -> str:
|
|
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
|
|
age = first_value(row, "age", default=None)
|
|
age_text = f"{age}岁" if age not in (None, "") else "—"
|
|
return f"{gender} · {age_text}"
|
|
|
|
|
|
def _appointment_info_cell(_value: Any, row: Any) -> str:
|
|
status = _status_cell(None, row)
|
|
doctor = display_text(first_value(row, "doctor_name"), "未分配")
|
|
date_text = display_text(first_value(row, "appointment_date"), "—")
|
|
time_text = display_text(first_value(row, "appointment_time"), "—")
|
|
channel = display_text(
|
|
first_value(row, "channel_name", "channel_source_name", "source_name"), "—"
|
|
)
|
|
return f"{status} {doctor}\n{date_text} {time_text}\n最近渠道:{channel}"
|
|
|
|
|
|
def _revisit_cell(_value: Any, row: Any) -> str:
|
|
revisit = first_value(
|
|
row,
|
|
"revisit_time",
|
|
"review_time",
|
|
"revisit_date",
|
|
"review_date",
|
|
default="",
|
|
)
|
|
return display_text(revisit, "无")
|
|
|
|
|
|
def _unserved_cell(_value: Any, row: Any) -> str:
|
|
value = first_value(
|
|
row,
|
|
"unserved_days",
|
|
"unservice_days",
|
|
"not_served_days",
|
|
default=None,
|
|
)
|
|
return display_text(value, "—")
|
|
|
|
|
|
def _offset_date(preset: str) -> date | None:
|
|
today = date.today()
|
|
offsets = {
|
|
"yesterday": -1,
|
|
"day_before": -2,
|
|
"today": 0,
|
|
"tomorrow": 1,
|
|
"day_after": 2,
|
|
}
|
|
if preset not in offsets:
|
|
return None
|
|
return today + timedelta(days=offsets[preset])
|
|
|
|
|
|
def _flatten_departments(
|
|
nodes: Sequence[Any],
|
|
*,
|
|
prefix: str = "",
|
|
) -> list[tuple[int, str]]:
|
|
result: list[tuple[int, str]] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
dept_id = _as_int(first_value(node, "id", default=0))
|
|
name = str(first_value(node, "name", "label", default="")).strip()
|
|
if dept_id <= 0 or not name:
|
|
continue
|
|
label = f"{prefix}{name}" if not prefix else f"{prefix} / {name}"
|
|
result.append((dept_id, label if not prefix else f"{prefix}/{name}"))
|
|
children = first_value(node, "children", "child", default=[])
|
|
if isinstance(children, Sequence) and not isinstance(children, (str, bytes)):
|
|
child_prefix = name if not prefix else f"{prefix}/{name}"
|
|
result.extend(_flatten_departments(children, prefix=child_prefix))
|
|
return result
|
|
|
|
|
|
def _stable_signature_value(value: Any) -> Any:
|
|
"""Return a deterministic, order-independent representation of API data."""
|
|
|
|
if value is None or isinstance(value, (bool, int, str)):
|
|
return value
|
|
if isinstance(value, float):
|
|
return repr(value)
|
|
if isinstance(value, Mapping):
|
|
items = (
|
|
(str(key), _stable_signature_value(item))
|
|
for key, item in value.items()
|
|
)
|
|
return tuple(sorted(items, key=lambda pair: pair[0]))
|
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
return tuple(_stable_signature_value(item) for item in value)
|
|
if isinstance(value, (set, frozenset)):
|
|
frozen = (_stable_signature_value(item) for item in value)
|
|
return tuple(sorted(frozen, key=repr))
|
|
dataclass_fields = getattr(type(value), "__dataclass_fields__", None)
|
|
if isinstance(dataclass_fields, Mapping):
|
|
return (
|
|
type(value).__qualname__,
|
|
tuple(
|
|
(name, _stable_signature_value(getattr(value, name, None)))
|
|
for name in dataclass_fields
|
|
),
|
|
)
|
|
state = getattr(value, "__dict__", None)
|
|
if isinstance(state, Mapping):
|
|
return (type(value).__qualname__, _stable_signature_value(state))
|
|
isoformat = getattr(value, "isoformat", None)
|
|
if callable(isoformat):
|
|
try:
|
|
return (type(value).__qualname__, isoformat())
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return (type(value).__qualname__, str(value))
|
|
|
|
|
|
def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any) -> tuple[Any, ...]:
|
|
"""Build the stable render signature used to short-circuit unchanged polling."""
|
|
|
|
return (
|
|
"appointments-render-v1",
|
|
total,
|
|
_stable_signature_value(rows),
|
|
_stable_signature_value(extend),
|
|
)
|
|
|
|
|
|
class AppointmentsPage(QWidget):
|
|
"""Desktop appointment list with status tabs, filters, call and prescription."""
|
|
|
|
video_requested = Signal(dict)
|
|
|
|
def __init__(
|
|
self,
|
|
repository: Any,
|
|
permissions: Any = None,
|
|
current_user: Any = None,
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.setObjectName("AppointmentsPage")
|
|
self.setStyleSheet(APPOINTMENTS_LIGHT_QSS)
|
|
self.repository = repository
|
|
self.permissions = permissions
|
|
self.current_user = current_user
|
|
self._page = 1
|
|
self._page_size = 15
|
|
self._generation = 0
|
|
self._dept_generation = 0
|
|
self._dict_generation = 0
|
|
self._action_generation = 0
|
|
self._prescription_generation = 0
|
|
self._loading = False
|
|
self._mutation_pending = False
|
|
self._prescription_busy = False
|
|
self._status: str | int = 1
|
|
self._date_preset = "today"
|
|
self._assistant_unassigned = False
|
|
self._start_date = date.today().isoformat()
|
|
self._end_date = self._start_date
|
|
self._status_counts: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0}
|
|
self._dict_labels: dict[str, dict[str, str]] = {}
|
|
self._last_render_signature: tuple[Any, ...] | None = None
|
|
self._responsive_narrow: bool | None = None
|
|
self._can_plain_phone = _canonical_allowed(
|
|
permissions, "tcm.diagnosis/phonePlain", default=False
|
|
)
|
|
self._native_video_capable = _supports_native_video(repository)
|
|
self._is_admin = _is_admin_user(current_user)
|
|
|
|
root = QVBoxLayout(self)
|
|
root.setContentsMargins(18, 3, 6, 8)
|
|
root.setSpacing(4)
|
|
# 其余列表页都有“面包屑 + 标题 + 副标题”,这一页此前把标题隐藏了,
|
|
# 导致同一套列表页有两种页头形态。保留页头以对齐全局页面骨架。
|
|
self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。")
|
|
root.addWidget(self.header)
|
|
self.filter_panel = self._build_filter_panel()
|
|
root.addWidget(self.filter_panel)
|
|
self.banner = MessageBanner()
|
|
root.addWidget(self.banner)
|
|
self.content_host = self._build_content()
|
|
root.addWidget(self.content_host, 1)
|
|
|
|
self.poll_timer = QTimer(self)
|
|
self.poll_timer.setInterval(LIST_POLL_MS)
|
|
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True))
|
|
self._apply_responsive_layout()
|
|
|
|
def _diagnosis_dialog(self) -> DiagnosisDialog:
|
|
dialog = getattr(self, "_diagnosis_dialog_impl", None)
|
|
if dialog is None:
|
|
dialog = DiagnosisDialog(self.repository, self, permissions=self.permissions)
|
|
dialog.saved.connect(lambda: self.refresh(silent=True))
|
|
self._diagnosis_dialog_impl = dialog
|
|
return dialog
|
|
|
|
def _build_filter_panel(self) -> QWidget:
|
|
frame = QFrame()
|
|
frame.setObjectName("AppointmentFilterPanel")
|
|
layout = QVBoxLayout(frame)
|
|
layout.setContentsMargins(6, 4, 8, 6)
|
|
layout.setSpacing(4)
|
|
|
|
date_row = QHBoxLayout()
|
|
date_row.setSpacing(4)
|
|
self.date_buttons: dict[str, QPushButton] = {}
|
|
self._date_stat_labels: dict[str, str] = {}
|
|
for source_label, preset in DATE_PRESETS:
|
|
label = "全部" if preset == "" else source_label
|
|
button = QPushButton(f"{label} 0")
|
|
button.setCheckable(True)
|
|
button.setProperty("appointmentStat", True)
|
|
button.setMinimumWidth(0)
|
|
button.clicked.connect(
|
|
lambda _checked=False, value=preset: self._set_date_preset(value)
|
|
)
|
|
self.date_buttons[preset] = button
|
|
self._date_stat_labels[preset] = label
|
|
date_row.addWidget(button, 1)
|
|
self.date_buttons["today"].setChecked(True)
|
|
self.date_overflow_button = QPushButton("日期")
|
|
self.date_overflow_button.setProperty("appointmentStat", True)
|
|
date_menu = QMenu(self.date_overflow_button)
|
|
for source_label, preset in DATE_PRESETS:
|
|
action = date_menu.addAction(source_label if preset else "不限日期")
|
|
action.triggered.connect(
|
|
lambda _checked=False, value=preset: self._set_date_preset(value)
|
|
)
|
|
date_menu.addSeparator()
|
|
date_menu.addAction("自定义日期").triggered.connect(self._open_custom_date)
|
|
self.date_overflow_button.setMenu(date_menu)
|
|
date_row.addWidget(self.date_overflow_button)
|
|
|
|
self.pending_stat_button = QPushButton("待预约 0")
|
|
self.pending_stat_button.setProperty("appointmentStat", True)
|
|
self.pending_stat_button.setMinimumWidth(0)
|
|
self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1))
|
|
date_row.addWidget(self.pending_stat_button, 1)
|
|
self.completed_stat_button = QPushButton("已完成 0")
|
|
self.completed_stat_button.setProperty("appointmentStat", True)
|
|
self.completed_stat_button.setMinimumWidth(0)
|
|
self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3))
|
|
date_row.addWidget(self.completed_stat_button, 1)
|
|
self.unassigned_stat_button = QPushButton("待分配医助 0")
|
|
self.unassigned_stat_button.setCheckable(True)
|
|
self.unassigned_stat_button.setProperty("appointmentStat", True)
|
|
self.unassigned_stat_button.setProperty("appointmentStatKind", "warning")
|
|
self.unassigned_stat_button.setMinimumWidth(0)
|
|
self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter)
|
|
date_row.addWidget(self.unassigned_stat_button, 1)
|
|
|
|
self.patient_input = QLineEdit()
|
|
self.patient_input.setObjectName("AppointmentPatientSearch")
|
|
self.patient_input.setPlaceholderText("患者姓名 / 手机号")
|
|
self.patient_input.setClearButtonEnabled(True)
|
|
self.patient_input.returnPressed.connect(self._search)
|
|
date_row.addWidget(self.patient_input, 2)
|
|
|
|
search = QPushButton("查询")
|
|
search.setProperty("variant", "primary")
|
|
search.clicked.connect(self._search)
|
|
date_row.addWidget(search)
|
|
layout.addLayout(date_row)
|
|
|
|
filter_row = QHBoxLayout()
|
|
filter_row.setSpacing(5)
|
|
self.status_filter_label = QLabel("挂号状态:")
|
|
self.status_filter_label.setObjectName("FilterRowLabel")
|
|
filter_row.addWidget(self.status_filter_label)
|
|
self.tab_bar = QTabBar()
|
|
self.tab_bar.setObjectName("AppointmentStatusTabs")
|
|
self._tab_indexes: dict[str | int, int] = {}
|
|
for label, value in STATUS_TABS:
|
|
index = self.tab_bar.addTab(label)
|
|
self.tab_bar.setTabData(index, value)
|
|
self._tab_indexes[value] = index
|
|
self.tab_bar.currentChanged.connect(self._tab_changed)
|
|
filter_row.addWidget(self.tab_bar)
|
|
|
|
self.filter_dividers: list[QLabel] = []
|
|
divider = QLabel("│")
|
|
divider.setObjectName("FilterDivider")
|
|
self.filter_dividers.append(divider)
|
|
filter_row.addWidget(divider)
|
|
|
|
self.confirm_filter_label = QLabel("确认状态:")
|
|
self.confirm_filter_label.setObjectName("FilterRowLabel")
|
|
filter_row.addWidget(self.confirm_filter_label)
|
|
self.confirmed_filter = QComboBox(frame)
|
|
self.confirmed_filter.addItem("全部", "")
|
|
self.confirmed_filter.addItem("已确认", "1")
|
|
self.confirmed_filter.addItem("未确认", "0")
|
|
self.confirmed_filter.hide()
|
|
self.confirmed_filter.currentIndexChanged.connect(self._confirmed_combo_changed)
|
|
self.confirmed_group = QButtonGroup(self)
|
|
self.confirmed_group.setExclusive(True)
|
|
self.confirmed_buttons: dict[str, QPushButton] = {}
|
|
for label, value in (("全部", ""), ("已确认", "1"), ("未确认", "0")):
|
|
button = QPushButton(label)
|
|
button.setCheckable(True)
|
|
button.setProperty("filterChoice", True)
|
|
button.clicked.connect(
|
|
lambda _checked=False, selected=value: self._set_confirmed_choice(selected)
|
|
)
|
|
self.confirmed_group.addButton(button)
|
|
self.confirmed_buttons[value] = button
|
|
filter_row.addWidget(button)
|
|
self.confirmed_buttons[""].setChecked(True)
|
|
|
|
divider = QLabel("│")
|
|
divider.setObjectName("FilterDivider")
|
|
self.filter_dividers.append(divider)
|
|
filter_row.addWidget(divider)
|
|
|
|
self.more_filters_button = QPushButton("更多筛选")
|
|
self.more_filters_button.setCheckable(True)
|
|
self.more_filters_button.setProperty("filterChoice", True)
|
|
self.more_filters_button.clicked.connect(self._toggle_advanced_filters)
|
|
filter_row.addWidget(self.more_filters_button)
|
|
|
|
self.dept_filter = QComboBox()
|
|
self.dept_filter.addItem("全部部门", "")
|
|
self.dept_filter.setMinimumWidth(150)
|
|
self.dept_filter.currentIndexChanged.connect(self._search)
|
|
self.dept_filter.hide()
|
|
filter_row.addWidget(self.dept_filter)
|
|
|
|
self.doctor_input = QLineEdit(frame)
|
|
self.doctor_input.setPlaceholderText("医生")
|
|
self.doctor_input.setClearButtonEnabled(True)
|
|
self.doctor_input.setMaximumWidth(120)
|
|
self.doctor_input.hide()
|
|
self.doctor_input.returnPressed.connect(self._search)
|
|
filter_row.addWidget(self.doctor_input)
|
|
filter_row.addStretch(1)
|
|
|
|
custom = QPushButton("自定义日期")
|
|
custom.setProperty("variant", "ghost")
|
|
custom.clicked.connect(self._open_custom_date)
|
|
custom.hide()
|
|
self.custom_date_button = custom
|
|
filter_row.addWidget(custom)
|
|
|
|
reset = QPushButton("重置")
|
|
reset.setProperty("variant", "ghost")
|
|
reset.clicked.connect(self._reset_filters)
|
|
reset.hide()
|
|
self.reset_filter_button = reset
|
|
filter_row.addWidget(reset)
|
|
layout.addLayout(filter_row)
|
|
return frame
|
|
|
|
def _build_content(self) -> QWidget:
|
|
host = QWidget()
|
|
host.setMinimumHeight(0)
|
|
layout = QHBoxLayout(host)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(4)
|
|
self.content_layout = layout
|
|
self.table_card = self._build_table_card()
|
|
layout.addWidget(self.table_card, 1)
|
|
return host
|
|
|
|
def _build_table_card(self) -> QWidget:
|
|
card = QFrame()
|
|
card.setObjectName("AppointmentMainCard")
|
|
card.setMinimumHeight(0)
|
|
layout = QVBoxLayout(card)
|
|
layout.setContentsMargins(4, 8, 8, 8)
|
|
layout.setSpacing(8)
|
|
|
|
compatibility_host = QWidget(card)
|
|
compatibility_host.hide()
|
|
compatibility_actions = QHBoxLayout(compatibility_host)
|
|
compatibility_actions.setContentsMargins(0, 0, 0, 0)
|
|
self.edit_button = self._action_button("编辑患者", "tcm.diagnosis/edit", self._edit_patient)
|
|
compatibility_actions.addWidget(self.edit_button)
|
|
self.qr_button = self._action_button(
|
|
"视频二维码", "tcm.diagnosis/videoQr", self._request_video_qr
|
|
)
|
|
compatibility_actions.addWidget(self.qr_button)
|
|
self.call_button = self._action_button(
|
|
"通话", "doctor.appointment/prescription", self._request_video
|
|
)
|
|
compatibility_actions.addWidget(self.call_button)
|
|
self.complete_button = self._action_button(
|
|
"完成", "doctor.appointment/complete", self._complete_selected
|
|
)
|
|
compatibility_actions.addWidget(self.complete_button)
|
|
self.prescription_button = self._action_button(
|
|
"开方", "tcm.diagnosis/kaifang", self._open_prescription
|
|
)
|
|
compatibility_actions.addWidget(self.prescription_button)
|
|
self.case_button = self._action_button("病历", "tcm.diagnosis/kaifang", self._view_case)
|
|
compatibility_actions.addWidget(self.case_button)
|
|
self.ai_button = QPushButton("AI 报告", compatibility_host)
|
|
self.ai_button.setProperty("variant", "secondary")
|
|
self.ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
|
self.ai_button.setEnabled(False)
|
|
self.ai_button.clicked.connect(self._open_ai_report)
|
|
compatibility_actions.addWidget(self.ai_button)
|
|
self.cancel_button = self._action_button(
|
|
"取消挂号",
|
|
"doctor.appointment/cancel",
|
|
self._cancel_selected,
|
|
danger=True,
|
|
)
|
|
compatibility_actions.addWidget(self.cancel_button)
|
|
for index in range(compatibility_actions.count()):
|
|
widget = compatibility_actions.itemAt(index).widget()
|
|
if isinstance(widget, QPushButton):
|
|
widget.setProperty("compactAction", True)
|
|
|
|
actions = QHBoxLayout()
|
|
actions.setSpacing(6)
|
|
self.toolbar_edit_button = QPushButton("编辑患者", card)
|
|
self.toolbar_edit_button.setProperty("variant", "primary")
|
|
self.toolbar_edit_button.setProperty("compactAction", True)
|
|
self.toolbar_edit_button.setVisible(
|
|
_canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False)
|
|
)
|
|
self.toolbar_edit_button.setEnabled(False)
|
|
self.toolbar_edit_button.clicked.connect(self._edit_patient)
|
|
actions.addWidget(self.toolbar_edit_button)
|
|
self.toolbar_qr_button = QPushButton("视频二维码", card)
|
|
self.toolbar_qr_button.setProperty("variant", "secondary")
|
|
self.toolbar_qr_button.setProperty("compactAction", True)
|
|
self.toolbar_qr_button.setVisible(
|
|
_canonical_allowed(self.permissions, "tcm.diagnosis/videoQr", default=False)
|
|
)
|
|
self.toolbar_qr_button.setEnabled(False)
|
|
self.toolbar_qr_button.clicked.connect(self._request_video_qr)
|
|
actions.addWidget(self.toolbar_qr_button)
|
|
self.toolbar_ai_consult_button = QPushButton("AI 分析", card)
|
|
self.toolbar_ai_consult_button.setProperty("variant", "secondary")
|
|
self.toolbar_ai_consult_button.setProperty("compactAction", True)
|
|
self.toolbar_ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
|
|
self.toolbar_ai_consult_button.setEnabled(False)
|
|
self.toolbar_ai_consult_button.clicked.connect(self._open_ai_consult)
|
|
actions.addWidget(self.toolbar_ai_consult_button)
|
|
self.toolbar_cancel_button = QPushButton("取消挂号", card)
|
|
self.toolbar_cancel_button.setProperty("variant", "danger")
|
|
self.toolbar_cancel_button.setProperty("compactAction", True)
|
|
self.toolbar_cancel_button.setVisible(
|
|
_canonical_allowed(self.permissions, "doctor.appointment/cancel", default=False)
|
|
)
|
|
self.toolbar_cancel_button.setEnabled(False)
|
|
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
|
|
actions.addWidget(self.toolbar_cancel_button)
|
|
actions.addStretch(1)
|
|
refresh = QPushButton("刷新", card)
|
|
refresh.setProperty("variant", "ghost")
|
|
refresh.setProperty("compactAction", True)
|
|
refresh.clicked.connect(lambda: self.refresh())
|
|
actions.addWidget(refresh)
|
|
layout.addLayout(actions)
|
|
|
|
self.table = SortableTable(
|
|
[
|
|
TableColumn("_selected", "", 36, alignment=Qt.AlignmentFlag.AlignCenter),
|
|
TableColumn("id", "ID", 66),
|
|
TableColumn(
|
|
"patient_name",
|
|
"患者",
|
|
100,
|
|
),
|
|
TableColumn("gender", "性别 / 年龄", 98, formatter=_gender_age_cell),
|
|
TableColumn("appointment_date", "挂号信息", 202, formatter=_appointment_info_cell),
|
|
TableColumn("diagnosis_confirmed", "确认", 90, formatter=_confirmed_cell),
|
|
TableColumn("revisit_time", "复诊", 100, formatter=_revisit_cell),
|
|
TableColumn("assistant_name", "助理", 110),
|
|
TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell),
|
|
TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell),
|
|
TableColumn(
|
|
"_im_consult",
|
|
"IM 问诊",
|
|
104,
|
|
alignment=Qt.AlignmentFlag.AlignCenter,
|
|
),
|
|
]
|
|
)
|
|
self.table.setObjectName("AppointmentTable")
|
|
self.table.setMinimumHeight(0)
|
|
self.table.setWordWrap(True)
|
|
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
|
header = self.table.horizontalHeader()
|
|
header.setFixedHeight(34)
|
|
header.setStretchLastSection(False)
|
|
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed)
|
|
self.table.verticalHeader().setDefaultSectionSize(60)
|
|
self.table.itemSelectionChanged.connect(self._selection_changed)
|
|
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
|
|
layout.addWidget(self.table, 1)
|
|
self.pager = Pager(self._page_size)
|
|
self.pager.setMaximumHeight(38)
|
|
self.pager.page_changed.connect(self._page_changed)
|
|
layout.addWidget(self.pager)
|
|
return card
|
|
|
|
def _apply_responsive_layout(self) -> None:
|
|
"""Collapse only the date filters when horizontal space is limited."""
|
|
|
|
narrow = self.width() < 1120
|
|
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
|
|
self.date_buttons[preset].setVisible(not narrow)
|
|
self.date_overflow_button.setVisible(narrow)
|
|
self.status_filter_label.setVisible(not narrow)
|
|
self.confirm_filter_label.setVisible(not narrow)
|
|
self._responsive_narrow = narrow
|
|
|
|
def resizeEvent(self, event: Any) -> None:
|
|
super().resizeEvent(event)
|
|
self._apply_responsive_layout()
|
|
|
|
def _run_video_row_action(self, row: Any, callback: Any) -> None:
|
|
appointment_id = _appointment_id(row)
|
|
for row_index in range(self.table.rowCount()):
|
|
cell = self.table.item(row_index, 0)
|
|
if (
|
|
cell is not None
|
|
and _appointment_id(cell.data(Qt.ItemDataRole.UserRole)) == appointment_id
|
|
):
|
|
self.table.selectRow(row_index)
|
|
break
|
|
callback()
|
|
|
|
def _action_button(
|
|
self,
|
|
label: str,
|
|
permission: str,
|
|
slot: Any,
|
|
*,
|
|
danger: bool = False,
|
|
) -> QPushButton:
|
|
# Permission visibility is evaluated before the toolbar layout adopts
|
|
# the button. Give it a real owner up front so setVisible(True) cannot
|
|
# create a transient top-level HWND on Windows.
|
|
button = QPushButton(label, self)
|
|
button.setProperty("variant", "danger" if danger else "secondary")
|
|
button.setVisible(_canonical_allowed(self.permissions, permission, default=False))
|
|
button.setEnabled(False)
|
|
button.clicked.connect(slot)
|
|
button.setProperty("permission", permission)
|
|
return button
|
|
|
|
def _set_status_from_stat(self, status: int) -> None:
|
|
index = self._tab_indexes.get(status)
|
|
if index is not None:
|
|
self.tab_bar.setCurrentIndex(index)
|
|
return
|
|
self._status = status
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _toggle_unassigned_filter(self, checked: bool) -> None:
|
|
self._assistant_unassigned = bool(checked)
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _set_confirmed_choice(self, value: str) -> None:
|
|
index = self.confirmed_filter.findData(value)
|
|
self.confirmed_filter.setCurrentIndex(max(0, index))
|
|
|
|
def _confirmed_combo_changed(self, _index: int = 0) -> None:
|
|
value = str(self.confirmed_filter.currentData() or "")
|
|
for key, button in self.confirmed_buttons.items():
|
|
button.setChecked(key == value)
|
|
self._search()
|
|
|
|
def _toggle_advanced_filters(self, checked: bool) -> None:
|
|
self.dept_filter.setVisible(checked)
|
|
self.doctor_input.setVisible(checked and self._is_admin)
|
|
self.custom_date_button.setVisible(checked)
|
|
self.reset_filter_button.setVisible(checked)
|
|
|
|
def _tab_changed(self, index: int) -> None:
|
|
if index < 0:
|
|
return
|
|
value = self.tab_bar.tabData(index)
|
|
self._status = "all" if value == "all" else _as_int(value, 1)
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _set_date_preset(self, preset: str) -> None:
|
|
self._date_preset = preset
|
|
for key, button in self.date_buttons.items():
|
|
button.setChecked(key == preset)
|
|
overflow_labels = {
|
|
"yesterday": "昨天",
|
|
"day_before": "前天",
|
|
"tomorrow": "明天",
|
|
"day_after": "后天",
|
|
}
|
|
self.date_overflow_button.setText(overflow_labels.get(preset, "日期"))
|
|
target = _offset_date(preset)
|
|
if target is None:
|
|
self._start_date = ""
|
|
self._end_date = ""
|
|
else:
|
|
text = target.isoformat()
|
|
self._start_date = text
|
|
self._end_date = text
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _open_custom_date(self) -> None:
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("自定义日期")
|
|
dialog.resize(440, 300)
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(24, 22, 24, 18)
|
|
layout.setSpacing(12)
|
|
title = QLabel("自定义日期")
|
|
title.setProperty("dialogRole", "title")
|
|
layout.addWidget(title)
|
|
start = QDateEdit(
|
|
QDate.fromString(self._start_date or date.today().isoformat(), "yyyy-MM-dd")
|
|
)
|
|
start.setCalendarPopup(True)
|
|
start.setDisplayFormat("yyyy-MM-dd")
|
|
end = QDateEdit(QDate.fromString(self._end_date or date.today().isoformat(), "yyyy-MM-dd"))
|
|
end.setCalendarPopup(True)
|
|
end.setDisplayFormat("yyyy-MM-dd")
|
|
layout.addWidget(QLabel("开始日期"))
|
|
layout.addWidget(start)
|
|
layout.addWidget(QLabel("结束日期"))
|
|
layout.addWidget(end)
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
)
|
|
primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
|
|
primary_button.setText("应用日期")
|
|
primary_button.setProperty("variant", "primary")
|
|
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
|
buttons.accepted.connect(dialog.accept)
|
|
buttons.rejected.connect(dialog.reject)
|
|
layout.addWidget(buttons)
|
|
mark_business_dialog(dialog, "AppointmentCustomDateDialog")
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
start_date = start.date().toString("yyyy-MM-dd")
|
|
end_date = end.date().toString("yyyy-MM-dd")
|
|
if start_date > end_date:
|
|
show_toast(self, "开始日期不能晚于结束日期。", "warning")
|
|
return
|
|
self._date_preset = ""
|
|
for button in self.date_buttons.values():
|
|
button.setChecked(False)
|
|
self.date_buttons[""].setChecked(True)
|
|
self.date_overflow_button.setText("自定义")
|
|
self._start_date = start_date
|
|
self._end_date = end_date
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _search(self) -> None:
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _reset_filters(self) -> None:
|
|
self.patient_input.clear()
|
|
self.doctor_input.clear()
|
|
self._assistant_unassigned = False
|
|
self.unassigned_stat_button.setChecked(False)
|
|
self.confirmed_filter.setCurrentIndex(0)
|
|
self.dept_filter.setCurrentIndex(0)
|
|
self._status = 1
|
|
index = self._tab_indexes.get(1, 0)
|
|
self.tab_bar.blockSignals(True)
|
|
self.tab_bar.setCurrentIndex(index)
|
|
self.tab_bar.blockSignals(False)
|
|
self._set_date_preset("today")
|
|
|
|
def _page_changed(self, page: int) -> None:
|
|
self._page = max(1, page)
|
|
self.refresh()
|
|
|
|
def _query_filters(self) -> dict[str, Any]:
|
|
filters: dict[str, Any] = {
|
|
"include_status_counts": 1,
|
|
"patient_name": self.patient_input.text().strip(),
|
|
}
|
|
if self._status != "all":
|
|
filters["status"] = self._status
|
|
if self._start_date:
|
|
filters["start_date"] = self._start_date
|
|
if self._end_date:
|
|
filters["end_date"] = self._end_date
|
|
if not self._start_date and not self._end_date:
|
|
filters["diag_scope_relax"] = 1
|
|
confirmed = self.confirmed_filter.currentData()
|
|
if confirmed not in (None, ""):
|
|
filters["diagnosis_confirmed"] = confirmed
|
|
dept_id = self.dept_filter.currentData()
|
|
if dept_id not in (None, ""):
|
|
filters["assistant_dept_id"] = dept_id
|
|
if self._is_admin:
|
|
doctor_name = self.doctor_input.text().strip()
|
|
if doctor_name:
|
|
filters["doctor_name"] = doctor_name
|
|
if self._assistant_unassigned:
|
|
filters["assistant_unassigned"] = 1
|
|
return filters
|
|
|
|
def refresh(self, *, silent: bool = False) -> None:
|
|
if self._loading and not silent:
|
|
return
|
|
self._generation += 1
|
|
generation = self._generation
|
|
self._loading = True
|
|
if not silent:
|
|
self.banner.show_message("正在加载挂号列表…", "info")
|
|
filters = self._query_filters()
|
|
page = self._page
|
|
page_size = self._page_size
|
|
run_async(
|
|
lambda: invoke(
|
|
self.repository,
|
|
"list_appointments",
|
|
page_no=page,
|
|
page_size=page_size,
|
|
**filters,
|
|
),
|
|
on_success=lambda result: self._loaded(result, generation, silent),
|
|
on_error=lambda error: self._load_error(error, generation, silent),
|
|
on_finished=lambda: self._load_finished(generation),
|
|
)
|
|
|
|
def _loaded(self, result: Any, generation: int, silent: bool) -> None:
|
|
if generation != self._generation:
|
|
return
|
|
rows = page_items(result)
|
|
total = page_total(result)
|
|
extend = get_value(result, "extend", {}) or {}
|
|
signature = (
|
|
self._page,
|
|
_stable_signature_value(self._query_filters()),
|
|
*_appointment_result_signature(rows, total, extend),
|
|
)
|
|
if signature == self._last_render_signature:
|
|
if not silent:
|
|
self.banner.clear()
|
|
return
|
|
self._last_render_signature = signature
|
|
status_count = get_value(extend, "status_count", {}) or {}
|
|
if isinstance(status_count, Mapping):
|
|
for key in (1, 2, 3, 4):
|
|
value = status_count.get(key, status_count.get(str(key)))
|
|
if isinstance(value, (int, float)):
|
|
self._status_counts[key] = int(value)
|
|
self._update_tab_badges()
|
|
self._update_stat_chips(rows, total, extend)
|
|
self.table.set_rows(rows)
|
|
self._install_table_selectors()
|
|
self._install_appointment_info_cells()
|
|
self._install_im_consult_actions()
|
|
self._fit_table_rows()
|
|
for row_index in range(self.table.rowCount()):
|
|
source_item = self.table.item(row_index, 0)
|
|
source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
|
confirmed_kind = (
|
|
"success"
|
|
if _as_bool(first_value(source, "diagnosis_confirmed", default=False))
|
|
else "warning"
|
|
)
|
|
prescription_kind = (
|
|
"success"
|
|
if _as_bool(first_value(source, "has_prescription", default=False))
|
|
else "muted"
|
|
)
|
|
for column, kind in (
|
|
(5, confirmed_kind),
|
|
(8, prescription_kind),
|
|
):
|
|
item = self.table.item(row_index, column)
|
|
if item is not None:
|
|
item.setForeground(QBrush(QColor(_SEMANTIC_COLORS[kind])))
|
|
self.pager.update_state(self._page, total)
|
|
if not silent:
|
|
self.banner.clear()
|
|
if rows and self.table.currentRow() < 0:
|
|
self.table.selectRow(0)
|
|
self._selection_changed()
|
|
|
|
def _update_stat_chips(
|
|
self,
|
|
rows: Sequence[Any],
|
|
total: int,
|
|
extend: Any,
|
|
) -> None:
|
|
date_counts = first_value(extend, "date_counts", "date_count", default={})
|
|
if not isinstance(date_counts, Mapping):
|
|
date_counts = {}
|
|
visible_dates: dict[str, int] = {}
|
|
for row in rows:
|
|
key = str(first_value(row, "appointment_date", default="") or "")
|
|
if key:
|
|
visible_dates[key] = visible_dates.get(key, 0) + 1
|
|
for preset, button in self.date_buttons.items():
|
|
label = self._date_stat_labels[preset]
|
|
if preset == "":
|
|
count = total
|
|
else:
|
|
target = _offset_date(preset)
|
|
date_key = target.isoformat() if target is not None else ""
|
|
raw = date_counts.get(
|
|
preset,
|
|
date_counts.get(date_key, visible_dates.get(date_key, 0)),
|
|
)
|
|
count = _as_int(raw)
|
|
button.setText(f"{label} {count}")
|
|
self.pending_stat_button.setText(f"待预约 {self._status_counts.get(1, 0)}")
|
|
self.completed_stat_button.setText(f"已完成 {self._status_counts.get(3, 0)}")
|
|
unassigned = first_value(
|
|
extend,
|
|
"unassigned_count",
|
|
"assistant_unassigned_count",
|
|
default=None,
|
|
)
|
|
if unassigned is None:
|
|
unassigned = sum(
|
|
1
|
|
for row in rows
|
|
if _as_int(first_value(row, "assistant_id", default=0)) <= 0
|
|
and not str(first_value(row, "assistant_name", default="") or "").strip()
|
|
)
|
|
unassigned_count = _as_int(unassigned)
|
|
self.unassigned_stat_button.setText(f"待分配医助 {unassigned_count}")
|
|
# 只有真的有待办时才点亮,避免筛选栏常态就是一排彩色。
|
|
self.unassigned_stat_button.setProperty("hasPending", unassigned_count > 0)
|
|
style = self.unassigned_stat_button.style()
|
|
style.unpolish(self.unassigned_stat_button)
|
|
style.polish(self.unassigned_stat_button)
|
|
|
|
def _install_table_selectors(self) -> None:
|
|
for row_index in range(self.table.rowCount()):
|
|
host = QWidget(self.table)
|
|
layout = QHBoxLayout(host)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
selector = QCheckBox(host)
|
|
selector.setProperty("appointmentSelector", True)
|
|
selector.setToolTip("选择患者")
|
|
selector.clicked.connect(
|
|
lambda checked=False, index=row_index: (
|
|
self.table.selectRow(index) if checked else None
|
|
)
|
|
)
|
|
layout.addWidget(selector)
|
|
self.table.setCellWidget(row_index, 0, host)
|
|
item = self.table.item(row_index, 0)
|
|
if item is not None:
|
|
item.setText("")
|
|
|
|
def _install_appointment_info_cells(self) -> None:
|
|
for row_index in range(self.table.rowCount()):
|
|
source_item = self.table.item(row_index, 0)
|
|
row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
|
host = QWidget(self.table)
|
|
host.setProperty("appointmentInfoHost", True)
|
|
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
layout = QVBoxLayout(host)
|
|
layout.setContentsMargins(7, 4, 5, 3)
|
|
layout.setSpacing(1)
|
|
heading = QHBoxLayout()
|
|
heading.setContentsMargins(0, 0, 0, 0)
|
|
heading.setSpacing(5)
|
|
status_text = _status_cell(None, row)
|
|
status = QLabel(status_text, host)
|
|
status.setProperty("tableAppointmentStatus", True)
|
|
status.setProperty(
|
|
"tableAppointmentStatusKind",
|
|
"warning" if "挂号" in status_text or _status_value(row) == 1 else "primary",
|
|
)
|
|
heading.addWidget(status)
|
|
doctor = QLabel(display_text(first_value(row, "doctor_name"), "未分配"), host)
|
|
heading.addWidget(doctor)
|
|
heading.addStretch(1)
|
|
if _status_value(row) == 1 and _canonical_allowed(
|
|
self.permissions, "doctor.appointment/cancel", default=False
|
|
):
|
|
cancel = QPushButton("取消", host)
|
|
cancel.setProperty("tableCancelAction", True)
|
|
cancel.clicked.connect(
|
|
lambda _checked=False, source=row: self._run_video_row_action(
|
|
source, self._cancel_selected
|
|
)
|
|
)
|
|
heading.addWidget(cancel)
|
|
layout.addLayout(heading)
|
|
date_label = QLabel(
|
|
f"{display_text(first_value(row, 'appointment_date'), '—')} "
|
|
f"{display_text(first_value(row, 'appointment_time'), '—')}",
|
|
host,
|
|
)
|
|
date_label.setProperty("tableAppointmentMeta", True)
|
|
layout.addWidget(date_label)
|
|
channel = display_text(
|
|
first_value(row, "channel_name", "channel_source_name", "source_name"),
|
|
"—",
|
|
)
|
|
channel_label = QLabel(f"最近渠道:{channel}", host)
|
|
channel_label.setProperty("tableAppointmentMeta", True)
|
|
layout.addWidget(channel_label)
|
|
self.table.setCellWidget(row_index, 4, host)
|
|
patient = self.table.item(row_index, 2)
|
|
if patient is not None:
|
|
font = patient.font()
|
|
font.setBold(True)
|
|
patient.setFont(font)
|
|
|
|
def _install_im_consult_actions(self) -> None:
|
|
"""Render a doctor-initiated IM entry for every appointment row."""
|
|
|
|
action_column = 10
|
|
has_permission = _canonical_allowed(
|
|
self.permissions,
|
|
"doctor.appointment/prescription",
|
|
default=False,
|
|
)
|
|
for row_index in range(self.table.rowCount()):
|
|
source_item = self.table.item(row_index, 0)
|
|
row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
|
host = QWidget(self.table)
|
|
host.setProperty("appointmentImHost", True)
|
|
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
layout = QHBoxLayout(host)
|
|
layout.setContentsMargins(5, 0, 5, 0)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
button = QPushButton("IM 问诊", host)
|
|
button.setObjectName("AppointmentImConsultButton")
|
|
button.setProperty("appointmentImAction", True)
|
|
patient_name = display_text(first_value(row, "patient_name"), "患者")
|
|
button.setAccessibleName(f"与{patient_name}进行 IM 问诊")
|
|
|
|
identifiers_ready = (
|
|
_appointment_id(row) > 0
|
|
and _diagnosis_id(row) > 0
|
|
and _video_patient_id(row) > 0
|
|
)
|
|
status_error = _im_consult_status_error(row)
|
|
enabled = (
|
|
has_permission
|
|
and self._native_video_capable
|
|
and identifiers_ready
|
|
and not status_error
|
|
)
|
|
button.setEnabled(enabled)
|
|
if not has_permission:
|
|
tooltip = "当前账号无 IM 问诊权限"
|
|
elif not self._native_video_capable:
|
|
tooltip = "当前工作站未配置完整的视频问诊能力"
|
|
elif not identifiers_ready:
|
|
tooltip = "患者、诊单或挂号标识不完整"
|
|
elif status_error:
|
|
tooltip = status_error
|
|
else:
|
|
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
|
|
button.setToolTip(tooltip)
|
|
button.clicked.connect(
|
|
lambda _checked=False, source=row: self._run_video_row_action(
|
|
source,
|
|
self._request_video,
|
|
)
|
|
)
|
|
layout.addWidget(button)
|
|
self.table.setCellWidget(row_index, action_column, host)
|
|
item = self.table.item(row_index, action_column)
|
|
if item is not None:
|
|
item.setText("")
|
|
|
|
def _fit_table_rows(self) -> None:
|
|
"""Give each appointment row enough height for its real line count."""
|
|
|
|
line_height = max(16, self.table.fontMetrics().lineSpacing())
|
|
for row_index in range(self.table.rowCount()):
|
|
line_count = max(
|
|
(
|
|
(item.text().count("\n") + 1)
|
|
for column in range(self.table.columnCount())
|
|
if (item := self.table.item(row_index, column)) is not None
|
|
),
|
|
default=1,
|
|
)
|
|
height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING
|
|
self.table.setRowHeight(row_index, max(60, min(66, height)))
|
|
|
|
def _load_error(self, error: Exception, generation: int, silent: bool) -> None:
|
|
if generation != self._generation:
|
|
return
|
|
if silent:
|
|
return
|
|
self.banner.show_message(friendly_error(error), "danger")
|
|
|
|
def _load_finished(self, generation: int) -> None:
|
|
if generation == self._generation:
|
|
self._loading = False
|
|
|
|
def _update_tab_badges(self) -> None:
|
|
for label, value in STATUS_TABS:
|
|
index = self._tab_indexes.get(value)
|
|
if index is None:
|
|
continue
|
|
if value == "all":
|
|
self.tab_bar.setTabText(index, label)
|
|
continue
|
|
count = self._status_counts.get(_as_int(value), 0)
|
|
text = f"{label} ({count})" if count else label
|
|
self.tab_bar.setTabText(index, text)
|
|
|
|
def _selection_changed(self) -> None:
|
|
row = self.table.current_data()
|
|
has_row = row is not None
|
|
status = _status_value(row) if has_row else 0
|
|
not_completed = has_row and status != 3
|
|
self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
|
self.qr_button.setEnabled(
|
|
not_completed
|
|
and _video_patient_id(row) > 0
|
|
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
|
)
|
|
self.call_button.setEnabled(
|
|
has_row
|
|
and not _im_consult_status_error(row)
|
|
and self._native_video_capable
|
|
and _appointment_id(row) > 0
|
|
and _diagnosis_id(row) > 0
|
|
and _video_patient_id(row) > 0
|
|
)
|
|
self.complete_button.setEnabled(not_completed and _appointment_id(row) > 0)
|
|
self.prescription_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
|
self.prescription_button.setText(prescription_action_label(row) if has_row else "开方")
|
|
self.case_button.setEnabled(has_row and _appointment_id(row) > 0)
|
|
self.ai_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
|
self.toolbar_ai_consult_button.setEnabled(
|
|
has_row and can_open_ai_consult(self.permissions) and _diagnosis_id(row) > 0
|
|
)
|
|
self.cancel_button.setEnabled(has_row and status == 1)
|
|
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
|
self.toolbar_qr_button.setEnabled(
|
|
not_completed
|
|
and _video_patient_id(row) > 0
|
|
and _as_int(first_value(row, "doctor_id", default=0)) > 0
|
|
)
|
|
self.toolbar_cancel_button.setEnabled(has_row and status == 1)
|
|
|
|
def _current_row(self) -> Any | None:
|
|
return self.table.current_data()
|
|
|
|
def _edit_patient(self) -> None:
|
|
if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False):
|
|
return
|
|
row = self._current_row()
|
|
diagnosis_id = _diagnosis_id(row)
|
|
if diagnosis_id <= 0:
|
|
show_toast(self, "该预约没有关联诊单信息。", "warning")
|
|
return
|
|
self._diagnosis_dialog().open_for(diagnosis_id, editable=True, seed=row)
|
|
|
|
def _open_ai_report(self) -> None:
|
|
if not can_open_diagnosis_ai_report(self.permissions):
|
|
show_toast(self, "当前账号没有查看 AI 报告权限。", "warning")
|
|
return
|
|
row = self._current_row()
|
|
diagnosis_id = _diagnosis_id(row)
|
|
if diagnosis_id <= 0:
|
|
show_toast(self, "该预约没有关联诊单信息。", "warning")
|
|
return
|
|
payload = dict(row) if isinstance(row, Mapping) else {}
|
|
payload.update(
|
|
{
|
|
"id": diagnosis_id,
|
|
"diagnosis_id": diagnosis_id,
|
|
"patient_name": first_value(row, "patient_name", default="患者"),
|
|
}
|
|
)
|
|
present_diagnosis_ai_report(self.repository, self.permissions, self, payload)
|
|
|
|
def open_selected_ai_consult(self) -> bool:
|
|
"""Open the diagnosis-scoped assistant for the selected appointment."""
|
|
|
|
row = self._current_row()
|
|
diagnosis_id = _diagnosis_id(row)
|
|
if (
|
|
row is None
|
|
or diagnosis_id <= 0
|
|
or not can_open_ai_consult(self.permissions)
|
|
):
|
|
return False
|
|
present_ai_consult(
|
|
self.repository,
|
|
self.permissions,
|
|
self,
|
|
diagnosis_id=diagnosis_id,
|
|
patient_id=_video_patient_id(row),
|
|
seed=row,
|
|
source_title="问诊列表",
|
|
)
|
|
return True
|
|
|
|
def _open_ai_consult(self) -> None:
|
|
if not self.open_selected_ai_consult():
|
|
show_toast(self, "请先选择一条有关联诊单的问诊记录。", "warning")
|
|
|
|
def _request_video(self) -> None:
|
|
if not _canonical_allowed(
|
|
self.permissions, "doctor.appointment/prescription", default=False
|
|
):
|
|
return
|
|
if not self._native_video_capable:
|
|
show_toast(self, "当前工作站未配置完整的视频问诊能力。", "warning")
|
|
return
|
|
row = self._current_row()
|
|
if row is None:
|
|
return
|
|
status_error = _im_consult_status_error(row)
|
|
if status_error:
|
|
show_toast(self, f"{status_error}。", "warning")
|
|
return
|
|
appointment_id = _appointment_id(row)
|
|
diagnosis_id = _diagnosis_id(row)
|
|
patient_id = _video_patient_id(row)
|
|
if appointment_id <= 0 or diagnosis_id <= 0 or patient_id <= 0:
|
|
show_toast(self, "患者、诊单或挂号标识不完整,无法进入 IM 问诊。", "warning")
|
|
return
|
|
self.video_requested.emit(
|
|
{
|
|
"source": "appointments",
|
|
"appointment_id": appointment_id,
|
|
"patient_id": patient_id,
|
|
"diagnosis_id": diagnosis_id,
|
|
"patient_name": first_value(row, "patient_name", default="患者"),
|
|
# Open IM first. The doctor explicitly starts video from the
|
|
# conversation instead of requiring an already-live call.
|
|
"mode": "im",
|
|
"record": row,
|
|
}
|
|
)
|
|
|
|
def _request_video_qr(self) -> None:
|
|
if not _canonical_allowed(self.permissions, "tcm.diagnosis/videoQr", default=False):
|
|
return
|
|
required_methods = (
|
|
"get_mini_program_config",
|
|
"generate_video_qrcode",
|
|
"download_public_image",
|
|
)
|
|
if not all(callable(getattr(self.repository, name, None)) for name in required_methods):
|
|
show_toast(self, "当前仓库未提供视频二维码能力。", "warning")
|
|
return
|
|
row = self._current_row()
|
|
if row is None or _status_value(row) == 3:
|
|
return
|
|
diagnosis_id = _diagnosis_id(row)
|
|
patient_id = _video_patient_id(row)
|
|
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
|
|
share_user_id = _as_int(first_value(self.current_user, "id", default=0))
|
|
if diagnosis_id <= 0 or patient_id <= 0 or doctor_id <= 0 or share_user_id <= 0:
|
|
show_toast(self, "患者或不完整的医生/用户标识,无法生成二维码。", "warning")
|
|
return
|
|
snapshot = deepcopy(row)
|
|
self._action_generation += 1
|
|
generation = self._action_generation
|
|
self.banner.show_message("正在生成视频二维码…", "info")
|
|
|
|
def _worker() -> dict[str, Any]:
|
|
config = invoke(self.repository, "get_mini_program_config")
|
|
if not str(first_value(config, "app_id", default="") or "").strip():
|
|
raise ValueError("小程序未配置,请先在管理后台配置小程序信息")
|
|
generated = invoke(
|
|
self.repository,
|
|
"generate_video_qrcode",
|
|
diagnosis_id=diagnosis_id,
|
|
doctor_id=doctor_id,
|
|
patient_id=patient_id,
|
|
share_user_id=share_user_id,
|
|
)
|
|
if not isinstance(generated, Mapping):
|
|
raise ValueError("服务器未返回有效的二维码结果")
|
|
result = dict(generated)
|
|
url = str(result.get("qrcode_url") or "").strip()
|
|
if not url:
|
|
raise ValueError("服务器未返回可用的二维码地址")
|
|
try:
|
|
result["_image_bytes"] = invoke(self.repository, "download_public_image", url=url)
|
|
except Exception as exc:
|
|
result["_image_error"] = friendly_error(exc)
|
|
return result
|
|
|
|
run_async(
|
|
_worker,
|
|
on_success=lambda result: self._show_qr(snapshot, result, generation),
|
|
on_error=lambda error: self._action_error(error, generation),
|
|
)
|
|
|
|
def _show_qr(self, row: Any, result: Any, generation: int) -> None:
|
|
if generation != self._action_generation:
|
|
return
|
|
url = str(first_value(result, "qrcode_url", default="") or "").strip()
|
|
if not url:
|
|
self.banner.show_message("服务器未返回可用的二维码地址。", "danger")
|
|
return
|
|
self.banner.clear()
|
|
dialog = self._build_qr_dialog(row, url, result)
|
|
dialog.exec()
|
|
|
|
def _build_qr_dialog(self, row: Any, url: str, result: Any) -> QDialog:
|
|
"""Build an in-app QR preview with external-open and retry fallbacks."""
|
|
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("小程序二维码")
|
|
dialog.setObjectName("VideoQrDialog")
|
|
dialog.resize(470, 590)
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(24, 22, 24, 18)
|
|
layout.setSpacing(12)
|
|
title = QLabel("视频问诊二维码")
|
|
title.setProperty("dialogRole", "title")
|
|
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(title)
|
|
message = QLabel(
|
|
f"患者:{display_text(first_value(row, 'patient_name'), '患者')}\n"
|
|
"请使用微信扫描下方二维码进入视频问诊"
|
|
)
|
|
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(message)
|
|
|
|
image_label = QLabel()
|
|
image_label.setObjectName("VideoQrImage")
|
|
image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
image_label.setWordWrap(True)
|
|
image_label.setFixedSize(300, 300)
|
|
image_bytes = first_value(result, "_image_bytes", default=b"")
|
|
pixmap = QPixmap()
|
|
loaded = isinstance(image_bytes, (bytes, bytearray)) and pixmap.loadFromData(
|
|
bytes(image_bytes)
|
|
)
|
|
if loaded:
|
|
image_label.setPixmap(
|
|
pixmap.scaled(
|
|
280,
|
|
280,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation,
|
|
)
|
|
)
|
|
else:
|
|
reason = str(first_value(result, "_image_error", default="") or "").strip()
|
|
image_label.setText(
|
|
"二维码图片加载失败\n请重新生成或在浏览器中打开" + (f"\n{reason}" if reason else "")
|
|
)
|
|
image_row = QHBoxLayout()
|
|
image_row.addStretch(1)
|
|
image_row.addWidget(image_label)
|
|
image_row.addStretch(1)
|
|
layout.addLayout(image_row)
|
|
|
|
link = QLabel(f'<a href="{escape(url, quote=True)}">打开服务器二维码</a>')
|
|
link.setOpenExternalLinks(True)
|
|
link.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(link)
|
|
field = QLineEdit(url)
|
|
field.setObjectName("VideoQrUrl")
|
|
field.setReadOnly(True)
|
|
layout.addWidget(field)
|
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
retry_button = buttons.addButton("重新生成", QDialogButtonBox.ButtonRole.ActionRole)
|
|
open_button = buttons.addButton("浏览器打开", QDialogButtonBox.ButtonRole.ActionRole)
|
|
retry_button.setProperty("variant", "secondary")
|
|
open_button.setProperty("variant", "primary")
|
|
open_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromUserInput(url)))
|
|
retry_button.clicked.connect(dialog.accept)
|
|
retry_button.clicked.connect(lambda: QTimer.singleShot(0, self._request_video_qr))
|
|
buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭")
|
|
buttons.rejected.connect(dialog.reject)
|
|
layout.addWidget(buttons)
|
|
mark_business_dialog(dialog, "VideoQrDialog")
|
|
for button in (retry_button, open_button):
|
|
button.style().unpolish(button)
|
|
button.style().polish(button)
|
|
return dialog
|
|
|
|
def _complete_selected(self) -> None:
|
|
if not _canonical_allowed(self.permissions, "doctor.appointment/complete", default=False):
|
|
return
|
|
row = self._current_row()
|
|
appointment_id = _appointment_id(row)
|
|
if appointment_id <= 0 or self._mutation_pending:
|
|
return
|
|
can_note = _canonical_allowed(
|
|
self.permissions, "doctor.appointment/addDoctorNote", default=False
|
|
)
|
|
note = ""
|
|
if can_note:
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("完成问诊")
|
|
dialog.resize(500, 360)
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(24, 22, 24, 18)
|
|
layout.setSpacing(12)
|
|
title = QLabel("完成问诊")
|
|
title.setProperty("dialogRole", "title")
|
|
layout.addWidget(title)
|
|
prompt = QLabel("是否添加医生备注?")
|
|
prompt.setProperty("dialogRole", "subtitle")
|
|
layout.addWidget(prompt)
|
|
editor = QTextEdit()
|
|
editor.setPlaceholderText("填写完成备注,将追加到医生备注时间轴")
|
|
editor.setMaximumHeight(120)
|
|
layout.addWidget(editor)
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
)
|
|
primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
|
|
primary_button.setText("确认完成")
|
|
primary_button.setProperty("variant", "primary")
|
|
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
|
buttons.accepted.connect(dialog.accept)
|
|
buttons.rejected.connect(dialog.reject)
|
|
layout.addWidget(buttons)
|
|
mark_business_dialog(dialog, "AppointmentCompleteDialog")
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
note = editor.toPlainText().strip()[:NOTE_LIMIT]
|
|
else:
|
|
answer = QMessageBox.question(
|
|
self,
|
|
"完成问诊",
|
|
"确认完成该挂号吗?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
|
QMessageBox.StandardButton.Cancel,
|
|
)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
diagnosis_id = _diagnosis_id(row)
|
|
snapshot_note = note
|
|
self._mutation_pending = True
|
|
self._action_generation += 1
|
|
generation = self._action_generation
|
|
self.banner.show_message("正在完成问诊…", "info")
|
|
|
|
def _worker() -> str:
|
|
invoke(self.repository, "complete_appointment", appointment_id=appointment_id)
|
|
if snapshot_note and diagnosis_id > 0:
|
|
try:
|
|
invoke(
|
|
self.repository,
|
|
"add_doctor_note",
|
|
diagnosis_id=diagnosis_id,
|
|
content=snapshot_note,
|
|
)
|
|
except Exception:
|
|
return "问诊已完成,但备注未保存"
|
|
elif snapshot_note and diagnosis_id <= 0:
|
|
return "问诊已完成,但该预约没有关联诊单,备注未保存"
|
|
return ""
|
|
|
|
run_async(
|
|
_worker,
|
|
on_success=lambda warning: self._complete_done(warning, generation),
|
|
on_error=lambda error: self._action_error(error, generation),
|
|
on_finished=lambda: self._mutation_finished(generation),
|
|
)
|
|
|
|
def _complete_done(self, warning: str, generation: int) -> None:
|
|
if generation != self._action_generation:
|
|
return
|
|
if warning:
|
|
self.banner.show_message(warning, "warning")
|
|
else:
|
|
self.banner.show_message("操作成功。", "success")
|
|
self.refresh(silent=True)
|
|
|
|
def _cancel_selected(self) -> None:
|
|
if not _canonical_allowed(self.permissions, "doctor.appointment/cancel", default=False):
|
|
return
|
|
row = self._current_row()
|
|
appointment_id = _appointment_id(row)
|
|
if appointment_id <= 0 or _status_value(row) != 1 or self._mutation_pending:
|
|
return
|
|
answer = QMessageBox.question(
|
|
self,
|
|
"取消挂号",
|
|
"确定要取消该挂号吗?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
|
QMessageBox.StandardButton.Cancel,
|
|
)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
self._mutation_pending = True
|
|
self._action_generation += 1
|
|
generation = self._action_generation
|
|
self.banner.show_message("正在取消挂号…", "info")
|
|
run_async(
|
|
lambda: invoke(
|
|
self.repository,
|
|
"cancel_diagnosis_appointment",
|
|
appointment_id=appointment_id,
|
|
),
|
|
on_success=lambda _result: self._cancel_done(generation),
|
|
on_error=lambda error: self._action_error(error, generation),
|
|
on_finished=lambda: self._mutation_finished(generation),
|
|
)
|
|
|
|
def _cancel_done(self, generation: int) -> None:
|
|
if generation != self._action_generation:
|
|
return
|
|
self.banner.show_message("取消成功。", "success")
|
|
self.refresh(silent=True)
|
|
|
|
def _mutation_finished(self, generation: int) -> None:
|
|
if generation == self._action_generation:
|
|
self._mutation_pending = False
|
|
self._selection_changed()
|
|
|
|
def _action_error(self, error: Exception, generation: int) -> None:
|
|
if generation == self._action_generation:
|
|
self.banner.show_message(friendly_error(error), "danger")
|
|
|
|
def _open_prescription(self) -> None:
|
|
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False):
|
|
return
|
|
row = self._current_row()
|
|
if _diagnosis_id(row) <= 0 or _appointment_id(row) <= 0:
|
|
show_toast(self, "该预约缺少诊单信息,请先编辑患者。", "warning")
|
|
return
|
|
mode = "view" if prescription_action_label(row) == "查看" else "open"
|
|
self._begin_prescription_load(row, mode=mode)
|
|
|
|
def _view_case(self) -> None:
|
|
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False):
|
|
return
|
|
row = self._current_row()
|
|
appointment_id = _appointment_id(row)
|
|
if appointment_id <= 0:
|
|
show_toast(self, "预约信息不完整。", "warning")
|
|
return
|
|
self._prescription_generation += 1
|
|
generation = self._prescription_generation
|
|
self._prescription_busy = True
|
|
self.banner.show_message("正在加载病历处方…", "info")
|
|
run_async(
|
|
lambda: invoke(
|
|
self.repository,
|
|
"get_prescription_by_appointment",
|
|
appointment_id=appointment_id,
|
|
),
|
|
on_success=lambda existing: self._case_loaded(existing, generation),
|
|
on_error=lambda error: self._prescription_error(error, generation),
|
|
on_finished=lambda: self._prescription_finished(generation),
|
|
)
|
|
|
|
def _case_loaded(self, existing: Any, generation: int) -> None:
|
|
if generation != self._prescription_generation:
|
|
return
|
|
self.banner.clear()
|
|
if existing is None or _as_int(first_value(existing, "id", default=0)) <= 0:
|
|
show_toast(self, "该预约暂无病历记录,请先开方。", "warning")
|
|
return
|
|
dialog = PrescriptionDetailDialog(
|
|
existing,
|
|
initial_tab="case",
|
|
can_open_diagnosis=_canonical_allowed(
|
|
self.permissions, "tcm.diagnosis/readonlyDetail", default=False
|
|
),
|
|
parent=self,
|
|
)
|
|
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
|
|
dialog.exec()
|
|
|
|
def _begin_prescription_load(self, row: Any, *, mode: str = "open") -> None:
|
|
snapshot = deepcopy(row)
|
|
self._prescription_generation += 1
|
|
generation = self._prescription_generation
|
|
self._prescription_busy = True
|
|
self.prescription_button.setEnabled(False)
|
|
self.banner.show_message("正在核对诊单处方上下文…", "info")
|
|
appointment_id = _appointment_id(snapshot)
|
|
run_async(
|
|
lambda: invoke(
|
|
self.repository,
|
|
"get_prescription_by_appointment",
|
|
appointment_id=appointment_id,
|
|
),
|
|
on_success=lambda existing: self._prescription_loaded(
|
|
existing,
|
|
snapshot,
|
|
generation,
|
|
mode,
|
|
),
|
|
on_error=lambda error: self._prescription_error(error, generation),
|
|
on_finished=lambda: self._prescription_finished(generation),
|
|
)
|
|
|
|
def _prescription_loaded(
|
|
self,
|
|
existing: Any,
|
|
row: Any,
|
|
generation: int,
|
|
mode: str = "open",
|
|
) -> None:
|
|
if generation != self._prescription_generation:
|
|
return
|
|
self.banner.clear()
|
|
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
|
|
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
|
|
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
|
|
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
|
|
expected_id = _as_int(
|
|
first_value(row, "current_prescription_id", default=0)
|
|
)
|
|
actual_appointment_id = _as_int(
|
|
first_value(existing, "appointment_id", default=0)
|
|
)
|
|
expected_appointment_id = _appointment_id(row)
|
|
identity_changed = (
|
|
(expected_id > 0 and actual_id != expected_id)
|
|
or (
|
|
actual_appointment_id > 0
|
|
and expected_appointment_id > 0
|
|
and actual_appointment_id != expected_appointment_id
|
|
)
|
|
)
|
|
if mode == "view" and (not approved or voided or identity_changed):
|
|
self.banner.show_message(
|
|
"当前挂号的处方状态已变化,请刷新列表后重试。",
|
|
"warning",
|
|
)
|
|
return
|
|
if not approved or voided:
|
|
self._open_existing_prescription_editor(existing)
|
|
else:
|
|
dialog = PrescriptionDetailDialog(
|
|
existing,
|
|
can_open_diagnosis=_canonical_allowed(
|
|
self.permissions, "tcm.diagnosis/readonlyDetail", default=False
|
|
),
|
|
parent=self,
|
|
)
|
|
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
|
|
dialog.exec()
|
|
return
|
|
if mode == "view":
|
|
self.banner.show_message(
|
|
"当前挂号没有可查看的处方,请刷新列表后重试。",
|
|
"warning",
|
|
)
|
|
return
|
|
self._begin_case_record_load(row)
|
|
|
|
def _open_existing_prescription_editor(self, prescription: Any) -> None:
|
|
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
|
|
if prescription_id <= 0:
|
|
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
|
|
return
|
|
dialog = PrescriptionEditorDialog(
|
|
self.repository,
|
|
prescription,
|
|
mode="edit",
|
|
current_user=self.current_user,
|
|
parent=self,
|
|
)
|
|
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
|
if diagnosis_signal is not None:
|
|
diagnosis_signal.connect(self._open_diagnosis_id)
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
frozen_payload = MappingProxyType(dialog.payload())
|
|
self._mutation_pending = True
|
|
self._action_generation += 1
|
|
generation = self._action_generation
|
|
self.banner.show_message("正在保存处方…", "info")
|
|
run_async(
|
|
lambda: invoke(
|
|
self.repository,
|
|
"update_prescription",
|
|
prescription=prescription_id,
|
|
changes=frozen_payload,
|
|
),
|
|
on_success=lambda _result: self._prescription_updated(generation),
|
|
on_error=lambda error: self._action_error(error, generation),
|
|
on_finished=lambda: self._mutation_finished(generation),
|
|
)
|
|
|
|
def _prescription_updated(self, generation: int) -> None:
|
|
if generation != self._action_generation:
|
|
return
|
|
self.banner.show_message("处方已保存并重新进入待审核。", "success")
|
|
self.refresh(silent=True)
|
|
|
|
def _begin_case_record_load(self, row: Any) -> None:
|
|
snapshot = deepcopy(row)
|
|
diagnosis_id = _diagnosis_id(snapshot)
|
|
self._prescription_generation += 1
|
|
generation = self._prescription_generation
|
|
self._prescription_busy = True
|
|
self.prescription_button.setEnabled(False)
|
|
self.banner.show_message("正在生成不可变病例快照…", "info")
|
|
run_async(
|
|
lambda: invoke(
|
|
self.repository,
|
|
"get_diagnosis_detail",
|
|
diagnosis_id=diagnosis_id,
|
|
),
|
|
on_success=lambda detail: self._case_record_loaded(detail, snapshot, generation),
|
|
on_error=lambda error: self._prescription_error(error, generation),
|
|
on_finished=lambda: self._prescription_finished(generation),
|
|
)
|
|
|
|
def _case_record_loaded(self, detail: Any, row: Any, generation: int) -> None:
|
|
if generation != self._prescription_generation:
|
|
return
|
|
self.banner.clear()
|
|
actual_id = _as_int(first_value(detail, "id", "diagnosis_id", default=0))
|
|
expected = _diagnosis_id(row)
|
|
if actual_id != expected:
|
|
self.banner.show_message("服务端诊单与当前行不一致,已停止开方。", "danger")
|
|
return
|
|
self._open_prescription_editor(row, deepcopy(detail))
|
|
|
|
def _prescription_seed(self, record: Any, case_record: Any) -> dict[str, Any]:
|
|
diagnosis = get_value(case_record, "diagnosis", None) or case_record or {}
|
|
patient = get_value(case_record, "patient", None) or {}
|
|
|
|
def authoritative(*keys: str, default: Any = None) -> Any:
|
|
return first_value(
|
|
diagnosis,
|
|
*keys,
|
|
default=first_value(
|
|
patient, *keys, default=first_value(record, *keys, default=default)
|
|
),
|
|
)
|
|
|
|
gender = authoritative("gender", default=1)
|
|
if _as_int(gender, 1) == 2:
|
|
gender = 0
|
|
diagnosis_id = _diagnosis_id(record)
|
|
appointment_id = _appointment_id(record)
|
|
return {
|
|
"diagnosis_id": diagnosis_id,
|
|
"appointment_id": appointment_id,
|
|
"case_record": deepcopy(case_record),
|
|
"patient_name": authoritative("patient_name", "name", default=""),
|
|
"phone": authoritative("phone", "patient_phone", default=""),
|
|
"gender": gender,
|
|
"age": authoritative("age", default=None),
|
|
"visit_no": build_prescription_visit_no(
|
|
diagnosis_id=diagnosis_id, appointment_id=appointment_id
|
|
),
|
|
"tongue": authoritative("tongue", "tongue_coating", default=""),
|
|
"tongue_image": authoritative("tongue_image", default=""),
|
|
"pulse": authoritative("pulse", default=""),
|
|
"pulse_condition": authoritative("pulse_condition", default=""),
|
|
"clinical_diagnosis": build_prescription_clinical_diagnosis(
|
|
diagnosis, patient, record, case_record
|
|
),
|
|
"doctor_name": first_value(
|
|
self.current_user, "name", "real_name", default=first_value(record, "doctor_name")
|
|
),
|
|
}
|
|
|
|
def _open_diagnosis_id(self, diagnosis_id: int) -> None:
|
|
if diagnosis_id > 0:
|
|
self._diagnosis_dialog().open_view_only(diagnosis_id)
|
|
|
|
def _open_prescription_editor(self, record: Any, case_record: Any) -> None:
|
|
seed = self._prescription_seed(record, case_record)
|
|
dialog = PrescriptionEditorDialog(
|
|
self.repository,
|
|
seed,
|
|
mode="add",
|
|
current_user=self.current_user,
|
|
parent=self,
|
|
)
|
|
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
|
if diagnosis_signal is not None:
|
|
diagnosis_signal.connect(self._open_diagnosis_id)
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
payload = dialog.payload()
|
|
payload["diagnosis_id"] = seed["diagnosis_id"]
|
|
payload["appointment_id"] = seed["appointment_id"]
|
|
payload["case_record"] = deepcopy(case_record)
|
|
frozen_payload = MappingProxyType(payload)
|
|
self._mutation_pending = True
|
|
self._action_generation += 1
|
|
generation = self._action_generation
|
|
self.banner.show_message("正在提交处方…", "info")
|
|
run_async(
|
|
lambda: invoke(self.repository, "create_prescription", prescription=frozen_payload),
|
|
on_success=lambda _result: self._prescription_created(generation),
|
|
on_error=lambda error: self._action_error(error, generation),
|
|
on_finished=lambda: self._mutation_finished(generation),
|
|
)
|
|
|
|
def _prescription_created(self, generation: int) -> None:
|
|
if generation != self._action_generation:
|
|
return
|
|
self.banner.show_message("处方已开具并提交审核。", "success")
|
|
self.refresh(silent=True)
|
|
|
|
def _prescription_error(self, error: Exception, generation: int) -> None:
|
|
if generation == self._prescription_generation:
|
|
self.banner.show_message(friendly_error(error), "danger")
|
|
|
|
def _prescription_finished(self, generation: int) -> None:
|
|
if generation == self._prescription_generation:
|
|
self._prescription_busy = False
|
|
self._selection_changed()
|
|
|
|
def _open_detail(self) -> None:
|
|
row = self._current_row()
|
|
appointment_id = _appointment_id(row)
|
|
if appointment_id <= 0:
|
|
return
|
|
if not callable(getattr(self.repository, "get_appointment_detail", None)):
|
|
show_toast(self, "当前仓库未提供挂号详情能力。", "warning")
|
|
return
|
|
self._action_generation += 1
|
|
generation = self._action_generation
|
|
diagnosis_id = _diagnosis_id(row)
|
|
self.banner.show_message("正在加载挂号详情…", "info")
|
|
|
|
def _worker() -> dict[str, Any]:
|
|
detail = dict(
|
|
invoke(self.repository, "get_appointment_detail", appointment_id=appointment_id)
|
|
)
|
|
target = diagnosis_id or _as_int(detail.get("patient_id") or detail.get("diagnosis_id"))
|
|
if target > 0 and callable(getattr(self.repository, "get_diagnosis_detail", None)):
|
|
try:
|
|
diagnosis = invoke(
|
|
self.repository,
|
|
"get_diagnosis_detail",
|
|
diagnosis_id=target,
|
|
)
|
|
if isinstance(diagnosis, Mapping):
|
|
merged = dict(detail)
|
|
merged.update(dict(diagnosis))
|
|
return merged
|
|
except Exception:
|
|
return detail
|
|
return detail
|
|
|
|
run_async(
|
|
_worker,
|
|
on_success=lambda detail: self._show_detail(detail, generation),
|
|
on_error=lambda error: self._action_error(error, generation),
|
|
)
|
|
|
|
def _show_detail(self, detail: Mapping[str, Any], generation: int) -> None:
|
|
if generation != self._action_generation:
|
|
return
|
|
self.banner.clear()
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("挂号详情")
|
|
dialog.setObjectName("AppointmentDetailDialog")
|
|
dialog.resize(720, 560)
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(24, 22, 24, 18)
|
|
layout.setSpacing(12)
|
|
title = QLabel("挂号详情")
|
|
title.setProperty("dialogRole", "title")
|
|
layout.addWidget(title)
|
|
body = QTextEdit()
|
|
body.setReadOnly(True)
|
|
body.setHtml(self._detail_html(detail))
|
|
layout.addWidget(body)
|
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭")
|
|
buttons.rejected.connect(dialog.reject)
|
|
layout.addWidget(buttons)
|
|
mark_business_dialog(dialog, "AppointmentDetailDialog")
|
|
dialog.exec()
|
|
|
|
def _detail_html(self, detail: Mapping[str, Any]) -> str:
|
|
def cell(label: str, value: Any) -> str:
|
|
return (
|
|
f"<tr><th style='text-align:left;color:#667085;padding:4px 12px 4px 0;'>"
|
|
f"{escape(label)}</th>"
|
|
f"<td style='padding:4px 0;'>{escape(display_text(value))}</td></tr>"
|
|
)
|
|
|
|
period = first_value(detail, "period", default="")
|
|
period_text = (
|
|
"上午"
|
|
if period == "morning"
|
|
else "下午"
|
|
if period == "afternoon"
|
|
else display_text(period)
|
|
)
|
|
sections = [
|
|
(
|
|
"预约信息",
|
|
[
|
|
("挂号ID", first_value(detail, "id")),
|
|
("状态", first_value(detail, "status_desc")),
|
|
("医生", first_value(detail, "doctor_name")),
|
|
(
|
|
"预约时间",
|
|
f"{display_text(first_value(detail, 'appointment_date'))} "
|
|
f"{display_text(first_value(detail, 'appointment_time'))}",
|
|
),
|
|
("时段", period_text),
|
|
(
|
|
"预约类型",
|
|
first_value(detail, "appointment_type_desc", "appointment_type_text"),
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"患者信息",
|
|
[
|
|
("姓名", first_value(detail, "patient_name")),
|
|
("手机号", first_value(detail, "phone", "patient_phone")),
|
|
("身份证号", first_value(detail, "id_card")),
|
|
("性别", gender_text(first_value(detail, "gender"))),
|
|
("年龄", first_value(detail, "age")),
|
|
("诊断日期", first_value(detail, "diagnosis_date")),
|
|
("身高(cm)", first_value(detail, "height")),
|
|
("体重(kg)", first_value(detail, "weight")),
|
|
("地区", first_value(detail, "region")),
|
|
],
|
|
),
|
|
(
|
|
"备注",
|
|
[("备注", first_value(detail, "remark") or "无")],
|
|
),
|
|
]
|
|
parts = ["<div style='font-family:Microsoft YaHei,sans-serif;font-size:13px;'>"]
|
|
for title, rows in sections:
|
|
parts.append(f"<h3 style='margin:12px 0 6px;'>{escape(title)}</h3><table>")
|
|
for label, value in rows:
|
|
parts.append(cell(label, value))
|
|
parts.append("</table>")
|
|
parts.append("</div>")
|
|
return "".join(parts)
|
|
|
|
def _load_departments(self) -> None:
|
|
if not callable(getattr(self.repository, "list_departments", None)):
|
|
return
|
|
self._dept_generation += 1
|
|
generation = self._dept_generation
|
|
run_async(
|
|
lambda: invoke(self.repository, "list_departments"),
|
|
on_success=lambda rows: self._departments_loaded(rows, generation),
|
|
on_error=lambda _error: None,
|
|
)
|
|
|
|
def _departments_loaded(self, rows: Any, generation: int) -> None:
|
|
if generation != self._dept_generation:
|
|
return
|
|
current = self.dept_filter.currentData()
|
|
self.dept_filter.blockSignals(True)
|
|
self.dept_filter.clear()
|
|
self.dept_filter.addItem("全部部门", "")
|
|
sequence = rows if isinstance(rows, Sequence) and not isinstance(rows, (str, bytes)) else []
|
|
for dept_id, label in _flatten_departments(sequence):
|
|
self.dept_filter.addItem(label, dept_id)
|
|
index = self.dept_filter.findData(current)
|
|
self.dept_filter.setCurrentIndex(max(0, index))
|
|
self.dept_filter.blockSignals(False)
|
|
|
|
def showEvent(self, event: Any) -> None:
|
|
super().showEvent(event)
|
|
self._load_departments()
|
|
if not self.poll_timer.isActive():
|
|
self.poll_timer.start()
|
|
if self.table.rowCount() == 0 and not self._loading:
|
|
self.refresh()
|
|
|
|
def hideEvent(self, event: Any) -> None:
|
|
self.poll_timer.stop()
|
|
super().hideEvent(event)
|
|
|
|
|
|
__all__ = ["AppointmentsPage", "prescription_action_label"]
|