995 lines
37 KiB
Python
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"]
|