更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
@@ -9,8 +9,8 @@ from html import escape
from types import MappingProxyType
from typing import Any
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
@@ -21,10 +21,9 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
@@ -104,8 +103,7 @@ APPOINTMENTS_LIGHT_QSS = """
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QFrame#AppointmentMainCard,
#AppointmentsPage QFrame#VideoConsultPanel {
#AppointmentsPage QFrame#AppointmentMainCard {
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -209,6 +207,34 @@ APPOINTMENTS_LIGHT_QSS = """
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;
}
@@ -257,77 +283,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #EEF1FF;
border-bottom-color: #5265F6;
}
#AppointmentsPage QLabel#VideoConsultTitle {
color: #15224A;
font-size: 16px;
font-weight: 700;
}
#AppointmentsPage QListWidget#VideoConsultList {
background-color: transparent;
border: 0;
border-radius: 0;
outline: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item {
min-height: 80px;
margin: 0 0 7px 0;
padding: 0;
background-color: transparent;
border: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item:hover {
background-color: transparent;
}
#AppointmentsPage QListWidget#VideoConsultList::item:selected {
background-color: transparent;
}
#AppointmentsPage QFrame[videoPatientCard="true"] {
background-color: #FCFDFF;
border: 1px solid #E1E6F1;
border-radius: 9px;
}
#AppointmentsPage QFrame[videoPatientCard="true"]:hover {
background-color: #F9FAFF;
border-color: #C9D2FF;
}
#AppointmentsPage QLabel[videoStatus="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 7px;
color: #5265F6;
background-color: #EEF1FF;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoStatusKind="warning"] {
color: #B97715;
background-color: #FFF4DF;
}
#AppointmentsPage QLabel[videoIdentity="true"] {
color: #24335E;
font-size: 12px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoAssistant="true"] {
color: #59698E;
font-size: 11px;
}
#AppointmentsPage QPushButton[videoAction="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 5px;
color: #5265F6;
background-color: transparent;
border: 0;
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QPushButton[videoAction="true"]:hover {
background-color: #EEF1FF;
}
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 28px;
max-height: 28px;
@@ -342,11 +297,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #F0F1FF;
border-color: #E0E4FF;
}
#AppointmentsPage QPushButton#VideoMoreButton {
min-height: 36px;
color: #405074;
background-color: #FFFFFF;
}
"""
@@ -452,14 +402,37 @@ 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, "has_prescription", default=None)
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, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
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 "开方"
@@ -689,7 +662,6 @@ class AppointmentsPage(QWidget):
self._dict_labels: dict[str, dict[str, str]] = {}
self._last_render_signature: tuple[Any, ...] | None = None
self._responsive_narrow: bool | None = None
self._video_panel_user_open = False
self._can_plain_phone = _canonical_allowed(
permissions, "tcm.diagnosis/phonePlain", default=False
)
@@ -889,35 +861,9 @@ class AppointmentsPage(QWidget):
layout.setSpacing(4)
self.content_layout = layout
self.table_card = self._build_table_card()
self.video_panel = self._build_video_panel()
layout.addWidget(self.table_card, 3)
layout.addWidget(self.video_panel, 1)
layout.addWidget(self.table_card, 1)
return host
def _build_video_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("VideoConsultPanel")
panel.setMinimumWidth(0)
panel.setMaximumWidth(420)
layout = QVBoxLayout(panel)
layout.setContentsMargins(14, 12, 14, 8)
layout.setSpacing(8)
title = QLabel("视频问诊")
title.setObjectName("VideoConsultTitle")
layout.addWidget(title)
self.video_list = QListWidget()
self.video_list.setObjectName("VideoConsultList")
self.video_list.setSpacing(0)
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.video_list.currentRowChanged.connect(self._video_row_selected)
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.video_list, 1)
more = QPushButton("查看更多患者 ﹀")
more.setObjectName("VideoMoreButton")
more.clicked.connect(lambda: self.pager.next.click())
layout.addWidget(more)
return panel
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("AppointmentMainCard")
@@ -1004,12 +950,6 @@ class AppointmentsPage(QWidget):
self.toolbar_cancel_button.setEnabled(False)
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
actions.addWidget(self.toolbar_cancel_button)
self.video_panel_button = QPushButton("视频问诊", card)
self.video_panel_button.setCheckable(True)
self.video_panel_button.setProperty("variant", "ghost")
self.video_panel_button.setProperty("compactAction", True)
self.video_panel_button.clicked.connect(self._toggle_video_panel)
actions.addWidget(self.video_panel_button)
actions.addStretch(1)
refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost")
@@ -1034,13 +974,23 @@ class AppointmentsPage(QWidget):
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)
self.table.horizontalHeader().setFixedHeight(34)
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())
@@ -1051,157 +1001,21 @@ class AppointmentsPage(QWidget):
layout.addWidget(self.pager)
return card
def _toggle_video_panel(self, checked: bool) -> None:
self._video_panel_user_open = checked
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Keep the table usable while retaining a video-list entry at low widths."""
"""Collapse only the date filters when horizontal space is limited."""
panel = getattr(self, "video_panel", None)
button = getattr(self, "video_panel_button", None)
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
return
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)
if narrow:
if self._responsive_narrow is not True:
self._video_panel_user_open = False
button.show()
button.setChecked(self._video_panel_user_open)
panel.setVisible(self._video_panel_user_open)
if self._video_panel_user_open:
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
else:
button.hide()
button.setChecked(False)
panel.show()
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
self._responsive_narrow = narrow
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._apply_responsive_layout()
def _sync_video_list(self, rows: Sequence[Any]) -> None:
selected_id = (
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
if self.video_list.currentItem()
else 0
)
self.video_list.clear()
row_to_select = -1
for index, row in enumerate(rows):
status = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}岁" if age not in (None, "") else "")
if part and part != "—"
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
item = QListWidgetItem(
f"{status} {identity} {assistant}\n"
"进入视频问诊 查看 诊单 开方 预约 补全身份"
)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setSizeHint(QSize(0, 84))
self.video_list.addItem(item)
self.video_list.setItemWidget(item, self._build_video_patient_card(row))
if selected_id and _appointment_id(row) == selected_id:
row_to_select = index
if row_to_select >= 0:
self.video_list.setCurrentRow(row_to_select)
else:
self.video_list.setCurrentRow(-1)
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
def _build_video_patient_card(self, row: Any) -> QWidget:
card = QFrame()
card.setProperty("videoPatientCard", True)
layout = QVBoxLayout(card)
layout.setContentsMargins(10, 7, 10, 6)
layout.setSpacing(5)
status_text = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}岁" if age not in (None, "") else "")
if part and part != "—"
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(7)
status = QLabel(status_text)
status.setProperty("videoStatus", True)
status.setProperty("videoStatusKind", "warning" if "挂号" in status_text else "primary")
heading.addWidget(status)
identity_label = QLabel(identity)
identity_label.setProperty("videoIdentity", True)
heading.addWidget(identity_label)
heading.addStretch(1)
assistant_label = QLabel(assistant)
assistant_label.setProperty("videoAssistant", True)
heading.addWidget(assistant_label)
layout.addLayout(heading)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(1)
def add_action(
label: str,
callback: Any,
permission: str | None = None,
kind: str = "",
) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
button = QPushButton(label, card)
button.setProperty("videoAction", True)
if kind:
button.setProperty("videoActionKind", kind)
button.clicked.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
actions.addWidget(button)
add_action(
"进入视频问诊",
self._request_video,
"doctor.appointment/prescription",
)
add_action("查看", self._open_detail)
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
more = QPushButton("更多", card)
more.setProperty("videoAction", True)
more.setProperty("videoActionKind", "warning")
more.clicked.connect(
lambda _checked=False, source=row, anchor=more: self._show_video_more_menu(
anchor, source
)
)
actions.addWidget(more)
actions.addStretch(1)
layout.addLayout(actions)
return card
def _run_video_row_action(self, row: Any, callback: Any) -> None:
appointment_id = _appointment_id(row)
for row_index in range(self.table.rowCount()):
@@ -1214,47 +1028,6 @@ class AppointmentsPage(QWidget):
break
callback()
def _show_video_more_menu(self, anchor: QPushButton, row: Any) -> None:
menu = QMenu(anchor)
def add_action(label: str, callback: Any, permission: str | None = None) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
action = QAction(label, menu)
action.triggered.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
menu.addAction(action)
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
if can_open_diagnosis_ai_report(self.permissions):
add_action("AI 报告", self._open_ai_report)
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
if menu.actions():
menu.exec(anchor.mapToGlobal(anchor.rect().bottomLeft()))
def _video_row_selected(self, index: int) -> None:
if index < 0:
return
item = self.video_list.item(index)
source = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
appointment_id = _appointment_id(source)
if appointment_id <= 0:
return
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
def _action_button(
self,
label: str,
@@ -1484,7 +1257,7 @@ class AppointmentsPage(QWidget):
self.table.set_rows(rows)
self._install_table_selectors()
self._install_appointment_info_cells()
self._sync_video_list(rows)
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)
@@ -1634,6 +1407,67 @@ class AppointmentsPage(QWidget):
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."""
@@ -1685,7 +1519,12 @@ class AppointmentsPage(QWidget):
and _as_int(first_value(row, "doctor_id", default=0)) > 0
)
self.call_button.setEnabled(
not_completed and self._native_video_capable and _appointment_id(row) > 0
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)
@@ -1760,15 +1599,15 @@ class AppointmentsPage(QWidget):
row = self._current_row()
if row is None:
return
status = _status_value(row)
if status == 3:
show_toast(self, "已完成的挂号不可再发起通话。", "warning")
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, "患者、诊单或挂号标识不完整,无法进入视频问诊。", "warning")
show_toast(self, "患者、诊单或挂号标识不完整,无法进入 IM 问诊。", "warning")
return
self.video_requested.emit(
{
@@ -1777,6 +1616,8 @@ class AppointmentsPage(QWidget):
"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,
}
@@ -2069,7 +1910,8 @@ class AppointmentsPage(QWidget):
if _diagnosis_id(row) <= 0 or _appointment_id(row) <= 0:
show_toast(self, "该预约缺少诊单信息,请先编辑患者。", "warning")
return
self._begin_prescription_load(row)
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):
@@ -2112,7 +1954,7 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
def _begin_prescription_load(self, row: Any) -> None:
def _begin_prescription_load(self, row: Any, *, mode: str = "open") -> None:
snapshot = deepcopy(row)
self._prescription_generation += 1
generation = self._prescription_generation
@@ -2126,18 +1968,51 @@ class AppointmentsPage(QWidget):
"get_prescription_by_appointment",
appointment_id=appointment_id,
),
on_success=lambda existing: self._prescription_loaded(existing, snapshot, generation),
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) -> None:
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:
@@ -2151,6 +2026,12 @@ class AppointmentsPage(QWidget):
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: