From 690da33722f3b084f7584d2279a45f6e8edbaef3 Mon Sep 17 00:00:00 2001 From: gr Date: Tue, 27 Oct 2026 16:05:19 +0800 Subject: [PATCH] =?UTF-8?q?i=E7=BB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/mock_repository.py | 8 +- .../doctor_workstation/services/repository.py | 16 +- .../ui/dialogs/diagnosis.py | 105 ++++++- .../ui/dialogs/prescription.py | 277 +++++++++++++++--- 4 files changed, 369 insertions(+), 37 deletions(-) diff --git a/app/src/doctor_workstation/services/mock_repository.py b/app/src/doctor_workstation/services/mock_repository.py index f5e2077ca..023fd9a73 100644 --- a/app/src/doctor_workstation/services/mock_repository.py +++ b/app/src/doctor_workstation/services/mock_repository.py @@ -6,7 +6,7 @@ import hashlib import json import struct import zlib -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from copy import deepcopy from datetime import date, datetime, timedelta from os import PathLike @@ -2771,10 +2771,14 @@ class DemoDoctorRepository: *, call_record_id: int | None = None, mime_type: str = "audio/webm", + on_progress: Callable[[int, int], None] | None = None, ) -> dict[str, Any]: """Upload then attach a demo local-audio file using the production sequence.""" source_path = Path(path) + size = source_path.stat().st_size if source_path.is_file() else 0 + if on_progress is not None: + on_progress(0, size) is_audio = source_path.suffix.lower() in { ".ogg", ".opus", @@ -2811,6 +2815,8 @@ class DemoDoctorRepository: record["local_audio_status_text"] = "已保存" else: self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id) + if on_progress is not None: + on_progress(size, size) return { "diagnosis_id": diagnosis_id, "call_record_id": target_id, diff --git a/app/src/doctor_workstation/services/repository.py b/app/src/doctor_workstation/services/repository.py index 6fd3362fe..aa3e01765 100644 --- a/app/src/doctor_workstation/services/repository.py +++ b/app/src/doctor_workstation/services/repository.py @@ -683,6 +683,7 @@ class DoctorRepository(Protocol): *, call_record_id: int | None = None, mime_type: str = "audio/webm", + on_progress: Callable[[int, int], None] | None = None, ) -> dict[str, Any]: """Upload a locally captured call recording to a real call record.""" @@ -2512,8 +2513,13 @@ class RemoteDoctorRepository: *, call_record_id: int | None = None, mime_type: str = "audio/webm", + on_progress: Callable[[int, int], None] | None = None, ) -> dict[str, Any]: - """Chunk-upload one local audio recording and attach it atomically.""" + """Chunk-upload one local audio recording and attach it atomically. + + ``on_progress(sent_bytes, total_bytes)`` runs on the calling thread after + every confirmed chunk; its failures never interrupt the upload. + """ if diagnosis_id <= 0: raise ValueError("diagnosis_id must be positive") @@ -2533,6 +2539,13 @@ class RemoteDoctorRepository: clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm" final_result: Mapping[str, Any] | None = None bound_call_record_id = call_record_id + + def report(sent: int) -> None: + if on_progress is not None: + with suppress(Exception): + on_progress(min(sent, file_size), file_size) + + report(0) with recording_path.open("rb") as stream: for chunk_index in range(chunk_total): content = stream.read(chunk_size) @@ -2567,6 +2580,7 @@ class RemoteDoctorRepository: raise ApiProtocolError( "uploadCallRecording returned a different call_record_id" ) + report((chunk_index + 1) * chunk_size) if final_result is None or not bool(final_result.get("completed")): raise ApiProtocolError("uploadCallRecording did not complete the local audio upload") diff --git a/app/src/doctor_workstation/ui/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py index 71a3e9bbd..d5e3f50b9 100644 --- a/app/src/doctor_workstation/ui/dialogs/diagnosis.py +++ b/app/src/doctor_workstation/ui/dialogs/diagnosis.py @@ -2,13 +2,26 @@ from __future__ import annotations +import mimetypes import re import sqlite3 from collections.abc import Mapping, Sequence from contextlib import suppress +from pathlib import Path from typing import Any -from PySide6.QtCore import QDate, QDateTime, QEvent, QPoint, QRect, Qt, QTimer, QUrl, Signal +from PySide6.QtCore import ( + QDate, + QDateTime, + QEvent, + QObject, + QPoint, + QRect, + Qt, + QTimer, + QUrl, + Signal, +) from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QAbstractItemView, @@ -22,6 +35,7 @@ from PySide6.QtWidgets import ( QLabel, QMessageBox, QPlainTextEdit, + QProgressBar, QPushButton, QScrollArea, QSizePolicy, @@ -83,6 +97,13 @@ from .issued_prescription_ai import can_open_issued_ai, present_issued_prescript from .local_audio_queue import LocalAudioQueueDialog from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report +class _UploadProgressRelay(QObject): + """把后台线程中的分片上传进度转回界面线程(信号跨线程自动排队)。""" + + # 用 object 传字节数:Qt 的 int 是 32 位,超过 2 GB 的视频会溢出。 + progressed = Signal(object, object) + + _PHONE_PERMISSION = "tcm.diagnosis/phonePlain" _PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders" _ORDER_DETAIL_PERMISSION = "tcm.prescriptionOrder/detail" @@ -1512,9 +1533,38 @@ class DiagnosisDialog(QDialog): ) toolbar.addWidget(self.video_upload_button) layout.addLayout(toolbar) + layout.addWidget(self._build_video_upload_progress()) layout.addWidget(self._new_table("video", "DiagnosisVideoTable"), 1) return page + def _build_video_upload_progress(self) -> QFrame: + """上传本地回放时的进度条:按分片实时更新,医生能看到上传在进行、进行到哪。""" + frame = QFrame() + frame.setObjectName("DiagnosisVideoUploadProgress") + frame.setStyleSheet( + "QFrame#DiagnosisVideoUploadProgress { background-color: #EEF1FA;" + " border: 1px solid #C9D1F3; border-radius: 8px; }" + "QFrame#DiagnosisVideoUploadProgress QLabel { color: #3347B8; background: transparent;" + " font-size: 13px; font-weight: 500; }" + "QFrame#DiagnosisVideoUploadProgress QProgressBar { background-color: #FFFFFF;" + " border: 0; border-radius: 4px; min-height: 8px; max-height: 8px; }" + "QFrame#DiagnosisVideoUploadProgress QProgressBar::chunk { background-color: #4F63D9;" + " border-radius: 4px; }" + ) + row = QVBoxLayout(frame) + row.setContentsMargins(12, 8, 12, 10) + row.setSpacing(6) + self.video_upload_status = QLabel() + self.video_upload_status.setWordWrap(True) + row.addWidget(self.video_upload_status) + self.video_upload_bar = QProgressBar() + self.video_upload_bar.setRange(0, 1000) + self.video_upload_bar.setTextVisible(False) + row.addWidget(self.video_upload_bar) + frame.hide() + self.video_upload_frame = frame + return frame + def _build_orders_tab(self) -> QWidget: page = QWidget() page.setObjectName("DiagnosisTabOrders") @@ -3323,6 +3373,15 @@ class DiagnosisDialog(QDialog): ) if not path: return + # 明确按视频上报类型;默认的 audio/webm 会让 .webm 视频被服务端当成本机录音。 + mime_type = mimetypes.guess_type(path)[0] or "" + if not mime_type.startswith("video/"): + mime_type = "video/mp4" + file_name = Path(path).name + relay = _UploadProgressRelay(self) + relay.progressed.connect(lambda sent, total: self._video_upload_progressed(file_name, sent, total)) + self._video_upload_relay = relay + self._video_upload_progressed(file_name, 0, 0) self._run_media_mutation( lambda: invoke( self.repository, @@ -3330,13 +3389,47 @@ class DiagnosisDialog(QDialog): path=path, diagnosis_id=self._diagnosis_id, call_record_id=call_record_id, + mime_type=mime_type, + on_progress=relay.progressed.emit, ), "视频回放已追加到指定通话记录。" if call_record_id is not None else "视频回放已上传并创建通话记录。", "video", + on_finished=lambda: self._video_upload_finished(relay), ) + def _video_upload_progressed(self, file_name: str, sent: int, total: int) -> None: + self.video_upload_frame.show() + self.video_upload_button.setEnabled(False) + if total <= 0: + self.video_upload_bar.setRange(0, 0) # 未拿到大小前显示滚动条 + self.video_upload_status.setText(f"正在准备上传「{file_name}」…") + self.video_upload_button.setText("上传中…") + return + self.video_upload_bar.setRange(0, 1000) + ratio = min(1.0, max(0.0, sent / total)) + self.video_upload_bar.setValue(int(ratio * 1000)) + percent = int(ratio * 100) + mb = 1024 * 1024 + if sent >= total: + text = f"「{file_name}」已上传完成({total / mb:.1f} MB),正在生成通话记录…" + else: + text = f"正在上传「{file_name}」 {percent}%({sent / mb:.1f} / {total / mb:.1f} MB),请勿关闭窗口" + self.video_upload_status.setText(text) + self.video_upload_button.setText(f"上传中 {percent}%") + self._show_message(text, "info") + + def _video_upload_finished(self, relay: QObject) -> None: + if getattr(self, "_video_upload_relay", None) is not relay: + return + self._video_upload_relay = None + self.video_upload_frame.hide() + self.video_upload_bar.setRange(0, 1000) + self.video_upload_bar.setValue(0) + self.video_upload_button.setText("上传本地回放") + self.video_upload_button.setEnabled(True) + def _open_local_audio_queue(self) -> None: if self._diagnosis_id <= 0 or not self._can_video_upload: self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning") @@ -3436,7 +3529,14 @@ class DiagnosisDialog(QDialog): "chat", ) - def _run_media_mutation(self, operation: Any, success_text: str, reload_key: str) -> None: + def _run_media_mutation( + self, + operation: Any, + success_text: str, + reload_key: str, + *, + on_finished: Any = None, + ) -> None: self._media_generation += 1 generation = self._media_generation diagnosis_id = self._diagnosis_id @@ -3447,6 +3547,7 @@ class DiagnosisDialog(QDialog): success_text, reload_key, diagnosis_id, generation ), on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "media"), + on_finished=on_finished, ) def _media_mutation_success( diff --git a/app/src/doctor_workstation/ui/dialogs/prescription.py b/app/src/doctor_workstation/ui/dialogs/prescription.py index 56c85b127..77b5fdad2 100644 --- a/app/src/doctor_workstation/ui/dialogs/prescription.py +++ b/app/src/doctor_workstation/ui/dialogs/prescription.py @@ -28,6 +28,7 @@ from PySide6.QtCore import ( QDate, QEvent, QIODevice, + QModelIndex, QMarginsF, QObject, QPoint, @@ -51,6 +52,8 @@ from PySide6.QtGui import ( QPainter, QPdfWriter, QPen, + QStandardItem, + QStandardItemModel, QTextDocument, ) from PySide6.QtPrintSupport import QPrintDialog, QPrinter @@ -61,6 +64,7 @@ from PySide6.QtWidgets import ( QCalendarWidget, QCheckBox, QComboBox, + QCompleter, QDialog, QDialogButtonBox, QDoubleSpinBox, @@ -81,6 +85,9 @@ from PySide6.QtWidgets import ( QSizePolicy, QSpinBox, QSplitter, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, QTableWidget, QTableWidgetItem, QTabWidget, @@ -323,7 +330,8 @@ QFrame#PrescriptionDrawerSurface QDoubleSpinBox { border: 1px solid #EDEDEE; border-radius: 7px; padding: 0 11px; - selection-background-color: #EEF1FA; font-size: 14px; + selection-background-color: #C9D1F3; + selection-color: #1A1C1F; font-size: 14px; } QFrame#PrescriptionDrawerSurface QComboBox { min-height: 32px; @@ -333,7 +341,17 @@ QFrame#PrescriptionDrawerSurface QComboBox { border-radius: 7px; padding-left: 11px; padding-right: 28px; - selection-background-color: #EEF1FA; font-size: 14px; + selection-background-color: #C9D1F3; + selection-color: #1A1C1F; font-size: 14px; +} +QFrame#PrescriptionHerbTableRow QComboBox, +QFrame#PrescriptionHerbTableRow QDoubleSpinBox { + border: 1px solid #D2D2D3; + font-weight: 500; +} +QFrame#PrescriptionHerbTableRow QComboBox:focus, +QFrame#PrescriptionHerbTableRow QDoubleSpinBox:focus { + border: 1px solid #4F63D9; } QFrame#PrescriptionDrawerSurface QLineEdit[prescriptionControl="true"], QFrame#PrescriptionDrawerSurface QComboBox[prescriptionControl="true"], @@ -901,6 +919,23 @@ def _float(value: Any, default: float = 0.0) -> float: return default +# 处方表单里的数字框、下拉框不响应鼠标滚轮:滚轮只滚动页面, +# 避免医生滚动表单时把鼠标停在输入框上而误改剂量、药材或剂数。 +class NoWheelSpinBox(QSpinBox): + def wheelEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + event.ignore() + + +class NoWheelDoubleSpinBox(QDoubleSpinBox): + def wheelEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + event.ignore() + + +class NoWheelComboBox(QComboBox): + def wheelEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + event.ignore() + + def _bool(value: Any) -> bool: if isinstance(value, str): return value.strip().lower() not in {"", "0", "false", "no", "off"} @@ -1412,6 +1447,47 @@ class MultiSelectComboBox(QComboBox): self.lineEdit().setText("、".join(self.values())) +_MEDICINE_NAME_ROLE = Qt.ItemDataRole.UserRole + 1 +_MEDICINE_ID_ROLE = Qt.ItemDataRole.UserRole + 2 +_MEDICINE_META_ROLE = Qt.ItemDataRole.UserRole + 3 + + +class _MedicineSuggestionDelegate(QStyledItemDelegate): + """药材候选行:左侧药名,右侧灰色「调配中心 · 单位 · ID」,与管理端下拉一致。""" + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802 + return QSize(super().sizeHint(option, index).width(), 36) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.text = "" + widget = opt.widget + (widget.style() if widget is not None else QApplication.style()).drawControl( + QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget + ) + rect = option.rect.adjusted(14, 0, -14, 0) + name = str(index.data(Qt.ItemDataRole.DisplayRole) or "") + meta = str(index.data(_MEDICINE_META_ROLE) or "") + enabled = bool(index.flags() & Qt.ItemFlag.ItemIsEnabled) + metrics = option.fontMetrics + align = Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft + painter.save() + if meta: + name_width = int(rect.width() * 0.42) + painter.setPen(QColor("#1A1C1F")) + painter.drawText(rect.adjusted(0, 0, name_width - rect.width(), 0), align, + metrics.elidedText(name, Qt.TextElideMode.ElideRight, name_width - 12)) + painter.setPen(QColor("#8A8B8D")) + meta_rect = rect.adjusted(name_width, 0, 0, 0) + painter.drawText(meta_rect, align, + metrics.elidedText(meta, Qt.TextElideMode.ElideRight, meta_rect.width())) + else: + painter.setPen(QColor("#1A1C1F" if enabled else "#8A8B8D")) + painter.drawText(rect, align, metrics.elidedText(name, Qt.TextElideMode.ElideRight, rect.width())) + painter.restore() + + class RemoteMedicineComboBox(QComboBox): """Editable remote medicine selector retaining both id and canonical name.""" @@ -1433,6 +1509,10 @@ class RemoteMedicineComboBox(QComboBox): # but no medicine id. New/changed text must still be selected from the # remote master-data list rather than submitted as arbitrary input. self._legacy_name = str(name or "").strip() if self._selected_id is None else "" + # 最近一次有效选择(药材库 id + 规范名);手输文字无法匹配药材库时恢复为它。 + self._selected_name = str(name or "").strip() if self._selected_id is not None else "" + self._last_valid: tuple[int | None, str] = (self._selected_id, str(name or "").strip()) + self._settle_pending = False self.setEditable(True) self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.setMaxVisibleItems(14) @@ -1440,6 +1520,23 @@ class RemoteMedicineComboBox(QComboBox): self.lineEdit().setPlaceholderText("搜索药材名称或拼音首字母") self.lineEdit().textEdited.connect(self._queue_search) self.currentIndexChanged.connect(self._selection_changed) + # 与管理端 MedicineNameSelect 一致:点击或输入即弹出药材库匹配列表, + # 只能从列表中选;列表不抢输入焦点,可边输入边看结果。 + self._suggestions = QStandardItemModel(self) + self._completer = QCompleter(self._suggestions, self) + self._completer.setCompletionMode(QCompleter.CompletionMode.UnfilteredPopupCompletion) + self._completer.setCompletionRole(_MEDICINE_NAME_ROLE) + self._completer.setMaxVisibleItems(14) + self._completer.activated[QModelIndex].connect(self._suggestion_activated) + popup = self._completer.popup() + popup.setItemDelegate(_MedicineSuggestionDelegate(popup)) + popup.setStyleSheet( + "QListView { background-color: #FFFFFF; border: 1px solid #E4E7ED; border-radius: 6px;" + " padding: 4px 0; font-size: 14px; outline: 0; }" + "QListView::item:selected, QListView::item:hover { background-color: #F5F7FA; }" + ) + self.lineEdit().setCompleter(self._completer) + self.lineEdit().installEventFilter(self) self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.setInterval(250) @@ -1451,9 +1548,9 @@ class RemoteMedicineComboBox(QComboBox): @property def medicine_id(self) -> int | None: - data = self.currentData() - value = _int(data, 0) - return value or self._selected_id + # 只认从药材库选中的 id;不能回退到旧下拉项的 data,否则改过文字的行 + # 仍会带着原药材 id 通过校验,相当于允许直接输入药名。 + return self._selected_id @property def medicine_name(self) -> str: @@ -1462,12 +1559,16 @@ class RemoteMedicineComboBox(QComboBox): @property def has_valid_selection(self) -> bool: name = self.medicine_name - return bool(self.medicine_id) or bool(self._legacy_name and name == self._legacy_name) + if self._selected_id is not None: + return not self._selected_name or name == self._selected_name + return bool(self._legacy_name and name == self._legacy_name) def set_value(self, medicine_id: Any, name: Any) -> None: self._selected_id = _int(medicine_id, 0) or None canonical_name = str(name or "").strip() self._legacy_name = canonical_name if self._selected_id is None else "" + self._selected_name = canonical_name if self._selected_id is not None else "" + self._last_valid = (self._selected_id, canonical_name) self.blockSignals(True) self.clear() if canonical_name: @@ -1478,20 +1579,117 @@ class RemoteMedicineComboBox(QComboBox): def showPopup(self) -> None: if self.count() <= 1: - self._queue_search(self.currentText()) + # 只是打开列表浏览,不清除已选药材。 self._timer.stop() - self._search() + self._search(browse=self.has_valid_selection or not self.medicine_name) super().showPopup() def _queue_search(self, _text: str) -> None: self._selected_id = None + self._selected_name = "" self._legacy_name = "" self._timer.start() - def _search(self) -> None: - query = self.currentText().strip() + def wheelEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + # 滚轮不切换药材,只滚动页面。 + event.ignore() + + def eventFilter(self, watched: Any, event: Any) -> bool: # noqa: N802 - Qt virtual + # 鼠标点一下输入框就列出药材库,和管理端下拉一样可以直接挑; + # 不看焦点原因,已获得焦点时再点一次也能打开。 + if ( + watched is self.lineEdit() + and event.type() == QEvent.Type.MouseButtonPress + and self.isEnabled() + and not self._completer.popup().isVisible() + ): + self._timer.stop() + self._search(browse=self.has_valid_selection or not self.medicine_name) + return super().eventFilter(watched, event) + + def focusOutEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().focusOutEvent(event) + # 打开下拉列表也会让输入框失焦,此时还在挑选,不做校正。 + if ( + event.reason() == Qt.FocusReason.PopupFocusReason + or self.view().isVisible() + or self._completer.popup().isVisible() + ): + return + self._settle_typed_text() + + def _suggestion_activated(self, index: QModelIndex) -> None: + medicine_id = _int(index.data(_MEDICINE_ID_ROLE), 0) + name = str(index.data(_MEDICINE_NAME_ROLE) or "").strip() + if not medicine_id or not name: + return + for row in range(self.count()): + data = self.itemData(row) + if isinstance(data, Mapping) and _int(data.get("id"), 0) == medicine_id: + self.setCurrentIndex(row) + self._selection_changed(row) + return + self.set_value(medicine_id, name) + + def _show_suggestions(self, rows: list[tuple[int, str, str]], note: str = "") -> None: + self._suggestions.clear() + for medicine_id, name, meta in rows: + item = QStandardItem(name) + item.setData(name, _MEDICINE_NAME_ROLE) + item.setData(medicine_id, _MEDICINE_ID_ROLE) + item.setData(meta, _MEDICINE_META_ROLE) + item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + self._suggestions.appendRow(item) + if note: + # 提示行不可选,只告诉医生当前状态(搜索中 / 无匹配)。 + item = QStandardItem(note) + item.setData(self.medicine_name, _MEDICINE_NAME_ROLE) + item.setFlags(Qt.ItemFlag.NoItemFlags) + self._suggestions.appendRow(item) + if self.lineEdit().hasFocus() and self.isEnabled(): + self._completer.complete() + + def _settle_typed_text(self) -> None: + """离开输入框时,手输文字必须落到药材库中的一味药,否则恢复原值。""" + if self.has_valid_selection: + return + text = self.medicine_name + if text and self._select_exact(text): + return + if not text: + self._restore_last_valid() + return + # 搜索结果可能还没回来:立即按当前文字查一次,结果到达后再定。 + self._settle_pending = True + self._timer.stop() + self._search() + + def _select_exact(self, text: str) -> bool: + key = "".join(text.split()).casefold() + for row in range(self.count()): + data = self.itemData(row) + if isinstance(data, Mapping) and "".join(str(data.get("name") or "").split()).casefold() == key: + self.setCurrentIndex(row) + self._selection_changed(row) + return True + return False + + def _restore_last_valid(self) -> None: + medicine_id, name = self._last_valid + if medicine_id is not None or name: + self.set_value(medicine_id, name) + return + self.blockSignals(True) + self.setEditText("") + self.blockSignals(False) + + def _search(self, browse: bool = False) -> None: + # browse:点开下拉时按空关键字列出药材库,与管理端打开下拉的行为一致。 + query = "" if browse else self.currentText().strip() self._generation += 1 generation = self._generation + if self.lineEdit().hasFocus() and self._suggestions.rowCount() == 0: + self._show_suggestions([], "正在搜索药材库…") run_async( lambda: _repository_action( self.repository, @@ -1510,6 +1708,7 @@ class RemoteMedicineComboBox(QComboBox): return rows = page_items(result) current_text = self.currentText().strip() + suggestions: list[tuple[int, str, str]] = [] self.blockSignals(True) self.clear() for row in rows: @@ -1524,11 +1723,23 @@ class RemoteMedicineComboBox(QComboBox): if meta: label = f"{name} {meta}" self.addItem(label, {"id": medicine_id, "name": name}) + suggestions.append((medicine_id, name, meta)) self.setEditText(current_text or query) self.blockSignals(False) + self._show_suggestions( + suggestions, + "" if suggestions else f"药材库中没有与「{query}」匹配的药材,请换个名称或拼音首字母", + ) + if self._settle_pending and not self.hasFocus(): + self._settle_pending = False + if not self._select_exact(query): + self._restore_last_valid() def _search_error(self, error: Exception, generation: int) -> None: if generation == self._generation: + if self._settle_pending and not self.hasFocus(): + self._settle_pending = False + self._restore_last_valid() self.search_failed.emit(friendly_error(error)) def _selection_changed(self, index: int) -> None: @@ -1538,9 +1749,9 @@ class RemoteMedicineComboBox(QComboBox): name = str(data.get("name") or "").strip() if name: self._legacy_name = "" + self._selected_name = name + self._last_valid = (self._selected_id, name) self.setEditText(name) - else: - self._selected_id = _int(data, 0) or self._selected_id class HerbRowWidget(QFrame): @@ -1575,7 +1786,7 @@ class HerbRowWidget(QFrame): if source_locked is None else bool(source_locked) ) - self.formula_combo = QComboBox(self) + self.formula_combo = NoWheelComboBox(self) self.formula_combo.addItem("主方", "主方") self.formula_combo.addItem("辅方", "辅方") _set_combo_data( @@ -1591,7 +1802,7 @@ class HerbRowWidget(QFrame): parent=self, ) self.medicine.setEnabled(not locked) - self.dosage = QDoubleSpinBox(self) + self.dosage = NoWheelDoubleSpinBox(self) self.dosage.setRange(0, 1e100) self.dosage.setDecimals(1) self.dosage.setSingleStep(0.5) @@ -2173,7 +2384,7 @@ class PrescriptionTemplateDialog(QDialog): self.name_edit.setMaxLength(100) self.name_edit.setPlaceholderText("请输入处方名称") form.addRow("处方名称", self.name_edit) - self.formula_combo = QComboBox() + self.formula_combo = NoWheelComboBox() self.formula_combo.addItem("主方", "主方") self.formula_combo.addItem("辅方", "辅方") _set_combo_data( @@ -2293,7 +2504,7 @@ class TemplateImportDialog(QDialog): self.name_edit.setPlaceholderText("请输入处方名称搜索") self.name_edit.returnPressed.connect(self.search) filters.addWidget(self.name_edit, 1) - self.formula_combo = QComboBox() + self.formula_combo = NoWheelComboBox() self.formula_combo.addItem("全部", "") self.formula_combo.addItem("主方", "主方") self.formula_combo.addItem("辅方", "辅方") @@ -2304,7 +2515,7 @@ class TemplateImportDialog(QDialog): root.addLayout(filters) mode_layout = QHBoxLayout() mode_layout.addWidget(QLabel("导入方式")) - self.mode_combo = QComboBox() + self.mode_combo = NoWheelComboBox() self.mode_combo.addItem("覆盖现有药材", "replace") self.mode_combo.addItem("追加到末尾", "append") mode_layout.addWidget(self.mode_combo) @@ -2511,7 +2722,7 @@ class PasteHerbsDialog(QDialog): root.addWidget(self.text_edit, 1) mode_row = QHBoxLayout() mode_row.addWidget(QLabel("导入方式")) - self.mode_combo = QComboBox() + self.mode_combo = NoWheelComboBox() self.mode_combo.addItem("覆盖现有药材", "replace") self.mode_combo.addItem("追加到末尾", "append") mode_row.addWidget(self.mode_combo) @@ -2735,8 +2946,8 @@ class PrescriptionEditorDialog(QDialog): self.revive_banner = MessageBanner() self.body_layout.addWidget(self.revive_banner) - self._responsive_grids: list[tuple[QGridLayout, int, list[tuple[QWidget, int]]]] = [] - self._form_columns = 3 + self._responsive_grids: list[tuple[QGridLayout, int, list[tuple[QWidget, int]]]] = [] + self._form_columns = 3 self.patient_section = self._build_patient_section() self.diagnosis_section = self._build_diagnosis_section() self.herbs_section = self._build_herbs_section() @@ -2978,7 +3189,7 @@ class PrescriptionEditorDialog(QDialog): gender_layout.addWidget(self.gender_female) gender_layout.addStretch(1) self.gender = gender_host - self.age = QSpinBox() + self.age = NoWheelSpinBox() self.age.setRange(0, 150) self.visit_no = QLineEdit() self.visit_no.setPlaceholderText("自动生成或手动输入") @@ -3130,7 +3341,7 @@ class PrescriptionEditorDialog(QDialog): for value in self.PRESCRIPTION_TYPES: self.prescription_type.addItem(value, value) self.prescription_type.currentIndexChanged.connect(self._type_changed) - self.dose_count = QSpinBox() + self.dose_count = NoWheelSpinBox() self.dose_count.setRange(1, 2_147_483_647) self.dose_unit = SelectComboBox() self.dose_unit.addItems(["剂", "丸", "袋", "盒", "瓶", "膏", "贴"]) @@ -3153,9 +3364,9 @@ class PrescriptionEditorDialog(QDialog): self.dosage_bag_count = LabeledIntCombo(range(1, 6), "袋") self.need_decoction = BoolRadioRow("代煎", "不代煎") self.bags_per_dose = LabeledIntCombo(range(1, 10), "包") - self.times_per_day = QSpinBox() + self.times_per_day = NoWheelSpinBox() self.times_per_day.setRange(1, 6) - self.usage_days = QSpinBox() + self.usage_days = NoWheelSpinBox() self.usage_days.setRange(1, 365) self._main_dosage_field = self._field("用量", self.dosage_amount) self._main_dosage_unit_field = self._field("用量单位", self.dosage_unit) @@ -3188,9 +3399,9 @@ class PrescriptionEditorDialog(QDialog): self.aux_dosage_bag_count = LabeledIntCombo(range(1, 6), "袋") self.aux_need_decoction = BoolRadioRow("代煎", "不代煎") self.aux_bags_per_dose = LabeledIntCombo(range(1, 10), "包") - self.aux_times_per_day = QSpinBox() + self.aux_times_per_day = NoWheelSpinBox() self.aux_times_per_day.setRange(1, 6) - self.aux_usage_days = QSpinBox() + self.aux_usage_days = NoWheelSpinBox() self.aux_usage_days.setRange(1, 365) self.aux_prescription_name = QLineEdit() self._aux_dosage_field = self._field("用量", self.aux_dosage_amount) @@ -3964,7 +4175,7 @@ class PatchPatientDialog(QDialog): self.patient_name = QLineEdit(str(first_value(prescription, "patient_name", default=""))) self.patient_name.setMaxLength(50) form.addRow("患者姓名 *", self.patient_name) - self.gender = QComboBox() + self.gender = NoWheelComboBox() self.gender.addItem("男", 1) self.gender.addItem("女", 0) _set_combo_data(self.gender, first_value(prescription, "gender", default=1)) @@ -5311,7 +5522,7 @@ class PrescriptionOrderDialog(QDialog): def _build_recipient_tab(self) -> QWidget: tab, form = self._form_tab() - self.diagnosis_id = QSpinBox() + self.diagnosis_id = NoWheelSpinBox() self.diagnosis_id.setRange(0, 2_000_000_000) self.diagnosis_id.valueChanged.connect(self._diagnosis_changed) form.addRow("诊单 ID *", self.diagnosis_id) @@ -5333,14 +5544,14 @@ class PrescriptionOrderDialog(QDialog): def _build_service_tab(self) -> QWidget: tab, form = self._form_tab() - self.ship_mode = QComboBox() + self.ship_mode = NoWheelComboBox() self.ship_mode.addItem("甘草药房", "gancao") self.ship_mode.addItem("洛阳药房", "direct") self.ship_mode.setEnabled(self.can_select_ship_mode) form.addRow("发货类型", self.ship_mode) self.is_follow_up = QCheckBox("复诊订单") form.addRow("是否复诊", self.is_follow_up) - self.medication_days = QSpinBox() + self.medication_days = NoWheelSpinBox() self.medication_days.setRange(0, 365) self.medication_days.setSpecialValueText("未填写") form.addRow("服用天数", self.medication_days) @@ -5365,7 +5576,7 @@ class PrescriptionOrderDialog(QDialog): def _build_amount_tab(self) -> QWidget: tab, form = self._form_tab() - self.fee_type = QComboBox() + self.fee_type = NoWheelComboBox() self.fee_type.addItem("药品费用", 3) self.fee_type.addItem("挂号费", 1) self.fee_type.addItem("问诊费", 2) @@ -5373,12 +5584,12 @@ class PrescriptionOrderDialog(QDialog): self.fee_type.addItem("尾款", 5) self.fee_type.addItem("其他", 6) form.addRow("费用类别 *", self.fee_type) - self.amount = QDoubleSpinBox() + self.amount = NoWheelDoubleSpinBox() self.amount.setRange(0, 10_000_000) self.amount.setDecimals(2) self.amount.setPrefix("¥ ") form.addRow("订单金额 *", self.amount) - self.internal_cost = QDoubleSpinBox(tab) + self.internal_cost = NoWheelDoubleSpinBox(tab) self.internal_cost.setRange(0, 10_000_000) self.internal_cost.setDecimals(2) self.internal_cost.setPrefix("¥ ")