更新
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
@@ -16,10 +17,12 @@ from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -91,6 +94,43 @@ def display_text(value: Any, default: str = "—") -> str:
|
||||
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."""
|
||||
|
||||
@@ -375,6 +415,11 @@ def friendly_error(error: Any) -> str:
|
||||
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):
|
||||
@@ -382,6 +427,38 @@ def friendly_error(error: Any) -> str:
|
||||
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."""
|
||||
|
||||
@@ -762,6 +839,92 @@ class SortableTable(QTableWidget):
|
||||
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."""
|
||||
|
||||
@@ -861,51 +1024,19 @@ class BusinessPager(QWidget):
|
||||
self.page_changed.emit(page)
|
||||
|
||||
|
||||
class Pager(QWidget):
|
||||
page_changed = Signal(int)
|
||||
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__(parent)
|
||||
super().__init__(page_size, parent)
|
||||
self.setObjectName("Pager")
|
||||
self.page = 1
|
||||
self.page_size = page_size
|
||||
self.total = 0
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 4, 0, 0)
|
||||
self.summary = QLabel("共 0 条")
|
||||
self.summary.setProperty("role", "muted")
|
||||
layout.addWidget(self.summary)
|
||||
layout.addStretch(1)
|
||||
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.setObjectName("PagerActive")
|
||||
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:
|
||||
@@ -957,6 +1088,7 @@ __all__ = [
|
||||
"clear_layout",
|
||||
"display_text",
|
||||
"first_value",
|
||||
"format_record_time",
|
||||
"friendly_error",
|
||||
"get_value",
|
||||
"has_permission",
|
||||
|
||||
Reference in New Issue
Block a user