This commit is contained in:
Your Name
2026-08-28 18:24:37 +08:00
parent 43ad07208f
commit ed48f8be31
383 changed files with 8673 additions and 2222 deletions
+7 -2
View File
@@ -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"
+26 -15
View File
@@ -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())
+27 -6
View File
@@ -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:
+69 -21
View File
@@ -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 -3
View File
@@ -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)