Files
zyt/app/src/doctor_workstation/ui/shell.py
T
2026-08-22 10:46:13 +08:00

1874 lines
71 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Authenticated application shell with permission-aware navigation."""
from __future__ import annotations
import sqlite3
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
from typing import Any
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import (
QColor,
QFont,
QIcon,
QKeySequence,
QMouseEvent,
QPainter,
QPen,
QPixmap,
QPolygonF,
QShortcut,
)
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QPushButton,
QSizePolicy,
QStackedWidget,
QTabBar,
QToolButton,
QVBoxLayout,
QWidget,
)
from doctor_workstation.resources import app_icon_path
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
ConsultationsPage,
PatientsPage,
PrescriptionLibraryPage,
PrescriptionsPage,
ReceptionPage,
)
from .theme import crisp_pixmap
from .widgets import (
EmptyState,
StatusBadge,
display_text,
first_value,
get_value,
show_toast,
)
_SHELL_EXPANDED_WIDTH = 190
_SHELL_COLLAPSED_WIDTH = 68
_SHELL_TOPBAR_HEIGHT = 62
_SHELL_TABS_HEIGHT = 0
_SHELL_OUTER_GUTTER = 13
_SHELL_PANEL_GAP = 0
_SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
def _bounded_initial_window_size(available_size: QSize | None) -> QSize:
"""Fit the design viewport inside the screen's logical available geometry."""
if available_size is None or not available_size.isValid():
return QSize(_SHELL_DESIGN_SIZE.width(), _SHELL_DESIGN_SIZE.height())
return QSize(
max(
_SHELL_MINIMUM_SIZE.width(),
min(_SHELL_DESIGN_SIZE.width(), available_size.width()),
),
max(
_SHELL_MINIMUM_SIZE.height(),
min(_SHELL_DESIGN_SIZE.height(), available_size.height()),
),
)
# The references place the workspace at page-specific global x anchors while
# keeping a 13 px outer gutter. These are the actual rail widths inside that
# gutter; the resulting global PageStack x values are 212/192/204/203/183/208.
_PAGE_SIDEBAR_WIDTHS = {
"reception": 199,
"appointments": 179,
"prescription_library": 191,
"prescriptions": 190,
"patients": 170,
"consultations": 195,
}
@dataclass(frozen=True)
class NavigationItem:
key: str
title: str
glyph: str
page_type: type[QWidget]
permissions: tuple[str, ...]
NAVIGATION = (
NavigationItem(
# 挂号与诊单是两条独立队列,早期两项都叫“问诊列表”,侧边栏出现两个同名
# 入口,医生无法判断该点哪个。按各自的业务对象命名以消除歧义。
"appointments",
"挂号列表",
"号",
AppointmentsPage,
("doctor.appointment/lists",),
),
NavigationItem(
"reception",
"接诊台",
"◎",
ReceptionPage,
("doctor.appointment/lists",),
),
NavigationItem(
"prescription_library",
"处方库",
"方",
PrescriptionLibraryPage,
("tcm.prescriptionLibrary/lists",),
),
NavigationItem(
"prescriptions",
"已开处方",
"笺",
PrescriptionsPage,
("tcm.prescription/lists",),
),
NavigationItem(
"patients",
"我的患者",
"患",
PatientsPage,
("firstvisit.myPatient/lists",),
),
NavigationItem(
"consultations",
"问诊列表",
"询",
ConsultationsPage,
("tcm.diagnosis/lists",),
),
)
# Shared ``doctor.appointment/lists`` is intentionally mapped only to reception;
# the appointment list page is resolved by menu route/component identity.
_NAVIGATION_BY_PERMISSION = {
item.permissions[0]: item for item in NAVIGATION if item.key != "appointments"
}
_MENU_ROUTE_IDENTIFIERS = {
"reception": {
"reception",
"patient/reception",
"patient/reception/index",
},
"appointments": {
"appointments",
"tcm/appointment",
"tcm/appointment/list",
"appointment/list",
},
"prescription_library": {
"prescription-library",
"prescription_library",
"consumer/prescription/list",
},
"prescriptions": {
"prescriptions",
"consumer/prescription/index",
},
"patients": {
"patients",
"first_visit/my_patients",
"first_visit/my_patients/index",
},
"consultations": {
"consultations",
"tcm/diagnosis",
"tcm/diagnosis/index",
},
}
def _canonical_allowed(permissions: Any, code: str) -> bool:
"""Apply the core exact/wildcard semantics without slash-dot aliases."""
if permissions is None:
return True
for method_name in ("allows", "has", "can_access_page", "has_page", "can"):
method = getattr(permissions, method_name, None)
if callable(method):
try:
return bool(method(code))
except (TypeError, ValueError):
continue
raw = permissions
for attr in ("codes", "permissions", "values"):
candidate = getattr(permissions, attr, None)
if candidate is not None and not callable(candidate):
raw = candidate
break
if isinstance(raw, Mapping):
nested = first_value(raw, "codes", "permissions", "values", default=None)
if nested is not None:
raw = nested
if isinstance(raw, Mapping):
available = {str(key) for key, enabled in raw.items() if enabled}
elif isinstance(raw, str):
available = {raw}
else:
try:
available = {str(value) for value in raw}
except TypeError:
return False
if "*" in available or code in available:
return True
return any(
grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available
)
def _menu_rows(value: Any) -> list[Mapping[str, Any]]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
return []
return [row for row in value if isinstance(row, Mapping)]
def _menu_visible(row: Mapping[str, Any]) -> bool:
value = get_value(row, "is_show", 1)
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "hidden", "no"}
return value != 0
def _menu_enabled(row: Mapping[str, Any]) -> bool:
value = get_value(row, "is_disable", 0)
if isinstance(value, str):
return value.strip().lower() not in {"1", "true", "disabled", "yes"}
return value != 1
def _menu_sort(row: Mapping[str, Any]) -> float:
try:
return float(first_value(row, "sort", "sort_order", "order", default=0))
except (TypeError, ValueError):
return 0.0
def _visible_menu_nodes(value: Any) -> list[Mapping[str, Any]]:
"""Flatten visible, enabled nodes; larger admin sort values come first."""
nodes = _menu_rows(value)
ordered = sorted(enumerate(nodes), key=lambda pair: (-_menu_sort(pair[1]), pair[0]))
result: list[Mapping[str, Any]] = []
for _index, node in ordered:
if not _menu_visible(node) or not _menu_enabled(node):
continue
result.append(node)
children = first_value(node, "children", "child", "childs", default=[])
result.extend(_visible_menu_nodes(children))
return result
def _normalise_route(value: Any) -> str:
route = str(value or "").strip().replace("\\", "/").lower()
route = route.split("?", 1)[0].split("#", 1)[0].strip("/")
if route.endswith(".vue"):
route = route[:-4]
return route
def _menu_permissions(row: Mapping[str, Any]) -> tuple[str, ...]:
value = first_value(row, "perms", "permission", "meta.perms", default="")
if isinstance(value, str):
return (value.strip(),) if value.strip() else ()
if isinstance(value, Sequence):
return tuple(str(item).strip() for item in value if str(item).strip())
return ()
def _match_navigation(row: Mapping[str, Any]) -> NavigationItem | None:
"""Prefer route/component identity when pages share the same permission code."""
identifiers = {
_normalise_route(first_value(row, "paths", "path")),
_normalise_route(get_value(row, "component", "")),
}
identifiers.discard("")
for item in NAVIGATION:
if identifiers & _MENU_ROUTE_IDENTIFIERS.get(item.key, set()):
return item
for permission in _menu_permissions(row):
item = _NAVIGATION_BY_PERMISSION.get(permission)
if item is not None:
return item
return None
def _resolve_navigation(
menu: Any,
permissions: Any,
*,
demo_mode: bool,
) -> list[tuple[NavigationItem, str]]:
"""Resolve only locally supported pages from the authoritative menu tree."""
rows = _menu_rows(menu)
if rows:
resolved: list[tuple[NavigationItem, str]] = []
seen: set[str] = set()
for row in _visible_menu_nodes(rows):
item = _match_navigation(row)
if item is None or item.key in seen:
continue
if not _canonical_allowed(permissions, item.permissions[0]):
continue
if item.key in {"appointments", "reception", "patients"}:
# Keep the product-facing navigation titles stable even when
# the server still carries an older menu label.
title = item.title
else:
title_value = first_value(
row, "name", "title", "meta.title", default=item.title
)
title = str(title_value).strip() or item.title
resolved.append((item, title))
seen.add(item.key)
resolved.sort(key=lambda entry: entry[0].key != "appointments")
return resolved
if demo_mode:
return [
(item, item.title)
for item in NAVIGATION
if _canonical_allowed(permissions, item.permissions[0])
]
return []
def _painted_shell_icon(kind: str, size: int = 18) -> QIcon:
"""Create a font-independent shell icon once, before widget painting."""
pixmap = crisp_pixmap(size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
try:
color = QColor("#5E69F6" if kind == "ai" else "#667085")
center_x = size / 2
center_y = size / 2
pen = QPen(color, 1.5)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
if kind in {"fold", "expand"}:
for offset in (-6.0, 0.0, 6.0):
painter.drawLine(
QPointF(center_x - 7.0, center_y + offset),
QPointF(center_x + 7.0, center_y + offset),
)
painter.setBrush(color)
painter.setPen(Qt.PenStyle.NoPen)
points = (
[
QPointF(center_x - 2.0, center_y - 3.5),
QPointF(center_x - 6.0, center_y),
QPointF(center_x - 2.0, center_y + 3.5),
]
if kind == "fold"
else [
QPointF(center_x + 2.0, center_y - 3.5),
QPointF(center_x + 6.0, center_y),
QPointF(center_x + 2.0, center_y + 3.5),
]
)
painter.drawPolygon(QPolygonF(points))
elif kind == "refresh":
painter.drawArc(
QRectF(center_x - 7, center_y - 7, 14, 14), 42 * 16, 286 * 16
)
painter.setBrush(color)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPolygon(
QPolygonF(
[
QPointF(center_x + 5.4, center_y - 7.2),
QPointF(center_x + 8.8, center_y - 6.4),
QPointF(center_x + 7.2, center_y - 3.2),
]
)
)
elif kind == "search":
painter.drawEllipse(QRectF(center_x - 6.0, center_y - 6.0, 10.5, 10.5))
painter.drawLine(
QPointF(center_x + 2.6, center_y + 2.6),
QPointF(center_x + 7.0, center_y + 7.0),
)
elif kind == "ai":
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPolygon(
QPolygonF(
[
QPointF(center_x, center_y - 8.0),
QPointF(center_x + 2.2, center_y - 2.2),
QPointF(center_x + 8.0, center_y),
QPointF(center_x + 2.2, center_y + 2.2),
QPointF(center_x, center_y + 8.0),
QPointF(center_x - 2.2, center_y + 2.2),
QPointF(center_x - 8.0, center_y),
QPointF(center_x - 2.2, center_y - 2.2),
]
)
)
painter.drawEllipse(QRectF(center_x + 6.0, center_y - 8.0, 3.2, 3.2))
elif kind == "fullscreen":
corner = 6.5
inset = 7.0
painter.drawLine(
QPointF(center_x - inset, center_y - 2.0),
QPointF(center_x - inset, center_y - corner),
)
painter.drawLine(
QPointF(center_x - inset, center_y - corner),
QPointF(center_x - 2.0, center_y - corner),
)
painter.drawLine(
QPointF(center_x + 2.0, center_y - corner),
QPointF(center_x + inset, center_y - corner),
)
painter.drawLine(
QPointF(center_x + inset, center_y - corner),
QPointF(center_x + inset, center_y - 2.0),
)
painter.drawLine(
QPointF(center_x - inset, center_y + 2.0),
QPointF(center_x - inset, center_y + corner),
)
painter.drawLine(
QPointF(center_x - inset, center_y + corner),
QPointF(center_x - 2.0, center_y + corner),
)
painter.drawLine(
QPointF(center_x + 2.0, center_y + corner),
QPointF(center_x + inset, center_y + corner),
)
painter.drawLine(
QPointF(center_x + inset, center_y + corner),
QPointF(center_x + inset, center_y + 2.0),
)
elif kind == "down":
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPolygon(
QPolygonF(
[
QPointF(center_x - 4.5, center_y - 2.0),
QPointF(center_x + 4.5, center_y - 2.0),
QPointF(center_x, center_y + 3.0),
]
)
)
elif kind == "minimize":
painter.drawLine(
QPointF(center_x - 5.0, center_y + 3.0),
QPointF(center_x + 5.0, center_y + 3.0),
)
elif kind == "notification":
painter.drawArc(
QRectF(center_x - 5.0, center_y - 5.5, 10.0, 11.0),
15 * 16,
150 * 16,
)
painter.drawLine(
QPointF(center_x - 5.0, center_y + 2.5),
QPointF(center_x + 5.0, center_y + 2.5),
)
painter.drawEllipse(QRectF(center_x - 1.0, center_y + 5.0, 2.0, 1.5))
elif kind == "settings":
painter.drawEllipse(QRectF(center_x - 5.5, center_y - 5.5, 11.0, 11.0))
painter.drawEllipse(QRectF(center_x - 2.0, center_y - 2.0, 4.0, 4.0))
for x1, y1, x2, y2 in (
(0, -8, 0, -5),
(0, 5, 0, 8),
(-8, 0, -5, 0),
(5, 0, 8, 0),
):
painter.drawLine(
QPointF(center_x + x1, center_y + y1),
QPointF(center_x + x2, center_y + y2),
)
elif kind == "close":
painter.drawLine(
QPointF(center_x - 3.5, center_y - 3.5),
QPointF(center_x + 3.5, center_y + 3.5),
)
painter.drawLine(
QPointF(center_x + 3.5, center_y - 3.5),
QPointF(center_x - 3.5, center_y + 3.5),
)
finally:
painter.end()
return QIcon(pixmap)
def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
"""Return a compact line icon with a dedicated checked-state color."""
def render(color: str) -> QPixmap:
pixmap = crisp_pixmap(size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor(color), 1.55)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
try:
if kind == "reception":
painter.drawEllipse(QRectF(3.5, 3.5, 11, 11))
painter.drawLine(QPointF(9, 6), QPointF(9, 12))
painter.drawLine(QPointF(6, 9), QPointF(12, 9))
elif kind == "appointments":
painter.drawRoundedRect(QRectF(2.5, 4, 13, 11.5), 2, 2)
painter.drawLine(QPointF(3, 7.5), QPointF(15, 7.5))
painter.drawLine(QPointF(6, 2.5), QPointF(6, 5.5))
painter.drawLine(QPointF(12, 2.5), QPointF(12, 5.5))
painter.drawLine(QPointF(6, 10.5), QPointF(8, 10.5))
painter.drawLine(QPointF(10.5, 10.5), QPointF(12.5, 10.5))
elif kind == "prescription_library":
painter.drawRoundedRect(QRectF(2.5, 5, 13, 10.5), 2, 2)
painter.drawLine(QPointF(3.5, 5), QPointF(6.5, 2.8))
painter.drawLine(QPointF(6.5, 2.8), QPointF(10, 5))
painter.drawLine(QPointF(6, 9), QPointF(12, 9))
painter.drawLine(QPointF(6, 12), QPointF(11, 12))
elif kind == "prescriptions":
painter.drawRoundedRect(QRectF(4, 2.5, 10, 13), 1.5, 1.5)
painter.drawLine(QPointF(6.5, 6.5), QPointF(11.5, 6.5))
painter.drawLine(QPointF(6.5, 9.5), QPointF(11.5, 9.5))
painter.drawLine(QPointF(6.5, 12.5), QPointF(10, 12.5))
elif kind == "patients":
painter.drawEllipse(QRectF(6.5, 2.5, 5, 5))
painter.drawArc(QRectF(3.5, 8, 11, 8), 15 * 16, 150 * 16)
painter.drawArc(QRectF(3.5, 8, 11, 8), 195 * 16, 150 * 16)
else:
painter.drawRoundedRect(QRectF(2.5, 3, 13, 10.5), 2.5, 2.5)
painter.drawLine(QPointF(6, 7), QPointF(12, 7))
painter.drawLine(QPointF(6, 10), QPointF(10.5, 10))
painter.drawLine(QPointF(6, 13), QPointF(4.5, 15.5))
finally:
painter.end()
return pixmap
icon = QIcon()
icon.addPixmap(render("#7481A3"), QIcon.Mode.Normal, QIcon.State.Off)
icon.addPixmap(render("#FFFFFF"), QIcon.Mode.Normal, QIcon.State.On)
icon.addPixmap(render("#405074"), QIcon.Mode.Active, QIcon.State.Off)
icon.addPixmap(render("#A4ADC3"), QIcon.Mode.Disabled, QIcon.State.Off)
return icon
class _ShellBrandMark(QWidget):
"""Render the approved application brand mark in the navigation rail."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._brand_pixmap = QPixmap(str(app_icon_path()))
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
if not self._brand_pixmap.isNull():
painter.drawPixmap(rect, self._brand_pixmap, QRectF(self._brand_pixmap.rect()))
class _AssistantRobot(QWidget):
"""Small source-matched robot illustration for the persistent AI card."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setFixedSize(56, 56)
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EEF2FF"))
painter.drawEllipse(QRectF(1, 1, 54, 54))
painter.setBrush(QColor("#DDE6FF"))
painter.drawEllipse(QRectF(9, 13, 38, 36))
pen = QPen(QColor("#6A78ED"), 1.5)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(QColor("#FFFFFF"))
painter.drawRoundedRect(QRectF(12, 17, 32, 25), 9, 9)
painter.setBrush(QColor("#223D83"))
painter.drawRoundedRect(QRectF(16, 22, 24, 13), 6, 6)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#65D7F0"))
painter.drawEllipse(QRectF(21, 26, 3.5, 3.5))
painter.drawEllipse(QRectF(31.5, 26, 3.5, 3.5))
painter.setBrush(QColor("#5E6EF4"))
painter.drawRoundedRect(QRectF(19, 40, 18, 9), 4, 4)
painter.setPen(pen)
painter.drawLine(QPointF(28, 17), QPointF(28, 10))
painter.setBrush(QColor("#6B77F4"))
painter.drawEllipse(QRectF(25.5, 6.5, 5, 5))
painter.drawLine(QPointF(12, 28), QPointF(7.5, 31))
painter.drawLine(QPointF(44, 28), QPointF(48.5, 31))
class _AssistantStatus(QLabel):
"""Status copy with a painted dot instead of a Unicode circle glyph."""
def __init__(self, text: str, parent: QWidget | None = None) -> None:
super().__init__(text, parent)
self._online = True
self.setFixedHeight(18)
def set_online(self, online: bool) -> None:
self._online = online
self.update()
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#28B98B" if self._online else "#EC5266")
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawEllipse(QRectF(1, self.height() / 2 - 3, 6, 6))
painter.setPen(color)
font = QFont(self.font())
font.setPixelSize(10)
painter.setFont(font)
painter.drawText(
QRectF(12, 0, self.width() - 12, self.height()),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
self.text(),
)
class _PaintedIconButton(QToolButton):
"""Font-independent shell icon used for every directional affordance."""
def __init__(
self,
kind: str,
*,
size: int = 42,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.kind = kind
icon_size = 12 if kind == "close" else 18
self.setObjectName("ShellPaintedIconButton")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setAutoRaise(True)
self.setFixedSize(size, size)
self.setIcon(_painted_shell_icon(kind, icon_size))
self.setIconSize(QSize(icon_size, icon_size))
content_size = max(0, size - 2)
self.setStyleSheet(
f"""
QToolButton#ShellPaintedIconButton {{
min-width: {content_size}px;
max-width: {content_size}px;
min-height: {content_size}px;
max-height: {content_size}px;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
}}
QToolButton#ShellPaintedIconButton:hover {{
background: #F0F3FC;
border-color: #E2E7F4;
}}
QToolButton#ShellPaintedIconButton:pressed {{ background: #EEF1FF; }}
QToolButton#ShellPaintedIconButton:focus {{ border-color: #8D9BFF; }}
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {{
background: #FFF0F2;
border-color: #F4C4CC;
}}
QToolButton#ShellPaintedIconButton::menu-indicator {{ image: none; width: 0; }}
"""
)
class _UserMenuButton(QToolButton):
"""Compact admin-style user dropdown without font-glyph arrows."""
def __init__(self, display_name: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.display_name = display_name or "医生"
self.setObjectName("ShellUserMenu")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.setFixedHeight(_SHELL_TOPBAR_HEIGHT)
name_width = self.fontMetrics().horizontalAdvance(self.display_name)
self.setFixedWidth(max(104, min(174, name_width + 72)))
self.setAccessibleName(f"用户菜单:{self.display_name}")
self.setText(self.display_name)
avatar = crisp_pixmap(34)
painter = QPainter(avatar)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#E9EDFF"))
painter.drawEllipse(QRectF(0, 0, 34, 34))
painter.setPen(QColor("#3446AF"))
avatar_font = QFont(painter.font())
avatar_font.setWeight(QFont.Weight.DemiBold)
painter.setFont(avatar_font)
painter.drawText(
QRectF(0, 0, 34, 34), Qt.AlignmentFlag.AlignCenter, self.display_name[:1]
)
painter.end()
self.setIcon(QIcon(avatar))
self.setIconSize(QSize(34, 34))
self.setStyleSheet(
"""
QToolButton#ShellUserMenu {
color: #15224A;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
padding: 0 18px 0 7px;
font-size: 13px;
font-weight: 500;
}
QToolButton#ShellUserMenu:hover { background: #F0F3FC; border-color: #E2E7F4; }
QToolButton#ShellUserMenu:pressed { background: #EEF1FF; }
QToolButton#ShellUserMenu:focus { border-color: #8D9BFF; }
QToolButton#ShellUserMenu::menu-indicator {
subcontrol-origin: padding;
subcontrol-position: right center;
right: 7px;
}
"""
)
class _ShellCanvas(QWidget):
"""Outer frameless-window gutter with native edge resizing."""
_RESIZE_MARGIN = 8
def _resize_edges(self, position: QPointF) -> Qt.Edge:
edges = Qt.Edge(0)
if position.x() <= self._RESIZE_MARGIN:
edges |= Qt.Edge.LeftEdge
elif position.x() >= self.width() - self._RESIZE_MARGIN:
edges |= Qt.Edge.RightEdge
if position.y() <= self._RESIZE_MARGIN:
edges |= Qt.Edge.TopEdge
elif position.y() >= self.height() - self._RESIZE_MARGIN:
edges |= Qt.Edge.BottomEdge
return edges
def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt virtual
edges = self._resize_edges(event.position())
diagonal_down = Qt.Edge.LeftEdge | Qt.Edge.TopEdge
diagonal_up = Qt.Edge.LeftEdge | Qt.Edge.BottomEdge
if edges in (diagonal_down, Qt.Edge.RightEdge | Qt.Edge.BottomEdge):
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
elif edges in (diagonal_up, Qt.Edge.RightEdge | Qt.Edge.TopEdge):
self.setCursor(Qt.CursorShape.SizeBDiagCursor)
elif edges in (Qt.Edge.LeftEdge, Qt.Edge.RightEdge):
self.setCursor(Qt.CursorShape.SizeHorCursor)
elif edges in (Qt.Edge.TopEdge, Qt.Edge.BottomEdge):
self.setCursor(Qt.CursorShape.SizeVerCursor)
else:
self.unsetCursor()
super().mouseMoveEvent(event)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt virtual
if event.button() == Qt.MouseButton.LeftButton:
edges = self._resize_edges(event.position())
handle = self.window().windowHandle()
if edges and handle is not None and handle.startSystemResize(edges):
event.accept()
return
super().mousePressEvent(event)
class _ShellTopBar(QFrame):
"""Integrated title bar that retains native system move/maximize behavior."""
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt virtual
if event.button() == Qt.MouseButton.LeftButton:
window = self.window()
handle = window.windowHandle()
if handle is not None and handle.startSystemMove():
event.accept()
return
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt virtual
if event.button() == Qt.MouseButton.LeftButton:
window = self.window()
if window.isMaximized():
window.showNormal()
else:
window.showMaximized()
event.accept()
return
super().mouseDoubleClickEvent(event)
class ShellWindow(QMainWindow):
"""Main workstation window.
The expected construction signature is ``ShellWindow(repository, session,
permissions=None)``. ``session`` may be a Session dataclass, the payload
emitted by :class:`LoginWindow`, or a plain mapping.
"""
logout_requested = Signal()
video_requested = Signal(dict)
page_changed = Signal(str)
update_check_requested = Signal()
def __init__(
self,
repository: Any,
session: Any,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("甄养堂 · 问诊中心")
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
self.repository = repository
self.login_payload = session
self.session = get_value(session, "session", None) or session
self.current_user = (
get_value(session, "user", None)
or get_value(self.session, "user", None)
or get_value(session, "current_user", None)
or session
)
if permissions is not None:
self.permissions = permissions
else:
session_permissions = get_value(self.session, "permissions", None)
self.permissions = (
session_permissions
if session_permissions is not None
else get_value(self.current_user, "permissions", None)
)
self._can_ai_assistant = can_open_ai_consult(self.permissions)
session_menu = get_value(self.session, "menu", None)
self.menu = (
session_menu if session_menu is not None else get_value(session, "menu", [])
)
self.demo_mode = bool(
get_value(session, "demo_mode", False)
or get_value(self.session, "metadata.demo", False)
or get_value(self.session, "metadata.demo_mode", False)
)
self.navigation = _resolve_navigation(
self.menu,
self.permissions,
demo_mode=self.demo_mode,
)
self.pages: dict[str, QWidget] = {}
self.nav_buttons: dict[str, QPushButton] = {}
self.page_titles: dict[int, str] = {}
self._fixed_tab_key: str | None = None
self._sidebar_collapsed = False
self._active_page_key: str | None = None
self._activation_page: QWidget | None = None
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
available_size = screen.availableGeometry().size() if screen is not None else None
self.resize(_bounded_initial_window_size(available_size))
canvas = _ShellCanvas(self)
canvas.setObjectName("AppCanvas")
canvas.setMouseTracking(True)
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(
_SHELL_OUTER_GUTTER,
_SHELL_OUTER_GUTTER,
_SHELL_OUTER_GUTTER,
_SHELL_OUTER_GUTTER,
)
root.setSpacing(_SHELL_PANEL_GAP)
self.sidebar = self._build_sidebar()
root.addWidget(self.sidebar)
self.workspace = QWidget()
self.workspace.setObjectName("ShellWorkspace")
self.workspace.setStyleSheet(
"""
QWidget#ShellWorkspace {
background-color: #FCFDFE;
border: 0;
border-radius: 16px;
}
"""
)
workspace_layout = QVBoxLayout(self.workspace)
workspace_layout.setContentsMargins(0, 0, 0, 0)
workspace_layout.setSpacing(0)
self.topbar = self._build_topbar()
workspace_layout.addWidget(self.topbar)
self.tabs_host = self._build_tab_strip()
workspace_layout.addWidget(self.tabs_host)
self.stack = QStackedWidget()
self.stack.setObjectName("ShellPageStack")
self.stack.setStyleSheet(
"QStackedWidget#ShellPageStack {"
" background-color: #FCFDFE; border: 0;"
" border-bottom-left-radius: 16px; border-bottom-right-radius: 16px;"
" }"
)
workspace_layout.addWidget(self.stack, 1)
root.addWidget(self.workspace, 1)
self._register_pages()
self.search_shortcut = QShortcut(QKeySequence("Ctrl+K"), self)
self.search_shortcut.activated.connect(self._focus_global_search)
def _build_sidebar(self) -> QWidget:
sidebar = QWidget()
sidebar.setObjectName("Sidebar")
sidebar.setFixedWidth(_SHELL_EXPANDED_WIDTH)
sidebar.setStyleSheet(
"""
QWidget#Sidebar {
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 #F4F7FE, stop: 1 #EEF3FD
);
border: 0;
border-radius: 16px;
}
QFrame#ShellBrand {
background-color: transparent;
border: 0;
border-radius: 0;
}
QLabel#ShellBrandName { color: #111F46; font-size: 14px; font-weight: 700; }
QLabel#ShellBrandSubtitle { color: #8190B2; font-size: 10px; }
QPushButton#ShellNavButton {
min-height: 45px;
max-height: 45px;
padding: 0 14px;
margin: 0;
border: 1px solid transparent;
border-radius: 8px;
background-color: transparent;
color: #3F4E75;
text-align: left;
font-size: 13px;
font-weight: 600;
}
QPushButton#ShellNavButton:hover {
background-color: #EEF2FC;
border-color: #E4E9F6;
color: #111F46;
}
QPushButton#ShellNavButton:pressed { background-color: #EEF1FF; }
QPushButton#ShellNavButton:focus { border-color: #8D9BFF; }
QPushButton#ShellNavButton:checked {
color: #FFFFFF;
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 #5360F2, stop: 1 #7068F7
);
border-color: #6171F7;
font-weight: 700;
}
QPushButton#ShellNavButton[collapsed="true"] {
padding: 0;
text-align: center;
margin: 0;
}
QFrame#ShellAssistantCard {
background-color: rgba(255, 255, 255, 230);
border: 1px solid #E5EAF6;
border-radius: 12px;
}
QLabel#ShellAssistantGlyph {
color: #5265F6;
background-color: #EEF1FF;
border: 1px solid rgba(82, 101, 246, 48);
border-radius: 18px;
font-size: 12px;
font-weight: 800;
}
QLabel#ShellAssistantName { color: #111F46; font-size: 13px; font-weight: 700; }
QLabel#ShellAssistantStatus { color: #159C79; font-size: 10px; }
QPushButton#ShellAssistantButton {
min-height: 34px;
color: #FFFFFF;
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 #5360F2, stop: 1 #7068F7
);
border: 0;
border-radius: 9px;
font-weight: 700;
}
QPushButton#ShellAssistantButton:hover { background-color: #4658E8; }
QPushButton#ShellSettingsButton {
min-height: 42px;
color: #6E7C9F;
font-size: 11px;
font-weight: 600;
background-color: transparent;
border: 1px solid transparent;
border-radius: 10px;
}
QPushButton#ShellSettingsButton:hover {
color: #5265F6;
background-color: #EEF1FF;
}
QPushButton#ShellSettingsButton:focus { border-color: #8D9BFF; }
"""
)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
brand = QFrame(sidebar)
brand.setObjectName("ShellBrand")
brand.setFixedHeight(75)
brand_layout = QHBoxLayout(brand)
brand_layout.setContentsMargins(21, 17, 10, 8)
brand_layout.setSpacing(10)
self.brand_mark = _ShellBrandMark(brand)
self.brand_mark.setObjectName("ShellBrandMark")
self.brand_mark.setFixedSize(36, 36)
brand_layout.addWidget(self.brand_mark)
self.brand_copy = QWidget(brand)
brand_copy_layout = QVBoxLayout(self.brand_copy)
brand_copy_layout.setContentsMargins(0, 0, 0, 0)
brand_copy_layout.setSpacing(1)
self.brand_name = QLabel("问诊中心", self.brand_copy)
self.brand_name.setObjectName("ShellBrandName")
brand_copy_layout.addWidget(self.brand_name)
self.brand_subtitle = QLabel("糖尿病专科版", self.brand_copy)
self.brand_subtitle.setObjectName("ShellBrandSubtitle")
brand_copy_layout.addWidget(self.brand_subtitle)
brand_layout.addWidget(self.brand_copy, 1)
layout.addWidget(brand)
self.nav_layout = QVBoxLayout()
self.nav_layout.setContentsMargins(12, 19, 12, 0)
self.nav_layout.setSpacing(0)
layout.addLayout(self.nav_layout)
layout.addStretch(1)
self.assistant_card = QFrame(sidebar)
self.assistant_card.setObjectName("ShellAssistantCard")
self.assistant_card.setFixedHeight(157)
assistant_layout = QVBoxLayout(self.assistant_card)
assistant_layout.setContentsMargins(12, 12, 12, 20)
assistant_layout.setSpacing(0)
assistant_head_host = QWidget(self.assistant_card)
assistant_head_host.setFixedHeight(60)
assistant_head = QHBoxLayout(assistant_head_host)
assistant_head.setContentsMargins(0, 0, 0, 0)
assistant_head.setSpacing(9)
glyph = _AssistantRobot(assistant_head_host)
assistant_head.addWidget(glyph)
assistant_copy = QVBoxLayout()
assistant_copy.setContentsMargins(0, 5, 0, 5)
assistant_copy.setSpacing(1)
assistant_name = QLabel("AI 助手", self.assistant_card)
assistant_name.setObjectName("ShellAssistantName")
assistant_copy.addWidget(assistant_name)
self.assistant_status = _AssistantStatus("在线", assistant_head_host)
self.assistant_status.setObjectName("ShellAssistantStatus")
assistant_copy.addWidget(self.assistant_status)
assistant_head.addLayout(assistant_copy, 1)
assistant_layout.addWidget(assistant_head_host)
assistant_layout.addStretch(1)
self.assistant_button = QPushButton("开始对话", self.assistant_card)
self.assistant_button.setObjectName("ShellAssistantButton")
self.assistant_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.assistant_button.setFixedHeight(36)
self.assistant_button.clicked.connect(self._open_ai_assistant)
assistant_layout.addWidget(self.assistant_button)
outer_assistant = QHBoxLayout()
outer_assistant.setContentsMargins(13, 0, 13, 0)
outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant)
self.assistant_card.setVisible(self._can_ai_assistant)
self.upload_settings_button = QPushButton("设置 ", sidebar)
self.upload_settings_button.setObjectName("ShellSettingsButton")
self.upload_settings_button.setAccessibleName("本机录音上传设置")
self.upload_settings_button.setToolTip("查看本机录音上传记录")
self.upload_settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.upload_settings_button.setFixedHeight(76)
self.upload_settings_button.clicked.connect(self._open_local_audio_settings)
# Keep the former attribute as a compatibility alias for integrations
# that inspect the bottom sidebar control.
self.model_label = self.upload_settings_button
layout.addWidget(self.upload_settings_button)
return sidebar
def _build_topbar(self) -> QWidget:
topbar = _ShellTopBar(self.workspace)
topbar.setObjectName("TopBar")
topbar.setFixedHeight(_SHELL_TOPBAR_HEIGHT)
topbar.setStyleSheet(
"""
QFrame#TopBar {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #EFF1F7;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
QFrame#ShellGlobalSearch {
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
}
QLineEdit#ShellGlobalSearchInput {
min-height: 34px;
max-height: 34px;
padding: 0 8px;
color: #405074;
background-color: transparent;
border: 0;
border-radius: 9px;
}
QLineEdit#ShellGlobalSearchInput QToolButton {
min-width: 22px;
max-width: 22px;
min-height: 18px;
max-height: 18px;
margin: 0;
padding: 0;
background-color: transparent;
border: 0;
border-radius: 0;
}
QLineEdit#ShellGlobalSearchInput QToolButton:hover,
QLineEdit#ShellGlobalSearchInput QToolButton:pressed,
QLineEdit#ShellGlobalSearchInput QToolButton:focus {
background-color: transparent;
border: 0;
}
QLabel#ShellShortcutHint {
color: #8A95AF;
background-color: #F0F3FC;
border: 1px solid #E2E7F4;
border-radius: 6px;
padding: 2px 6px;
font-size: 10px;
}
QPushButton#ShellAiEntry {
min-width: 38px;
max-width: 38px;
min-height: 38px;
max-height: 38px;
padding: 0;
color: #5265F6;
background-color: transparent;
border: 0;
border-radius: 9px;
font-weight: 700;
}
QPushButton#ShellAiEntry:hover { background-color: #EEF1FF; }
QLabel#ShellConnectionBadge {
color: #7481A3;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
}
QLabel#ShellConnectionBadge[kind="success"] {
color: #16876C;
background-color: #E8F6F1;
border-color: #B9E3D7;
}
QLabel#ShellConnectionBadge[kind="danger"] {
color: #EC5266;
background-color: #FFF0F2;
border-color: #F4C4CC;
}
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {
background-color: #FFF0F2;
border-color: #F4C4CC;
}
QMenu {
color: #15224A;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
padding: 6px;
}
QMenu::item { min-width: 112px; min-height: 32px; padding: 0 12px; border-radius: 6px; }
QMenu::item:selected { color: #3C4FD9; background-color: #EEF1FF; }
QMenu::item:disabled { color: #A4ADC3; }
QMenu::separator { height: 1px; background: #E2E7F4; margin: 5px 8px; }
"""
)
layout = QHBoxLayout(topbar)
layout.setContentsMargins(20, 0, 10, 0)
layout.setSpacing(5)
self.fold_button = _PaintedIconButton("fold", size=38, parent=topbar)
self.fold_button.setToolTip("收起或展开菜单")
self.fold_button.setAccessibleName("收起或展开菜单")
self.fold_button.clicked.connect(self.toggle_sidebar)
layout.addWidget(self.fold_button)
layout.addSpacing(9)
self.refresh_button = _PaintedIconButton("refresh", size=38, parent=topbar)
self.refresh_button.setToolTip("刷新当前页面")
self.refresh_button.setAccessibleName("刷新当前页面")
self.refresh_button.clicked.connect(self.refresh_current_page)
self.refresh_button.hide()
search_host = QFrame(topbar)
search_host.setObjectName("ShellGlobalSearch")
search_host.setFixedSize(265, 36)
search_layout = QHBoxLayout(search_host)
search_layout.setContentsMargins(4, 0, 7, 0)
search_layout.setSpacing(3)
self.global_search = QLineEdit(search_host)
self.global_search.setObjectName("ShellGlobalSearchInput")
self.global_search.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.global_search.setPlaceholderText("搜索患者姓名、手机号、病历号")
self.global_search.setClearButtonEnabled(True)
self.global_search.addAction(
_painted_shell_icon("search", 16),
QLineEdit.ActionPosition.LeadingPosition,
)
self.global_search.returnPressed.connect(self._run_global_search)
search_layout.addWidget(self.global_search, 1)
shortcut_hint = QLabel("Ctrl K", search_host)
shortcut_hint.setObjectName("ShellShortcutHint")
shortcut_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
shortcut_hint.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
search_layout.addWidget(shortcut_hint, 0, Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(search_host)
self.context_label = QLabel("工作台", topbar)
self.context_label.setObjectName("ShellBreadcrumbCurrent")
self.context_label.hide()
layout.addStretch(1)
self.ai_top_button = QPushButton("", topbar)
self.ai_top_button.setObjectName("ShellAiEntry")
self.ai_top_button.setIcon(_painted_shell_icon("ai", 18))
self.ai_top_button.setIconSize(QSize(18, 18))
self.ai_top_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.ai_top_button.setToolTip("AI 助手")
self.ai_top_button.setAccessibleName("AI 助手")
self.ai_top_button.clicked.connect(self._open_ai_assistant)
layout.addWidget(self.ai_top_button)
self.ai_top_button.setVisible(self._can_ai_assistant)
self.notification_button = _PaintedIconButton(
"notification", size=38, parent=topbar
)
self.notification_button.setToolTip("消息通知")
self.notification_button.clicked.connect(
lambda: show_toast(self, "当前没有新的系统通知。", "info")
)
layout.addWidget(self.notification_button)
self.settings_button = _PaintedIconButton("settings", size=38, parent=topbar)
self.settings_button.setToolTip("设置中心")
self.settings_button.clicked.connect(
lambda: show_toast(self, "设置中心将沿用当前账号的系统配置。", "info")
)
layout.addWidget(self.settings_button)
self.connection_badge = StatusBadge("服务正常", "success", topbar)
self.connection_badge.setObjectName("ShellConnectionBadge")
self.connection_badge.hide()
display_name = display_text(
first_value(
self.current_user,
"name",
"display_name",
"nickname",
"account",
default="医生",
)
)
self.user_menu_button = _UserMenuButton(display_name, topbar)
self.user_menu_button.setToolTip(f"{display_name} · {self._role_text()}")
user_menu = QMenu(self.user_menu_button)
account_action = user_menu.addAction(self._role_text())
account_action.setEnabled(False)
user_menu.addSeparator()
update_action = user_menu.addAction("检查更新")
update_action.triggered.connect(self.update_check_requested.emit)
user_menu.addSeparator()
logout_action = user_menu.addAction("退出登录")
logout_action.triggered.connect(lambda: self.logout_requested.emit())
self.user_menu_button.setMenu(user_menu)
layout.addWidget(self.user_menu_button)
self.minimize_button = _PaintedIconButton("minimize", size=34, parent=topbar)
self.minimize_button.setToolTip("最小化")
self.minimize_button.clicked.connect(self.showMinimized)
layout.addWidget(self.minimize_button)
self.fullscreen_button = _PaintedIconButton(
"fullscreen", size=34, parent=topbar
)
self.fullscreen_button.setToolTip("最大化")
self.fullscreen_button.setAccessibleName("切换最大化")
self.fullscreen_button.clicked.connect(self._toggle_maximized)
layout.addWidget(self.fullscreen_button)
self.close_button = _PaintedIconButton("close", size=34, parent=topbar)
self.close_button.setProperty("windowControl", "close")
self.close_button.setToolTip("关闭")
self.close_button.clicked.connect(self.close)
layout.addWidget(self.close_button)
return topbar
def _build_tab_strip(self) -> QWidget:
tabs_host = QFrame()
tabs_host.setObjectName("MultipleTabs")
tabs_host.setFixedHeight(_SHELL_TABS_HEIGHT)
tabs_host.setStyleSheet(
"""
QFrame#MultipleTabs {
background-color: #FFFFFF;
border-top: 0;
border-bottom: 1px solid #D8DEEA;
}
QTabBar#ShellTabs { background-color: #FFFFFF; }
QTabBar#ShellTabs::tab {
min-height: 40px;
max-height: 40px;
min-width: 84px;
padding: 0 14px;
margin: 0 2px;
color: #667085;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
font-size: 13px;
font-weight: 500;
}
QTabBar#ShellTabs::tab:hover { color: #172033; background-color: #F7F8FC; }
QTabBar#ShellTabs::tab:selected {
color: #3446AF;
background-color: #F7F8FC;
border-bottom: 2px solid #4F63D9;
font-weight: 700;
}
QMenu {
color: #172033;
background-color: #FFFFFF;
border: 1px solid #D8DEEA;
border-radius: 10px;
padding: 6px;
}
QMenu::item { min-width: 112px; min-height: 32px; padding: 0 12px; border-radius: 6px; }
QMenu::item:selected { color: #3446AF; background-color: #E9EDFF; }
QMenu::item:disabled { color: #98A2B3; }
QMenu::separator { height: 1px; background: #D8DEEA; margin: 5px 8px; }
"""
)
layout = QHBoxLayout(tabs_host)
layout.setContentsMargins(16, 0, 0, 0)
layout.setSpacing(0)
self.tab_bar = QTabBar()
self.tab_bar.setObjectName("ShellTabs")
self.tab_bar.setDocumentMode(True)
self.tab_bar.setDrawBase(False)
self.tab_bar.setExpanding(False)
self.tab_bar.setUsesScrollButtons(True)
self.tab_bar.setElideMode(Qt.TextElideMode.ElideRight)
self.tab_bar.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.tab_bar.currentChanged.connect(self._tab_selected)
layout.addWidget(self.tab_bar, 1)
self.tabs_menu_button = _PaintedIconButton("down")
self.tabs_menu_button.setToolTip("标签页操作")
self.tabs_menu_button.setAccessibleName("标签页操作")
self.tabs_menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
tabs_menu = QMenu(self.tabs_menu_button)
self.close_current_action = tabs_menu.addAction("关闭当前")
self.close_current_action.triggered.connect(self.close_current_tab)
self.close_other_action = tabs_menu.addAction("关闭其他")
self.close_other_action.triggered.connect(self.close_other_tabs)
self.close_all_action = tabs_menu.addAction("关闭全部")
self.close_all_action.triggered.connect(self.close_all_tabs)
self.tabs_menu_button.setMenu(tabs_menu)
layout.addWidget(self.tabs_menu_button)
self._update_tab_actions()
tabs_host.hide()
return tabs_host
def _role_text(self) -> str:
department = first_value(
self.current_user, "department_name", "department.name", default=""
)
role_ids = (
first_value(self.current_user, "role_ids", "role_id", default=[]) or []
)
if not isinstance(role_ids, (list, tuple, set, frozenset)):
role_ids = [role_ids]
role_values = {str(value) for value in role_ids}
role = (
"医生"
if "1" in role_values
else "医助"
if "2" in role_values
else "医疗人员"
)
return f"{department} · {role}" if department else role
def toggle_sidebar(self) -> None:
"""Toggle the admin-style compact menu while preserving the page state."""
self._sidebar_collapsed = not self._sidebar_collapsed
self.sidebar.setFixedWidth(
_SHELL_COLLAPSED_WIDTH
if self._sidebar_collapsed
else self._expanded_sidebar_width()
)
self.brand_copy.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(
self._can_ai_assistant and not self._sidebar_collapsed
)
self.upload_settings_button.setVisible(not self._sidebar_collapsed)
margins = (7, 14, 7, 0) if self._sidebar_collapsed else (12, 19, 12, 0)
self.nav_layout.setContentsMargins(*margins)
self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold"
self.fold_button.setIcon(_painted_shell_icon(self.fold_button.kind))
for button in self.nav_buttons.values():
title = str(button.property("navTitle") or "")
button.setText("" if self._sidebar_collapsed else title)
button.setToolTip(title if self._sidebar_collapsed else "")
button.setProperty("collapsed", self._sidebar_collapsed)
button.style().unpolish(button)
button.style().polish(button)
def _expanded_sidebar_width(self, key: str | None = None) -> int:
if key is None:
current = self.stack.currentWidget() if hasattr(self, "stack") else None
key = next(
(name for name, page in self.pages.items() if page is current), None
)
return _PAGE_SIDEBAR_WIDTHS.get(str(key or ""), _SHELL_EXPANDED_WIDTH)
def _toggle_maximized(self) -> None:
if self.isMaximized():
self.showNormal()
self.fullscreen_button.setToolTip("最大化")
else:
self.showMaximized()
self.fullscreen_button.setToolTip("还原")
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
self.fullscreen_button.setToolTip("全屏模式")
else:
self.showFullScreen()
self.fullscreen_button.setToolTip("退出全屏")
def _focus_global_search(self) -> None:
self.global_search.setFocus(Qt.FocusReason.ShortcutFocusReason)
self.global_search.selectAll()
def _run_global_search(self) -> None:
query = self.global_search.text().strip()
if not query:
self._focus_global_search()
return
page = self.stack.currentWidget()
if page is None:
return
target: QLineEdit | None = None
for attribute in (
"patient_input",
"keyword_edit",
"search_edit",
"patient_filter",
"name_filter",
"sn_filter",
):
candidate = getattr(page, attribute, None)
if isinstance(candidate, QLineEdit) and not candidate.isHidden():
target = candidate
break
if target is None:
for candidate in page.findChildren(QLineEdit):
placeholder = candidate.placeholderText()
if not candidate.isHidden() and any(
marker in placeholder
for marker in ("患者", "姓名", "手机号", "处方")
):
target = candidate
break
if target is None:
show_toast(self, "当前页面没有可用的快捷搜索条件。", "warning")
return
target.setText(query)
search = getattr(page, "_search", None)
if callable(search):
search()
else:
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
show_toast(self, f"正在当前页面搜索“{query}”。", "info")
def _open_ai_assistant(self) -> None:
if not self._can_ai_assistant:
show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger")
return
# The shell assistant is the global entry point. Always let the doctor
# choose from the complete permission-scoped patient list, even when a
# page happens to retain a selected row. Page-level AI actions keep the
# faster "open the selected patient" behaviour.
select_and_present_ai_consult(
self.repository,
self.permissions,
self,
initial_query=self.global_search.text().strip(),
)
def _open_local_audio_settings(self) -> None:
current = self._local_audio_settings_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(self.repository, None, self)
except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
show_toast(self, f"无法打开本机录音上传设置:{error}", "error")
return
self._local_audio_settings_dialog = dialog
def clear_dialog(_result: int) -> None:
if self._local_audio_settings_dialog is dialog:
self._local_audio_settings_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
def _tab_index_for_key(self, key: str) -> int:
for index in range(self.tab_bar.count()):
if self.tab_bar.tabData(index) == key:
return index
return -1
def visited_tab_keys(self) -> tuple[str, ...]:
"""Return open tabs in visit order for tests and session diagnostics."""
return tuple(
str(self.tab_bar.tabData(index)) for index in range(self.tab_bar.count())
)
def _ensure_tab(self, key: str, title: str) -> int:
index = self._tab_index_for_key(key)
if index >= 0:
self.tab_bar.setTabText(index, title)
return index
index = self.tab_bar.addTab(title)
self.tab_bar.setTabData(index, key)
if self._fixed_tab_key is None:
self._fixed_tab_key = key
else:
close_button = _PaintedIconButton("close", size=18, parent=self.tab_bar)
close_button.setToolTip(f"关闭 {title}")
close_button.setAccessibleName(f"关闭标签页:{title}")
close_button.clicked.connect(
lambda _checked=False, page_key=key: self.close_tab(page_key)
)
self.tab_bar.setTabButton(
index, QTabBar.ButtonPosition.RightSide, close_button
)
self._update_tab_actions()
return index
def _set_current_tab(self, key: str) -> None:
index = self._tab_index_for_key(key)
if index < 0:
return
blocked = self.tab_bar.blockSignals(True)
self.tab_bar.setCurrentIndex(index)
self.tab_bar.blockSignals(blocked)
self._update_tab_actions()
def _tab_selected(self, index: int) -> None:
if index < 0:
return
key = str(self.tab_bar.tabData(index) or "")
page = self.pages.get(key)
if page is None:
return
self._navigate(self.stack.indexOf(page), key)
def _update_tab_actions(self) -> None:
if not hasattr(self, "close_current_action"):
return
current_index = self.tab_bar.currentIndex()
current_key = (
str(self.tab_bar.tabData(current_index) or "") if current_index >= 0 else ""
)
removable = [
key for key in self.visited_tab_keys() if key != self._fixed_tab_key
]
self.close_current_action.setEnabled(
bool(current_key and current_key != self._fixed_tab_key)
)
self.close_other_action.setEnabled(any(key != current_key for key in removable))
self.close_all_action.setEnabled(bool(removable))
def close_tab(self, key: str) -> bool:
"""Close one non-fixed visited page and re-route if it was active."""
index = self._tab_index_for_key(key)
if index < 0 or key == self._fixed_tab_key:
return False
active_key = ""
if self.tab_bar.currentIndex() >= 0:
active_key = str(self.tab_bar.tabData(self.tab_bar.currentIndex()) or "")
keys = list(self.visited_tab_keys())
fallback = keys[index - 1] if index > 0 else keys[index + 1]
close_button = self.tab_bar.tabButton(index, QTabBar.ButtonPosition.RightSide)
blocked = self.tab_bar.blockSignals(True)
self.tab_bar.removeTab(index)
self.tab_bar.blockSignals(blocked)
if close_button is not None:
close_button.deleteLater()
if active_key == key:
self.navigate(fallback)
else:
self._set_current_tab(active_key)
self._update_tab_actions()
return True
def close_current_tab(self) -> bool:
index = self.tab_bar.currentIndex()
if index < 0:
return False
return self.close_tab(str(self.tab_bar.tabData(index) or ""))
def close_other_tabs(self) -> None:
current_index = self.tab_bar.currentIndex()
current_key = (
str(self.tab_bar.tabData(current_index) or "") if current_index >= 0 else ""
)
keep = {current_key, self._fixed_tab_key}
for key in reversed(self.visited_tab_keys()):
if key not in keep:
self.close_tab(key)
if current_key:
self._set_current_tab(current_key)
self._update_tab_actions()
def close_all_tabs(self) -> None:
for key in reversed(self.visited_tab_keys()):
if key != self._fixed_tab_key:
self.close_tab(key)
if self._fixed_tab_key:
self.navigate(self._fixed_tab_key)
self._update_tab_actions()
def _register_pages(self) -> None:
self.nav_group = QButtonGroup(self)
self.nav_group.setExclusive(True)
first_button: QPushButton | None = None
for item, title in self.navigation:
page = item.page_type(
self.repository,
permissions=self.permissions,
current_user=self.current_user,
parent=self.stack,
)
self._install_activation_refresh_gate(page)
if hasattr(page, "video_requested"):
page.video_requested.connect(
lambda payload: self.video_requested.emit(payload)
)
index = self.stack.addWidget(page)
self.pages[item.key] = page
self.page_titles[index] = title
button = QPushButton(title)
button.setObjectName("ShellNavButton")
button.setIcon(_painted_navigation_icon(item.key))
button.setIconSize(QSize(18, 18))
button.setProperty("navGlyph", item.glyph)
button.setProperty("navTitle", title)
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(
lambda _checked=False, page_index=index, key=item.key: self._navigate(
page_index, key
)
)
self.nav_group.addButton(button, index)
self.nav_layout.addWidget(button)
self.nav_buttons[item.key] = button
if first_button is None:
first_button = button
if first_button is None:
denied = EmptyState(
"暂无可用工作区",
"当前账号没有医生工作站页面权限,请联系管理员调整授权。",
)
index = self.stack.addWidget(denied)
self.page_titles[index] = "权限受限"
self.stack.setCurrentIndex(index)
self.context_label.setText("权限受限")
return
first_button.setChecked(True)
first_index = self.nav_group.id(first_button)
self._navigate(
first_index,
next(
key
for key, button in self.nav_buttons.items()
if button is first_button
),
)
def _install_activation_refresh_gate(self, page: QWidget) -> None:
"""Coalesce lifecycle and shell refreshes during one page activation."""
refresh = getattr(page, "refresh", None)
if not callable(refresh):
return
@wraps(refresh)
def activation_refresh(*args: Any, **kwargs: Any) -> Any:
if page is self._activation_page:
if self._activation_refreshed:
return None
self._activation_refreshed = True
return refresh(*args, **kwargs)
page.refresh = activation_refresh # type: ignore[attr-defined,method-assign]
def _ensure_activation_refresh(self, page: QWidget, generation: int) -> None:
if generation != self._activation_generation or page is not self._activation_page:
return
if not self._activation_refreshed:
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
def _finish_activation(self, page: QWidget, generation: int) -> None:
if generation != self._activation_generation or page is not self._activation_page:
return
self._ensure_activation_refresh(page, generation)
self._activation_page = None
def _navigate(self, index: int, key: str) -> None:
if index < 0 or index >= self.stack.count():
return
page = self.stack.widget(index)
if page is None:
return
if self._active_page_key == key and self.stack.currentWidget() is page:
return
self._activation_generation += 1
activation_generation = self._activation_generation
self._activation_page = page
self._activation_refreshed = False
self.stack.setCurrentIndex(index)
if not self._sidebar_collapsed:
self.sidebar.setFixedWidth(self._expanded_sidebar_width(key))
title = self.page_titles.get(index, "工作台")
self.context_label.setText(title)
self._ensure_tab(key, title)
self._set_current_tab(key)
button = self.nav_buttons.get(key)
if button is not None:
button.setChecked(True)
self._active_page_key = key
self._ensure_activation_refresh(page, activation_generation)
if self.isVisible():
QTimer.singleShot(
0,
lambda page=page, generation=activation_generation: self._finish_activation(
page, generation
),
)
self.page_changed.emit(key)
def navigate(self, key: str) -> bool:
"""Navigate to a visible page by stable key; return whether it exists."""
button = self.nav_buttons.get(key)
page = self.pages.get(key)
if button is None or page is None:
return False
self._navigate(self.stack.indexOf(page), key)
return True
def refresh_current_page(self) -> None:
page = self.stack.currentWidget()
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
page = self.stack.currentWidget()
if page is None or page is not self._activation_page:
return
generation = self._activation_generation
QTimer.singleShot(
0,
lambda page=page, generation=generation: self._finish_activation(page, generation),
)
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
if visible:
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
super().setVisible(visible)
def set_connection_state(self, online: bool, message: str = "") -> None:
self.connection_badge.set_status(
message or ("服务正常" if online else "连接中断"),
"success" if online else "danger",
)
self.assistant_status.setText("在线" if online else "服务离线")
self.assistant_status.set_online(online)
__all__ = ["NAVIGATION", "NavigationItem", "ShellWindow"]