更新
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:
|
||||
|
||||
Reference in New Issue
Block a user