From 5926083221d1b700b2de0aa91c608a99c2887d74 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Nov 2026 11:48:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/dialogs/diagnosis.py | 46 +++- .../ui/pages/consultations.py | 11 +- .../ui/pages/prescriptions.py | 214 +++++++++++++++++- app/tests/test_consultation_departments.py | 19 +- app/tests/test_prescription_security_ui.py | 112 ++++++++- app/tests/test_prescriptions_tech_blue.py | 88 +++++++ .../http/middleware/AuthMiddleware.php | 11 + 7 files changed, 483 insertions(+), 18 deletions(-) diff --git a/app/src/doctor_workstation/ui/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py index 2fe3bbb78..8f749273e 100644 --- a/app/src/doctor_workstation/ui/dialogs/diagnosis.py +++ b/app/src/doctor_workstation/ui/dialogs/diagnosis.py @@ -31,6 +31,7 @@ from PySide6.QtWidgets import ( QFrame, QGridLayout, QHBoxLayout, + QHeaderView, QInputDialog, QLabel, QMessageBox, @@ -977,6 +978,7 @@ class DiagnosisDialog(QDialog): self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.readonly_scroll.viewport().installEventFilter(self) self.readonly_scroll.verticalScrollBar().setSingleStep(28) content = QWidget() self.readonly_content_layout = QVBoxLayout(content) @@ -1001,6 +1003,25 @@ class DiagnosisDialog(QDialog): self.readonly_content_layout.addWidget(self.readonly_error) self.readonly_patient_card = self._build_readonly_patient_card() self.readonly_content_layout.addWidget(self.readonly_patient_card) + self.readonly_prescriptions_table = self._new_table( + "prescription", "DiagnosisReadonlyPrescriptionsTable" + ) + # Keep the open action visible beside the editor; full prescription + # contents remain available through the existing detail dialog. + self.readonly_prescriptions_table.setColumnHidden(1, True) + self.readonly_prescriptions_table.setColumnHidden(3, True) + self.readonly_prescriptions_table.horizontalHeaderItem(0).setText("开方日期") + self.readonly_prescriptions_table.horizontalHeaderItem(2).setText("临床诊断") + for column, width in ((0, 100), (4, 70), (5, 84), (6, 64)): + self.readonly_prescriptions_table.setColumnWidth(column, width) + self.readonly_prescriptions_table.horizontalHeader().setSectionResizeMode( + 2, QHeaderView.ResizeMode.Stretch + ) + self.readonly_prescriptions_table.setMinimumHeight(220) + self._add_readonly_section("prescription", "历史处方", self.readonly_prescriptions_table) + self.readonly_content_layout.setAlignment( + self._readonly_sections["prescription"], Qt.AlignmentFlag.AlignLeft + ) self.case_grid = CaseGrid() self.readonly_content_layout.addWidget(self.case_grid) @@ -1993,6 +2014,17 @@ class DiagnosisDialog(QDialog): self._sync_host_geometry() def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API + if ( + hasattr(self, "readonly_scroll") + and watched is self.readonly_scroll.viewport() + and event.type() == QEvent.Type.Resize + and "prescription" in self._readonly_sections + ): + # Other patient cards can have a wider minimum size; the history + # actions must still fit inside the visible side pane. + self._readonly_sections["prescription"].setFixedWidth( + max(0, self.readonly_scroll.viewport().width() - 32) + ) if watched is self._owner and event.type() in { QEvent.Type.Resize, QEvent.Type.Move, @@ -2747,7 +2779,7 @@ class DiagnosisDialog(QDialog): def _load_visible_readonly_sections(self) -> None: permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS} - for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"): + for key in ("prescription", "notes", "daily", "orders", "video", "chat", "assign", "appointment"): if self._tab_allowed(permission_map.get(key, ())): self._ensure_tab_loaded(key) @@ -3853,12 +3885,15 @@ class DiagnosisDialog(QDialog): first_value(row, "doctor_name"), "已作废" if voided - else first_value(row, "status_text", "audit_status_text", default="正常"), + else first_value( + row, "status_text", "audit_status_text", + default=_audit_text(first_value(row, "audit_status", default=None)), + ), "", ) ) for table in self._table_registry["prescription"]: - table.set_empty_text("暂无病历记录,开方后会自动显示。") + table.set_empty_text("暂无历史处方,开方后会自动显示。") table.set_matrix(matrix) self._semanticize(table, 5) for row_index, row in enumerate(rows): @@ -3875,6 +3910,7 @@ class DiagnosisDialog(QDialog): voided = _truthy(first_value(row, "void_status", "is_void", default=False)) can_edit = ( self._editable + and table is not self.readonly_prescriptions_table and self._can_edit_prescription and (not approved or voided) ) @@ -3958,6 +3994,10 @@ class DiagnosisDialog(QDialog): if actual_id != prescription_id: self._show_message("服务端处方与当前行不一致,已停止打开。", "danger") return + detail_diagnosis_id = _int(first_value(prescription, "diagnosis_id", default=0), 0) + if self._diagnosis_id > 0 and detail_diagnosis_id != self._diagnosis_id: + self._show_message("该处方不属于当前诊单,已停止打开。", "danger") + return self._clear_message() if edit: self._open_prescription_edit_dialog(prescription, prescription_id) diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index 87c46b86e..e6b3d832f 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -40,6 +40,7 @@ from PySide6.QtWidgets import ( from ...core import UserProfile from ...core.appointment_modes import appointment_type_value, can_appointment_video +from ...core.errors import ApiBusinessError from .. import icons from ..consultations_style import consultations_stylesheet from ..diagnosis_index_widgets import ( @@ -2235,9 +2236,15 @@ class ConsultationsPage(QWidget): self._departments_loading = False self._departments_loaded = False self.department_combo.clear() - self.department_combo.addItem("部门加载失败", "") + permission_denied = isinstance(error, ApiBusinessError) and any( + text in str(error) for text in ("权限不足", "无权限", "无权") + ) + self.department_combo.addItem("暂无部门筛选权限" if permission_denied else "部门加载失败", "") self.department_combo.setEnabled(False) - message = f"部门加载失败:{friendly_error(error)}。点击重试。" + message = ( + "当前账号无权读取部门,请联系管理员检查部门筛选权限,处理后点击重试。" + if permission_denied else f"部门加载失败:{friendly_error(error)}。点击重试。" + ) self.department_combo.setToolTip(message) self.department_retry_button.setToolTip(message) self.department_retry_button.show() diff --git a/app/src/doctor_workstation/ui/pages/prescriptions.py b/app/src/doctor_workstation/ui/pages/prescriptions.py index 5a0a65f83..069a3769a 100644 --- a/app/src/doctor_workstation/ui/pages/prescriptions.py +++ b/app/src/doctor_workstation/ui/pages/prescriptions.py @@ -8,12 +8,24 @@ from html import escape from math import ceil from typing import Any -from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer +from PySide6.QtCore import ( + QDateTime, + QEvent, + QModelIndex, + QPersistentModelIndex, + QPointF, + QRectF, + QSize, + Qt, + QTimer, +) from PySide6.QtGui import ( QColor, QFont, QFontMetrics, QIcon, + QKeySequence, + QMouseEvent, QPainter, QPen, QTextDocument, @@ -44,6 +56,7 @@ from PySide6.QtWidgets import ( QStyledItemDelegate, QStyleOptionButton, QStyleOptionViewItem, + QTextEdit, QToolTip, QVBoxLayout, QWidget, @@ -969,7 +982,184 @@ class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate): return QSize(width, max(80, ceil(document.size().height()) + 28)) +class _PrescriptionCopyText(QTextEdit): + """One transient, read-only text surface for the active painted cell.""" + + def __init__(self, table: _PrescriptionBlueTable) -> None: + super().__init__(table.viewport()) + self.table = table + self.setObjectName("PrescriptionCopyText") + self.setReadOnly(True) + self.setFrameShape(QFrame.Shape.NoFrame) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setStyleSheet( + f"QTextEdit#PrescriptionCopyText {{background:{PALETTE['accent_soft']};" + f"color:{PALETTE['text']};border:0;padding:0;" + f"selection-background-color:{PALETTE['accent']};selection-color:white;}}" + ) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect( + lambda point: table.customContextMenuRequested.emit(self.viewport().mapTo(table.viewport(), point)) + ) + self.hide() + + def keyPressEvent(self, event: Any) -> None: # noqa: N802 + if event.matches(QKeySequence.StandardKey.Copy): + if self.textCursor().hasSelection(): + self.copy() + else: + self.table.copy_cell() + event.accept() + elif event.key() == Qt.Key.Key_Escape: + self.table._hide_copy_text() + self.table.setFocus() + event.accept() + else: + super().keyPressEvent(event) + + def wheelEvent(self, event: Any) -> None: # noqa: N802 + self.table._hide_copy_text() + QApplication.sendEvent(self.table.viewport(), event) + + class _PrescriptionBlueTable(SortableTable): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._copy_index = QPersistentModelIndex() + self._copy_drag = False + self.copy_text = _PrescriptionCopyText(self) + for signal in ( + self.model().modelAboutToBeReset, self.model().layoutAboutToBeChanged, + self.model().rowsAboutToBeRemoved, self.model().rowsAboutToBeInserted, + self.horizontalScrollBar().valueChanged, self.verticalScrollBar().valueChanged, + self.horizontalHeader().sectionResized, self.horizontalHeader().sectionMoved, + ): + signal.connect(self._hide_copy_text) + self.model().dataChanged.connect(self._copy_data_changed) + self.selectionModel().currentChanged.connect(self._copy_current_changed) + + @staticmethod + def _copyable(index: QModelIndex) -> bool: + return index.isValid() and index.column() in (1, 3, 4, 5, 6, 7, 8, 9, 10) + + def cell_copy_text(self, index: QModelIndex) -> str: + if not self._copyable(index): + return "" + if index.column() == 1: + return str(index.data(Qt.ItemDataRole.AccessibleTextRole) or index.data() or "") + return str(index.data() or "") + + def row_copy_text(self, row: int) -> str: + header = self.horizontalHeader() + return "\t".join( + self.cell_copy_text(self.model().index(row, column)).replace("\n", " ") + for column in (header.logicalIndex(visual) for visual in range(self.columnCount())) + if column not in (0, 2) and not self.isColumnHidden(column) + ) + + def copy_cell(self) -> None: + text = self.cell_copy_text(self.currentIndex()) + if text: + QApplication.clipboard().setText(text) + + def _hide_copy_text(self, *_args: Any) -> None: + self.copy_text.hide() + self._copy_index = QPersistentModelIndex() + self._copy_drag = False + + def _copy_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None: + if current != self._copy_index: + self._hide_copy_text() + + def _copy_data_changed(self, top: QModelIndex, bottom: QModelIndex, *_args: Any) -> None: + if (self._copy_index.isValid() + and top.row() <= self._copy_index.row() <= bottom.row() + and top.column() <= self._copy_index.column() <= bottom.column()): + self._hide_copy_text() + + def _show_copy_text(self, index: QModelIndex) -> None: + self._copy_index = QPersistentModelIndex(index) + self.copy_text.setGeometry(self.visualRect(index).adjusted(1, 1, -1, -1)) + text = self.cell_copy_text(index) + if index.column() == 5: + parts = text.rsplit(" · ", 2) + text = parts[0] + ("\n" + " · ".join(parts[1:]) if len(parts) > 1 else "") + elif index.column() == 10: + text = "\n".join(text.rsplit(" ", 1)) + font = QFont(body_family()) + font.setPixelSize(14) + self.copy_text.setFont(font) + self.copy_text.setPlainText(text) + document = self.copy_text.document() + document.setDocumentMargin(0) + document.setTextWidth(max(1, self.copy_text.width() - 24)) + top = max(11, round((self.copy_text.height() - document.size().height()) / 2)) + self.copy_text.setViewportMargins(11, top, 11, 0) + self.copy_text.show() + self.copy_text.raise_() + self.copy_text.setFocus(Qt.FocusReason.MouseFocusReason) + + def _forward_copy_mouse(self, event: QMouseEvent) -> None: + target = self.copy_text.viewport() + local = QPointF(target.mapFromGlobal(event.globalPosition().toPoint())) + forwarded = QMouseEvent( + event.type(), local, event.globalPosition(), event.button(), event.buttons(), event.modifiers() + ) + QApplication.sendEvent(target, forwarded) + event.accept() + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + index = self.indexAt(event.position().toPoint()) + self._hide_copy_text() + super().mousePressEvent(event) + if event.button() == Qt.MouseButton.LeftButton and self._copyable(index): + self._show_copy_text(index) + self._copy_drag = True + self._forward_copy_mouse(event) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if self._copy_drag: + self._forward_copy_mouse(event) + return + self.viewport().setCursor( + Qt.CursorShape.IBeamCursor if self._copyable(self.indexAt(event.position().toPoint())) + else Qt.CursorShape.ArrowCursor + ) + super().mouseMoveEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802 + index = self.indexAt(event.position().toPoint()) + if event.button() == Qt.MouseButton.LeftButton and self._copyable(index): + self.setCurrentIndex(index) + if self._copy_index != index: + self._show_copy_text(index) + self._forward_copy_mouse(event) + return + super().mouseDoubleClickEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if self._copy_drag: + self._forward_copy_mouse(event) + self._copy_drag = False + return + super().mouseReleaseEvent(event) + + def keyPressEvent(self, event: Any) -> None: # noqa: N802 + if event.matches(QKeySequence.StandardKey.Copy): + if self._copyable(self.currentIndex()): + self.copy_cell() + elif self.currentRow() >= 0: + QApplication.clipboard().setText(self.row_copy_text(self.currentRow())) + event.accept() + return + super().keyPressEvent(event) + + def resizeEvent(self, event: Any) -> None: # noqa: N802 + if hasattr(self, "copy_text"): + self._hide_copy_text() + super().resizeEvent(event) + def setCellWidget(self, row: int, column: int, widget: QWidget) -> None: # noqa: N802 super().setCellWidget(row, column, widget) if column == 2: @@ -1827,18 +2017,26 @@ class PrescriptionsPage(QWidget): present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value) def _open_ai_context_menu(self, position: Any) -> None: - if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)): - return item = self.table.itemAt(position) if item is None: return - self.table.selectRow(item.row()) + self.table.setCurrentItem(item) row = self._selected() - if _is_blank_prescription(row): - return menu = QMenu(self.table) - action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告") - action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report)) + selected = self.table.copy_text.textCursor().selectedText() if self.table.copy_text.isVisible() else "" + if selected: + menu.addAction("复制选中文字", lambda: QApplication.clipboard().setText(selected.replace("\u2029", "\n"))) + text = self.table.cell_copy_text(self.table.indexFromItem(item)) + if text: + menu.addAction("复制单元格", lambda: QApplication.clipboard().setText(text)) + row_text = self.table.row_copy_text(item.row()) + menu.addAction("复制整行", lambda: QApplication.clipboard().setText(row_text)) + if (can_open_issued_ai(self.permissions) + and callable(getattr(self.repository, "list_prescription_ai_reports", None)) + and not _is_blank_prescription(row)): + menu.addSeparator() + action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告") + action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report)) self._ai_context_menu = menu menu.popup(self.table.viewport().mapToGlobal(position)) diff --git a/app/tests/test_consultation_departments.py b/app/tests/test_consultation_departments.py index a2ff59c4f..4d61d447f 100644 --- a/app/tests/test_consultation_departments.py +++ b/app/tests/test_consultation_departments.py @@ -11,7 +11,7 @@ import pytest from PySide6.QtCore import Signal from PySide6.QtWidgets import QApplication, QWidget -from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError +from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError, ApiTimeoutError from doctor_workstation.services.repository import RemoteDoctorRepository from doctor_workstation.ui.pages import consultations as module @@ -160,8 +160,8 @@ def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch widget._load_departments() assert not widget._departments_loaded assert not widget._departments_loading - assert widget.department_combo.currentText() == "部门加载失败" - assert "无权限" in widget.department_combo.toolTip() + assert widget.department_combo.currentText() == "暂无部门筛选权限" + assert "联系管理员" in widget.department_combo.toolTip() assert not widget.department_retry_button.isHidden() client.departments = TREE widget.department_retry_button.click() @@ -170,6 +170,19 @@ def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch assert all(params == {"apply_data_scope": 1} for _, params in client.calls) +def test_network_failure_remains_retryable_and_is_not_reported_as_permission_denied(page: Any, monkeypatch: pytest.MonkeyPatch) -> None: + widget, client = page + monkeypatch.setattr(module, "run_async", immediate) + client.departments = ApiTimeoutError("request timed out") + widget._load_departments() + assert widget.department_combo.currentText() == "部门加载失败" + assert "超时" in widget.department_combo.toolTip() + assert "联系管理员" not in widget.department_combo.toolTip() + client.departments = TREE + widget.department_retry_button.click() + assert widget._departments_loaded + + def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None: widget, _client = page jobs: list[dict[str, Any]] = [] diff --git a/app/tests/test_prescription_security_ui.py b/app/tests/test_prescription_security_ui.py index 024e096a5..aaf37f840 100644 --- a/app/tests/test_prescription_security_ui.py +++ b/app/tests/test_prescription_security_ui.py @@ -7,8 +7,8 @@ from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication, QDialog +from PySide6.QtCore import QPoint, Qt +from PySide6.QtWidgets import QApplication, QDialog, QPushButton from doctor_workstation.core import PermissionSet from doctor_workstation.ui import widgets @@ -412,6 +412,114 @@ def _finish_queued(callback: dict[str, Any], result: Any) -> None: callback["on_finished"]() +@pytest.mark.parametrize("owner_width", [1024, 1440]) +def test_embedded_prescription_history_opens_full_detail_without_changing_draft( + application: QApplication, monkeypatch: pytest.MonkeyPatch, owner_width: int, +) -> None: + listed: list[int] = [] + fetched: list[int] = [] + shown: list[int] = [] + + class Repository(_DiagnosisRepository): + def get_doctor_notes(self, _diagnosis_id: int) -> list[Any]: + return [] + + def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]: + listed.append(diagnosis_id) + return [ + {"id": 92, "diagnosis_id": diagnosis_id, "prescription_date": "2026-11-03", + "doctor_name": "陈医生", "clinical_diagnosis": "气阴两虚", "audit_status": 1}, + {"id": 81, "diagnosis_id": diagnosis_id, "prescription_date": "2026-10-20", + "doctor_name": "李医生", "clinical_diagnosis": "脾虚", "void_status": 1}, + ] + + def get_prescription(self, prescription_id: int) -> dict[str, Any]: + fetched.append(prescription_id) + return {"id": prescription_id, "diagnosis_id": 745, "patient_name": "测试患者", + "herbs": [{"name": "黄芪", "dosage": 15}], "usage_instruction": "水煎服"} + + def inspect_detail(dialog: dialog_module.PrescriptionDetailDialog) -> int: + shown.append(dialog.prescription["id"]) + assert "黄芪" in dialog.document.toPlainText() + assert "水煎服" in dialog.document.toPlainText() + assert dialog.diagnosis_button.isHidden() + assert dialog.orders_button.isHidden() + return QDialog.DialogCode.Rejected + + monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async) + monkeypatch.setattr(dialog_module.PrescriptionDetailDialog, "exec", inspect_detail) + owner = QDialog() + owner.resize(owner_width, 800) + owner.show() + editor = PrescriptionEditorDialog( + Repository(), {"diagnosis_id": 745, "patient_name": "测试患者"}, mode="edit", + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/chufang"]), + parent=owner, + ) + editor.show() + application.processEvents() + editor.clinical_diagnosis.setPlainText("尚未保存的新诊断") + draft = editor.payload() + editor.diagnosis_button.click() + for _ in range(6): + application.processEvents() + detail = editor._diagnosis_view + assert detail is not None + table = detail.readonly_prescriptions_table + assert listed == [745] + assert table.rowCount() == 2 + assert table.item(0, 5).text() == "审核通过" + assert table.item(1, 5).text() == "已作废" + assert table.horizontalScrollBar().maximum() == 0 + assert table.mapToGlobal(QPoint(table.width(), 0)).x() <= detail.readonly_scroll.viewport().mapToGlobal( + QPoint(detail.readonly_scroll.viewport().width(), 0) + ).x() + assert detail._readonly_sections["prescription"].y() < detail.case_grid.y() + actions = table.cellWidget(1, 6).findChildren(QPushButton) + assert [button.text() for button in actions] == ["查看"] + actions[0].click() + assert fetched == [81] and shown == [81] + assert editor.payload() == draft + assert editor.diagnosis_host.isVisibleTo(editor) + detail.readonly_close_button.click() + editor.diagnosis_button.click() + assert listed == [745, 745] + assert editor.payload() == draft + editor.reject() + owner.close() + application.processEvents() + + +def test_readonly_history_respects_permission_and_shows_empty_and_failure( + application: QApplication, monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[int] = [] + + class Repository(_DiagnosisRepository): + def get_doctor_notes(self, _diagnosis_id: int) -> list[Any]: + return [] + + def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[Any]: + calls.append(diagnosis_id) + if len(calls) > 1: + raise RuntimeError("测试加载失败") + return [] + + monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async) + dialog = DiagnosisDialog(Repository(), permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"])) + dialog.open_for(745, editable=False) + assert dialog._readonly_sections["prescription"].isHidden() + assert calls == [] + dialog.refresh_permissions(PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/chufang"])) + dialog.open_for(745, editable=False) + assert "暂无历史处方" in dialog.readonly_prescriptions_table.empty_label.text() + dialog.open_for(745, editable=False) + assert "加载失败" in dialog.readonly_prescriptions_table.empty_label.text() + assert dialog.readonly_prescriptions_table.rowCount() == 0 + dialog.close() + application.processEvents() + + def test_prescription_lists_snapshot_queries_and_supersede_stale_refresh( application: QApplication, monkeypatch: pytest.MonkeyPatch, diff --git a/app/tests/test_prescriptions_tech_blue.py b/app/tests/test_prescriptions_tech_blue.py index 3c529ec04..a931b5c04 100644 --- a/app/tests/test_prescriptions_tech_blue.py +++ b/app/tests/test_prescriptions_tech_blue.py @@ -246,6 +246,94 @@ def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(applicatio assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled +@pytest.mark.parametrize("column", [1, 3, 4, 5, 6, 7, 8, 9, 10]) +def test_all_prescription_data_cells_select_and_copy_without_editing( + shell_factory, application, column, +) -> None: + _shell, page = shell_factory() + table = page.table + item = table.item(0, column) + original = item.text() + row_id = _record_id(table, 0) + QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(item).center()) + assert table.copy_text.isVisible() + assert _record_id(table, table.currentRow()) == row_id + assert table.cellWidget(0, column) is None + QTest.keyClick(table.copy_text, Qt.Key.Key_A, Qt.KeyboardModifier.ControlModifier) + QTest.keyClick(table.copy_text, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier) + copied = application.clipboard().text() + if column == 1: + assert original in copied and f"ID: {row_id}" in copied + elif column == 5: + assert copied == original.replace(" · ", "\n", 1).replace(" · ", " · ") + elif column == 10: + assert copied == "\n".join(original.rsplit(" ", 1)) + else: + assert copied == original + QTest.keyClicks(table.copy_text, "must-not-edit") + assert item.text() == original + assert table.item(0, 0).checkState() == Qt.CheckState.Unchecked + + +def test_text_drag_copy_and_double_click_do_not_open_prescription(shell_factory, application, monkeypatch): + _shell, page = shell_factory() + table = page.table + opened = [] + monkeypatch.setattr(page, "_view_selected", lambda: opened.append(True)) + item = table.item(1, 8) + rect = table.visualItemRect(item) + start = QPoint(rect.left() + 12, rect.center().y()) + end = QPoint(rect.right() - 12, start.y()) + QTest.mousePress(table.viewport(), Qt.MouseButton.LeftButton, pos=start) + QTest.mouseMove(table.viewport(), end, 30) + QTest.mouseRelease(table.viewport(), Qt.MouseButton.LeftButton, pos=end) + assert table.copy_text.textCursor().selectedText() == item.text() + QTest.keyClick(table.copy_text, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier) + assert application.clipboard().text() == item.text() + table.copy_text.customContextMenuRequested.emit(QPoint(4, 4)) + menu = page._ai_context_menu + selected_action = next(action for action in menu.actions() if action.text() == "复制选中文字") + application.clipboard().clear() + selected_action.trigger() + assert application.clipboard().text() == item.text() + menu.close() + QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=start + QPoint(8, 0)) + assert table.copy_text.textCursor().hasSelection() + assert opened == [] + + +def test_copy_context_menu_and_refresh_follow_the_visible_row(shell_factory, application): + _shell, page = shell_factory() + table = page.table + # Copy remains available without AI/report permissions, including blank prescriptions. + page.permissions = PermissionSet([]) + table.sortItems(1, Qt.SortOrder.DescendingOrder) + item = table.item(0, 1) + page._open_ai_context_menu(table.visualItemRect(item).center()) + menu = page._ai_context_menu + actions = {action.text(): action for action in menu.actions()} + assert set(actions) == {"复制单元格", "复制整行"} + actions["复制单元格"].trigger() + assert item.text() in application.clipboard().text() + assert f"ID: {_record_id(table, 0)}" in application.clipboard().text() + actions["复制整行"].trigger() + copied = application.clipboard().text() + for column in range(3, 11): + assert table.item(0, column).text() in copied + assert "\t" in copied and "复制" not in copied + menu.close() + QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(item).center()) + assert table.copy_text.isVisible() + table.sortItems(1, Qt.SortOrder.AscendingOrder) + assert table.copy_text.isHidden() + table.setCurrentItem(table.item(0, 5)) + QTest.keyClick(table, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier) + assert application.clipboard().text() == table.item(0, 5).text() + QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(table.item(0, 5)).center()) + page.refresh() + assert table.copy_text.isHidden() + + def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None: _shell, page = shell_factory(1024, 768) page.filter_disclosure.set_expanded(True) diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index eadfd739f..ebfd576e7 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -80,6 +80,17 @@ class AuthMiddleware // 当前访问路径 $accessUri = strtolower($request->controller() . '/' . $request->action()); + // 患者列表的部门筛选复用列表权限;只允许 GET 查询显式启用数据范围的部门树。 + // 必须与 DeptController::all 读取同一个 GET 参数,避免请求体参数意外放行全量部门。 + if ($accessUri === 'dept.dept/all' + && $request->isGet() + && in_array($request->get('apply_data_scope'), [1, '1'], true)) { + $uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []); + if (in_array('tcm.diagnosis/lists', $uris, true)) { + return $next($request); + } + } + // Screenshot analysis shares the assistant grant and must never use the // unregistered-route bypass, even before menus have been synchronized. if ($accessUri === 'tcm.diagnosis/aitonguescreenshot') {