This commit is contained in:
Your Name
2026-08-22 08:51:35 +08:00
parent 6c444a4a04
commit c06d293424
69 changed files with 11431 additions and 1601 deletions
+174 -42
View File
@@ -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",