This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
+687
View File
@@ -0,0 +1,687 @@
"""Shared widgets and safe asynchronous helpers for the UI layer."""
from __future__ import annotations
import inspect
import traceback
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import QResizeEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core.errors import AuthenticationExpiredError
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
def set_authentication_expired_handler(
handler: AuthenticationExpiredHandler | None,
) -> None:
"""Install the application-level expired-session callback.
The callback returns ``True`` when it consumed the error. Returning
``False`` preserves the originating operation's local error handling, as is
required when authentication itself fails on the login screen.
"""
global _AUTHENTICATION_EXPIRED_HANDLER
_AUTHENTICATION_EXPIRED_HANDLER = handler
def get_value(value: Any, key: str, default: Any = None) -> Any:
"""Read a dotted key from mappings, dataclasses, or ordinary objects."""
current = value
for part in key.split("."):
if current is None:
return default
if isinstance(current, Mapping):
current = current.get(part, default)
else:
marker = object()
candidate = getattr(current, part, marker)
if candidate is marker:
raw = getattr(current, "raw", None)
candidate = raw.get(part, marker) if isinstance(raw, Mapping) else marker
current = default if candidate is marker else candidate
if current is default:
return default
return current
def first_value(value: Any, *keys: str, default: Any = None) -> Any:
"""Return the first present, non-empty value from ``keys``."""
for key in keys:
candidate = get_value(value, key, None)
if candidate is not None and candidate != "":
return candidate
return default
def display_text(value: Any, default: str = "—") -> str:
if value is None or value == "":
return default
if isinstance(value, bool):
return "是" if value else "否"
if isinstance(value, (datetime, date)):
return value.strftime("%Y-%m-%d %H:%M" if isinstance(value, datetime) else "%Y-%m-%d")
return str(value)
def gender_text(value: Any, default: str = "—") -> str:
"""Format the legacy gender codes without exposing numeric API values."""
normalized = str(value).strip().lower() if value not in (None, "") else ""
labels = {
"0": "未知",
"1": "男",
"2": "女",
"m": "男",
"male": "男",
"f": "女",
"female": "女",
"unknown": "未知",
}
return labels.get(normalized, display_text(value, default))
def page_items(result: Any) -> list[Any]:
"""Extract rows from common PageResult/dict response shapes."""
if result is None:
return []
if isinstance(result, (list, tuple)):
return list(result)
for key in ("items", "lists", "results", "rows", "data"):
items = get_value(result, key, None)
if isinstance(items, (list, tuple)):
return list(items)
if key == "data" and items is not None and items is not result:
nested = page_items(items)
if nested:
return nested
return []
def page_total(result: Any, fallback: int = 0) -> int:
for key in ("total", "count", "total_count", "data.total", "data.count"):
value = get_value(result, key, None)
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
pass
return fallback
def has_permission(permissions: Any, codes: str | Sequence[str], default: bool = True) -> bool:
"""Check canonical permissions with exact and resource-wildcard semantics.
A sequence uses OR semantics, matching the admin client's route guards.
Permission names are opaque: ``resource/action`` never aliases
``resource.action``.
"""
if permissions is None:
return default
requested = tuple(
code.strip()
for code in ((codes,) if isinstance(codes, str) else tuple(codes))
if code and code.strip()
)
if not requested:
return True
for method_name in ("allows", "has", "can", "contains", "has_permission"):
method = getattr(permissions, method_name, None)
if callable(method):
for code in requested:
try:
if bool(method(code)):
return True
except (TypeError, ValueError):
continue
raw = permissions
for attr in ("codes", "permissions", "values"):
candidate = getattr(permissions, attr, None)
if candidate is not None and not callable(candidate):
raw = candidate
break
if isinstance(raw, Mapping):
available = {str(key).strip() for key, enabled in raw.items() if enabled}
elif isinstance(raw, str):
available = {raw}
else:
try:
available = {str(item).strip() for item in raw}
except TypeError:
return default
return any(
"*" in available
or code in available
or any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available)
for code in requested
)
def invoke(repository: Any, method_name: str, /, **kwargs: Any) -> Any:
"""Invoke a repository method with keyword filtering for contract tolerance."""
method = getattr(repository, method_name, None)
if method is None and method_name == "save_prescription_template":
template = kwargs.get("template") or {}
template_id = kwargs.get("template_id", kwargs.get("id"))
if template_id is None:
creator = (
getattr(repository, "create_prescription_template", None)
or repository.add_prescription_template
)
return creator(template=template)
updater = (
getattr(repository, "update_prescription_template", None)
or repository.edit_prescription_template
)
return updater(template_id, changes=template)
aliases = {
"reception_queue": "list_appointments",
"reception_detail": "get_reception",
"prescription_library": "list_prescription_templates",
"prescriptions": "list_prescriptions",
"prescription_detail": "get_prescription",
"patients": "list_patients",
"consultations": "list_consultations",
}
resolved_name = method_name
if method is None:
resolved_name = aliases.get(method_name, method_name)
method = getattr(repository, resolved_name, None)
if method is None and method_name == "reception_detail":
resolved_name = "reception"
method = getattr(repository, resolved_name)
if method is None:
raise AttributeError(f"repository has no method {method_name!r}")
call_kwargs = dict(kwargs)
if resolved_name.startswith("list_") and "page" in call_kwargs and "page_no" not in call_kwargs:
call_kwargs["page_no"] = call_kwargs.pop("page")
if (
resolved_name == "get_prescription"
and "id" in call_kwargs
and "prescription_id" not in call_kwargs
):
call_kwargs["prescription_id"] = call_kwargs.pop("id")
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
return method(**call_kwargs)
parameters = signature.parameters
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
return method(**call_kwargs)
accepted = {
name: value
for name, value in call_kwargs.items()
if name in parameters
and parameters[name].kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
if len(parameters) == 1 and not accepted:
only = next(iter(parameters.values()))
if only.name in {"payload", "data", "query", "filters", "params"}:
return method(call_kwargs)
return method(**accepted)
class WorkerSignals(QObject):
result = Signal(object)
error = Signal(object, str)
finished = Signal()
class Worker(QRunnable):
"""A small QRunnable that marshals results back through Qt signals."""
def __init__(self, function: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
super().__init__()
self.function = function
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
@Slot()
def run(self) -> None:
try:
result = self.function(*self.args, **self.kwargs)
except Exception as exc: # UI boundary: report domain and transport errors alike.
self.signals.error.emit(exc, traceback.format_exc())
else:
self.signals.result.emit(result)
finally:
self.signals.finished.emit()
_RUNNING_WORKERS: set[Worker] = set()
def _dispatch_async_error(
error: Exception,
local_handler: Callable[[Exception], None] | None,
) -> None:
"""Route session expiry globally before falling back to a page handler."""
handled = False
if isinstance(error, AuthenticationExpiredError):
handler = _AUTHENTICATION_EXPIRED_HANDLER
if handler is not None:
try:
handled = bool(handler(error))
except Exception:
traceback.print_exc()
if not handled and local_handler is not None:
local_handler(error)
def run_async(
function: Callable[..., Any],
*args: Any,
on_success: Callable[[Any], None] | None = None,
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
if on_success is not None:
worker.signals.result.connect(on_success)
worker.signals.error.connect(lambda exc, _tb: _dispatch_async_error(exc, on_error))
if on_finished is not None:
worker.signals.finished.connect(on_finished)
worker.signals.finished.connect(lambda: _RUNNING_WORKERS.discard(worker))
(pool or QThreadPool.globalInstance()).start(worker)
return worker
def friendly_error(error: Any) -> str:
text = str(error).strip()
return text or "操作未完成,请稍后重试。"
class PageHeader(QWidget):
"""Consistent title, subtitle, and action area for business pages."""
def __init__(
self,
title: str,
subtitle: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
text_layout = QVBoxLayout()
text_layout.setSpacing(3)
self.title_label = QLabel(title)
self.title_label.setProperty("role", "pageTitle")
text_layout.addWidget(self.title_label)
self.subtitle_label = QLabel(subtitle)
self.subtitle_label.setProperty("role", "muted")
self.subtitle_label.setWordWrap(True)
self.subtitle_label.setVisible(bool(subtitle))
text_layout.addWidget(self.subtitle_label)
layout.addLayout(text_layout, 1)
self.actions = QHBoxLayout()
self.actions.setSpacing(8)
layout.addLayout(self.actions)
def add_action(self, widget: QWidget) -> QWidget:
self.actions.addWidget(widget)
return widget
def set_subtitle(self, text: str) -> None:
self.subtitle_label.setText(text)
self.subtitle_label.setVisible(bool(text))
class StatusBadge(QLabel):
def __init__(
self, text: str = "", kind: str = "neutral", parent: QWidget | None = None
) -> None:
super().__init__(text, parent)
self.setObjectName("StatusBadge")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
def set_status(self, text: str, kind: str = "neutral") -> None:
self.setText(text)
self.set_kind(kind)
class EmptyState(QWidget):
action_requested = Signal()
def __init__(
self,
title: str = "暂无数据",
description: str = "调整筛选条件后再试试。",
action_text: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 44, 24, 44)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(8)
glyph = QLabel("○")
glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
glyph.setStyleSheet("font-size: 30px; color: #9DAEA5;")
layout.addWidget(glyph)
title_label = QLabel(title)
title_label.setProperty("role", "sectionTitle")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
description_label = QLabel(description)
description_label.setProperty("role", "muted")
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
description_label.setWordWrap(True)
layout.addWidget(description_label)
self.action_button = QPushButton(action_text)
self.action_button.setProperty("variant", "secondary")
self.action_button.setVisible(bool(action_text))
self.action_button.clicked.connect(self.action_requested)
layout.addWidget(self.action_button, 0, Qt.AlignmentFlag.AlignCenter)
class MessageBanner(QFrame):
def __init__(self, text: str = "", kind: str = "info", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("MessageBanner")
self.setProperty("kind", kind)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(9)
self.icon = QLabel("i")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20)
self.icon.setStyleSheet("font-weight: 700;")
self.label = QLabel(text)
self.label.setWordWrap(True)
layout.addWidget(self.icon)
layout.addWidget(self.label, 1)
self.setVisible(bool(text))
def show_message(self, text: str, kind: str = "info") -> None:
glyphs = {"info": "i", "success": "✓", "warning": "!", "danger": "!"}
self.label.setText(text)
self.icon.setText(glyphs.get(kind, "i"))
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
self.setVisible(bool(text))
def clear(self) -> None:
self.setVisible(False)
self.label.clear()
class Toast(QLabel):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("Toast")
self.setWordWrap(True)
self.setMaximumWidth(420)
self.hide()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
self.setText(text)
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
self.adjustSize()
parent = self.parentWidget()
if parent is not None:
self.move(max(16, parent.width() - self.width() - 24), 20)
self.raise_()
self.show()
self._timer.start(duration)
def show_toast(parent: QWidget, text: str, kind: str = "info", duration: int = 2800) -> None:
window = parent.window()
toast = getattr(window, "_doctor_workstation_toast", None)
if not isinstance(toast, Toast):
toast = Toast(window)
window._doctor_workstation_toast = toast
toast.show_message(text, kind, duration)
class BusyOverlay(QFrame):
"""Non-blocking visual guard for a card or page while a worker is active."""
def __init__(self, parent: QWidget, text: str = "正在加载…") -> None:
super().__init__(parent)
self.setObjectName("BusyOverlay")
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(10)
self.label = QLabel(text)
self.label.setProperty("role", "muted")
progress = QProgressBar()
progress.setRange(0, 0)
progress.setFixedWidth(140)
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignCenter)
self.hide()
def set_message(self, text: str) -> None:
self.label.setText(text)
def showEvent(self, event: Any) -> None:
self.setGeometry(self.parentWidget().rect())
self.raise_()
super().showEvent(event)
class OverlayHost(QWidget):
"""Widget base that automatically sizes a BusyOverlay child."""
def resizeEvent(self, event: QResizeEvent) -> None:
overlay = getattr(self, "busy_overlay", None)
if isinstance(overlay, BusyOverlay):
overlay.setGeometry(self.rect())
super().resizeEvent(event)
@dataclass(frozen=True)
class TableColumn:
key: str
title: str
width: int = 0
formatter: Callable[[Any, Any], str] | None = None
alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
class SortableTable(QTableWidget):
"""A QTableWidget that safely retains the source object after sorting."""
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.columns = list(columns)
self.setColumnCount(len(self.columns))
self.setHorizontalHeaderLabels([column.title for column in self.columns])
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setSortingEnabled(True)
self.verticalHeader().setVisible(False)
self.horizontalHeader().setStretchLastSection(True)
for index, column in enumerate(self.columns):
if column.width:
self.setColumnWidth(index, column.width)
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "appointment_id", default=None)
self.setSortingEnabled(False)
self.clearContents()
materialized = list(rows)
self.setRowCount(len(materialized))
row_to_select = -1
for row_index, row in enumerate(materialized):
row_id = first_value(row, "id", "appointment_id", default=None)
if selected_id is not None and row_id == selected_id:
row_to_select = row_index
for column_index, column in enumerate(self.columns):
raw = get_value(row, column.key, None)
text = column.formatter(raw, row) if column.formatter else display_text(raw)
item = QTableWidgetItem(text)
item.setTextAlignment(column.alignment)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setToolTip(text if len(text) > 18 else "")
self.setItem(row_index, column_index, item)
self.setSortingEnabled(True)
if row_to_select >= 0:
self.selectRow(row_to_select)
def current_data(self) -> Any:
row = self.currentRow()
if row < 0:
return None
item = self.item(row, 0)
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
class Pager(QWidget):
page_changed = Signal(int)
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 4, 0, 0)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
self.previous = QPushButton("上一页")
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.page_label = QLabel("1 / 1")
self.page_label.setMinimumWidth(58)
self.page_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_label)
self.next = QPushButton("下一页")
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"共 {self.total} 条")
self.page_label.setText(f"{self.page} / {self.page_count}")
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < self.page_count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def card_layout(card: QFrame, margins: int = 18, spacing: int = 12) -> QVBoxLayout:
layout = QVBoxLayout(card)
layout.setContentsMargins(margins, margins, margins, margins)
layout.setSpacing(spacing)
return layout
def section_title(text: str, trailing: QWidget | None = None) -> QWidget:
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel(text)
title.setProperty("role", "sectionTitle")
layout.addWidget(title)
layout.addStretch(1)
if trailing is not None:
layout.addWidget(trailing)
return container
def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
child_layout = item.layout()
if widget is not None:
widget.deleteLater()
elif child_layout is not None:
clear_layout(child_layout) # type: ignore[arg-type]
__all__ = [
"BusyOverlay",
"EmptyState",
"MessageBanner",
"OverlayHost",
"PageHeader",
"Pager",
"SortableTable",
"StatusBadge",
"TableColumn",
"Toast",
"Worker",
"card_layout",
"clear_layout",
"display_text",
"first_value",
"friendly_error",
"get_value",
"has_permission",
"invoke",
"page_items",
"page_total",
"run_async",
"section_title",
"set_authentication_expired_handler",
"show_toast",
]