Files
zyt/app/src/doctor_workstation/ui/shell.py
T
2026-08-11 09:12:51 +08:00

995 lines
37 KiB
Python

"""Authenticated application shell with permission-aware navigation."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygonF
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QPushButton,
QSizePolicy,
QStackedWidget,
QTabBar,
QToolButton,
QVBoxLayout,
QWidget,
)
from .pages import (
ConsultationsPage,
PatientsPage,
PrescriptionLibraryPage,
PrescriptionsPage,
ReceptionPage,
)
from .widgets import EmptyState, StatusBadge, display_text, first_value, get_value
@dataclass(frozen=True)
class NavigationItem:
key: str
title: str
glyph: str
page_type: type[QWidget]
permissions: tuple[str, ...]
NAVIGATION = (
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",),
),
)
_NAVIGATION_BY_PERMISSION = {item.permissions[0]: item for item in NAVIGATION}
_MENU_ROUTE_IDENTIFIERS = {
"reception": {
"reception",
"patient/reception",
"patient/reception/index",
},
"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:
for permission in _menu_permissions(row):
item = _NAVIGATION_BY_PERMISSION.get(permission)
if item is not None:
return item
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[item.key]:
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
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)
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 = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
try:
color = QColor("#606266")
center_x = size / 2
center_y = size / 2
pen = QPen(color, 1.6)
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 == "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 == "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)
class _PaintedIconButton(QToolButton):
"""Font-independent shell icon used for every directional affordance."""
def __init__(
self,
kind: str,
*,
size: int = 40,
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))
self.setStyleSheet(
"""
QToolButton#ShellPaintedIconButton { background: transparent; border: 0; }
QToolButton#ShellPaintedIconButton:hover { background: #F0F2F5; }
QToolButton#ShellPaintedIconButton:pressed { background: #E6E8EB; }
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(50)
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 = QPixmap(34, 34)
avatar.fill(Qt.GlobalColor.transparent)
painter = QPainter(avatar)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EEF0FF"))
painter.drawEllipse(QRectF(0, 0, 34, 34))
painter.setPen(QColor("#4A5DFF"))
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: #303133;
background: transparent;
border: 0;
padding: 0 18px 0 7px;
font-size: 13px;
font-weight: 500;
}
QToolButton#ShellUserMenu:hover { background: #F0F2F5; }
QToolButton#ShellUserMenu:pressed { background: #E6E8EB; }
QToolButton#ShellUserMenu::menu-indicator {
subcontrol-origin: padding;
subcontrol-position: right center;
right: 7px;
}
"""
)
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)
def __init__(
self,
repository: Any,
session: Any,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
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)
)
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.setWindowTitle("臻阳堂 · 医生工作站")
self.setMinimumSize(1024, 640)
self.resize(1280, 800)
canvas = QWidget()
canvas.setObjectName("AppCanvas")
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self.sidebar = self._build_sidebar()
root.addWidget(self.sidebar)
self.workspace = QWidget()
self.workspace.setObjectName("ShellWorkspace")
self.workspace.setStyleSheet("QWidget#ShellWorkspace { background-color: #F6F6F6; }")
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: #F6F6F6; }")
workspace_layout.addWidget(self.stack, 1)
root.addWidget(self.workspace, 1)
self._register_pages()
def _build_sidebar(self) -> QWidget:
sidebar = QWidget()
sidebar.setObjectName("Sidebar")
sidebar.setFixedWidth(183)
sidebar.setStyleSheet(
"""
QWidget#Sidebar { background-color: #1D2124; border-right: 1px solid #25292D; }
QFrame#ShellBrand { background-color: #1D2124; border: 0; }
QLabel#ShellBrandMark {
color: #FFFFFF;
background-color: #4A5DFF;
border: 0;
border-radius: 7px;
font-size: 15px;
font-weight: 700;
}
QLabel#ShellBrandName { color: #FFFFFF; font-size: 14px; font-weight: 600; }
QPushButton#ShellNavButton {
min-height: 46px;
max-height: 46px;
padding: 0 16px;
border: 0;
border-radius: 0;
background-color: transparent;
color: #E5EAF3;
text-align: left;
font-size: 14px;
font-weight: 400;
}
QPushButton#ShellNavButton:hover { background-color: #292E32; color: #FFFFFF; }
QPushButton#ShellNavButton:pressed { background-color: #34393E; }
QPushButton#ShellNavButton:checked { background-color: #4A5DFF; color: #FFFFFF; }
QPushButton#ShellNavButton[collapsed="true"] { padding: 0; text-align: center; }
"""
)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
brand = QFrame()
brand.setObjectName("ShellBrand")
brand.setFixedHeight(50)
brand_layout = QHBoxLayout(brand)
brand_layout.setContentsMargins(10, 0, 8, 0)
brand_layout.setSpacing(9)
self.brand_mark = QLabel("诊")
self.brand_mark.setObjectName("ShellBrandMark")
self.brand_mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.brand_mark.setFixedSize(30, 30)
brand_layout.addWidget(self.brand_mark)
self.brand_name = QLabel("医生工作站")
self.brand_name.setObjectName("ShellBrandName")
brand_layout.addWidget(self.brand_name, 1)
layout.addWidget(brand)
self.nav_layout = QVBoxLayout()
self.nav_layout.setContentsMargins(0, 0, 0, 0)
self.nav_layout.setSpacing(0)
layout.addLayout(self.nav_layout)
layout.addStretch(1)
return sidebar
def _build_topbar(self) -> QWidget:
topbar = QFrame()
topbar.setObjectName("TopBar")
topbar.setFixedHeight(50)
topbar.setStyleSheet(
"""
QFrame#TopBar { background-color: #FFFFFF; border-bottom: 1px solid #E4E7ED; }
QLabel#ShellBreadcrumbHome { color: #909399; font-size: 13px; }
QLabel#ShellBreadcrumbSeparator { color: #C0C4CC; font-size: 13px; }
QLabel#ShellBreadcrumbCurrent { color: #303133; font-size: 13px; font-weight: 500; }
QLabel#ShellConnectionBadge {
color: #606266;
background-color: #F0F2F5;
border: 0;
border-radius: 3px;
padding: 3px 7px;
font-size: 11px;
font-weight: 500;
}
QLabel#ShellConnectionBadge[kind="success"] { color: #529B2E; background-color: #F0F9EB; }
QLabel#ShellConnectionBadge[kind="danger"] { color: #C45656; background-color: #FEF0F0; }
QMenu {
color: #303133;
background-color: #FFFFFF;
border: 1px solid #E4E7ED;
padding: 5px;
}
QMenu::item { min-width: 112px; min-height: 30px; padding: 0 12px; }
QMenu::item:selected { color: #4A5DFF; background-color: #EEF0FF; }
"""
)
layout = QHBoxLayout(topbar)
layout.setContentsMargins(8, 0, 8, 0)
layout.setSpacing(0)
self.fold_button = _PaintedIconButton("fold")
self.fold_button.setToolTip("收起或展开菜单")
self.fold_button.setAccessibleName("收起或展开菜单")
self.fold_button.clicked.connect(self.toggle_sidebar)
layout.addWidget(self.fold_button)
self.refresh_button = _PaintedIconButton("refresh")
self.refresh_button.setToolTip("刷新当前页面")
self.refresh_button.setAccessibleName("刷新当前页面")
self.refresh_button.clicked.connect(self.refresh_current_page)
layout.addWidget(self.refresh_button)
breadcrumb_home = QLabel("首页")
breadcrumb_home.setObjectName("ShellBreadcrumbHome")
layout.addWidget(breadcrumb_home)
breadcrumb_separator = QLabel(" / ")
breadcrumb_separator.setObjectName("ShellBreadcrumbSeparator")
layout.addWidget(breadcrumb_separator)
self.context_label = QLabel("工作台")
self.context_label.setObjectName("ShellBreadcrumbCurrent")
layout.addWidget(self.context_label)
layout.addStretch(1)
self.connection_badge = StatusBadge("服务正常", "success")
self.connection_badge.setObjectName("ShellConnectionBadge")
layout.addWidget(self.connection_badge)
display_name = display_text(
first_value(
self.current_user, "name", "display_name", "nickname", "account", default="医生"
)
)
self.fullscreen_button = _PaintedIconButton("fullscreen")
self.fullscreen_button.setToolTip("全屏模式")
self.fullscreen_button.setAccessibleName("切换全屏模式")
self.fullscreen_button.clicked.connect(self._toggle_fullscreen)
layout.addWidget(self.fullscreen_button)
self.user_menu_button = _UserMenuButton(display_name)
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()
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)
return topbar
def _build_tab_strip(self) -> QWidget:
tabs_host = QFrame()
tabs_host.setObjectName("MultipleTabs")
tabs_host.setFixedHeight(40)
tabs_host.setStyleSheet(
"""
QFrame#MultipleTabs {
background-color: #FFFFFF;
border-top: 0;
border-bottom: 1px solid #E4E7ED;
}
QTabBar#ShellTabs { background-color: #FFFFFF; }
QTabBar#ShellTabs::tab {
min-height: 38px;
max-height: 38px;
min-width: 72px;
padding: 0 14px;
margin: 0;
color: #606266;
background-color: #FFFFFF;
border: 0;
border-top: 2px solid transparent;
border-radius: 0;
font-size: 13px;
font-weight: 400;
}
QTabBar#ShellTabs::tab:hover { color: #303133; background-color: #F5F7FA; }
QTabBar#ShellTabs::tab:selected {
color: #303133;
background-color: #EEF0FF;
border-top: 2px solid #4A5DFF;
}
QMenu {
color: #303133;
background-color: #FFFFFF;
border: 1px solid #E4E7ED;
padding: 5px;
}
QMenu::item { min-width: 112px; min-height: 30px; padding: 0 12px; }
QMenu::item:selected { color: #4A5DFF; background-color: #EEF0FF; }
"""
)
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()
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(64 if self._sidebar_collapsed else 183)
self.brand_name.setVisible(not self._sidebar_collapsed)
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():
glyph = str(button.property("navGlyph") or "")
title = str(button.property("navTitle") or "")
button.setText(glyph if self._sidebar_collapsed else f"{glyph} {title}")
button.setToolTip(title if self._sidebar_collapsed else "")
button.setProperty("collapsed", self._sidebar_collapsed)
button.style().unpolish(button)
button.style().polish(button)
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
self.fullscreen_button.setToolTip("全屏模式")
else:
self.showFullScreen()
self.fullscreen_button.setToolTip("退出全屏")
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,
)
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(f"{item.glyph} {title}")
button.setObjectName("ShellNavButton")
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 _navigate(self, index: int, key: str) -> None:
if index < 0 or index >= self.stack.count():
return
self.stack.setCurrentIndex(index)
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)
page = self.stack.widget(index)
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
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 set_connection_state(self, online: bool, message: str = "") -> None:
self.connection_badge.set_status(
message or ("服务正常" if online else "连接中断"),
"success" if online else "danger",
)
__all__ = ["NAVIGATION", "NavigationItem", "ShellWindow"]