Files
zyt/app/src/doctor_workstation/ui/widgets.py
T
2026-08-22 08:51:35 +08:00

1103 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared widgets and safe asynchronous helpers for the UI layer."""
from __future__ import annotations
import inspect
import re
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 QColor, QPainter, QPaintEvent, QResizeEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QMenu,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core.errors import (
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
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)
_UNIX_TIMESTAMP_RE = re.compile(r"\d{10,13}(?:\.\d+)?")
_RECORD_TIME_FORMAT = "%Y-%m-%d %H:%M"
def format_record_time(value: Any, default: str = "—") -> str:
"""Format a record timestamp that may arrive as Unix seconds, milliseconds, or ISO text.
The desktop pages used to render raw ``str(int)`` for fields like
``create_time`` whenever the server returned a numeric epoch. This
helper keeps the legacy strings (already formatted dates) untouched
while normalising the numeric and ISO variants to ``YYYY-MM-DD HH:MM``
so the column reads as a real creation date.
"""
if value is None or value == "":
return default
if isinstance(value, datetime):
return value.strftime(_RECORD_TIME_FORMAT)
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
raw = str(value).strip()
if not raw:
return default
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
stamp = float(raw)
if stamp >= 10_000_000_000:
stamp /= 1000.0
try:
return datetime.fromtimestamp(stamp).strftime(_RECORD_TIME_FORMAT)
except (OSError, OverflowError, ValueError):
return raw
normalized = raw.replace("T", " ").replace("Z", "")
if len(normalized) >= 16:
return normalized[:16]
return raw
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,
priority: int = 0,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle.
Higher-priority queued work starts first when a pool thread becomes free.
Running work is never interrupted.
"""
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, priority)
return worker
def friendly_error(error: Any) -> str:
text = str(error).strip()
lowered = text.lower()
if "certificate_verify_failed" in lowered or "self-signed certificate" in lowered:
return (
"服务器证书不受系统信任。若这是可信内网的自签名服务器,请展开“服务器设置”,"
"勾选“信任自签名证书(仅内网调试)”后重新登录,设置会自动应用。"
)
if isinstance(error, AuthenticationExpiredError):
return "登录状态已失效,请重新登录。"
if isinstance(error, ApiTimeoutError) or "timed out" in lowered or "timeout" in lowered:
return "连接服务器超时,请检查网络后重试。"
if isinstance(error, ApiProtocolError) or any(
marker in lowered
for marker in (
"api response envelope",
"api response is not valid json",
"invalid json",
)
):
return "服务器返回的数据格式不正确,请联系管理员检查接口。"
if isinstance(error, ApiHttpError):
status_code = getattr(error, "status_code", None)
suffix = f"(状态码 {status_code})" if status_code else ""
return f"服务器请求失败{suffix},请稍后重试。"
if isinstance(error, ApiTransportError) or any(
marker in lowered
for marker in (
"connection refused",
"connecterror",
"connection error",
"failed to connect",
"getaddrinfo failed",
"name or service not known",
"network is unreachable",
)
):
return "无法连接服务器,请检查服务器地址与网络。"
if any(marker in lowered for marker in ("unauthorized", "forbidden", "permission denied")):
return "当前账号无权执行此操作。"
if "not found" in lowered:
return "未找到所需数据。"
# 友好的 Dify / AI 上游错误映射。原文来自服务端 DifyChatService 的 error_code 分支,
# 当上游拒绝、超时或 quota 受限时,原文对医生不友好,按场景给出可执行引导。
upstream_hint = _ai_upstream_hint(text)
if upstream_hint is not None:
return upstream_hint
if any("\u4e00" <= character <= "\u9fff" for character in text):
return text
if isinstance(error, TypeError):
return "程序执行失败,请重试;若问题持续出现,请联系管理员。"
return "操作未完成,请稍后重试。"
_AI_UPSTREAM_HINTS: tuple[tuple[str, str], ...] = (
("模型未能处理本次请求", "AI 助手暂时无法处理本次请求,请稍后重试;若多次出现请联系管理员检查 AI 服务配置。"),
("模型响应超时", "AI 助手响应超时,请稍后重试。"),
("暂时无法连接 AI 服务", "无法连接 AI 助手服务,请检查网络或稍后重试。"),
("模型服务繁忙", "AI 助手服务繁忙,请稍后重试。"),
("AI 助手未返回内容", "AI 助手未返回内容,请稍后重试。"),
("AI 助手暂时不可用", "AI 助手暂时不可用,请稍后重试。"),
("病例数据编码失败", "病例数据无法发送到 AI 助手,请联系管理员。"),
("无法初始化 AI 请求", "无法初始化 AI 助手请求,请联系管理员。"),
("AI 服务凭据无效或无权限", "AI 助手服务凭据无效或无权限,请联系管理员。"),
("AI 服务配置无效", "AI 助手服务配置无效,请联系管理员。"),
("该模型服务尚未完整配置", "AI 助手服务尚未完整配置,请联系管理员。"),
("AI 报告功能未启用", "AI 助手功能尚未启用,请联系管理员。"),
("不支持的 AI 模型", "AI 助手模型不受支持,请联系管理员。"),
)
_AI_UPSTREAM_CODE = re.compile(r"[((]([A-Z][A-Z0-9_]{2,39})[))]")
def _ai_upstream_hint(text: str) -> str | None:
for marker, hint in _AI_UPSTREAM_HINTS:
if marker in text:
# 服务端会在消息尾部附带上游错误码。保留它,医生截图反馈时管理员
# 能直接区分是配置问题、资料体积超限还是上游拒绝。
match = _AI_UPSTREAM_CODE.search(text)
if match is not None:
return f"{hint}({match.group(1)})"
return hint
return None
class PageHeader(QWidget):
"""Reference-design page heading with breadcrumb, copy and actions."""
def __init__(
self,
title: str,
subtitle: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("PageHeader")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
breadcrumb = QHBoxLayout()
breadcrumb.setContentsMargins(0, 0, 0, 0)
breadcrumb.setSpacing(7)
home_label = QLabel("首页", self)
home_label.setProperty("role", "breadcrumb")
breadcrumb.addWidget(home_label)
separator = QLabel("›", self)
separator.setProperty("role", "breadcrumbSeparator")
breadcrumb.addWidget(separator)
self.breadcrumb_label = QLabel(title, self)
self.breadcrumb_label.setProperty("role", "breadcrumbCurrent")
breadcrumb.addWidget(self.breadcrumb_label)
breadcrumb.addStretch(1)
layout.addLayout(breadcrumb)
heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(16)
text_layout = QVBoxLayout()
text_layout.setSpacing(3)
self.title_label = QLabel(title, self)
self.title_label.setProperty("role", "pageTitle")
text_layout.addWidget(self.title_label)
self.subtitle_label = QLabel(subtitle, self)
self.subtitle_label.setProperty("role", "muted")
self.subtitle_label.setWordWrap(True)
self.subtitle_label.setVisible(bool(subtitle))
text_layout.addWidget(self.subtitle_label)
heading.addLayout(text_layout, 1)
self.actions = QHBoxLayout()
self.actions.setSpacing(8)
heading.addLayout(self.actions)
layout.addLayout(heading)
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 MetricCard(QFrame):
"""Compact dashboard metric used above dense business tables."""
def __init__(
self,
title: str,
value: str = "0",
*,
hint: str = "",
kind: str = "accent",
glyph: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("MetricCard")
self.setProperty("kind", kind)
self.setMinimumHeight(82)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 12, 14, 12)
layout.setSpacing(12)
copy = QVBoxLayout()
copy.setSpacing(3)
title_label = QLabel(title, self)
title_label.setProperty("role", "metricTitle")
copy.addWidget(title_label)
self.value_label = QLabel(str(value), self)
self.value_label.setProperty("role", "metricValue")
copy.addWidget(self.value_label)
self.hint_label = QLabel(hint, self)
self.hint_label.setProperty("role", "metricHint")
self.hint_label.setVisible(bool(hint))
copy.addWidget(self.hint_label)
layout.addLayout(copy, 1)
self.glyph_label = QLabel(glyph, self)
self.glyph_label.setObjectName("MetricGlyph")
self.glyph_label.setProperty("kind", kind)
self.glyph_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.glyph_label.setFixedSize(38, 38)
self.glyph_label.setVisible(bool(glyph))
layout.addWidget(self.glyph_label)
def set_value(self, value: Any) -> None:
self.value_label.setText(display_text(value, "0"))
def set_hint(self, text: str) -> None:
self.hint_label.setText(text)
self.hint_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)
# QSS padding alone is not a reliable minimum on every Windows DPI
# scale. Keep enough physical row height so badge text is never
# squeezed into the thin coloured strip seen in list item widgets.
self.setMinimumHeight(24)
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)
self.setObjectName("EmptyState")
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 44, 24, 44)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(8)
glyph = QLabel("○", self)
glyph.setObjectName("EmptyStateGlyph")
glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
glyph.setFixedSize(44, 44)
layout.addWidget(glyph)
title_label = QLabel(title, self)
title_label.setProperty("role", "sectionTitle")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
description_label = QLabel(description, self)
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)
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)
self.icon.setObjectName("MessageBannerIcon")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20)
self.label = QLabel(text, self)
self.label.setObjectName("MessageBannerText")
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 _BusyTrack(QWidget):
"""Indeterminate bar painted in-process, without Windows QProgressBar HWNDs.
``QProgressBar.setRange(0, 0)`` uses the native Vista/Win11 animation, which
creates extra untitled windows titled with the application name.
"""
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("BusyOverlayProgress")
self.setFixedSize(140, 6)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(32)
self._timer.timeout.connect(self._tick)
def showEvent(self, event: Any) -> None:
self._timer.start()
super().showEvent(event)
def hideEvent(self, event: Any) -> None:
self._timer.stop()
super().hideEvent(event)
def _tick(self) -> None:
self._phase = (self._phase + 0.035) % 1.0
self.update()
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
rect = self.rect()
painter.setBrush(QColor("#D8DEEA"))
painter.drawRoundedRect(rect, 3, 3)
chunk_width = max(36, int(rect.width() * 0.32))
span = rect.width() + chunk_width
x = int(self._phase * span) - chunk_width
painter.setBrush(QColor("#4F63D9"))
painter.drawRoundedRect(x, 0, chunk_width, rect.height(), 3, 3)
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")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(10)
self.label = QLabel(text, self)
self.label.setProperty("role", "muted")
self.progress = _BusyTrack(self)
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignCenter)
self.hide()
def set_message(self, text: str) -> None:
self.label.setText(text)
def showEvent(self, event: Any) -> None:
parent = self.parentWidget()
if parent is not None:
self.setGeometry(parent.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.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setMinimumHeight(0)
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 "\n" in text or 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
@dataclass(frozen=True)
class RowAction:
"""One entry in a table row's action cell."""
label: str
callback: Callable[[], None]
danger: bool = False
#: Keep this action inline even when it would otherwise overflow.
pinned: bool = False
class RowActions(QWidget):
"""Row action cell that shows a couple of buttons and hides the rest.
List rows used to render every permitted action as its own small button —
up to seven on the diagnosis list. That widened the action column, pushed
the data columns into ellipsis, and left each row a different width, which
is what made the tables feel heavy and misaligned. Only the first
``max_visible`` non-destructive actions stay inline now; everything else,
destructive actions included, moves into a single 更多 menu.
"""
def __init__(
self,
actions: Sequence[RowAction],
parent: QWidget | None = None,
*,
max_visible: int = 2,
) -> None:
super().__init__(parent)
self.setObjectName("RowActions")
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(2)
inline: list[RowAction] = []
overflow: list[RowAction] = []
for action in actions:
room_left = len(inline) < max_visible
if action.pinned or (room_left and not action.danger):
inline.append(action)
else:
overflow.append(action)
self.buttons: list[QPushButton] = []
for action in inline:
button = QPushButton(action.label, self)
button.setProperty("rowAction", True)
if action.danger:
button.setProperty("variant", "dangerGhost")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, run=action.callback: run())
layout.addWidget(button)
self.buttons.append(button)
self.more_button: QToolButton | None = None
if overflow:
more = QToolButton(self)
more.setObjectName("RowActionsMore")
more.setText("更多")
more.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
more.setCursor(Qt.CursorShape.PointingHandCursor)
menu = QMenu(more)
for action in overflow:
entry = menu.addAction(action.label)
if action.danger:
entry.setProperty("danger", True)
entry.triggered.connect(lambda _checked=False, run=action.callback: run())
more.setMenu(menu)
layout.addWidget(more)
self.more_button = more
self.menu = menu
layout.addStretch(1)
@property
def visible_labels(self) -> list[str]:
return [button.text() for button in self.buttons]
@property
def overflow_labels(self) -> list[str]:
if self.more_button is None:
return []
return [entry.text() for entry in self.more_button.menu().actions()]
class BusinessPager(QWidget):
"""Compact numbered pager shared by dense business-list pages."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 4, 16, 4)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("‹", self)
self.previous.setProperty("variant", "ghost")
self.previous.setAccessibleName("上一页")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("›", self)
self.next.setProperty("variant", "ghost")
self.next.setAccessibleName("下一页")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
# The business contract fixes this list to one page size. A label is
# intentionally used instead of a one-option combo box so the control
# does not advertise an interaction that cannot change anything.
self.page_size_label = QLabel(f"{page_size} 条/页", self)
self.page_size_label.setProperty("pagerSize", True)
self.page_size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_size_label)
self.page_label: QPushButton | None = None
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.total = max(0, total)
self.page = min(max(1, page), self.page_count)
self.summary.setText(f"共 {self.total} 条")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("…", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
class Pager(BusinessPager):
"""Numbered pager for the appointment/patient lists.
This used to be a separate ``上一页 / 1 / 1 / 下一页`` control, so the
workstation shipped three different pagination footers depending on which
list you opened. It now reuses :class:`BusinessPager` verbatim and only
keeps its own object name and default page size, giving every list the same
footer while leaving the ``page_changed`` / ``update_state`` API unchanged.
"""
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(page_size, parent)
self.setObjectName("Pager")
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__ = [
"BusinessPager",
"BusyOverlay",
"EmptyState",
"MessageBanner",
"MetricCard",
"OverlayHost",
"PageHeader",
"Pager",
"SortableTable",
"StatusBadge",
"TableColumn",
"Toast",
"Worker",
"card_layout",
"clear_layout",
"display_text",
"first_value",
"format_record_time",
"friendly_error",
"get_value",
"has_permission",
"invoke",
"page_items",
"page_total",
"run_async",
"section_title",
"set_authentication_expired_handler",
"show_toast",
]