更新
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user