i给
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("¥ ")
|
||||
|
||||
Reference in New Issue
Block a user