1874 lines
71 KiB
Python
1874 lines
71 KiB
Python
"""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"]
|