更新
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.4.1"
|
||||
__version__ = "1.4.2"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -489,7 +489,7 @@ class DemoDoctorRepository:
|
||||
return detail
|
||||
raise ApiBusinessError("挂号不存在", code=0)
|
||||
|
||||
def list_departments(self) -> list[dict[str, Any]]:
|
||||
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||
"""Return a small demo department tree."""
|
||||
|
||||
return [
|
||||
|
||||
@@ -335,8 +335,8 @@ class DoctorRepository(Protocol):
|
||||
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
|
||||
"""Return one appointment detail from ``doctor.appointment/detail``."""
|
||||
|
||||
def list_departments(self) -> list[dict[str, Any]]:
|
||||
"""Return the department tree used by appointment list filters."""
|
||||
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||
"""Return a department tree, optionally scoped to the account's data permissions."""
|
||||
|
||||
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
||||
"""Return an editable or permission-aware readonly diagnosis detail."""
|
||||
@@ -976,17 +976,20 @@ class RemoteDoctorRepository:
|
||||
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
|
||||
return dict(_require_mapping(payload, "doctor.appointment/detail"))
|
||||
|
||||
def list_departments(self) -> list[dict[str, Any]]:
|
||||
"""Load the department tree through ``dept.dept/all``."""
|
||||
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||
"""Load ``dept.dept/all`` with optional server-enforced role data scope."""
|
||||
|
||||
payload = self.client.get("dept.dept/all")
|
||||
if isinstance(payload, list):
|
||||
return [dict(row) for row in payload if isinstance(row, Mapping)]
|
||||
if isinstance(payload, Mapping):
|
||||
rows = payload.get("lists", payload.get("data", payload.get("tree")))
|
||||
if isinstance(rows, list):
|
||||
return [dict(row) for row in rows if isinstance(row, Mapping)]
|
||||
return []
|
||||
payload = self.client.get(
|
||||
"dept.dept/all", {"apply_data_scope": 1} if apply_data_scope else None
|
||||
)
|
||||
for _depth in range(5):
|
||||
if isinstance(payload, list):
|
||||
return [dict(row) for row in payload if isinstance(row, Mapping)]
|
||||
if isinstance(payload, Mapping):
|
||||
payload = payload.get("lists", payload.get("data", payload.get("tree")))
|
||||
else:
|
||||
break
|
||||
raise ApiProtocolError("部门数据格式异常,请重试")
|
||||
|
||||
def list_reception_queue(
|
||||
self,
|
||||
@@ -2056,7 +2059,10 @@ class RemoteDoctorRepository:
|
||||
) -> PageResult[Consultation]:
|
||||
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
||||
|
||||
request_filters = dict(filters)
|
||||
request_filters = dict(filters)
|
||||
department_id = request_filters.pop("department_id", None)
|
||||
if department_id not in (None, ""):
|
||||
request_filters.setdefault("assistant_dept_id", department_id)
|
||||
start_date = str(request_filters.pop("start_date", "") or "").strip()
|
||||
end_date = str(request_filters.pop("end_date", "") or "").strip()
|
||||
if start_date and start_date == end_date:
|
||||
|
||||
@@ -2799,8 +2799,9 @@ class NotesTimeline(QWidget):
|
||||
|
||||
add_requested = Signal()
|
||||
upload_requested = Signal(str)
|
||||
delete_attachment_requested = Signal(int, str, str)
|
||||
open_attachment_requested = Signal(str)
|
||||
delete_attachment_requested = Signal(int, str, str)
|
||||
open_attachment_requested = Signal(str)
|
||||
preview_images_requested = Signal(object, int)
|
||||
|
||||
def __init__(self, *, editable: bool = False, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -2879,11 +2880,14 @@ class NotesTimeline(QWidget):
|
||||
attachments = QWidget()
|
||||
attachments_layout = FlowLayout(attachments, horizontal_spacing=8, vertical_spacing=8)
|
||||
images = _pick(note, "tongue_images", "images", default=[]) or []
|
||||
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
|
||||
images = [images]
|
||||
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
|
||||
for image in images:
|
||||
path = str(_pick(image, "url", "path", "file_url", default=image) or "")
|
||||
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
|
||||
images = [images]
|
||||
image_paths = [
|
||||
str(_pick(image, "url", "path", "file_url", default=image) or "")
|
||||
for image in images
|
||||
]
|
||||
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
|
||||
for image_index, path in enumerate(image_paths):
|
||||
image_host = QWidget()
|
||||
image_layout = QGridLayout(image_host)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -2897,10 +2901,12 @@ class NotesTimeline(QWidget):
|
||||
object_name="DiagnosisTongueThumb",
|
||||
parent=image_host,
|
||||
)
|
||||
thumb.setEnabled(bool(path))
|
||||
thumb.clicked.connect(
|
||||
lambda _checked=False, target=path: self.open_attachment_requested.emit(target)
|
||||
)
|
||||
thumb.setEnabled(bool(path))
|
||||
thumb.clicked.connect(
|
||||
lambda _checked=False, sources=image_paths, index=image_index: (
|
||||
self.preview_images_requested.emit(sources, index)
|
||||
)
|
||||
)
|
||||
image_layout.addWidget(thumb, 0, 0)
|
||||
if self._can_delete and note_id > 0 and path:
|
||||
remove = QPushButton("×")
|
||||
|
||||
@@ -1973,6 +1973,8 @@ class DiagnosisTableHost(QFrame):
|
||||
return button
|
||||
|
||||
def _columns_resized(self, *_args: int) -> None:
|
||||
if getattr(self, "_fitting_columns", False):
|
||||
return
|
||||
if self.tech_blue:
|
||||
self._sync_row_heights()
|
||||
self._schedule_fixed_height_sync()
|
||||
@@ -2001,6 +2003,7 @@ class DiagnosisTableHost(QFrame):
|
||||
self._fixed_height_timer.start(0)
|
||||
|
||||
def _sync_fixed_height(self) -> None:
|
||||
self._fit_main_columns()
|
||||
# Use actual viewport geometry: scrollbar extent and QSS frame padding
|
||||
# can differ from sizeHint(). The unmanaged sibling cannot raise any
|
||||
# ancestor's minimum height, so style changes cannot create feedback.
|
||||
@@ -2016,6 +2019,31 @@ class DiagnosisTableHost(QFrame):
|
||||
self.fixed.verticalScrollBar().setValue(self.main.verticalScrollBar().value())
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
|
||||
def _fit_main_columns(self) -> None:
|
||||
"""Fill wide viewports without shrinking clinical columns on small ones.
|
||||
|
||||
The right-hand view is pinned independently. A wider main viewport used
|
||||
to expose unpainted space after its last fixed-width column, breaking
|
||||
both the header and selected-row background after a search.
|
||||
"""
|
||||
widths = list(self.LEFT_WIDTHS)
|
||||
extra = max(0, self.main.viewport().width() - sum(widths))
|
||||
# Give longer patient, appointment and follow-up text most of the space.
|
||||
for column, weight in ((2, 1), (4, 3), (6, 2), (7, 1), (9, 1)):
|
||||
widths[column] += extra * weight // 8
|
||||
widths[4] += max(0, self.main.viewport().width() - sum(widths))
|
||||
if widths == [self.main.columnWidth(column) for column in range(10)]:
|
||||
return
|
||||
self._fitting_columns = True
|
||||
try:
|
||||
for column, width in enumerate(widths):
|
||||
self.main.setColumnWidth(column, width)
|
||||
finally:
|
||||
self._fitting_columns = False
|
||||
# Wrapping changes with the allocated width; both panes must use the
|
||||
# same recalculated heights, including appointment action overlays.
|
||||
self._sync_row_heights()
|
||||
|
||||
def _position_empty(self) -> None:
|
||||
viewport = self.main.viewport()
|
||||
|
||||
@@ -1103,6 +1103,11 @@ class ImagePreviewDialog(QDialog):
|
||||
self._invalidate_request()
|
||||
super().closeEvent(event)
|
||||
|
||||
def done(self, result: int) -> None:
|
||||
# QDialog.reject() (including Escape) bypasses closeEvent.
|
||||
self._invalidate_request()
|
||||
super().done(result)
|
||||
|
||||
|
||||
def _clock(milliseconds: int) -> str:
|
||||
seconds = max(0, int(milliseconds) // 1000)
|
||||
|
||||
@@ -59,7 +59,12 @@ from ..diagnosis_drawer import (
|
||||
set_tag_item,
|
||||
)
|
||||
from ..diagnosis_editors import DailyRecordEditorDialog
|
||||
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
||||
from ..diagnosis_media import (
|
||||
ImagePreviewDialog,
|
||||
RecordingPlaybackCell,
|
||||
RecordingPlayerDialog,
|
||||
safe_image_sources,
|
||||
)
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..widgets import (
|
||||
display_text,
|
||||
@@ -832,6 +837,8 @@ class DiagnosisDialog(QDialog):
|
||||
self._saving = False
|
||||
self._load_mode = "readonly"
|
||||
self._generation = 0
|
||||
self._dictionary_requested_generation = -1
|
||||
self._dictionary_error_message = ""
|
||||
self._save_generation = 0
|
||||
self._orders_generation = 0
|
||||
self._order_detail_generation = 0
|
||||
@@ -846,6 +853,7 @@ class DiagnosisDialog(QDialog):
|
||||
self._loading_tabs: set[str] = set()
|
||||
self._daily_todo_status: int | None = None
|
||||
self._recording_players: list[RecordingPlayerDialog] = []
|
||||
self._image_preview: ImagePreviewDialog | None = None
|
||||
self._inline_recording_cells: list[RecordingPlaybackCell] = []
|
||||
self._last_order_detail_dialog: QDialog | None = None
|
||||
self._local_audio_dialog: LocalAudioQueueDialog | None = None
|
||||
@@ -965,6 +973,7 @@ class DiagnosisDialog(QDialog):
|
||||
self._add_readonly_section("daily", "日常记录", daily)
|
||||
notes = NotesTimeline(editable=False)
|
||||
notes.open_attachment_requested.connect(self._open_safe_resource)
|
||||
notes.preview_images_requested.connect(self._preview_note_images)
|
||||
self._notes_timelines.append(notes)
|
||||
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
|
||||
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
|
||||
@@ -1437,6 +1446,7 @@ class DiagnosisDialog(QDialog):
|
||||
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
||||
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
||||
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
||||
timeline.preview_images_requested.connect(self._preview_note_images)
|
||||
self.drawer_notes_timeline = timeline
|
||||
self._notes_timelines.append(timeline)
|
||||
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
||||
@@ -1698,22 +1708,23 @@ class DiagnosisDialog(QDialog):
|
||||
if hasattr(self, "readonly_ai_button"):
|
||||
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
||||
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
||||
current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex())
|
||||
self.tabs.clear()
|
||||
for key, label, codes in _TAB_DEFINITIONS:
|
||||
if not self._tab_allowed(codes):
|
||||
continue
|
||||
index = self.tabs.addTab(self._tab_pages[key], label)
|
||||
self.tabs.tabBar().setTabData(index, key)
|
||||
target = next(
|
||||
(
|
||||
index
|
||||
for index in range(self.tabs.count())
|
||||
if self.tabs.tabBar().tabData(index) == current_key
|
||||
),
|
||||
0,
|
||||
)
|
||||
self.tabs.setCurrentIndex(target)
|
||||
previous_key = self._current_tab_key()
|
||||
allowed_tabs = [
|
||||
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
|
||||
]
|
||||
if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [
|
||||
key for key, _label in allowed_tabs
|
||||
]:
|
||||
blocked = self.tabs.blockSignals(True)
|
||||
self.tabs.clear()
|
||||
target = 0
|
||||
for key, label in allowed_tabs:
|
||||
index = self.tabs.addTab(self._tab_pages[key], label)
|
||||
self.tabs.tabBar().setTabData(index, key)
|
||||
if key == previous_key:
|
||||
target = index
|
||||
self.tabs.setCurrentIndex(target)
|
||||
self.tabs.blockSignals(blocked)
|
||||
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
||||
for key, section in self._readonly_sections.items():
|
||||
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
|
||||
@@ -1743,6 +1754,9 @@ class DiagnosisDialog(QDialog):
|
||||
self._sync_order_offset_actions()
|
||||
for panel in self._chat_panels:
|
||||
panel.sync_button.setVisible(self._can_chat_sync)
|
||||
self._sync_save_button()
|
||||
if self._current_tab_key() != previous_key:
|
||||
self._tab_changed(self.tabs.currentIndex())
|
||||
|
||||
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
||||
self.drawer_banner.show_message(text, kind, action_text)
|
||||
@@ -1751,6 +1765,9 @@ class DiagnosisDialog(QDialog):
|
||||
self.readonly_error.show()
|
||||
|
||||
def _clear_message(self) -> None:
|
||||
if self._dictionary_error_message and self._authoritative_detail_loaded:
|
||||
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||||
return
|
||||
self.drawer_banner.clear()
|
||||
self.readonly_error.hide()
|
||||
|
||||
@@ -1996,6 +2013,7 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
self._authoritative_detail_loaded = False
|
||||
self._saving = False
|
||||
self._dictionary_error_message = ""
|
||||
self._generation += 1
|
||||
self._save_generation += 1
|
||||
self._orders_generation += 1
|
||||
@@ -2125,14 +2143,26 @@ class DiagnosisDialog(QDialog):
|
||||
),
|
||||
0,
|
||||
)
|
||||
return {"detail": detail, "patient_id": patient_id}
|
||||
|
||||
def _load_dictionary_choices(self) -> dict[str, Any]:
|
||||
"""Load optional editor labels without extending the authoritative-detail gate."""
|
||||
dictionaries: dict[str, list[tuple[str, Any]]] = {}
|
||||
dictionary_errors: list[str] = []
|
||||
if callable(getattr(self.repository, "get_dictionary", None)):
|
||||
dictionary_types = {
|
||||
dictionary_types = sorted(
|
||||
{
|
||||
"diagnosis_type",
|
||||
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
|
||||
}
|
||||
for dictionary_type in sorted(dictionary_types):
|
||||
)
|
||||
if callable(getattr(self.repository, "get_dictionaries", None)):
|
||||
raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types)
|
||||
dictionaries = {
|
||||
key: _dictionary_choices(get_value(raw, key, []), key)
|
||||
for key in dictionary_types
|
||||
}
|
||||
elif callable(getattr(self.repository, "get_dictionary", None)):
|
||||
for dictionary_type in dictionary_types:
|
||||
try:
|
||||
raw = invoke(
|
||||
self.repository,
|
||||
@@ -2143,12 +2173,41 @@ class DiagnosisDialog(QDialog):
|
||||
except Exception as error:
|
||||
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
|
||||
return {
|
||||
"detail": detail,
|
||||
"patient_id": patient_id,
|
||||
"dictionaries": dictionaries,
|
||||
"dictionary_errors": dictionary_errors,
|
||||
}
|
||||
|
||||
def _ensure_dictionary_loaded(self, *, force: bool = False) -> None:
|
||||
if self._standalone_readonly or not self._authoritative_detail_loaded:
|
||||
return
|
||||
generation = self._generation
|
||||
if not force and self._dictionary_requested_generation == generation:
|
||||
return
|
||||
self._dictionary_requested_generation = generation
|
||||
clear_error = self.drawer_banner.label.text() == self._dictionary_error_message
|
||||
self._dictionary_error_message = ""
|
||||
if clear_error:
|
||||
self._clear_message()
|
||||
run_async(
|
||||
self._load_dictionary_choices,
|
||||
on_success=lambda result: self._dictionary_choices_loaded(result, generation),
|
||||
on_error=lambda error: self._dictionary_choices_loaded(
|
||||
{"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation
|
||||
),
|
||||
)
|
||||
|
||||
def _dictionary_choices_loaded(self, result: Any, generation: int) -> None:
|
||||
if generation != self._generation or not self._authoritative_detail_loaded:
|
||||
return
|
||||
# A clinician may already have edited fields while the optional labels load.
|
||||
# Update only choices; re-rendering the detail would overwrite that draft.
|
||||
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True)
|
||||
errors = get_value(result, "dictionary_errors", []) or []
|
||||
if errors:
|
||||
self._dictionary_requested_generation = -1
|
||||
self._dictionary_error_message = ";".join(str(item) for item in errors)
|
||||
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||||
|
||||
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
|
||||
filters: dict[str, Any] = {
|
||||
"context_diagnosis_id": diagnosis_id,
|
||||
@@ -2170,12 +2229,12 @@ class DiagnosisDialog(QDialog):
|
||||
detail = get_value(result, "detail", None)
|
||||
self._detail = detail
|
||||
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
|
||||
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {})
|
||||
self._authoritative_detail_loaded = True
|
||||
self._show_authoritative_content(True)
|
||||
self._render(detail, [], [])
|
||||
self._sync_form_interactivity()
|
||||
self._sync_save_button()
|
||||
self._set_loading(False)
|
||||
errors = get_value(result, "dictionary_errors", []) or []
|
||||
if errors:
|
||||
self._show_message(";".join(str(item) for item in errors), "warning")
|
||||
@@ -2202,7 +2261,9 @@ class DiagnosisDialog(QDialog):
|
||||
if generation == self._generation:
|
||||
self._set_loading(False)
|
||||
|
||||
def _apply_dictionary_choices(self, dictionaries: Mapping[str, Any]) -> None:
|
||||
def _apply_dictionary_choices(
|
||||
self, dictionaries: Mapping[str, Any], *, preserve: bool = False
|
||||
) -> None:
|
||||
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
|
||||
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
|
||||
labels = {
|
||||
@@ -2219,15 +2280,17 @@ class DiagnosisDialog(QDialog):
|
||||
("会诊", "consultation"),
|
||||
)
|
||||
),
|
||||
preserve=False,
|
||||
preserve=preserve,
|
||||
)
|
||||
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
|
||||
if dictionary_type not in dictionaries:
|
||||
continue
|
||||
editor = self._choice_fields.get(key)
|
||||
choices = list(dictionaries.get(dictionary_type, []))
|
||||
if not _multiple:
|
||||
choices.insert(0, ("无", ""))
|
||||
if editor is not None:
|
||||
editor.set_choices(choices, preserve=False)
|
||||
editor.set_choices(choices, preserve=preserve)
|
||||
# Defer until the drawer has a real width so wrapped chips get height.
|
||||
QTimer.singleShot(0, self._sync_choice_field_heights)
|
||||
|
||||
@@ -2306,9 +2369,14 @@ class DiagnosisDialog(QDialog):
|
||||
if not self._authoritative_detail_loaded:
|
||||
self._retry_detail()
|
||||
return
|
||||
if (self._dictionary_error_message
|
||||
and self.drawer_banner.label.text() == self._dictionary_error_message):
|
||||
self._ensure_dictionary_loaded(force=True)
|
||||
return
|
||||
self._ensure_tab_loaded(self._current_tab_key(), force=True)
|
||||
|
||||
def _invalidate_requests(self) -> None:
|
||||
self._close_image_preview()
|
||||
self._stop_watching_local_audio_uploads()
|
||||
self._video_reload_pending = False
|
||||
self._generation += 1
|
||||
@@ -2627,7 +2695,10 @@ class DiagnosisDialog(QDialog):
|
||||
table.set_empty_text(message)
|
||||
|
||||
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
|
||||
if key == "basic" or not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
||||
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
||||
return
|
||||
if key == "basic":
|
||||
self._ensure_dictionary_loaded(force=force)
|
||||
return
|
||||
if key == "daily":
|
||||
source = next(
|
||||
@@ -3421,6 +3492,27 @@ class DiagnosisDialog(QDialog):
|
||||
self._sync_order_offset_actions()
|
||||
self._mutation_error(error, diagnosis_id, generation, "offset")
|
||||
|
||||
def _close_image_preview(self) -> None:
|
||||
preview, self._image_preview = self._image_preview, None
|
||||
if preview is not None:
|
||||
with suppress(RuntimeError):
|
||||
preview.close()
|
||||
|
||||
def _image_preview_finished(self, _result: int) -> None:
|
||||
if self.sender() is self._image_preview:
|
||||
self._image_preview = None
|
||||
|
||||
def _preview_note_images(self, sources: Sequence[str], index: int) -> None:
|
||||
"""Keep the diagnosis open while viewing the clicked note's image group."""
|
||||
if not safe_image_sources(sources):
|
||||
QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。")
|
||||
return
|
||||
self._close_image_preview()
|
||||
preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self)
|
||||
self._image_preview = preview
|
||||
preview.finished.connect(self._image_preview_finished)
|
||||
preview.open()
|
||||
|
||||
def _open_safe_resource(self, target: str) -> None:
|
||||
url = QUrl(str(target).strip())
|
||||
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
||||
|
||||
@@ -14,7 +14,8 @@ from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices, QPixmap
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QComboBox,
|
||||
QCompleter,
|
||||
QDateEdit,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -98,7 +99,32 @@ def _as_int(value: Any, default: int = 0) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
def _department_options(rows: Any) -> list[tuple[int, str]]:
|
||||
"""Flatten the server-scoped tree without deriving extra options from profiles."""
|
||||
if not isinstance(rows, (list, tuple)):
|
||||
raise ValueError("部门数据格式异常,请重试")
|
||||
options: list[tuple[int, str]] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
def visit(nodes: Sequence[Any], parents: tuple[str, ...] = ()) -> None:
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
label = str(node.get("name") or "").strip()
|
||||
department_id = _as_int(node.get("id"))
|
||||
path = (*parents, label) if label else parents
|
||||
if department_id > 0 and label and department_id not in seen:
|
||||
options.append((department_id, " / ".join(path)))
|
||||
seen.add(department_id)
|
||||
children = node.get("children")
|
||||
if isinstance(children, (list, tuple)):
|
||||
visit(children, path)
|
||||
|
||||
visit(rows)
|
||||
return options
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
@@ -930,7 +956,10 @@ class ConsultationsPage(QWidget):
|
||||
self._page_size = 15
|
||||
self._generation = 0
|
||||
self._count_generation = 0
|
||||
self._options_generation = 0
|
||||
self._options_generation = 0
|
||||
self._departments_generation = 0
|
||||
self._departments_loaded = False
|
||||
self._departments_loading = False
|
||||
self._prescription_generation = 0
|
||||
self._mutation_generation = 0
|
||||
self._menu_generation = 0
|
||||
@@ -1172,15 +1201,21 @@ class ConsultationsPage(QWidget):
|
||||
self.diagnosis_confirmed_combo.setObjectName("DiagnosisConfirmationFilter")
|
||||
self.diagnosis_confirmed_combo.activated.connect(self._confirmation_combo_changed)
|
||||
self.confirmed_combo = self.diagnosis_confirmed_combo
|
||||
self.department_combo = self._fixed_combo((("全部部门", ""),))
|
||||
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
|
||||
department_name = display_text(
|
||||
first_value(current_user, "department_name", "department", "dept_name", default=""),
|
||||
"",
|
||||
)
|
||||
department_id = first_value(current_user, "department_id", "dept_id", default="")
|
||||
if department_name:
|
||||
self.department_combo.addItem(department_name, department_id or department_name)
|
||||
self.department_combo = self._fixed_combo((("全部部门", ""),))
|
||||
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
|
||||
self.department_combo.setEditable(True)
|
||||
self.department_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||
self.department_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
self.department_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)
|
||||
self.department_combo.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
self.department_combo.lineEdit().setPlaceholderText("输入部门名称搜索")
|
||||
self.department_combo.lineEdit().returnPressed.connect(self._search)
|
||||
self.department_retry_button = QToolButton()
|
||||
self.department_retry_button.setObjectName("DiagnosisDepartmentRetry")
|
||||
self.department_retry_button.setText("重试")
|
||||
self.department_retry_button.setAccessibleName("重新加载部门")
|
||||
self.department_retry_button.clicked.connect(lambda: self._load_departments(force=True))
|
||||
self.department_retry_button.hide()
|
||||
self.unserved_sort_combo = self._fixed_combo(
|
||||
(
|
||||
("未服务天数默认排序", ""),
|
||||
@@ -1202,9 +1237,9 @@ class ConsultationsPage(QWidget):
|
||||
self.secondary_filter_flow.flow.addWidget(
|
||||
self._filter_field("确认诊单:", self.diagnosis_confirmed_combo)
|
||||
)
|
||||
self.secondary_filter_flow.flow.addWidget(
|
||||
self._filter_field("部门:", self.department_combo)
|
||||
)
|
||||
department_field = self._filter_field("部门:", self.department_combo)
|
||||
department_field.layout().addWidget(self.department_retry_button)
|
||||
self.secondary_filter_flow.flow.addWidget(department_field)
|
||||
self.keyword_edit = QLineEdit(secondary_filters)
|
||||
self.keyword_edit.setObjectName("DiagnosisKeyword")
|
||||
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
|
||||
@@ -1864,7 +1899,16 @@ class ConsultationsPage(QWidget):
|
||||
self._page = 1
|
||||
self.refresh()
|
||||
|
||||
def _validate_ranges(self) -> bool:
|
||||
def _validate_ranges(self) -> bool:
|
||||
if (
|
||||
self.department_combo.isEnabled()
|
||||
and self.department_combo.currentText().strip()
|
||||
and self.department_combo.currentText() != self.department_combo.itemText(
|
||||
self.department_combo.currentIndex()
|
||||
)
|
||||
):
|
||||
self.banner.show_message("请从部门搜索结果中选择部门,或清空部门筛选。", "warning")
|
||||
return False
|
||||
pairs = (
|
||||
(
|
||||
self._date_value(self.latest_appointment_start_date),
|
||||
@@ -1918,7 +1962,11 @@ class ConsultationsPage(QWidget):
|
||||
"keyword": self.keyword_edit.text().strip(),
|
||||
"patient_name": self.patient_name_edit.text().strip(),
|
||||
"doctor_name": self.doctor_edit.text().strip(),
|
||||
"department_id": self._combo_value(self.department_combo),
|
||||
"assistant_dept_id": (
|
||||
self.department_combo.currentData() or ""
|
||||
if self.department_combo.currentText().strip()
|
||||
else ""
|
||||
),
|
||||
"has_appointment": self._combo_value(self.has_appointment_combo),
|
||||
"diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo),
|
||||
"diagnosis_type": self._combo_value(self.diagnosis_type_combo),
|
||||
@@ -2130,7 +2178,60 @@ class ConsultationsPage(QWidget):
|
||||
}
|
||||
self._update_quick_buttons()
|
||||
|
||||
def _load_filter_options(self) -> None:
|
||||
def _load_departments(self, *, force: bool = False) -> None:
|
||||
if not force and (self._departments_loaded or self._departments_loading):
|
||||
return
|
||||
self._departments_generation += 1
|
||||
generation = self._departments_generation
|
||||
self._departments_loading = True
|
||||
self.department_combo.setEnabled(False)
|
||||
self.department_combo.setToolTip("正在加载可选部门…")
|
||||
self.department_retry_button.hide()
|
||||
|
||||
def load() -> list[tuple[int, str]]:
|
||||
# Call directly: invoke() can discard unsupported kwargs, which would
|
||||
# silently remove the permission scope on an older repository.
|
||||
return _department_options(self.repository.list_departments(apply_data_scope=True))
|
||||
|
||||
run_async(
|
||||
load,
|
||||
on_success=lambda options: self._apply_departments(options, generation),
|
||||
on_error=lambda error: self._departments_error(error, generation),
|
||||
)
|
||||
|
||||
def _apply_departments(self, options: list[tuple[int, str]], generation: int) -> None:
|
||||
if generation != self._departments_generation:
|
||||
return
|
||||
current = self.department_combo.currentData()
|
||||
blocked = self.department_combo.blockSignals(True)
|
||||
self.department_combo.clear()
|
||||
self.department_combo.addItem("全部部门" if options else "暂无可选部门", "")
|
||||
for department_id, label in options:
|
||||
self.department_combo.addItem(label, department_id)
|
||||
self.department_combo.setCurrentIndex(max(0, self.department_combo.findData(current)))
|
||||
self.department_combo.blockSignals(blocked)
|
||||
self._departments_loaded = True
|
||||
self._departments_loading = False
|
||||
self.department_combo.setEnabled(bool(options))
|
||||
self.department_combo.setToolTip(
|
||||
"输入部门名称搜索;选择父级包含下级部门" if options else "当前账号暂无可选部门"
|
||||
)
|
||||
self.department_retry_button.setVisible(not options)
|
||||
|
||||
def _departments_error(self, error: Exception, generation: int) -> None:
|
||||
if generation != self._departments_generation:
|
||||
return
|
||||
self._departments_loading = False
|
||||
self._departments_loaded = False
|
||||
self.department_combo.clear()
|
||||
self.department_combo.addItem("部门加载失败", "")
|
||||
self.department_combo.setEnabled(False)
|
||||
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
|
||||
self.department_combo.setToolTip(message)
|
||||
self.department_retry_button.setToolTip(message)
|
||||
self.department_retry_button.show()
|
||||
|
||||
def _load_filter_options(self) -> None:
|
||||
if self._options_loaded:
|
||||
return
|
||||
self._options_loaded = True
|
||||
@@ -3421,9 +3522,10 @@ class ConsultationsPage(QWidget):
|
||||
self.refresh(silent=True)
|
||||
self._refresh_counts()
|
||||
|
||||
def showEvent(self, event: Any) -> None:
|
||||
super().showEvent(event)
|
||||
self._load_filter_options()
|
||||
def showEvent(self, event: Any) -> None:
|
||||
super().showEvent(event)
|
||||
self._load_departments()
|
||||
self._load_filter_options()
|
||||
if not self.poll_timer.isActive():
|
||||
self.poll_timer.start()
|
||||
if self.table.rowCount() == 0 and not self._loading:
|
||||
|
||||
@@ -8,7 +8,7 @@ from html import escape
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
|
||||
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
@@ -19,7 +19,8 @@ from PySide6.QtGui import (
|
||||
QTextDocument,
|
||||
QTextOption,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
@@ -563,7 +564,165 @@ def _create_time_cell(value: Any, _row: Any) -> str:
|
||||
return format_record_time(raw)
|
||||
|
||||
|
||||
class DoctorMultiSelect(QWidget):
|
||||
class _DoctorOptionDelegate(QStyledItemDelegate):
|
||||
"""Draw an explicit checked state independent of native item indicators."""
|
||||
|
||||
def sizeHint(self, option: Any, index: QModelIndex) -> QSize:
|
||||
return QSize(220, 36)
|
||||
|
||||
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||
checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = option.rect.adjusted(2, 1, -2, -1)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
|
||||
painter.drawRoundedRect(rect, 5, 5)
|
||||
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
|
||||
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
|
||||
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
|
||||
painter.drawRoundedRect(box, 3, 3)
|
||||
if checked:
|
||||
pen = QPen(QColor("#FFFFFF"), 2)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
x, y = int(box.x()), int(box.y())
|
||||
painter.drawLine(x + 4, y + 8, x + 7, y + 11)
|
||||
painter.drawLine(x + 7, y + 11, x + 12, y + 5)
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
text_rect = rect.adjusted(38, 0, -54, 0)
|
||||
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
|
||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
|
||||
if checked:
|
||||
painter.setPen(QColor("#1769E8"))
|
||||
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#ADC8F2"), 1))
|
||||
painter.drawRoundedRect(rect, 5, 5)
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event: Any, model: Any, option: Any, index: QModelIndex) -> bool:
|
||||
# The list handles whole-row toggles, independent of the native checkbox hit area.
|
||||
return False
|
||||
|
||||
|
||||
class _DoctorOptionsList(QListWidget):
|
||||
@staticmethod
|
||||
def _toggle(item: QListWidgetItem | None) -> None:
|
||||
if item is not None and item.flags() & Qt.ItemFlag.ItemIsEnabled:
|
||||
item.setCheckState(Qt.CheckState.Unchecked if item.checkState() == Qt.CheckState.Checked
|
||||
else Qt.CheckState.Checked)
|
||||
|
||||
def mousePressEvent(self, event: Any) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
item = self.itemAt(event.position().toPoint())
|
||||
self.setCurrentItem(item)
|
||||
self._toggle(item)
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: Any) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: Any) -> None:
|
||||
if event.key() == Qt.Key.Key_Space:
|
||||
if not event.isAutoRepeat():
|
||||
self._toggle(self.currentItem())
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class _DoctorSelectionDialog(QDialog):
|
||||
def __init__(self, options: Mapping[int, str], selected: set[int], parent: QWidget) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DoctorSelectionDialog")
|
||||
self.setWindowTitle("选择医生")
|
||||
self.resize(420, 500)
|
||||
self.setStyleSheet("""
|
||||
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
|
||||
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
|
||||
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
|
||||
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
|
||||
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
|
||||
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
|
||||
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
||||
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
|
||||
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
|
||||
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
|
||||
""")
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
self.search = QLineEdit(self)
|
||||
self.search.setObjectName("DoctorOptionSearch")
|
||||
self.search.setPlaceholderText("搜索医生姓名")
|
||||
self.search.setClearButtonEnabled(True)
|
||||
layout.addWidget(self.search)
|
||||
summary = QHBoxLayout()
|
||||
self.count_label = QLabel(self)
|
||||
self.count_label.setObjectName("DoctorSelectionCount")
|
||||
summary.addWidget(self.count_label, 1)
|
||||
self.clear_button = QPushButton("清空选择", self)
|
||||
self.clear_button.clicked.connect(self._clear)
|
||||
summary.addWidget(self.clear_button)
|
||||
layout.addLayout(summary)
|
||||
self.listing = _DoctorOptionsList(self)
|
||||
self.listing.setObjectName("DoctorOptionsList")
|
||||
self.listing.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
self.listing.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.listing.setMouseTracking(True)
|
||||
self.listing.setItemDelegate(_DoctorOptionDelegate(self.listing))
|
||||
for doctor_id, name in sorted(options.items(), key=lambda item: item[1]):
|
||||
item = QListWidgetItem(name)
|
||||
item.setToolTip(name)
|
||||
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.CheckState.Checked if doctor_id in selected else Qt.CheckState.Unchecked)
|
||||
self.listing.addItem(item)
|
||||
layout.addWidget(self.listing, 1)
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok, self)
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setObjectName("ConfirmDoctorSelection")
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
||||
self.buttons.accepted.connect(self.accept)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(self.buttons)
|
||||
self.listing.itemChanged.connect(self._update_summary)
|
||||
self.search.textChanged.connect(self._filter)
|
||||
self._update_summary()
|
||||
|
||||
def selected_ids(self) -> set[int]:
|
||||
return {_int(self.listing.item(i).data(Qt.ItemDataRole.UserRole))
|
||||
for i in range(self.listing.count())
|
||||
if self.listing.item(i).checkState() == Qt.CheckState.Checked}
|
||||
|
||||
def _update_summary(self, *_args: Any) -> None:
|
||||
count = len(self.selected_ids())
|
||||
self.count_label.setText(f"已选 {count} 位医生")
|
||||
self.clear_button.setEnabled(count > 0)
|
||||
|
||||
def _filter(self, text: str) -> None:
|
||||
query = text.strip().casefold()
|
||||
for i in range(self.listing.count()):
|
||||
item = self.listing.item(i)
|
||||
item.setHidden(query not in item.text().casefold())
|
||||
|
||||
def _clear(self) -> None:
|
||||
for i in range(self.listing.count()):
|
||||
self.listing.item(i).setCheckState(Qt.CheckState.Unchecked)
|
||||
|
||||
|
||||
class DoctorMultiSelect(QWidget):
|
||||
"""Compact checkable doctor selector fed by list rows/extend data."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
@@ -591,36 +750,14 @@ class DoctorMultiSelect(QWidget):
|
||||
self._options[doctor_id] = name
|
||||
self._update_text()
|
||||
|
||||
def _choose(self) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("选择医生")
|
||||
dialog.resize(360, 430)
|
||||
layout = QVBoxLayout(dialog)
|
||||
listing = QListWidget()
|
||||
for doctor_id, name in sorted(self._options.items(), key=lambda item: item[1]):
|
||||
item = QListWidgetItem(name)
|
||||
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(
|
||||
Qt.CheckState.Checked if doctor_id in self._selected else Qt.CheckState.Unchecked
|
||||
)
|
||||
listing.addItem(item)
|
||||
layout.addWidget(listing)
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
|
||||
)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
layout.addWidget(buttons)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
self._selected = {
|
||||
_int(listing.item(index).data(Qt.ItemDataRole.UserRole))
|
||||
for index in range(listing.count())
|
||||
if listing.item(index).checkState() == Qt.CheckState.Checked
|
||||
}
|
||||
self._selected.discard(0)
|
||||
self._update_text()
|
||||
def _choose(self) -> None:
|
||||
dialog = _DoctorSelectionDialog(self._options, self._selected, self)
|
||||
try:
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self._selected = dialog.selected_ids() - {0}
|
||||
self._update_text()
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
|
||||
def _update_text(self) -> None:
|
||||
if not self._selected:
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Permission-scoped department loading and diagnosis HTTP filter contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.pages import consultations as module
|
||||
|
||||
TREE = [
|
||||
{"id": 10, "name": "医助部", "children": [
|
||||
{"id": "11", "name": "一组", "children": [
|
||||
{"id": 12, "name": "专病组", "children": []},
|
||||
]},
|
||||
]},
|
||||
{"id": 20, "name": "另一部门", "children": []},
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, departments: Any = TREE) -> None:
|
||||
self.departments = departments
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
self.calls.append((endpoint, dict(params or {})))
|
||||
if endpoint == "dept.dept/all":
|
||||
if isinstance(self.departments, Exception):
|
||||
raise self.departments
|
||||
return self.departments
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
class DetailStub(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
|
||||
# Test only this page's controls; loading a global stylesheet is unrelated.
|
||||
client = Client()
|
||||
result = module.ConsultationsPage(
|
||||
RemoteDoctorRepository(client),
|
||||
current_user={"department_id": 99, "department_name": "个人所属部门"},
|
||||
)
|
||||
yield result, client
|
||||
result.poll_timer.stop()
|
||||
result.close()
|
||||
result.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def immediate(function: Any, *, on_success: Any = None, on_error: Any = None,
|
||||
on_finished: Any = None, **_kwargs: Any) -> None:
|
||||
try:
|
||||
value = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(value)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [TREE, {"lists": TREE}, {"tree": TREE}, {"data": {"lists": TREE}}])
|
||||
def test_repository_scoped_tree_shapes(payload: Any) -> None:
|
||||
client = Client(payload)
|
||||
assert RemoteDoctorRepository(client).list_departments(apply_data_scope=True) == TREE
|
||||
assert client.calls == [("dept.dept/all", {"apply_data_scope": 1})]
|
||||
|
||||
|
||||
def test_repository_malformed_is_not_a_permission_empty_result() -> None:
|
||||
with pytest.raises(ApiProtocolError):
|
||||
RemoteDoctorRepository(Client({"unexpected": 1})).list_departments(apply_data_scope=True)
|
||||
|
||||
|
||||
def test_repository_maps_legacy_department_key_and_preserves_canonical() -> None:
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
repository.list_consultations(department_id=12)
|
||||
assert client.calls[-1][1]["assistant_dept_id"] == 12
|
||||
assert "department_id" not in client.calls[-1][1]
|
||||
repository.list_consultations(department_id=12, assistant_dept_id=20)
|
||||
assert client.calls[-1][1]["assistant_dept_id"] == 20
|
||||
|
||||
|
||||
def test_nested_options_search_and_selected_id_reach_list_and_counts(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
widget._load_departments()
|
||||
combo = widget.department_combo
|
||||
assert combo.count() == 5
|
||||
assert combo.itemText(3) == "医助部 / 一组 / 专病组"
|
||||
assert combo.findData(99) == -1
|
||||
assert combo.isEditable()
|
||||
combo.completer().setCompletionPrefix("专病")
|
||||
assert combo.completer().completionCount() == 1
|
||||
combo.setCurrentIndex(combo.findData(12))
|
||||
widget._search()
|
||||
calls = [params for endpoint, params in client.calls if endpoint == "tcm.diagnosis/lists"]
|
||||
assert calls
|
||||
assert all(params["assistant_dept_id"] == 12 for params in calls)
|
||||
assert all("department_id" not in params for params in calls)
|
||||
assert calls[-1]["page_size"] == 15
|
||||
|
||||
|
||||
def test_unselected_search_text_never_becomes_an_id_or_silently_searches_all(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
widget._load_departments()
|
||||
widget.department_combo.setEditText("不存在的部门")
|
||||
widget._search()
|
||||
assert not any(endpoint == "tcm.diagnosis/lists" for endpoint, _ in client.calls)
|
||||
widget.department_combo.setEditText("")
|
||||
widget._search()
|
||||
assert widget._shared_filters()["assistant_dept_id"] == ""
|
||||
|
||||
|
||||
def test_permission_empty_has_no_profile_fallback_and_can_retry(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
client.departments = []
|
||||
widget._load_departments()
|
||||
assert widget.department_combo.count() == 1
|
||||
assert widget.department_combo.currentData() == ""
|
||||
assert widget.department_combo.currentText() == "暂无可选部门"
|
||||
assert not widget.department_combo.isEnabled()
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
client.departments = TREE
|
||||
widget.department_retry_button.click()
|
||||
assert widget.department_combo.isEnabled()
|
||||
assert widget.department_combo.findData(12) > 0
|
||||
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||
|
||||
|
||||
def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
client.departments = ApiBusinessError("无权限访问部门")
|
||||
widget._load_departments()
|
||||
assert not widget._departments_loaded
|
||||
assert not widget._departments_loading
|
||||
assert widget.department_combo.currentText() == "部门加载失败"
|
||||
assert "无权限" in widget.department_combo.toolTip()
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
client.departments = TREE
|
||||
widget.department_retry_button.click()
|
||||
assert widget._departments_loaded
|
||||
assert widget.department_retry_button.isHidden()
|
||||
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||
|
||||
|
||||
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
jobs: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(module, "run_async", lambda function, **callbacks: jobs.append(callbacks))
|
||||
widget._load_departments()
|
||||
widget._load_departments()
|
||||
assert len(jobs) == 1
|
||||
widget._load_departments(force=True)
|
||||
assert len(jobs) == 2
|
||||
jobs[1]["on_success"]([(12, "当前部门")])
|
||||
widget.department_combo.setCurrentIndex(1)
|
||||
jobs[0]["on_success"]([(99, "旧部门")])
|
||||
jobs[0]["on_error"](RuntimeError("旧请求失败"))
|
||||
assert widget.department_combo.currentData() == 12
|
||||
assert widget.department_combo.findData(99) == -1
|
||||
assert widget._departments_loaded
|
||||
assert widget.department_combo.isEnabled()
|
||||
|
||||
|
||||
def test_refresh_of_options_preserves_selected_id(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
widget._load_departments()
|
||||
widget.department_combo.setCurrentIndex(widget.department_combo.findData(12))
|
||||
widget._load_departments(force=True)
|
||||
assert widget.department_combo.currentData() == 12
|
||||
|
||||
|
||||
def test_legacy_repository_is_not_called_without_scope(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
calls: list[bool] = []
|
||||
|
||||
class OldRepository:
|
||||
def list_departments(self):
|
||||
calls.append(True)
|
||||
return TREE
|
||||
|
||||
widget.repository = OldRepository()
|
||||
widget._load_departments()
|
||||
assert calls == []
|
||||
assert not widget._departments_loaded
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Critical-path request counts and late metadata safety, without global Qt styling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import diagnosis as module
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, delay: float = 0) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.delay = delay
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any]) -> Any:
|
||||
self.calls.append((endpoint, params))
|
||||
time.sleep(self.delay)
|
||||
if endpoint == "config/dict":
|
||||
return {
|
||||
key: [{"name": "口干", "value": "dry"}, {"name": "口苦", "value": "bitter"}]
|
||||
for key in params["type"].split(",")
|
||||
}
|
||||
if endpoint.endswith("Detail") or endpoint.endswith("/detail"):
|
||||
return {
|
||||
"id": params["id"], "patient_id": params["id"] + 1000,
|
||||
"patient_name": f"Patient {params['id']}", "appetite": ["dry"],
|
||||
"diagnosis_type": "first_visit", "chief_complaint": "Original",
|
||||
}
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
jobs: list[dict[str, Any]] = []
|
||||
|
||||
def enqueue(function: Any, **callbacks: Any) -> object:
|
||||
jobs.append({"function": function, **callbacks})
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(module, "run_async", enqueue)
|
||||
return jobs
|
||||
|
||||
|
||||
def complete(job: dict[str, Any]) -> None:
|
||||
job["on_success"](job["function"]())
|
||||
if job.get("on_finished"):
|
||||
job["on_finished"]()
|
||||
|
||||
|
||||
def dialog_for(client: Client) -> module.DiagnosisDialog:
|
||||
return module.DiagnosisDialog(
|
||||
RemoteDoctorRepository(client), permissions=PermissionSet(["*"])
|
||||
)
|
||||
|
||||
|
||||
def test_detail_unlocks_before_single_batch_and_preserves_draft(
|
||||
application: QApplication, queued: list[dict[str, Any]]
|
||||
) -> None:
|
||||
client = Client()
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
dialog.open_for(501, editable=True, seed={"patient_name": "Unverified"})
|
||||
assert dialog.isVisible()
|
||||
assert dialog.drawer_loading.isVisibleTo(dialog)
|
||||
assert dialog.edit_fields["patient_name"].isReadOnly()
|
||||
assert not dialog.save_button.isEnabled()
|
||||
assert len(queued) == 1
|
||||
complete(queued[0])
|
||||
assert client.calls == [("tcm.diagnosis/detail", {"id": 501})]
|
||||
assert dialog._authoritative_detail_loaded
|
||||
assert dialog.save_button.isEnabled()
|
||||
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||
assert len(queued) == 2
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("Unsaved draft")
|
||||
dialog.edit_fields["appetite"].setPlainText("bitter")
|
||||
complete(queued[1])
|
||||
assert len(client.calls) == 2
|
||||
assert client.calls[1][0] == "config/dict"
|
||||
assert len(client.calls[1][1]["type"].split(",")) == 16
|
||||
assert dialog.edit_fields["appetite"].toPlainText() == "bitter"
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Unsaved draft"
|
||||
assert dialog._loaded_tabs == set()
|
||||
assert not any("getCallRecords" in endpoint for endpoint, _params in client.calls)
|
||||
dialog._ensure_tab_loaded("basic")
|
||||
dialog.refresh_permissions()
|
||||
assert len(queued) == 2
|
||||
assert len(client.calls) == 2
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close", [False, True])
|
||||
def test_late_detail_and_dictionary_callbacks_cannot_touch_another_opening(
|
||||
application: QApplication, queued: list[dict[str, Any]], close: bool
|
||||
) -> None:
|
||||
dialog = dialog_for(Client())
|
||||
try:
|
||||
dialog.open_for(501, editable=True)
|
||||
detail_job = queued[0]
|
||||
complete(detail_job)
|
||||
metadata_job = queued[1]
|
||||
if close:
|
||||
dialog.close()
|
||||
else:
|
||||
dialog.open_for(502, editable=True)
|
||||
complete(queued[2])
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("Keep current draft")
|
||||
before = dialog.edit_fields["patient_name"].toPlainText()
|
||||
complete(detail_job)
|
||||
complete(metadata_job)
|
||||
assert dialog.edit_fields["patient_name"].toPlainText() == before
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep current draft"
|
||||
if not close:
|
||||
assert before == "Patient 502"
|
||||
assert dialog._patient_id == 1502
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_dictionary_failure_does_not_relock_valid_detail_or_erase_choices(
|
||||
application: QApplication, queued: list[dict[str, Any]]
|
||||
) -> None:
|
||||
dialog = dialog_for(Client())
|
||||
try:
|
||||
dialog.open_for(501, editable=True)
|
||||
complete(queued[0])
|
||||
queued[1]["on_error"](RuntimeError("Delayed metadata unavailable"))
|
||||
assert dialog._authoritative_detail_loaded
|
||||
assert dialog.save_button.isEnabled()
|
||||
assert dialog.edit_fields["appetite"].toPlainText() == "dry"
|
||||
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||
dialog._retry_current()
|
||||
assert len(queued) == 3
|
||||
complete(queued[2])
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_inactive_basic_tab_defers_dictionary_request_until_selected(
|
||||
application: QApplication, queued: list[dict[str, Any]]
|
||||
) -> None:
|
||||
client = Client()
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
notes_index = next(
|
||||
i for i in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(i) == "notes"
|
||||
)
|
||||
dialog.tabs.setCurrentIndex(notes_index)
|
||||
dialog.open_for(501, editable=True)
|
||||
complete(queued[0])
|
||||
assert dialog._current_tab_key() == "notes"
|
||||
assert dialog._dictionary_requested_generation != dialog._generation
|
||||
assert len(client.calls) == 1
|
||||
# The only secondary request is for the active notes tab, not all tabs.
|
||||
assert len(queued) == 2
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
assert len(queued) == 3
|
||||
complete(queued[2])
|
||||
assert client.calls[-1][0] == "config/dict"
|
||||
dialog.tabs.setCurrentIndex(notes_index)
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
assert len(queued) == 3
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retry_from_banner", [False, True])
|
||||
def test_dictionary_error_survives_other_tab_success_and_retries_correct_request(
|
||||
application: QApplication, queued: list[dict[str, Any]], retry_from_banner: bool,
|
||||
) -> None:
|
||||
client = Client()
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
dialog.open_for(501, editable=True)
|
||||
complete(queued[0])
|
||||
metadata_job = queued[1]
|
||||
notes_index = next(i for i in range(dialog.tabs.count())
|
||||
if dialog.tabs.tabBar().tabData(i) == "notes")
|
||||
dialog.tabs.setCurrentIndex(notes_index)
|
||||
notes_job = queued[2]
|
||||
metadata_job["on_error"](RuntimeError("Metadata unavailable"))
|
||||
complete(notes_job)
|
||||
assert dialog.drawer_banner.label.text() == dialog._dictionary_error_message
|
||||
assert dialog.drawer_banner.label.text().startswith("病历选项加载失败")
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("Keep draft across retry")
|
||||
if retry_from_banner:
|
||||
dialog.drawer_banner.action_requested.emit()
|
||||
else:
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
assert len(queued) == 4
|
||||
complete(queued[3])
|
||||
assert client.calls[-1][0] == "config/dict"
|
||||
assert not dialog._dictionary_error_message
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep draft across retry"
|
||||
assert dialog._authoritative_detail_loaded
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_controlled_latency_removes_sixteen_round_trips_from_detail_gate(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
client = Client(delay=0.02)
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
types = sorted({"diagnosis_type", *(row[0] for row in module._CHOICE_DICTIONARIES.values())})
|
||||
started = time.perf_counter()
|
||||
dialog.repository.get_diagnosis_detail(501)
|
||||
for key in types:
|
||||
dialog.repository.get_dictionary(key)
|
||||
old_time = time.perf_counter() - started
|
||||
old_calls = len(client.calls)
|
||||
client.calls.clear()
|
||||
started = time.perf_counter()
|
||||
detail = dialog._load_bundle(501, "edit")
|
||||
new_time = time.perf_counter() - started
|
||||
assert detail["detail"]["id"] == 501
|
||||
assert len(client.calls) == 1
|
||||
dialog._load_dictionary_choices()
|
||||
assert len(client.calls) == 2
|
||||
assert old_calls == 17
|
||||
assert new_time < old_time / 4
|
||||
print(f"controlled 20 ms RTT: old gate={old_time:.3f}s/17 calls; "
|
||||
f"new gate={new_time:.3f}s/1 call; total new=2 calls")
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -356,8 +356,11 @@ def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
||||
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
|
||||
assert thumb.property("loadState") == "ready"
|
||||
assert thumb._rendered_pixmap.size() == QSize(64, 64)
|
||||
previews: list[tuple[list[str], int]] = []
|
||||
timeline.preview_images_requested.connect(lambda sources, index: previews.append((sources, index)))
|
||||
thumb.click()
|
||||
assert opened == [tongue_url]
|
||||
assert opened == []
|
||||
assert previews == [([tongue_url], 0)]
|
||||
|
||||
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
||||
assert len(remove_buttons) == 2
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QBuffer, QCoreApplication, QEvent, QIODevice, QPoint, Qt
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QImage, QPainter
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from test_diagnosis_drawer_visual import (
|
||||
ActionRepository,
|
||||
_open_dialog,
|
||||
_select_tab,
|
||||
)
|
||||
from test_diagnosis_drawer_visual import (
|
||||
application as application,
|
||||
)
|
||||
from test_diagnosis_drawer_visual import (
|
||||
immediate_async as immediate_async,
|
||||
)
|
||||
from test_diagnosis_media_thumbnail_visual import (
|
||||
_FakeManager,
|
||||
_hold_remote_load,
|
||||
_png_bytes,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.diagnosis_drawer import _RemoteImageButton
|
||||
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
|
||||
from doctor_workstation.ui.theme import _register_preferred_cjk_fonts
|
||||
|
||||
|
||||
def _synthetic_image() -> bytes:
|
||||
image = QImage(1600, 1200, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("#F4E3D8"))
|
||||
painter = QPainter(image)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#C37B85"))
|
||||
painter.drawEllipse(440, 160, 720, 900)
|
||||
painter.setPen(QColor("#67434A"))
|
||||
painter.setFont(QFont("Arial", 36))
|
||||
painter.drawText(image.rect(), Qt.AlignmentFlag.AlignCenter, "SYNTHETIC TEST IMAGE")
|
||||
painter.end()
|
||||
buffer = QBuffer()
|
||||
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
assert image.save(buffer, "PNG")
|
||||
return bytes(buffer.data())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["edit", "readonly"])
|
||||
def test_real_note_thumbnail_click_keeps_drawer_and_previews_all_eight_images(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch, mode: str,
|
||||
) -> None:
|
||||
"""Exercise the wired drawer click, not just the standalone lightbox class."""
|
||||
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
|
||||
external: list[str] = []
|
||||
monkeypatch.setattr(QDesktopServices, "openUrl", lambda url: external.append(url.toString()) or True)
|
||||
application.setFont(QFont(_register_preferred_cjk_fonts(), 10))
|
||||
payload = _synthetic_image()
|
||||
|
||||
def offline_request(preview: ImagePreviewDialog, target: str) -> None:
|
||||
preview._invalidate_request()
|
||||
preview.apply_payload(payload, preview._generation)
|
||||
|
||||
monkeypatch.setattr(ImagePreviewDialog, "_request", offline_request)
|
||||
dialog = _open_dialog(application, (1440, 900), mode=mode, repository=ActionRepository())
|
||||
urls = [f"https://media.example.invalid/tongue-{index}.jpg" for index in range(8)]
|
||||
if mode == "edit":
|
||||
_select_tab(dialog, "notes")
|
||||
dialog._fill_notes([{"id": 7001, "note_date": "2026-09-08", "content": "合成测试图片", "tongue_images": urls}])
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
timeline = dialog.drawer_notes_timeline if mode == "edit" else dialog._notes_timelines[0]
|
||||
application.processEvents()
|
||||
thumbs = timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
|
||||
assert len(thumbs) == 8
|
||||
for thumb in thumbs:
|
||||
assert thumb._apply_payload(payload, thumb._generation)
|
||||
original_parent = dialog.parentWidget()
|
||||
original_generation = dialog._generation
|
||||
for index in (0, 7, 3):
|
||||
QTest.mouseClick(thumbs[index], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||
application.processEvents()
|
||||
assert external == [], "Thumbnail click must not launch the system browser"
|
||||
preview = dialog._image_preview
|
||||
assert isinstance(preview, ImagePreviewDialog)
|
||||
assert preview.isVisible() and preview.parentWidget() is dialog
|
||||
assert dialog.isVisible() and dialog.parentWidget() is original_parent
|
||||
assert dialog._generation == original_generation
|
||||
assert preview.current_source == urls[index]
|
||||
assert preview.sources == urls
|
||||
assert not preview.canvas.pixmap().isNull()
|
||||
fitted_width = preview.canvas.pixmap().width()
|
||||
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||
assert preview.canvas.pixmap().width() == 1600 > fitted_width
|
||||
QTest.mouseClick(preview.next_button, Qt.MouseButton.LeftButton)
|
||||
assert preview.current_source == urls[(index + 1) % 8]
|
||||
QTest.mouseClick(preview.previous_button, Qt.MouseButton.LeftButton)
|
||||
assert preview.current_source == urls[index]
|
||||
if mode == "edit" and index == 7:
|
||||
output = Path(__file__).resolve().parents[2] / "artifacts/patient-render-image-fix"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
assert dialog.grab().save(str(output / "notes-eight-thumbnails.png"))
|
||||
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||
application.processEvents()
|
||||
assert preview.grab().save(str(output / "notes-image-preview.png"))
|
||||
if index == 3:
|
||||
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||
else:
|
||||
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||
application.processEvents()
|
||||
assert dialog.isVisible() and timeline.isVisible()
|
||||
assert dialog._image_preview is None
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
assert external == []
|
||||
QTest.mouseClick(thumbs[4], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||
preview = dialog._image_preview
|
||||
QTest.mouseClick(preview.external_button, Qt.MouseButton.LeftButton)
|
||||
assert external == [urls[4]]
|
||||
dialog.reject()
|
||||
application.processEvents()
|
||||
assert dialog._image_preview is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close_with_escape", [False, True])
|
||||
def test_preview_close_cancels_pending_response(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch, close_with_escape: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr(ImagePreviewDialog, "_request", lambda self, target: None)
|
||||
preview = ImagePreviewDialog(["https://media.example.invalid/pending.jpg"])
|
||||
manager = _FakeManager(preview)
|
||||
manager.queue(_png_bytes(80, 60))
|
||||
preview._manager = manager
|
||||
monkeypatch.undo()
|
||||
preview.reload_current()
|
||||
reply = manager.replies[-1]
|
||||
generation = preview._generation
|
||||
preview.show()
|
||||
if close_with_escape:
|
||||
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||
else:
|
||||
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||
assert reply.aborted
|
||||
assert preview._generation > generation
|
||||
reply.finished.emit()
|
||||
assert preview._pixmap.isNull()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Regressions for the blank strip between query results and pinned actions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
QTest.qWait(15)
|
||||
|
||||
|
||||
def rows(count):
|
||||
return [{"id": i + 1, "patient_name": f"演示患者{i + 1}", "age": 55,
|
||||
"gender": 1, "has_appointment": 0, "diagnosis_confirmed": 1}
|
||||
for i in range(count)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blue", [True, False])
|
||||
def test_filter_results_fill_viewport_through_scrollbar_and_window_changes(application, blue):
|
||||
host = DiagnosisTableHost(tech_blue=blue, action_policy={"view": True, "edit": True})
|
||||
host.resize(1650, 680)
|
||||
host.show()
|
||||
for width, count in ((1650, 70), (1650, 2), (1900, 2), (900, 2),
|
||||
(1900, 0), (1900, 2), (1366, 80), (1650, 2)):
|
||||
host.resize(width, 680)
|
||||
host.set_rows(rows(count))
|
||||
settle(application)
|
||||
main = host.main
|
||||
viewport = main.viewport()
|
||||
base_width = sum(host.LEFT_WIDTHS)
|
||||
assert main.horizontalHeader().length() == max(base_width, viewport.width())
|
||||
assert all(main.columnWidth(i) >= minimum for i, minimum in enumerate(host.LEFT_WIDTHS))
|
||||
assert main.viewport().height() == host.fixed.viewport().height()
|
||||
assert main.verticalScrollBar().maximum() == host.fixed.verticalScrollBar().maximum()
|
||||
if viewport.width() >= base_width:
|
||||
assert main.horizontalScrollBar().maximum() == 0
|
||||
if count:
|
||||
main.selectRow(1)
|
||||
settle(application)
|
||||
row = main.visualRect(host.model.index(1, 9))
|
||||
point = QPoint(viewport.width() - 4, row.bottom() - 5)
|
||||
# A real data cell, rather than the QTableView's blank background,
|
||||
# must occupy the entire strip leading into the pinned actions.
|
||||
assert main.indexAt(point).row() == 1
|
||||
assert main.indexAt(point).column() == 9
|
||||
if blue:
|
||||
capture = viewport.grab()
|
||||
scale = capture.devicePixelRatio()
|
||||
pixel = QPoint(round(point.x() * scale), round(point.y() * scale))
|
||||
assert capture.toImage().pixelColor(pixel).name() == "#eaf2ff"
|
||||
else:
|
||||
assert main.horizontalScrollBar().maximum() > 0
|
||||
main.horizontalScrollBar().setValue(main.horizontalScrollBar().maximum())
|
||||
settle(application)
|
||||
assert main.visualRect(host.model.index(0, 9)).right() < viewport.width()
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Doctor choices remain explicit and transactional across mouse/search/keyboard use."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(application):
|
||||
parent = DoctorMultiSelect()
|
||||
widget = _DoctorSelectionDialog({1: "医生甲", 2: "医生乙", 3: "医生丙"}, {1}, parent)
|
||||
widget.show()
|
||||
application.processEvents()
|
||||
yield widget
|
||||
widget.close()
|
||||
parent.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def item_for(dialog, doctor_id):
|
||||
return next(dialog.listing.item(i) for i in range(dialog.listing.count())
|
||||
if dialog.listing.item(i).data(Qt.ItemDataRole.UserRole) == doctor_id)
|
||||
|
||||
|
||||
def test_row_and_checkbox_toggle_once_and_keyboard(dialog, application):
|
||||
item = item_for(dialog, 2)
|
||||
rect = dialog.listing.visualItemRect(item)
|
||||
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
|
||||
assert dialog.selected_ids() == {1, 2}
|
||||
assert dialog.count_label.text() == "已选 2 位医生"
|
||||
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=QPoint(rect.left() + 20, rect.center().y()))
|
||||
assert dialog.selected_ids() == {1}
|
||||
dialog.listing.setCurrentItem(item)
|
||||
dialog.listing.setFocus()
|
||||
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
|
||||
assert dialog.selected_ids() == {1, 2}
|
||||
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
|
||||
assert dialog.selected_ids() == {1}
|
||||
|
||||
|
||||
def test_filter_preserves_checks_and_clear_includes_hidden(dialog):
|
||||
dialog.search.setText("乙")
|
||||
assert item_for(dialog, 1).isHidden()
|
||||
assert not item_for(dialog, 2).isHidden()
|
||||
assert dialog.selected_ids() == {1}
|
||||
dialog.clear_button.click()
|
||||
assert dialog.selected_ids() == set()
|
||||
assert not dialog.clear_button.isEnabled()
|
||||
dialog.search.clear()
|
||||
assert not item_for(dialog, 1).isHidden()
|
||||
|
||||
|
||||
def test_checked_row_blue_and_unchecked_white(dialog, application):
|
||||
application.processEvents()
|
||||
image = dialog.listing.viewport().grab().toImage()
|
||||
scale = image.devicePixelRatio()
|
||||
colors = []
|
||||
for doctor_id in (1, 2):
|
||||
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
|
||||
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
|
||||
int(rect.center().y() * scale)).name())
|
||||
assert colors == ["#eaf2ff", "#ffffff"]
|
||||
|
||||
|
||||
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
|
||||
selector = DoctorMultiSelect()
|
||||
selector.update_options([{"id": 1, "name": "医生甲"}, {"id": 2, "name": "医生乙"}])
|
||||
seen = []
|
||||
|
||||
def choose(accept):
|
||||
modal = application.activeModalWidget()
|
||||
seen.append(modal.selected_ids())
|
||||
item_for(modal, 2).setCheckState(Qt.CheckState.Checked)
|
||||
modal.accept() if accept else modal.reject()
|
||||
|
||||
QTimer.singleShot(0, lambda: choose(False))
|
||||
selector._choose()
|
||||
assert selector.values() == []
|
||||
QTimer.singleShot(0, lambda: choose(True))
|
||||
selector._choose()
|
||||
assert selector.values() == [2]
|
||||
assert selector.button.text() == "医生乙"
|
||||
QTimer.singleShot(0, lambda: choose(False))
|
||||
selector._choose()
|
||||
assert seen == [set(), set(), {2}]
|
||||
assert selector.values() == [2]
|
||||
selector.deleteLater()
|
||||
Reference in New Issue
Block a user