更新
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"""Zhenyang doctor workstation."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = False
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -400,16 +400,17 @@ class ApplicationController(QObject):
|
||||
"""Own windows, repositories and the authenticated application lifecycle."""
|
||||
|
||||
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
||||
super().__init__()
|
||||
self.application = application
|
||||
self.config = config
|
||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||
self.demo_repository = DemoDoctorRepository()
|
||||
super().__init__()
|
||||
self.application = application
|
||||
self.config = config
|
||||
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
||||
self.remote_repository: RemoteDoctorRepository | None = None
|
||||
self.login_window: LoginWindow | None = None
|
||||
self.shell_window: ShellWindow | None = None
|
||||
self.current_repository: Any = None
|
||||
self.current_demo_mode = config.demo_mode
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
@@ -505,10 +506,11 @@ class ApplicationController(QObject):
|
||||
if self.login_window is not None:
|
||||
self.login_window.config = self.config
|
||||
|
||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||
self.current_demo_mode = enabled
|
||||
if enabled:
|
||||
self._cancel_session_restore()
|
||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||
allowed = self.debug_mode and enabled
|
||||
self.current_demo_mode = allowed
|
||||
if allowed:
|
||||
self._cancel_session_restore()
|
||||
|
||||
def _rebuild_remote_repository(self) -> None:
|
||||
old = self.remote_repository
|
||||
@@ -668,7 +670,8 @@ class ApplicationController(QObject):
|
||||
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
||||
return
|
||||
|
||||
demo_mode = bool(payload.get("demo_mode"))
|
||||
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||
payload["demo_mode"] = demo_mode
|
||||
if not demo_mode and not session.menu:
|
||||
with suppress(Exception):
|
||||
repository.logout()
|
||||
@@ -1072,16 +1075,24 @@ class ApplicationController(QObject):
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Queue a normal application exit so owned resources are released."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
QTimer.singleShot(0, self.application.quit)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Invalidate asynchronous restoration and release owned resources."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
self.app_updater.shutdown()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
|
||||
@@ -14,6 +14,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from doctor_workstation import DEBUG_MODE, ONLINE_API_BASE_URL
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError: # pragma: no cover - optional during pure unit tests
|
||||
@@ -90,7 +92,8 @@ class AppConfig:
|
||||
"""Runtime configuration loaded from environment and user preferences."""
|
||||
|
||||
api_base_url: str = ""
|
||||
demo_mode: bool = True
|
||||
demo_mode: bool = False
|
||||
debug_mode: bool = DEBUG_MODE
|
||||
video_mode: str = "embedded"
|
||||
video_web_url: str = ""
|
||||
verify_ssl: bool = True
|
||||
@@ -115,18 +118,28 @@ class AppConfig:
|
||||
if load_dotenv is not None:
|
||||
load_dotenv(dotenv_path=env_file, override=False)
|
||||
|
||||
raw_url = os.getenv("DOCTOR_API_BASE_URL", "")
|
||||
debug_mode = bool(DEBUG_MODE)
|
||||
raw_url = (
|
||||
os.getenv("DOCTOR_API_BASE_URL", "") if debug_mode else ONLINE_API_BASE_URL
|
||||
)
|
||||
try:
|
||||
api_url = normalize_api_base_url(raw_url)
|
||||
except ValueError:
|
||||
except ValueError as error:
|
||||
if not debug_mode:
|
||||
raise ValueError("ONLINE_API_BASE_URL 必须是有效的 HTTP(S) 域名") from error
|
||||
api_url = ""
|
||||
if not debug_mode and not api_url:
|
||||
raise ValueError("正式模式下 ONLINE_API_BASE_URL 不能为空")
|
||||
|
||||
config = cls(
|
||||
api_base_url=api_url,
|
||||
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), True),
|
||||
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), False) if debug_mode else False,
|
||||
debug_mode=debug_mode,
|
||||
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
|
||||
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
|
||||
verify_ssl=_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True),
|
||||
verify_ssl=(
|
||||
_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True) if debug_mode else True
|
||||
),
|
||||
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
|
||||
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
|
||||
)
|
||||
@@ -137,7 +150,9 @@ class AppConfig:
|
||||
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return self
|
||||
allowed = {item.name for item in fields(self)}
|
||||
allowed = {item.name for item in fields(self)} - {"debug_mode"}
|
||||
if not self.debug_mode:
|
||||
allowed -= {"api_base_url", "demo_mode", "verify_ssl"}
|
||||
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
|
||||
if "api_base_url" in clean:
|
||||
try:
|
||||
@@ -159,12 +174,18 @@ class AppConfig:
|
||||
target = self.preferences_file
|
||||
temporary = target.with_suffix(".tmp")
|
||||
payload = asdict(self)
|
||||
payload.pop("debug_mode", None)
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
with suppress(OSError):
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(target)
|
||||
|
||||
def with_updates(self, **changes: Any) -> AppConfig:
|
||||
changes.pop("debug_mode", None)
|
||||
if not self.debug_mode:
|
||||
changes.pop("api_base_url", None)
|
||||
changes.pop("demo_mode", None)
|
||||
changes.pop("verify_ssl", None)
|
||||
if "api_base_url" in changes:
|
||||
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
|
||||
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1705,6 +1705,24 @@ class DemoDoctorRepository:
|
||||
}
|
||||
return deepcopy(dictionaries.get(dictionary_type, []))
|
||||
|
||||
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||
"""Demo mode never queues server-side chat notifications."""
|
||||
|
||||
return []
|
||||
|
||||
def get_dictionaries(
|
||||
self, dictionary_types: Sequence[str]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Mirror the remote batch dictionary contract used by readonly screens."""
|
||||
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
for dictionary_type in dictionary_types:
|
||||
clean = str(dictionary_type or "").strip()
|
||||
if not clean or clean in result:
|
||||
continue
|
||||
result[clean] = self.get_dictionary(clean)
|
||||
return result
|
||||
|
||||
def list_patients(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Patient]:
|
||||
|
||||
@@ -455,6 +455,14 @@ class DoctorRepository(Protocol):
|
||||
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
|
||||
"""Return one configuration dictionary."""
|
||||
|
||||
def get_dictionaries(
|
||||
self, dictionary_types: Sequence[str]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return several configuration dictionaries in one call."""
|
||||
|
||||
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||
"""Consume this account's queued chat notifications."""
|
||||
|
||||
def get_patient_order(self, order_id: int) -> dict[str, Any]:
|
||||
"""Return a patient-scoped order detail."""
|
||||
|
||||
@@ -1761,6 +1769,40 @@ class RemoteDoctorRepository:
|
||||
payload = self.client.get("config/dict", {"type": clean})
|
||||
return _dictionary_rows(payload, clean)
|
||||
|
||||
def get_dictionaries(
|
||||
self, dictionary_types: Sequence[str]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Load several dictionaries with one request.
|
||||
|
||||
``ConfigLogic::getDictByType`` splits ``type`` on commas and answers
|
||||
``{type_value: [rows...]}``, so readonly screens can translate every
|
||||
dictionary field without one round trip per type.
|
||||
"""
|
||||
|
||||
wanted = [
|
||||
str(dictionary_type or "").strip()
|
||||
for dictionary_type in dictionary_types
|
||||
if str(dictionary_type or "").strip()
|
||||
]
|
||||
ordered = list(dict.fromkeys(wanted))
|
||||
if not ordered:
|
||||
return {}
|
||||
payload = self.client.get("config/dict", {"type": ",".join(ordered)})
|
||||
return {
|
||||
dictionary_type: _dictionary_rows(payload, dictionary_type)
|
||||
for dictionary_type in ordered
|
||||
}
|
||||
|
||||
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||
"""Consume the queued chat notifications for the signed-in account.
|
||||
|
||||
与后台一致:``ChatController::notifications`` 取一次即消费(服务端删除缓存),
|
||||
所以每条通知只会被送到一个已登录的客户端一次,不能重复轮询后再补偿。
|
||||
"""
|
||||
|
||||
payload = self.client.get("chat/notifications")
|
||||
return _mapping_rows(payload)
|
||||
|
||||
def list_patients(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Patient]:
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""登录后常驻的聊天通知:患者打开会话、离开会话、面诊结束。
|
||||
|
||||
与后台 ``admin/src/components/chat-notify-toast`` 同一套合同:轮询
|
||||
``chat/notifications``(服务端取一次即消费),把结果堆成右上角卡片,点击卡片进入
|
||||
对应工作面,关闭按钮单独移除。桌面端额外做了一件网页做不到的事——新通知到达时让
|
||||
任务栏闪一下,医生切到别的窗口也能看见。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .widgets import display_text, first_value, run_async
|
||||
|
||||
# 后台 ChatNotifyLogic 写入的三种 type。
|
||||
PATIENT_OPENED_CHAT = "patient_opened_chat"
|
||||
PATIENT_LEFT_CHAT = "patient_left_chat"
|
||||
CONSULTATION_COMPLETE = "consultation_complete"
|
||||
|
||||
_TITLES: dict[str, str] = {
|
||||
PATIENT_OPENED_CHAT: "患者打开会话",
|
||||
PATIENT_LEFT_CHAT: "患者离开会话",
|
||||
CONSULTATION_COMPLETE: "面诊结束",
|
||||
}
|
||||
|
||||
_MAX_CARDS = 5
|
||||
_POLL_INTERVAL_MS = 5_000
|
||||
|
||||
CHAT_NOTIFICATION_QSS = """
|
||||
QFrame#ChatNotifyCard {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #BBF0CE;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; }
|
||||
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; }
|
||||
QLabel#ChatNotifyBadge {
|
||||
min-width: 34px;
|
||||
max-width: 34px;
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
color: #FFFFFF;
|
||||
background-color: #22C55E;
|
||||
border-radius: 9px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#ChatNotifyBadge[kind="left"] { background-color: #8A94B3; }
|
||||
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #3B82F6; }
|
||||
QLabel#ChatNotifyTitle { color: #1F2A44; font-size: 13px; font-weight: 700; }
|
||||
QLabel#ChatNotifyDesc { color: #4A5878; font-size: 12px; }
|
||||
QLabel#ChatNotifyTime { color: #8A94B3; font-size: 11px; }
|
||||
QPushButton#ChatNotifyOpen {
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
color: #3F4E75;
|
||||
background-color: #F4F6FC;
|
||||
border: 1px solid #DDE3F2;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton#ChatNotifyOpen:hover {
|
||||
color: #4451E2;
|
||||
background-color: #EEF1FF;
|
||||
border-color: #8D9BFF;
|
||||
}
|
||||
QPushButton#ChatNotifyClose {
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
color: #8A94B3;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; }
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatNotification:
|
||||
"""One queued notification, normalised from the admin payload."""
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
patient_name: str
|
||||
patient_id: str
|
||||
doctor_name: str
|
||||
diagnosis_id: int
|
||||
created_at: int
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return _TITLES.get(self.kind, "系统通知")
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
patient = self.patient_name or "患者"
|
||||
if self.kind == CONSULTATION_COMPLETE:
|
||||
doctor = self.doctor_name or "医生"
|
||||
return f"{patient} 的面诊已由 {doctor} 完成,请及时跟进"
|
||||
if self.kind == PATIENT_LEFT_CHAT:
|
||||
return f"{patient} 已离开问诊会话页面"
|
||||
return f"{patient} 已打开与您的会话,请及时查看"
|
||||
|
||||
@property
|
||||
def action_text(self) -> str:
|
||||
if self.kind == CONSULTATION_COMPLETE:
|
||||
return "查看诊单"
|
||||
if self.kind == PATIENT_OPENED_CHAT:
|
||||
return "去接诊台"
|
||||
return ""
|
||||
|
||||
|
||||
def parse_notification(row: Any) -> ChatNotification | None:
|
||||
"""Normalise one server row; unknown or id-less rows are dropped."""
|
||||
|
||||
if not isinstance(row, Mapping):
|
||||
return None
|
||||
identifier = str(first_value(row, "id", default="") or "").strip()
|
||||
kind = str(first_value(row, "type", default=PATIENT_OPENED_CHAT) or "").strip()
|
||||
if not identifier or kind not in _TITLES:
|
||||
return None
|
||||
try:
|
||||
diagnosis_id = int(first_value(row, "diagnosis_id", default=0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
diagnosis_id = 0
|
||||
try:
|
||||
created_at = int(first_value(row, "created_at", default=0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
created_at = 0
|
||||
return ChatNotification(
|
||||
id=identifier,
|
||||
kind=kind,
|
||||
patient_name=display_text(first_value(row, "patient_name"), ""),
|
||||
patient_id=str(first_value(row, "patient_id", default="") or "").strip(),
|
||||
doctor_name=display_text(first_value(row, "doctor_name"), ""),
|
||||
diagnosis_id=diagnosis_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def relative_time(created_at: int, *, now: float | None = None) -> str:
|
||||
"""Match the admin card's 刚刚 / N 分钟前 wording."""
|
||||
|
||||
if not created_at:
|
||||
return ""
|
||||
current = datetime.now().timestamp() if now is None else now
|
||||
delta = max(0, int(current - created_at))
|
||||
if delta < 60:
|
||||
return "刚刚"
|
||||
if delta < 3600:
|
||||
return f"{delta // 60} 分钟前"
|
||||
if delta < 86400:
|
||||
return f"{delta // 3600} 小时前"
|
||||
return datetime.fromtimestamp(created_at).strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
class _NotificationCard(QFrame):
|
||||
"""One dismissible card; the whole surface is a click target."""
|
||||
|
||||
opened = Signal(str)
|
||||
dismissed = Signal(str)
|
||||
|
||||
def __init__(self, notification: ChatNotification, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.notification = notification
|
||||
self.setObjectName("ChatNotifyCard")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
kind = (
|
||||
"complete"
|
||||
if notification.kind == CONSULTATION_COMPLETE
|
||||
else "left"
|
||||
if notification.kind == PATIENT_LEFT_CHAT
|
||||
else "opened"
|
||||
)
|
||||
self.setProperty("kind", kind)
|
||||
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(13, 11, 10, 12)
|
||||
root.setSpacing(11)
|
||||
badge = QLabel("✓" if kind == "complete" else "→" if kind == "left" else "话")
|
||||
badge.setObjectName("ChatNotifyBadge")
|
||||
badge.setProperty("kind", kind)
|
||||
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
root.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
body = QVBoxLayout()
|
||||
body.setContentsMargins(0, 0, 0, 0)
|
||||
body.setSpacing(3)
|
||||
title = QLabel(notification.title)
|
||||
title.setObjectName("ChatNotifyTitle")
|
||||
body.addWidget(title)
|
||||
description = QLabel(notification.description)
|
||||
description.setObjectName("ChatNotifyDesc")
|
||||
description.setWordWrap(True)
|
||||
body.addWidget(description)
|
||||
self.time_label = QLabel(relative_time(notification.created_at))
|
||||
self.time_label.setObjectName("ChatNotifyTime")
|
||||
body.addWidget(self.time_label)
|
||||
if notification.action_text:
|
||||
actions = QHBoxLayout()
|
||||
actions.setContentsMargins(0, 4, 0, 0)
|
||||
actions.setSpacing(8)
|
||||
self.open_button = QPushButton(notification.action_text)
|
||||
self.open_button.setObjectName("ChatNotifyOpen")
|
||||
self.open_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.open_button.clicked.connect(lambda: self.opened.emit(self.notification.id))
|
||||
actions.addWidget(self.open_button)
|
||||
actions.addStretch(1)
|
||||
body.addLayout(actions)
|
||||
root.addLayout(body, 1)
|
||||
|
||||
self.close_button = QPushButton("×")
|
||||
self.close_button.setObjectName("ChatNotifyClose")
|
||||
self.close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.close_button.setToolTip("忽略这条通知")
|
||||
self.close_button.clicked.connect(lambda: self.dismissed.emit(self.notification.id))
|
||||
root.addWidget(self.close_button, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
def refresh_time(self) -> None:
|
||||
self.time_label.setText(relative_time(self.notification.created_at))
|
||||
|
||||
def mouseReleaseEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.opened.emit(self.notification.id)
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class ChatNotificationCenter(QWidget):
|
||||
"""Polls the server queue and stacks cards over the shell's top-right corner."""
|
||||
|
||||
notification_activated = Signal(object)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
host: QWidget,
|
||||
*,
|
||||
interval_ms: int = _POLL_INTERVAL_MS,
|
||||
) -> None:
|
||||
super().__init__(host)
|
||||
self.repository = repository
|
||||
self.setObjectName("ChatNotifyLayer")
|
||||
self.setStyleSheet(CHAT_NOTIFICATION_QSS)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||
self._cards: dict[str, _NotificationCard] = {}
|
||||
self._pending: list[ChatNotification] = []
|
||||
self._loading = False
|
||||
self._generation = 0
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
self._layout = layout
|
||||
self.hide()
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(max(1_000, int(interval_ms)))
|
||||
self._timer.timeout.connect(self.poll)
|
||||
self._clock = QTimer(self)
|
||||
self._clock.setInterval(30_000)
|
||||
self._clock.timeout.connect(self._refresh_times)
|
||||
host.installEventFilter(self)
|
||||
|
||||
# ----- polling ------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
self._clock.start()
|
||||
self.poll()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._generation += 1
|
||||
self._timer.stop()
|
||||
self._clock.stop()
|
||||
|
||||
def poll(self) -> None:
|
||||
"""Ask for queued notifications; overlapping polls are skipped.
|
||||
|
||||
服务端取一次即消费,重叠请求会让通知丢在被丢弃的那次响应里。
|
||||
"""
|
||||
|
||||
if self._loading:
|
||||
return
|
||||
fetch = getattr(self.repository, "list_chat_notifications", None)
|
||||
if not callable(fetch):
|
||||
return
|
||||
self._loading = True
|
||||
generation = self._generation
|
||||
run_async(
|
||||
fetch,
|
||||
on_success=lambda rows: self._loaded(generation, rows),
|
||||
on_error=lambda _error: None, # 静默失败,避免打断问诊
|
||||
on_finished=self._finished,
|
||||
)
|
||||
|
||||
def _finished(self) -> None:
|
||||
self._loading = False
|
||||
|
||||
def _loaded(self, generation: int, rows: Any) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
self.add_notifications(rows)
|
||||
|
||||
# ----- cards --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def pending(self) -> list[ChatNotification]:
|
||||
return list(self._pending)
|
||||
|
||||
def add_notifications(self, rows: Any) -> int:
|
||||
"""Show new cards; returns how many were actually added."""
|
||||
|
||||
if isinstance(rows, Mapping) or not isinstance(rows, Sequence):
|
||||
candidates: Sequence[Any] = [rows]
|
||||
else:
|
||||
candidates = rows
|
||||
added = 0
|
||||
for row in candidates:
|
||||
notification = parse_notification(row)
|
||||
if notification is None or notification.id in self._cards:
|
||||
continue
|
||||
self._add_card(notification)
|
||||
added += 1
|
||||
if added:
|
||||
self._trim()
|
||||
self._relayout()
|
||||
self._alert_taskbar()
|
||||
return added
|
||||
|
||||
def _add_card(self, notification: ChatNotification) -> None:
|
||||
card = _NotificationCard(notification, self)
|
||||
card.opened.connect(self._activate)
|
||||
card.dismissed.connect(self.dismiss)
|
||||
self._cards[notification.id] = card
|
||||
self._pending.insert(0, notification)
|
||||
self._layout.insertWidget(0, card)
|
||||
|
||||
def _trim(self) -> None:
|
||||
while len(self._pending) > _MAX_CARDS:
|
||||
self.dismiss(self._pending[-1].id, relayout=False)
|
||||
|
||||
def dismiss(self, notification_id: str, *, relayout: bool = True) -> None:
|
||||
card = self._cards.pop(str(notification_id), None)
|
||||
self._pending = [item for item in self._pending if item.id != str(notification_id)]
|
||||
if card is not None:
|
||||
self._layout.removeWidget(card)
|
||||
card.hide()
|
||||
card.deleteLater()
|
||||
if relayout:
|
||||
self._relayout()
|
||||
|
||||
def clear(self) -> None:
|
||||
for notification_id in list(self._cards):
|
||||
self.dismiss(notification_id, relayout=False)
|
||||
self._relayout()
|
||||
|
||||
def _activate(self, notification_id: str) -> None:
|
||||
card = self._cards.get(str(notification_id))
|
||||
if card is None:
|
||||
return
|
||||
notification = card.notification
|
||||
self.dismiss(notification_id)
|
||||
self.notification_activated.emit(notification)
|
||||
|
||||
def _refresh_times(self) -> None:
|
||||
for card in self._cards.values():
|
||||
card.refresh_time()
|
||||
|
||||
def _alert_taskbar(self) -> None:
|
||||
"""Flash the taskbar entry when the doctor is working in another window."""
|
||||
|
||||
application = QApplication.instance()
|
||||
window = self.window()
|
||||
if application is None or window is None or window.isActiveWindow():
|
||||
return
|
||||
application.alert(window, 3_000)
|
||||
|
||||
# ----- placement ----------------------------------------------------
|
||||
|
||||
def _relayout(self) -> None:
|
||||
if not self._cards:
|
||||
self.hide()
|
||||
return
|
||||
host = self.parentWidget()
|
||||
if host is None:
|
||||
return
|
||||
width = min(360, max(260, host.width() - 48))
|
||||
self.setFixedWidth(width)
|
||||
self.adjustSize()
|
||||
self.move(max(12, host.width() - width - 24), 74)
|
||||
self.show()
|
||||
self.raise_()
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt virtual
|
||||
if watched is self.parentWidget() and event.type() in {
|
||||
QEvent.Type.Resize,
|
||||
QEvent.Type.Show,
|
||||
}:
|
||||
self._relayout()
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CHAT_NOTIFICATION_QSS",
|
||||
"CONSULTATION_COMPLETE",
|
||||
"PATIENT_LEFT_CHAT",
|
||||
"PATIENT_OPENED_CHAT",
|
||||
"ChatNotification",
|
||||
"ChatNotificationCenter",
|
||||
"parse_notification",
|
||||
"relative_time",
|
||||
]
|
||||
@@ -12,7 +12,19 @@ from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QDate, QPointF, QRect, QRectF, QSize, Qt, QThread, QTimer, QUrl, Signal
|
||||
from PySide6.QtCore import (
|
||||
QDate,
|
||||
QEvent,
|
||||
QPointF,
|
||||
QRect,
|
||||
QRectF,
|
||||
QSize,
|
||||
Qt,
|
||||
QThread,
|
||||
QTimer,
|
||||
QUrl,
|
||||
Signal,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QHideEvent,
|
||||
@@ -23,7 +35,7 @@ from PySide6.QtGui import (
|
||||
QResizeEvent,
|
||||
QShowEvent,
|
||||
)
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QAbstractItemView,
|
||||
@@ -50,6 +62,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .diagnosis_editors import FlowLayout
|
||||
from .diagnosis_media import shared_image_manager
|
||||
from .diagnosis_terms import FIELD_DICTIONARIES, format_timestamp, shared_terms
|
||||
|
||||
DIAGNOSIS_QSS = r"""
|
||||
QDialog#DiagnosisDialogRoot {
|
||||
@@ -2467,7 +2481,7 @@ class _RemoteImageButton(QPushButton):
|
||||
self._fallback_size = QSize(maximum_size) if cover else QSize(132, 64)
|
||||
self._fallback_text = fallback_text
|
||||
self._cover = cover
|
||||
self._manager = QNetworkAccessManager(self)
|
||||
self._manager = shared_image_manager(self)
|
||||
self._reply: QNetworkReply | None = None
|
||||
self._generation = 0
|
||||
self._rendered_pixmap = QPixmap()
|
||||
@@ -2508,6 +2522,18 @@ class _RemoteImageButton(QPushButton):
|
||||
reply.deleteLater()
|
||||
return self._generation
|
||||
|
||||
def abort_pending_request(self) -> None:
|
||||
"""Let an owner stop this download before it tears the thumbnail down."""
|
||||
|
||||
self._invalidate_request()
|
||||
|
||||
def event(self, event: QEvent) -> bool: # noqa: N802 - Qt virtual
|
||||
# Nothing is left to paint once the thumbnail is being deleted, so stop
|
||||
# the download instead of letting it run against a dying widget.
|
||||
if event.type() in {QEvent.Type.DeferredDelete, QEvent.Type.Close}:
|
||||
self._invalidate_request()
|
||||
return super().event(event)
|
||||
|
||||
def load_url(self, source: str) -> None:
|
||||
self._source = str(source).strip()
|
||||
generation = self._invalidate_request()
|
||||
@@ -3116,6 +3142,8 @@ class CaseGrid(QFrame):
|
||||
"2": "复诊",
|
||||
}.get(diagnosis_type, diagnosis_type or "病例")
|
||||
diagnosis_date = _pick(diagnosis, "diagnosis_date", "create_time", default="—")
|
||||
# create_time 是 Unix 秒级时间戳,直接显示会变成一串数字。
|
||||
diagnosis_date = format_timestamp(diagnosis_date, with_time=False) or diagnosis_date
|
||||
self.subtitle.setText(f"{type_text} · 诊断日期 {diagnosis_date}")
|
||||
for key, labels in self.value_labels.items():
|
||||
raw = _pick(
|
||||
@@ -3152,6 +3180,9 @@ class CaseGrid(QFrame):
|
||||
"pregnancy_history",
|
||||
}:
|
||||
rendered = {"0": "无", "1": "有"}.get(normalized, rendered)
|
||||
elif key in FIELD_DICTIONARIES and raw not in (None, ""):
|
||||
# 后端只读接口会补 `<field>_text`;快照类数据没有时按字典翻译。
|
||||
rendered = shared_terms().dictionary_label(key, raw) or rendered
|
||||
elif key == "age" and raw not in (None, ""):
|
||||
rendered = f"{rendered}岁"
|
||||
elif key == "height" and raw not in (None, ""):
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Safe in-application replay players for diagnosis call recordings.
|
||||
"""Safe in-application viewers for diagnosis recordings and attachment images.
|
||||
|
||||
The web diagnosis page keeps the preferred recording in the table and lists
|
||||
the remaining sources underneath it. This module mirrors that contract with
|
||||
QtMultimedia while retaining the older standalone dialog as a codec fallback.
|
||||
Attachment images follow the same rule: they are previewed inside the
|
||||
workstation like the admin ``el-image`` viewer, never handed straight to the
|
||||
operating system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,14 +15,16 @@ import weakref
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, QUrl, Signal
|
||||
from PySide6.QtGui import QCloseEvent, QDesktopServices
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QSize, Qt, QUrl, Signal
|
||||
from PySide6.QtGui import QCloseEvent, QDesktopServices, QKeyEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QStackedLayout,
|
||||
@@ -656,6 +661,449 @@ class RecordingPlayerDialog(QDialog):
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def shared_image_manager(fallback_parent: QObject | None = None) -> QNetworkAccessManager:
|
||||
"""Return the one image download manager owned by the application.
|
||||
|
||||
每个缩略图/预览窗口原来各自持有 ``QNetworkAccessManager``:关闭抽屉或对话框
|
||||
时,网络栈会连同尚未结束的请求一起析构,Windows 上直接以 0xC0000374 结束
|
||||
进程。改为由 QApplication 持有唯一管理器,请求可以比任何控件活得更久。
|
||||
"""
|
||||
|
||||
app = QCoreApplication.instance()
|
||||
if app is None:
|
||||
return QNetworkAccessManager(fallback_parent)
|
||||
manager = getattr(app, "_doctor_image_network_manager", None)
|
||||
if isinstance(manager, QNetworkAccessManager):
|
||||
try:
|
||||
manager.parent()
|
||||
except RuntimeError: # the C++ object was destroyed behind the wrapper
|
||||
manager = None
|
||||
if not isinstance(manager, QNetworkAccessManager):
|
||||
manager = QNetworkAccessManager(app)
|
||||
app._doctor_image_network_manager = manager
|
||||
return manager
|
||||
|
||||
|
||||
_IMAGE_SUFFIX_PATTERN = re.compile(
|
||||
r"\.(?:png|jpe?g|jfif|gif|bmp|webp|tiff?|heic|heif|avif)(?:\?|#|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_previewable_image(target: str) -> bool:
|
||||
"""Return whether a safe remote source can be decoded as an inline image."""
|
||||
|
||||
url = safe_http_url(target)
|
||||
if url is None:
|
||||
return False
|
||||
return bool(
|
||||
_IMAGE_SUFFIX_PATTERN.search(url.path() or "")
|
||||
or _IMAGE_SUFFIX_PATTERN.search(url.toString())
|
||||
)
|
||||
|
||||
|
||||
def safe_image_sources(sources: Sequence[Any] | Any) -> list[str]:
|
||||
"""Keep the ordered HTTP(S) sources a preview window can actually fetch."""
|
||||
|
||||
return [
|
||||
target
|
||||
for target in normalize_recording_urls(sources)
|
||||
if safe_http_url(target) is not None
|
||||
]
|
||||
|
||||
|
||||
def image_display_name(target: str, ordinal: int) -> str:
|
||||
"""Name an attachment from its own path so previews never invent a title."""
|
||||
|
||||
url = safe_http_url(target)
|
||||
name = ((url.path() if url is not None else "") or "").rsplit("/", 1)[-1].strip()
|
||||
return name or f"图片 {ordinal}"
|
||||
|
||||
|
||||
_IMAGE_PREVIEW_QSS = """
|
||||
QDialog#DiagnosisImagePreview { background: #FFFFFF; }
|
||||
QLabel#DiagnosisImagePreviewName { color: #1F2A44; font-size: 14px; font-weight: 600; }
|
||||
QLabel#DiagnosisImagePreviewCounter { color: #64739A; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus { color: #64739A; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #C0392B; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #9A650F; }
|
||||
QScrollArea#DiagnosisImagePreviewViewport {
|
||||
background: #11182E;
|
||||
border: 1px solid #E6EAF5;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel#DiagnosisImagePreviewCanvas {
|
||||
background: #11182E;
|
||||
color: #C7D0E8;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"] {
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
color: #3F4E75;
|
||||
background: #FAFBFE;
|
||||
border: 1px solid #D8DEEE;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"]:hover,
|
||||
QPushButton[imagePreviewControl="true"]:focus {
|
||||
color: #4451E2;
|
||||
background: #F0F2FF;
|
||||
border-color: #8D9BFF;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
background: #F0F2F8;
|
||||
border-color: #E6EAF5;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ImagePreviewDialog(QDialog):
|
||||
"""In-window lightbox for safe HTTP(S) attachment images.
|
||||
|
||||
后台用 ``el-image`` + ``preview-src-list`` 直接在页面内放大舌象与报告图片,
|
||||
工作站此前只能把地址交给系统浏览器。这里保持同一合同:医生留在工作站内
|
||||
翻看整组附件,必要时才手动安全外部打开。
|
||||
"""
|
||||
|
||||
_MAX_IMAGE_BYTES = 12 * 1024 * 1024
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sources: Sequence[Any] | Any,
|
||||
*,
|
||||
index: int = 0,
|
||||
names: Sequence[Any] | None = None,
|
||||
title: str = "图片预览",
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
if isinstance(sources, (str, bytes, bytearray)) or not isinstance(sources, Sequence):
|
||||
candidates: Sequence[Any] = [sources]
|
||||
else:
|
||||
candidates = sources
|
||||
ordered = [str(candidate or "").strip() for candidate in candidates]
|
||||
labels = [str(label or "").strip() for label in (names or [])]
|
||||
requested = ordered[index] if 0 <= index < len(ordered) else ""
|
||||
self._sources: list[str] = []
|
||||
self._names: list[str] = []
|
||||
for position, target in enumerate(ordered):
|
||||
if not target or target in self._sources or safe_http_url(target) is None:
|
||||
continue
|
||||
label = labels[position] if position < len(labels) else ""
|
||||
self._sources.append(target)
|
||||
self._names.append(label or image_display_name(target, len(self._sources)))
|
||||
self._index = self._sources.index(requested) if requested in self._sources else 0
|
||||
self._cache: dict[str, QPixmap] = {}
|
||||
self._pixmap = QPixmap()
|
||||
self._fit = True
|
||||
self._generation = 0
|
||||
self._reply: QNetworkReply | None = None
|
||||
self._manager = shared_image_manager(self)
|
||||
|
||||
self.setObjectName("DiagnosisImagePreview")
|
||||
self.setWindowTitle(title)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
self.setStyleSheet(_IMAGE_PREVIEW_QSS)
|
||||
self.resize(880, 660)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(18, 16, 18, 16)
|
||||
root.setSpacing(10)
|
||||
header = QHBoxLayout()
|
||||
header.setSpacing(10)
|
||||
self.name_label = QLabel(title)
|
||||
self.name_label.setObjectName("DiagnosisImagePreviewName")
|
||||
self.name_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
header.addWidget(self.name_label, 1)
|
||||
self.counter = QLabel("")
|
||||
self.counter.setObjectName("DiagnosisImagePreviewCounter")
|
||||
header.addWidget(self.counter, 0, Qt.AlignmentFlag.AlignRight)
|
||||
root.addLayout(header)
|
||||
|
||||
self.viewport = QScrollArea()
|
||||
self.viewport.setObjectName("DiagnosisImagePreviewViewport")
|
||||
self.viewport.setWidgetResizable(True)
|
||||
self.viewport.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.canvas = QLabel("正在加载图片…")
|
||||
self.canvas.setObjectName("DiagnosisImagePreviewCanvas")
|
||||
self.canvas.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.canvas.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
self.viewport.setWidget(self.canvas)
|
||||
root.addWidget(self.viewport, 1)
|
||||
|
||||
self.status = QLabel("")
|
||||
self.status.setObjectName("DiagnosisImagePreviewStatus")
|
||||
self.status.setWordWrap(True)
|
||||
self.status.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
root.addWidget(self.status)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(8)
|
||||
self.previous_button = QPushButton("上一张")
|
||||
self.previous_button.setProperty("imagePreviewControl", True)
|
||||
self.previous_button.clicked.connect(lambda: self.step(-1))
|
||||
controls.addWidget(self.previous_button)
|
||||
self.next_button = QPushButton("下一张")
|
||||
self.next_button.setProperty("imagePreviewControl", True)
|
||||
self.next_button.clicked.connect(lambda: self.step(1))
|
||||
controls.addWidget(self.next_button)
|
||||
controls.addStretch(1)
|
||||
self.zoom_button = QPushButton("原始大小")
|
||||
self.zoom_button.setProperty("imagePreviewControl", True)
|
||||
self.zoom_button.setToolTip("在适应窗口与原始像素之间切换")
|
||||
self.zoom_button.clicked.connect(self.toggle_zoom)
|
||||
controls.addWidget(self.zoom_button)
|
||||
self.reload_button = QPushButton("重新加载")
|
||||
self.reload_button.setProperty("imagePreviewControl", True)
|
||||
self.reload_button.clicked.connect(self.reload_current)
|
||||
controls.addWidget(self.reload_button)
|
||||
self.external_button = QPushButton("安全外部打开")
|
||||
self.external_button.setProperty("imagePreviewControl", True)
|
||||
self.external_button.clicked.connect(self._open_external)
|
||||
controls.addWidget(self.external_button)
|
||||
self.close_button = QPushButton("关闭")
|
||||
self.close_button.setProperty("imagePreviewControl", True)
|
||||
self.close_button.clicked.connect(self.close)
|
||||
controls.addWidget(self.close_button)
|
||||
root.addLayout(controls)
|
||||
|
||||
if not self._sources:
|
||||
self.canvas.setText("附件地址无效:仅支持包含主机名的 HTTP(S) 图片。")
|
||||
self.status.setText("没有可在工作站内预览的图片。")
|
||||
self.status.setProperty("kind", "danger")
|
||||
for button in (
|
||||
self.previous_button,
|
||||
self.next_button,
|
||||
self.zoom_button,
|
||||
self.reload_button,
|
||||
self.external_button,
|
||||
):
|
||||
button.setEnabled(False)
|
||||
return
|
||||
self.show_index(self._index)
|
||||
|
||||
@property
|
||||
def sources(self) -> list[str]:
|
||||
return list(self._sources)
|
||||
|
||||
@property
|
||||
def current_source(self) -> str:
|
||||
return self._sources[self._index] if self._sources else ""
|
||||
|
||||
def has_images(self) -> bool:
|
||||
return bool(self._sources)
|
||||
|
||||
def show_index(self, index: int) -> None:
|
||||
"""Move to one attachment, serving an already decoded image from cache."""
|
||||
|
||||
if not self._sources:
|
||||
return
|
||||
self._index = max(0, min(int(index), len(self._sources) - 1))
|
||||
target = self._sources[self._index]
|
||||
total = len(self._sources)
|
||||
self.name_label.setText(self._names[self._index])
|
||||
self.name_label.setToolTip(target)
|
||||
self.counter.setText(f"第 {self._index + 1} / {total} 张")
|
||||
self.previous_button.setEnabled(total > 1)
|
||||
self.next_button.setEnabled(total > 1)
|
||||
cached = self._cache.get(target)
|
||||
if cached is not None and not cached.isNull():
|
||||
self._invalidate_request()
|
||||
self._pixmap = cached
|
||||
self._set_status(target)
|
||||
self._render()
|
||||
return
|
||||
self._pixmap = QPixmap()
|
||||
self.canvas.setPixmap(QPixmap())
|
||||
self.canvas.setMinimumSize(0, 0)
|
||||
self.canvas.setText("正在加载图片…")
|
||||
self._set_status(target)
|
||||
self._request(target)
|
||||
|
||||
def step(self, delta: int) -> None:
|
||||
if len(self._sources) < 2:
|
||||
return
|
||||
self.show_index((self._index + int(delta)) % len(self._sources))
|
||||
|
||||
def reload_current(self) -> None:
|
||||
target = self.current_source
|
||||
if not target:
|
||||
return
|
||||
self._cache.pop(target, None)
|
||||
self.show_index(self._index)
|
||||
|
||||
def toggle_zoom(self) -> None:
|
||||
self._fit = not self._fit
|
||||
self.zoom_button.setText("原始大小" if self._fit else "适应窗口")
|
||||
self._render()
|
||||
|
||||
def _invalidate_request(self) -> int:
|
||||
self._generation += 1
|
||||
reply, self._reply = self._reply, None
|
||||
if reply is not None:
|
||||
reply.abort()
|
||||
reply.deleteLater()
|
||||
return self._generation
|
||||
|
||||
def _request(self, target: str) -> None:
|
||||
generation = self._invalidate_request()
|
||||
url = safe_http_url(target)
|
||||
if url is None:
|
||||
self._fail("附件地址无效:仅支持包含主机名的 HTTP(S) 图片。")
|
||||
return
|
||||
request = QNetworkRequest(url)
|
||||
request.setTransferTimeout(15_000)
|
||||
request.setMaximumRedirectsAllowed(4)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
||||
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy,
|
||||
)
|
||||
reply = self._manager.get(request)
|
||||
self._reply = reply
|
||||
reply.setProperty("imagePreviewGeneration", generation)
|
||||
reply.setProperty("imagePreviewOversize", False)
|
||||
reply.downloadProgress.connect(self._download_progress)
|
||||
reply.finished.connect(self._reply_finished)
|
||||
|
||||
def _download_progress(self, bytes_received: int, bytes_total: int) -> None:
|
||||
"""Abort as soon as a received or declared size exceeds the safety cap."""
|
||||
|
||||
reply = self.sender()
|
||||
if reply is not self._reply:
|
||||
return
|
||||
if bytes_received <= self._MAX_IMAGE_BYTES and (
|
||||
bytes_total < 0 or bytes_total <= self._MAX_IMAGE_BYTES
|
||||
):
|
||||
return
|
||||
reply.setProperty("imagePreviewOversize", True)
|
||||
reply.abort()
|
||||
|
||||
def _reply_finished(self) -> None:
|
||||
"""Use a QObject receiver connection so destruction disconnects this slot."""
|
||||
|
||||
reply = self.sender()
|
||||
if reply is None:
|
||||
return
|
||||
try:
|
||||
generation = int(reply.property("imagePreviewGeneration"))
|
||||
except (TypeError, ValueError):
|
||||
reply.deleteLater()
|
||||
return
|
||||
if reply is not self._reply or generation != self._generation:
|
||||
reply.deleteLater()
|
||||
return
|
||||
self._reply = None
|
||||
oversize = bool(reply.property("imagePreviewOversize"))
|
||||
error = reply.error()
|
||||
payload = b"" if oversize else bytes(reply.readAll())
|
||||
reply.deleteLater()
|
||||
if oversize:
|
||||
self._fail("图片超过 12 MB 安全上限,已停止加载,可安全外部打开。")
|
||||
return
|
||||
if error != QNetworkReply.NetworkError.NoError:
|
||||
self._fail("图片加载失败,可重新加载或安全外部打开。")
|
||||
return
|
||||
self.apply_payload(payload, generation)
|
||||
|
||||
def apply_payload(self, payload: bytes, generation: int | None = None) -> bool:
|
||||
"""Decode one response; kept public so offline tests can exercise rendering."""
|
||||
|
||||
if generation is not None and generation != self._generation:
|
||||
return False
|
||||
pixmap = QPixmap()
|
||||
if (
|
||||
not payload
|
||||
or len(payload) > self._MAX_IMAGE_BYTES
|
||||
or not pixmap.loadFromData(payload)
|
||||
or pixmap.isNull()
|
||||
):
|
||||
self._fail("图片格式不受支持,无法在工作站内预览。")
|
||||
return False
|
||||
self._cache[self.current_source] = pixmap
|
||||
self._pixmap = pixmap
|
||||
self._set_status(self.current_source)
|
||||
self._render()
|
||||
return True
|
||||
|
||||
def _set_status(self, text: str, kind: str = "") -> None:
|
||||
"""Keep the source address visible; tint it only when something failed."""
|
||||
|
||||
self.status.setText(text)
|
||||
self.status.setProperty("kind", kind)
|
||||
self._repolish_status()
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self._pixmap = QPixmap()
|
||||
self.canvas.setPixmap(QPixmap())
|
||||
self.canvas.setMinimumSize(0, 0)
|
||||
self.canvas.setText(message)
|
||||
self._set_status(self.current_source or message, "warning")
|
||||
|
||||
def _repolish_status(self) -> None:
|
||||
style = self.status.style()
|
||||
style.unpolish(self.status)
|
||||
style.polish(self.status)
|
||||
|
||||
def _render(self) -> None:
|
||||
if self._pixmap.isNull():
|
||||
return
|
||||
self.canvas.setText("")
|
||||
available = self.viewport.viewport().size() - QSize(10, 10)
|
||||
if self._fit and (
|
||||
self._pixmap.width() > available.width()
|
||||
or self._pixmap.height() > available.height()
|
||||
):
|
||||
rendered = self._pixmap.scaled(
|
||||
available,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
else:
|
||||
rendered = self._pixmap
|
||||
self.canvas.setMinimumSize(QSize(0, 0) if self._fit else rendered.size())
|
||||
self.canvas.setPixmap(rendered)
|
||||
|
||||
def _open_external(self) -> None:
|
||||
if not open_safe_http_url(self.current_source):
|
||||
self._set_status("系统未能打开该安全外部链接。", "danger")
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt virtual
|
||||
super().resizeEvent(event)
|
||||
if self._fit:
|
||||
self._render()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 - Qt virtual
|
||||
key = event.key()
|
||||
if key in {Qt.Key.Key_Left, Qt.Key.Key_Up, Qt.Key.Key_PageUp}:
|
||||
self.step(-1)
|
||||
event.accept()
|
||||
return
|
||||
if key in {
|
||||
Qt.Key.Key_Right,
|
||||
Qt.Key.Key_Down,
|
||||
Qt.Key.Key_PageDown,
|
||||
Qt.Key.Key_Space,
|
||||
}:
|
||||
self.step(1)
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual
|
||||
self._invalidate_request()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def _clock(milliseconds: int) -> str:
|
||||
seconds = max(0, int(milliseconds) // 1000)
|
||||
return f"{seconds // 60:02d}:{seconds % 60:02d}"
|
||||
@@ -663,13 +1111,18 @@ def _clock(milliseconds: int) -> str:
|
||||
|
||||
__all__ = [
|
||||
"MULTIMEDIA_AVAILABLE",
|
||||
"ImagePreviewDialog",
|
||||
"InlineRecordingPlayer",
|
||||
"RecordingPlaybackCell",
|
||||
"RecordingPlayerDialog",
|
||||
"alternate_recording_label",
|
||||
"image_display_name",
|
||||
"is_previewable_image",
|
||||
"normalize_recording_urls",
|
||||
"open_safe_http_url",
|
||||
"preferred_recording_url",
|
||||
"safe_http_url",
|
||||
"safe_image_sources",
|
||||
"shared_image_manager",
|
||||
"should_inline_recording",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
"""诊单字段的中文翻译:字典项、枚举与时间戳。
|
||||
|
||||
后台 ``AppointmentLogic::enrichDiagnosisLabels`` 会给只读接口补一份
|
||||
``<field>_text``,所以读取时永远优先用它(与 admin 的 ``makeTextOf`` 同一约定)。
|
||||
处方快照一类的历史数据没有这些字段,就按字段所属字典把 code 翻成中文:字典优先
|
||||
取仓储实时下发的 ``config/dict``,缺失时回退到与
|
||||
``server/sql/present_illness_dict_data.sql`` / ``tcm_diagnosis.sql`` 一致的内置种子。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
# 与 AppointmentLogic::enrichDiagnosisLabels 的 $singleDictFields 保持一致。
|
||||
SINGLE_VALUE_DICTIONARIES: dict[str, str] = {
|
||||
"diagnosis_type": "diagnosis_type",
|
||||
"syndrome_type": "syndrome_type",
|
||||
"diabetes_type": "diabetes_type",
|
||||
"water_intake": "water_intake",
|
||||
"weight_change": "weight_change",
|
||||
"fatty_liver_degree": "fatty_liver_degree",
|
||||
}
|
||||
|
||||
# 与 $multiDictFields 保持一致:值是数组或逗号/顿号分隔的字符串。
|
||||
MULTI_VALUE_DICTIONARIES: dict[str, str] = {
|
||||
"past_history": "past_history",
|
||||
"appetite": "appetite",
|
||||
"diet_condition": "diet_condition",
|
||||
"body_feeling": "body_feeling",
|
||||
"sleep_condition": "sleep_condition",
|
||||
"eye_condition": "eye_condition",
|
||||
"head_feeling": "head_feeling",
|
||||
"sweat_condition": "sweat_condition",
|
||||
"skin_condition": "skin_condition",
|
||||
"urine_condition": "urine_condition",
|
||||
"stool_condition": "stool_condition",
|
||||
"kidney_condition": "kidney_condition",
|
||||
}
|
||||
|
||||
FIELD_DICTIONARIES: dict[str, str] = {
|
||||
**SINGLE_VALUE_DICTIONARIES,
|
||||
**MULTI_VALUE_DICTIONARIES,
|
||||
}
|
||||
|
||||
DICTIONARY_TYPES: tuple[str, ...] = tuple(sorted(set(FIELD_DICTIONARIES.values())))
|
||||
|
||||
# 内置种子:与 server/sql/present_illness_dict_data.sql、tcm_diagnosis.sql 的
|
||||
# zyt_dict_data 初始数据一致;线上字典可被管理员改写,所以实时字典优先。
|
||||
SEED_DICTIONARIES: dict[str, dict[str, str]] = {
|
||||
"appetite": {
|
||||
"dry": "干",
|
||||
"bitter": "苦",
|
||||
"greasy": "腻",
|
||||
},
|
||||
"body_feeling": {
|
||||
"numbness": "麻木",
|
||||
"weakness": "乏力",
|
||||
"pain": "疼痛",
|
||||
"cold_aversion": "畏寒",
|
||||
"fever": "烧热",
|
||||
},
|
||||
"diagnosis_type": {
|
||||
"first_visit": "初诊",
|
||||
"follow_up": "复诊",
|
||||
"consultation": "会诊",
|
||||
},
|
||||
"diet_condition": {
|
||||
"overeating": "多食",
|
||||
"poor_appetite": "纳呆",
|
||||
"stomach_bloating": "胃胀",
|
||||
"stomach_pain": "胃痛",
|
||||
"acid_reflux": "反酸",
|
||||
"loss_of_appetite": "食欲减退",
|
||||
},
|
||||
"eye_condition": {
|
||||
"blurred": "模糊",
|
||||
"dry": "干涩",
|
||||
"tearing": "流泪",
|
||||
"floaters": "飞蚊症",
|
||||
"bleeding": "出血",
|
||||
},
|
||||
"fatty_liver_degree": {
|
||||
"mild": "轻度",
|
||||
"moderate": "中度",
|
||||
"severe": "重度",
|
||||
},
|
||||
"head_feeling": {
|
||||
"fatigue": "疲劳困倦",
|
||||
"dizziness": "头晕",
|
||||
"headache": "头痛",
|
||||
"tinnitus": "耳鸣",
|
||||
},
|
||||
"kidney_condition": {
|
||||
"soreness": "酸胀",
|
||||
"pain": "疼痛",
|
||||
"lower_back_pain": "腰痛",
|
||||
"sexual_dysfunction": "性功能下降",
|
||||
},
|
||||
"past_history": {
|
||||
"hypertension": "高血压",
|
||||
"diabetes": "糖尿病",
|
||||
"gastric_ulcer": "胃溃疡",
|
||||
"hyperlipidemia": "高血脂",
|
||||
"thyroid_nodule": "甲状腺结节",
|
||||
"superficial_gastritis": "浅表性胃炎",
|
||||
"stomach_disease": "胃病",
|
||||
"cerebral_infarction": "脑梗",
|
||||
"breast_nodule": "乳腺结节",
|
||||
"atrophic_gastritis": "萎缩性胃炎",
|
||||
"heart_disease": "心脏病",
|
||||
"cerebral_ischemia": "脑缺血",
|
||||
"intestinal_obstruction": "肠梗阻",
|
||||
"hepatitis_a": "甲肝",
|
||||
"hepatitis_b": "乙肝",
|
||||
"hepatitis_c": "丙肝",
|
||||
"big_three_positive": "大三阳",
|
||||
"cerebral_thrombosis": "脑血栓",
|
||||
"coronary_heart_disease": "冠心病",
|
||||
"angina_pectoris": "心绞痛",
|
||||
"palpitation": "心悸",
|
||||
"renal_insufficiency": "肾功能不全",
|
||||
"benign_tumor": "良性肿瘤",
|
||||
"pancreatitis": "胰腺炎",
|
||||
"small_three_positive": "小三阳",
|
||||
"palpitations": "心慌",
|
||||
"edema": "水肿",
|
||||
"infectious_disease": "传染病",
|
||||
"fundus_congestion": "眼底充血",
|
||||
"tuberculosis": "肺结核",
|
||||
"pneumonia": "肺炎",
|
||||
"pulmonary_nodule": "肺结节",
|
||||
"cardiac_stent": "心脏支架",
|
||||
"renal_stent": "肾脏支架",
|
||||
"hepatitis": "肝炎",
|
||||
"tumor": "肿瘤",
|
||||
"emphysema": "肺气肿",
|
||||
"moderate_fatty_liver": "中度脂肪肝",
|
||||
"lacunar_infarction": "腔梗",
|
||||
"alcoholic_liver": "酒精肝",
|
||||
"brain_atrophy": "脑萎缩",
|
||||
"liver_cyst": "肝囊肿",
|
||||
"stroke": "中风",
|
||||
"cerebral_hemorrhage": "脑出血",
|
||||
"hepatic_insufficiency": "肝功能不全",
|
||||
"arterial_plaque": "动脉斑块",
|
||||
"uterine_fibroids": "子宫肌瘤",
|
||||
"splenomegaly": "脾大",
|
||||
"gastric_perforation": "胃穿孔",
|
||||
"gastric_bleeding": "胃出血",
|
||||
},
|
||||
"skin_condition": {
|
||||
"dry": "干燥",
|
||||
"itching": "瘙痒",
|
||||
"peeling": "脱皮",
|
||||
"edema": "水肿",
|
||||
"eczema": "湿疹",
|
||||
},
|
||||
"sleep_condition": {
|
||||
"difficulty_falling_asleep": "入睡难",
|
||||
"easy_to_wake": "容易醒",
|
||||
"early_waking": "早醒",
|
||||
"many_dreams": "多梦",
|
||||
},
|
||||
"stool_condition": {
|
||||
"dry": "干燥",
|
||||
"constipation": "便秘",
|
||||
"sticky": "粘腻",
|
||||
"diarrhea": "腹泻",
|
||||
},
|
||||
"sweat_condition": {
|
||||
"daytime_sweating": "日间出汗",
|
||||
"night_sweating": "夜间出汗",
|
||||
"sticky_sweat": "汗粘",
|
||||
"excessive_sweating": "多汗",
|
||||
},
|
||||
"syndrome_type": {
|
||||
"qi_deficiency": "气虚",
|
||||
"blood_deficiency": "血虚",
|
||||
"yin_deficiency": "阴虚",
|
||||
"yang_deficiency": "阳虚",
|
||||
"qi_stagnation": "气滞",
|
||||
"blood_stasis": "血瘀",
|
||||
"phlegm_dampness": "痰湿",
|
||||
"damp_heat": "湿热",
|
||||
"cold_dampness": "寒湿",
|
||||
"wind_cold": "风寒",
|
||||
"wind_heat": "风热",
|
||||
},
|
||||
"urine_condition": {
|
||||
"urgency": "尿急",
|
||||
"yellow_urine": "尿黄",
|
||||
"foamy": "有泡",
|
||||
"frequency": "尿频",
|
||||
"painful": "尿痛",
|
||||
"nocturia": "夜尿多",
|
||||
},
|
||||
"water_intake": {
|
||||
"one_bottle": "1瓶矿泉水",
|
||||
"one_half_bottle": "1.5瓶矿泉水",
|
||||
"three_bottles": "3瓶矿泉水",
|
||||
"four_bottles": "4瓶矿泉水",
|
||||
},
|
||||
"weight_change": {
|
||||
"lose_5_jin": "瘦5斤",
|
||||
"lose_10_jin": "瘦10斤",
|
||||
"lose_over_10_jin": "瘦10斤以上",
|
||||
},
|
||||
}
|
||||
|
||||
_GENDER_LABELS: dict[str, str] = {
|
||||
"1": "男",
|
||||
"m": "男",
|
||||
"male": "男",
|
||||
"男": "男",
|
||||
"0": "女",
|
||||
"2": "女",
|
||||
"f": "女",
|
||||
"female": "女",
|
||||
"女": "女",
|
||||
}
|
||||
|
||||
_MARITAL_LABELS: dict[str, str] = {"0": "未婚", "1": "已婚", "2": "离异"}
|
||||
|
||||
# 后台 yesNoText:1 有,其余 无。
|
||||
_YES_NO_FIELDS = frozenset(
|
||||
{
|
||||
"trauma_history",
|
||||
"surgery_history",
|
||||
"allergy_history",
|
||||
"family_history",
|
||||
"pregnancy_history",
|
||||
}
|
||||
)
|
||||
|
||||
_CREATE_SOURCE_LABELS: dict[str, str] = {
|
||||
"mnp": "小程序建档",
|
||||
"mnp_daily": "小程序快捷建档",
|
||||
"admin": "后台创建",
|
||||
"doctor": "医生创建",
|
||||
}
|
||||
|
||||
_RECORD_SOURCE_LABELS: dict[str, str] = {"0": "医生录入", "1": "患者自录"}
|
||||
|
||||
# 纯内部列:启停标记、统计端展示位、排班偏移与软删除时间对医生没有意义。
|
||||
INTERNAL_FIELDS = frozenset(
|
||||
{
|
||||
"status",
|
||||
"show_card",
|
||||
"revisit_slot_start_offset",
|
||||
"delete_time",
|
||||
"is_delete",
|
||||
"is_deleted",
|
||||
"sort",
|
||||
"assistant_id",
|
||||
"doctor_id",
|
||||
"admin_id",
|
||||
}
|
||||
)
|
||||
|
||||
# 附件字段单独渲染成缩略图,不再以 URL 文本出现在字段网格里。
|
||||
IMAGE_FIELDS = frozenset(
|
||||
{
|
||||
"tongue_images",
|
||||
"report_files",
|
||||
"images",
|
||||
"breakfast_images",
|
||||
"lunch_images",
|
||||
"dinner_images",
|
||||
}
|
||||
)
|
||||
|
||||
_TIMESTAMP_SUFFIXES = ("_time", "_at", "_date")
|
||||
_TIMESTAMP_MIN = 10**9 # 2001-09-09,早于本项目任何真实数据
|
||||
_TIMESTAMP_MAX = 4 * 10**9 # 2096 年,之后按普通数字显示
|
||||
|
||||
|
||||
# 只读页的单位,与 admin PatientCaseCard / BloodRecordList 一致。
|
||||
_UNIT_SUFFIXES: dict[str, str] = {
|
||||
"height": " cm",
|
||||
"weight": " kg",
|
||||
"systolic_pressure": " mmHg",
|
||||
"diastolic_pressure": " mmHg",
|
||||
"fasting_blood_sugar": " mmol/L",
|
||||
"postprandial_blood_sugar": " mmol/L",
|
||||
"other_blood_sugar": " mmol/L",
|
||||
"blood_sugar": " mmol/L",
|
||||
"duration": " 分钟",
|
||||
"diabetes_discovery_year": "年",
|
||||
}
|
||||
|
||||
|
||||
def unit_suffix(field: str, value: Any) -> str:
|
||||
"""Return the unit a numeric readonly field should carry, if any."""
|
||||
|
||||
suffix = _UNIT_SUFFIXES.get(str(field or "").strip())
|
||||
if not suffix:
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text or text.endswith(suffix.strip()):
|
||||
return ""
|
||||
# 只给纯数字补单位,"17多"、"五年" 这类自由文本保持原样。
|
||||
normalized = text.replace(".", "", 1)
|
||||
return suffix if normalized.isdigit() else ""
|
||||
|
||||
|
||||
def format_timestamp(value: Any, *, with_time: bool = True) -> str | None:
|
||||
"""把后端的 Unix 秒级时间戳转成中文界面用的日期时间。"""
|
||||
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
seconds = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not _TIMESTAMP_MIN <= seconds <= _TIMESTAMP_MAX:
|
||||
return None
|
||||
moment = datetime.fromtimestamp(seconds)
|
||||
return moment.strftime("%Y-%m-%d %H:%M" if with_time else "%Y-%m-%d")
|
||||
|
||||
|
||||
def is_timestamp_field(field: str) -> bool:
|
||||
return str(field or "").endswith(_TIMESTAMP_SUFFIXES)
|
||||
|
||||
|
||||
def gender_label(value: Any) -> str | None:
|
||||
return _GENDER_LABELS.get(str(value or "").strip().lower())
|
||||
|
||||
|
||||
def split_values(value: Any) -> list[str]:
|
||||
"""按后台 enrichDiagnosisLabels 的方式拆多值字段。"""
|
||||
|
||||
if value in (None, "", []):
|
||||
return []
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
items = [str(item or "").strip() for item in value]
|
||||
else:
|
||||
items = [part.strip() for part in _split_text(str(value))]
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def _split_text(text: str) -> list[str]:
|
||||
normalized = text.replace(",", ",").replace("、", ",")
|
||||
return normalized.split(",")
|
||||
|
||||
|
||||
class TermIndex:
|
||||
"""字典翻译表:实时字典覆盖内置种子,两者都缺就回显原值。"""
|
||||
|
||||
def __init__(self, dictionaries: Mapping[str, Any] | None = None) -> None:
|
||||
self._dictionaries: dict[str, dict[str, str]] = {
|
||||
dictionary_type: dict(options)
|
||||
for dictionary_type, options in SEED_DICTIONARIES.items()
|
||||
}
|
||||
self.merge(dictionaries)
|
||||
|
||||
def merge(self, dictionaries: Mapping[str, Any] | None) -> None:
|
||||
"""并入 ``config/dict`` 下发的 ``{type: [{name, value}, ...]}``。"""
|
||||
|
||||
if not isinstance(dictionaries, Mapping):
|
||||
return
|
||||
for dictionary_type, rows in dictionaries.items():
|
||||
options = _options_from_rows(rows)
|
||||
if options:
|
||||
self._dictionaries.setdefault(str(dictionary_type), {}).update(options)
|
||||
|
||||
def dictionary(self, dictionary_type: str) -> dict[str, str]:
|
||||
return dict(self._dictionaries.get(str(dictionary_type), {}))
|
||||
|
||||
def dictionary_label(self, field: str, value: Any) -> str | None:
|
||||
"""翻译一个字典字段;不是字典字段或没有可翻译内容时返回 None。"""
|
||||
|
||||
dictionary_type = FIELD_DICTIONARIES.get(str(field or "").strip())
|
||||
if dictionary_type is None:
|
||||
return None
|
||||
options = self._dictionaries.get(dictionary_type, {})
|
||||
items = split_values(value)
|
||||
if not items:
|
||||
return None
|
||||
labels = [options.get(item, item) for item in items]
|
||||
return "、".join(label for label in labels if label) or None
|
||||
|
||||
def value_label(self, field: str, value: Any) -> str | None:
|
||||
"""翻译字典项或枚举;无法翻译时返回 None,由调用方回显原值。"""
|
||||
|
||||
key = str(field or "").strip()
|
||||
if value in (None, "", [], {}):
|
||||
return None
|
||||
dictionary_label = self.dictionary_label(key, value)
|
||||
if dictionary_label is not None:
|
||||
return dictionary_label
|
||||
if key in {"gender", "patient_gender", "sex"}:
|
||||
return gender_label(value)
|
||||
if key in {"marital_status", "marriage"}:
|
||||
return _MARITAL_LABELS.get(str(value).strip())
|
||||
if key in _YES_NO_FIELDS:
|
||||
text = str(value).strip()
|
||||
if text in {"0", "1"}:
|
||||
return "有" if text == "1" else "无"
|
||||
return None
|
||||
if key == "create_source":
|
||||
return _CREATE_SOURCE_LABELS.get(str(value).strip().lower())
|
||||
if key == "source":
|
||||
return _RECORD_SOURCE_LABELS.get(str(value).strip())
|
||||
if is_timestamp_field(key):
|
||||
return format_timestamp(value, with_time=not key.endswith("_date"))
|
||||
return None
|
||||
|
||||
def display(self, source: Any, field: str, *, default: str = "") -> str:
|
||||
"""按 admin ``textOf`` 的口径取值:先 ``<field>_text``,再字典/枚举,最后原值。"""
|
||||
|
||||
mapping = source if isinstance(source, Mapping) else {}
|
||||
key = str(field or "").strip()
|
||||
translated = mapping.get(f"{key}_text")
|
||||
if translated in (None, "", []):
|
||||
translated = mapping.get(f"{key}_desc")
|
||||
if translated not in (None, "", []):
|
||||
return _join(translated) + unit_suffix(key, translated)
|
||||
raw = mapping.get(key)
|
||||
if raw in (None, "", [], {}):
|
||||
return default
|
||||
labelled = self.value_label(key, raw)
|
||||
if labelled is not None:
|
||||
return labelled
|
||||
rendered = _join(raw)
|
||||
return rendered + unit_suffix(key, rendered)
|
||||
|
||||
|
||||
_SHARED_INDEX = TermIndex()
|
||||
|
||||
|
||||
def shared_terms() -> TermIndex:
|
||||
"""The process-wide index every readonly screen renders through.
|
||||
|
||||
只读界面只用它翻译展示文案,所以共享一份即可:任何界面取回实时字典后,
|
||||
处方快照那种拿不到 ``*_text`` 的旧数据也能跟着翻译正确。
|
||||
"""
|
||||
|
||||
return _SHARED_INDEX
|
||||
|
||||
|
||||
def merge_shared_dictionaries(dictionaries: Mapping[str, Any] | None) -> None:
|
||||
_SHARED_INDEX.merge(dictionaries)
|
||||
|
||||
|
||||
def _join(value: Any) -> str:
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(str(item).strip() for item in value if str(item).strip())
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _options_from_rows(rows: Any) -> dict[str, str]:
|
||||
"""从 ``config/dict`` 的行里取 ``value -> name``。"""
|
||||
|
||||
options: dict[str, str] = {}
|
||||
if isinstance(rows, Mapping):
|
||||
for value, name in rows.items():
|
||||
code = str(value).strip()
|
||||
label = str(name).strip()
|
||||
if code and label:
|
||||
options[code] = label
|
||||
return options
|
||||
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
|
||||
return options
|
||||
for row in rows:
|
||||
if not isinstance(row, Mapping):
|
||||
continue
|
||||
code = str(row.get("value", "")).strip()
|
||||
label = str(row.get("name", "")).strip()
|
||||
if code and label:
|
||||
options[code] = label
|
||||
return options
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DICTIONARY_TYPES",
|
||||
"FIELD_DICTIONARIES",
|
||||
"IMAGE_FIELDS",
|
||||
"INTERNAL_FIELDS",
|
||||
"MULTI_VALUE_DICTIONARIES",
|
||||
"SEED_DICTIONARIES",
|
||||
"SINGLE_VALUE_DICTIONARIES",
|
||||
"TermIndex",
|
||||
"format_timestamp",
|
||||
"merge_shared_dictionaries",
|
||||
"shared_terms",
|
||||
"gender_label",
|
||||
"is_timestamp_field",
|
||||
"split_values",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diagnosis_terms import shared_terms
|
||||
from ..widgets import (
|
||||
MessageBanner,
|
||||
display_text,
|
||||
@@ -3970,6 +3971,8 @@ def render_case_record_html(prescription: Any, *, print_layout: bool = False) ->
|
||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||
return html.escape(display_text(value, default))
|
||||
|
||||
terms = shared_terms()
|
||||
|
||||
def value(*keys: str, default: Any = None) -> Any:
|
||||
for key in keys:
|
||||
translated = case.get(f"{key}_text")
|
||||
@@ -3977,7 +3980,9 @@ def render_case_record_html(prescription: Any, *, print_layout: bool = False) ->
|
||||
return translated
|
||||
candidate = case.get(key)
|
||||
if candidate not in (None, ""):
|
||||
return candidate
|
||||
# 处方快照是开方当时的原始 code,没有后端补的 *_text,
|
||||
# 这里按字典把它翻成中文,翻不了才回显原值。
|
||||
return terms.value_label(key, candidate) or candidate
|
||||
return default
|
||||
|
||||
def present_date(raw: Any) -> Any:
|
||||
|
||||
@@ -460,6 +460,7 @@ class LoginWindow(QMainWindow):
|
||||
super().__init__(parent)
|
||||
self.repository = repository
|
||||
self.config = config
|
||||
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||
if demo_repository is None:
|
||||
demo_repository = getattr(config, "demo_repository", None)
|
||||
self.demo_repository = demo_repository
|
||||
@@ -751,6 +752,7 @@ class LoginWindow(QMainWindow):
|
||||
if self.demo_repository is None:
|
||||
self.demo_check.setToolTip("当前未配置演示数据")
|
||||
self.demo_check.toggled.connect(self._on_demo_toggled)
|
||||
self.demo_check.setVisible(self.debug_mode)
|
||||
choices.addWidget(self.demo_check)
|
||||
card_layout.addLayout(choices)
|
||||
card_layout.addSpacing(26)
|
||||
@@ -765,8 +767,12 @@ class LoginWindow(QMainWindow):
|
||||
self.login_button.setGraphicsEffect(login_shadow)
|
||||
self.login_button.clicked.connect(self.submit)
|
||||
card_layout.addWidget(self.login_button)
|
||||
card_layout.addSpacing(20)
|
||||
|
||||
self.debug_settings_section = QWidget()
|
||||
self.debug_settings_section.setObjectName("DebugSettingsSection")
|
||||
debug_settings_layout = QVBoxLayout(self.debug_settings_section)
|
||||
debug_settings_layout.setContentsMargins(0, 0, 0, 0)
|
||||
debug_settings_layout.setSpacing(0)
|
||||
debug_settings_layout.addSpacing(20)
|
||||
divider = QHBoxLayout()
|
||||
divider.setSpacing(18)
|
||||
line_left = QFrame()
|
||||
@@ -782,8 +788,8 @@ class LoginWindow(QMainWindow):
|
||||
line_right.setFrameShape(QFrame.Shape.HLine)
|
||||
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
||||
divider.addWidget(line_right, 1)
|
||||
card_layout.addLayout(divider)
|
||||
card_layout.addSpacing(20)
|
||||
debug_settings_layout.addLayout(divider)
|
||||
debug_settings_layout.addSpacing(20)
|
||||
|
||||
self.server_toggle = _ServerButton("服务器设置 +")
|
||||
self.server_toggle.setObjectName("ServerSettingsToggle")
|
||||
@@ -791,7 +797,7 @@ class LoginWindow(QMainWindow):
|
||||
self.server_toggle.setCheckable(True)
|
||||
self.server_toggle.setFixedHeight(56)
|
||||
self.server_toggle.clicked.connect(self._toggle_server_panel)
|
||||
card_layout.addWidget(self.server_toggle)
|
||||
debug_settings_layout.addWidget(self.server_toggle)
|
||||
|
||||
self.server_panel = QFrame()
|
||||
self.server_panel.setObjectName("SubtleCard")
|
||||
@@ -850,9 +856,11 @@ class LoginWindow(QMainWindow):
|
||||
self.server_hint.setWordWrap(True)
|
||||
server_layout.addWidget(self.server_hint)
|
||||
self.server_panel.setVisible(False)
|
||||
card_layout.addSpacing(9)
|
||||
card_layout.addWidget(self.server_panel)
|
||||
card_layout.addSpacing(12)
|
||||
debug_settings_layout.addSpacing(9)
|
||||
debug_settings_layout.addWidget(self.server_panel)
|
||||
debug_settings_layout.addSpacing(12)
|
||||
self.debug_settings_section.setVisible(self.debug_mode)
|
||||
card_layout.addWidget(self.debug_settings_section)
|
||||
|
||||
footnote_row = QHBoxLayout()
|
||||
footnote_row.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -922,8 +930,13 @@ class LoginWindow(QMainWindow):
|
||||
configured_url = getattr(self.config, "base_url", "") or getattr(
|
||||
self.config, "api_base_url", ""
|
||||
)
|
||||
restored_url = (
|
||||
self.settings.value("server/base_url", configured_url)
|
||||
if self.debug_mode
|
||||
else configured_url
|
||||
)
|
||||
self.server_url_edit.setText(
|
||||
str(self.settings.value("server/base_url", configured_url) or "")
|
||||
str(restored_url or "")
|
||||
)
|
||||
try:
|
||||
configured_timeout = getattr(self.config, "request_timeout", 60)
|
||||
@@ -937,15 +950,22 @@ class LoginWindow(QMainWindow):
|
||||
configured_verify_ssl = _setting_bool(
|
||||
getattr(self.config, "verify_ssl", True), True
|
||||
)
|
||||
verify_ssl = _setting_bool(
|
||||
self.settings.value("server/verify_ssl", configured_verify_ssl),
|
||||
configured_verify_ssl,
|
||||
verify_ssl = (
|
||||
_setting_bool(
|
||||
self.settings.value("server/verify_ssl", configured_verify_ssl),
|
||||
configured_verify_ssl,
|
||||
)
|
||||
if self.debug_mode
|
||||
else configured_verify_ssl
|
||||
)
|
||||
self.allow_self_signed_check.setChecked(not verify_ssl)
|
||||
if self.demo_repository is not None and bool(
|
||||
if self.debug_mode and self.demo_repository is not None and bool(
|
||||
getattr(self.config, "demo_mode", False)
|
||||
):
|
||||
self.demo_check.setChecked(True)
|
||||
elif not self.debug_mode:
|
||||
self.demo_check.setChecked(False)
|
||||
self.active_repository = self.repository
|
||||
self.account_edit.setText(remembered)
|
||||
self.restore_remembered_credentials()
|
||||
if remembered:
|
||||
@@ -954,6 +974,8 @@ class LoginWindow(QMainWindow):
|
||||
self.account_edit.setFocus()
|
||||
|
||||
def _credential_scope(self) -> str:
|
||||
if not self.debug_mode:
|
||||
return str(getattr(self.config, "api_base_url", "") or "").strip().rstrip("/")
|
||||
if hasattr(self, "server_url_edit"):
|
||||
scope = self.server_url_edit.text().strip()
|
||||
if scope:
|
||||
@@ -1000,6 +1022,12 @@ class LoginWindow(QMainWindow):
|
||||
self.reveal_button.setText("隐藏" if visible else "显示")
|
||||
|
||||
def _on_demo_toggled(self, enabled: bool) -> None:
|
||||
if enabled and not self.debug_mode:
|
||||
self.demo_check.blockSignals(True)
|
||||
self.demo_check.setChecked(False)
|
||||
self.demo_check.blockSignals(False)
|
||||
self.active_repository = self.repository
|
||||
return
|
||||
self.active_repository = self.demo_repository if enabled else self.repository
|
||||
self.server_toggle.setEnabled(not enabled and not self._loading)
|
||||
self.demo_mode_changed.emit(enabled)
|
||||
@@ -1013,6 +1041,10 @@ class LoginWindow(QMainWindow):
|
||||
self.password_edit.setPlaceholderText("请输入密码")
|
||||
|
||||
def _toggle_server_panel(self, expanded: bool) -> None:
|
||||
if not self.debug_mode:
|
||||
self.server_toggle.setChecked(False)
|
||||
self.server_panel.hide()
|
||||
return
|
||||
self.server_panel.setVisible(expanded)
|
||||
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
|
||||
self.server_panel.updateGeometry()
|
||||
@@ -1027,6 +1059,13 @@ class LoginWindow(QMainWindow):
|
||||
self._apply_server_settings(announce=True)
|
||||
|
||||
def _apply_server_settings(self, *, announce: bool) -> bool:
|
||||
if not self.debug_mode:
|
||||
base_url = str(getattr(self.config, "api_base_url", "") or "").strip()
|
||||
if base_url:
|
||||
self.server_url_edit.setText(base_url)
|
||||
return True
|
||||
self.error_banner.show_message("线上服务器地址尚未配置,请联系管理员。", "warning")
|
||||
return False
|
||||
base_url = self.server_url_edit.text().strip().rstrip("/")
|
||||
if base_url and not base_url.startswith(
|
||||
("https://", "http://localhost", "http://127.0.0.1")
|
||||
@@ -1079,7 +1118,7 @@ class LoginWindow(QMainWindow):
|
||||
def submit(self) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
demo_mode = self.demo_check.isChecked()
|
||||
demo_mode = self.debug_mode and self.demo_check.isChecked()
|
||||
remember_account = self.remember_check.isChecked()
|
||||
account = self.account_edit.text().strip()
|
||||
password = self.password_edit.text()
|
||||
@@ -1150,9 +1189,13 @@ class LoginWindow(QMainWindow):
|
||||
self.password_edit.setEnabled(not loading)
|
||||
self.remember_check.setEnabled(not loading)
|
||||
self.reveal_button.setEnabled(not loading)
|
||||
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
|
||||
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
|
||||
self.server_panel.setEnabled(not loading)
|
||||
self.demo_check.setEnabled(
|
||||
self.debug_mode and not loading and self.demo_repository is not None
|
||||
)
|
||||
self.server_toggle.setEnabled(
|
||||
self.debug_mode and not loading and not self.demo_check.isChecked()
|
||||
)
|
||||
self.server_panel.setEnabled(self.debug_mode and not loading)
|
||||
self.server_url_edit.setEnabled(not loading)
|
||||
self.timeout_spin.setEnabled(not loading)
|
||||
self.allow_self_signed_check.setEnabled(not loading)
|
||||
@@ -1184,7 +1227,7 @@ class LoginWindow(QMainWindow):
|
||||
if remember_account is None:
|
||||
remember_account = self.remember_check.isChecked()
|
||||
scope = self._credential_scope()
|
||||
is_demo = bool(payload.get("demo_mode"))
|
||||
is_demo = self.debug_mode and bool(payload.get("demo_mode"))
|
||||
password_saved = False
|
||||
clearer = getattr(self.credential_store, "clear_password", None)
|
||||
if (
|
||||
@@ -1223,13 +1266,18 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
def _on_login_error(self, error: Exception) -> None:
|
||||
error_text = str(error).lower()
|
||||
if (
|
||||
certificate_error = (
|
||||
"certificate_verify_failed" in error_text
|
||||
or "self-signed certificate" in error_text
|
||||
):
|
||||
)
|
||||
if certificate_error and self.debug_mode:
|
||||
self.server_toggle.setChecked(True)
|
||||
self._toggle_server_panel(True)
|
||||
message = friendly_error(error)
|
||||
message = (
|
||||
friendly_error(error)
|
||||
if self.debug_mode or not certificate_error
|
||||
else "服务器证书校验失败,请联系管理员检查线上域名和证书配置。"
|
||||
)
|
||||
self.error_banner.show_message(message, "danger")
|
||||
self.login_failed.emit(message)
|
||||
self.password_edit.selectAll()
|
||||
|
||||
@@ -41,6 +41,12 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from doctor_workstation.resources import app_icon_path
|
||||
|
||||
from .chat_notifications import (
|
||||
CONSULTATION_COMPLETE,
|
||||
PATIENT_OPENED_CHAT,
|
||||
ChatNotification,
|
||||
ChatNotificationCenter,
|
||||
)
|
||||
from .dialogs.ai_consult import can_open_ai_consult
|
||||
from .dialogs.ai_consult_picker import select_and_present_ai_consult
|
||||
from .dialogs.diagnosis import DiagnosisDialog
|
||||
@@ -898,6 +904,11 @@ class ShellWindow(QMainWindow):
|
||||
self._activation_refreshed = False
|
||||
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
|
||||
self._global_diagnosis_dialog: DiagnosisDialog | None = None
|
||||
# 与后台 chat-notify-toast 同一份数据源,登录后常驻轮询。
|
||||
self.chat_notifications = ChatNotificationCenter(repository, self)
|
||||
self.chat_notifications.notification_activated.connect(
|
||||
self._open_chat_notification
|
||||
)
|
||||
|
||||
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
|
||||
screen = self.screen() or QApplication.primaryScreen()
|
||||
@@ -1297,9 +1308,7 @@ class ShellWindow(QMainWindow):
|
||||
"notification", size=38, parent=topbar
|
||||
)
|
||||
self.notification_button.setToolTip("消息通知")
|
||||
self.notification_button.clicked.connect(
|
||||
lambda: show_toast(self, "当前没有新的系统通知。", "info")
|
||||
)
|
||||
self.notification_button.clicked.connect(self._show_chat_notification_summary)
|
||||
layout.addWidget(self.notification_button)
|
||||
self.settings_button = _PaintedIconButton("settings", size=38, parent=topbar)
|
||||
self.settings_button.setToolTip("设置中心")
|
||||
@@ -1898,6 +1907,8 @@ class ShellWindow(QMainWindow):
|
||||
|
||||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().showEvent(event)
|
||||
# 登录后就开始轮询:不管医生停在哪个页面,患者进入会话都要提示。
|
||||
self.chat_notifications.start()
|
||||
page = self.stack.currentWidget()
|
||||
if page is None or page is not self._activation_page:
|
||||
return
|
||||
@@ -1907,6 +1918,33 @@ class ShellWindow(QMainWindow):
|
||||
lambda page=page, generation=generation: self._finish_activation(page, generation),
|
||||
)
|
||||
|
||||
def closeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
self.chat_notifications.stop()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _open_chat_notification(self, notification: ChatNotification) -> None:
|
||||
"""Take the doctor where the notification's work actually lives."""
|
||||
|
||||
if notification.kind == CONSULTATION_COMPLETE and notification.diagnosis_id > 0:
|
||||
self.open_diagnosis_by_id(notification.diagnosis_id, modeless=True)
|
||||
return
|
||||
if notification.kind == PATIENT_OPENED_CHAT and self.navigate("reception"):
|
||||
return
|
||||
show_toast(self, notification.description, "info", 4200)
|
||||
|
||||
def _show_chat_notification_summary(self) -> None:
|
||||
pending = self.chat_notifications.pending
|
||||
if not pending:
|
||||
show_toast(self, "当前没有新的系统通知。", "info")
|
||||
return
|
||||
latest = pending[0]
|
||||
show_toast(
|
||||
self,
|
||||
f"待处理通知 {len(pending)} 条,最新:{latest.title} · {latest.description}",
|
||||
"info",
|
||||
4200,
|
||||
)
|
||||
|
||||
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
|
||||
if visible:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
|
||||
|
||||
Reference in New Issue
Block a user