This commit is contained in:
Your Name
2026-09-09 12:18:17 +08:00
parent 0cea43b027
commit 40319eea41
76 changed files with 4821 additions and 268 deletions
+1 -1
View File
@@ -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:
+215
View File
@@ -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()
+240
View File
@@ -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()
+75
View File
@@ -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()
+102
View File
@@ -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()