PCM_Viewer/main.py

2173 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
PCM Viewer - Widgets 版 Dashboard 入口
技术栈PyQt6 Widgets + QGraphicsView/QGraphicsScene
功能(当前版本,先实现基础骨架):
- 左侧画布:可拖拽的组件(图片组件 / Web 组件)
- 右侧面板:显示并编辑当前选中组件的几何和基本配置
"""
from __future__ import annotations
import json
import os
import sys
import socket
import threading
from dataclasses import dataclass, asdict, field
from typing import Optional, Dict, Any
import math
# 忽略 Windows 显示缩放设置,使用物理像素
# 这确保画布 1920x1080 能正确对应屏幕像素,避免 Web 组件被缩放
os.environ["QT_SCALE_FACTOR"] = "1"
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "0"
from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer, QPointF, QSizeF
from PyQt6.QtWidgets import QSizePolicy
from PyQt6.QtGui import QFontMetrics
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor, QShortcut, QKeySequence, QIcon, QAction
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QSystemTrayIcon,
QMenu,
QWidget,
QHBoxLayout,
QVBoxLayout,
QSplitter,
QPushButton,
QLabel,
QLineEdit,
QSpinBox,
QFileDialog,
QFormLayout,
QGraphicsView,
QGraphicsScene,
QGraphicsRectItem,
QGraphicsPixmapItem,
QGraphicsTextItem,
QGraphicsProxyWidget,
QGraphicsItem,
QMessageBox,
QPlainTextEdit,
QListWidget,
QListWidgetItem,
QColorDialog,
QGroupBox,
QDialog,
QDialogButtonBox,
QCheckBox,
QFrame,
)
# QWebEngineView 必须在 QApplication 创建之前导入Qt 要求)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QUrl
# 延迟导入InfluxDB 客户端只在需要时导入(减少启动时间)
# from influxdb_wrapper import InfluxDBClient
# 导入样式模块
from styles import MAIN_STYLE, Colors, CANVAS_STYLE, ITEM_STYLE
# 应用全局样式
def apply_styles(app: QApplication):
"""应用全局样式表"""
app.setStyleSheet(MAIN_STYLE)
# -----------------------------
# 数据结构(用于保存/加载布局)
# -----------------------------
@dataclass
class WidgetState:
widget_type: str # "image" | "web" | "label" | "arrow"
x: float
y: float
w: float
h: float
z: float = 0.0
config: Dict[str, Any] = field(default_factory=dict)
@dataclass
class InfluxSettings:
url: str = ""
token: str = ""
org: str = ""
bucket: str = ""
interval_ms: int = 1000
query: str = ""
# -----------------------------
# 画布组件基类
# -----------------------------
class DashboardItem(QGraphicsRectItem):
"""可拖拽、可简单缩放的组件基类"""
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(parent)
self.setRect(QRectF(0, 0, w, h))
self.setPos(x, y)
self.setFlags(
QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsRectItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
# 启用鼠标悬停事件,用于改变光标样式
self.setAcceptHoverEvents(True)
# 组件本体颜色稍微比画布亮一些,方便区分
self.setBrush(QBrush(QColor(60, 60, 60)))
self.setPen(QPen(QColor(120, 120, 120), 1))
self._resizing = False
self._resize_margin = 8
self._drag_start_rect: Optional[QRectF] = None
self._drag_start_pos = None
self._resize_edge = None
def _is_in_resize_area(self, pt: QPointF, r: QRectF) -> Optional[str]:
"""检测鼠标是否在可调整大小的区域,返回边缘标识"""
margin = self._resize_margin
w = r.width()
h = r.height()
x = pt.x()
y = pt.y()
# 四个角
if x <= margin and y <= margin:
return "top-left"
elif x >= w - margin and y <= margin:
return "top-right"
elif x <= margin and y >= h - margin:
return "bottom-left"
elif x >= w - margin and y >= h - margin:
return "bottom-right"
# 四个边
elif x <= margin:
return "left"
elif x >= w - margin:
return "right"
elif y <= margin:
return "top"
elif y >= h - margin:
return "bottom"
return None
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
r = self.rect()
pt = event.pos()
edge = self._is_in_resize_area(pt, r)
if edge:
self._resizing = True
self._resize_edge = edge
self._drag_start_rect = QRectF(r)
self._drag_start_pos = pt
event.accept()
return
super().mousePressEvent(event)
def hoverMoveEvent(self, event):
"""鼠标悬停移动时,根据位置改变光标样式"""
if not self._resizing:
r = self.rect()
pt = event.pos()
margin = self._resize_margin
# 右下角:调整大小光标
if (r.width() - margin <= pt.x() <= r.width() and
r.height() - margin <= pt.y() <= r.height()):
self.setCursor(QCursor(Qt.CursorShape.SizeFDiagCursor))
# 右上角
elif (r.width() - margin <= pt.x() <= r.width() and
0 <= pt.y() <= margin):
self.setCursor(QCursor(Qt.CursorShape.SizeBDiagCursor))
# 左下角
elif (0 <= pt.x() <= margin and
r.height() - margin <= pt.y() <= r.height()):
self.setCursor(QCursor(Qt.CursorShape.SizeBDiagCursor))
# 左上角
elif (0 <= pt.x() <= margin and 0 <= pt.y() <= margin):
self.setCursor(QCursor(Qt.CursorShape.SizeFDiagCursor))
# 右边缘
elif r.width() - margin <= pt.x() <= r.width():
self.setCursor(QCursor(Qt.CursorShape.SizeHorCursor))
# 左边缘
elif 0 <= pt.x() <= margin:
self.setCursor(QCursor(Qt.CursorShape.SizeHorCursor))
# 下边缘
elif r.height() - margin <= pt.y() <= r.height():
self.setCursor(QCursor(Qt.CursorShape.SizeVerCursor))
# 上边缘
elif 0 <= pt.y() <= margin:
self.setCursor(QCursor(Qt.CursorShape.SizeVerCursor))
else:
# 其他区域恢复默认光标如果正在拖拽Qt会自动处理为移动光标
self.unsetCursor()
super().hoverMoveEvent(event)
def mouseMoveEvent(self, event):
if self._resizing and self._drag_start_rect is not None and self._drag_start_pos is not None and self._resize_edge:
delta = event.pos() - self._drag_start_pos
r = self._drag_start_rect
new_w = r.width()
new_h = r.height()
pos_delta_x = 0
pos_delta_y = 0
# 根据拖拽的边缘调整大小和位置
if "right" in self._resize_edge:
new_w = max(40, r.width() + delta.x())
elif "left" in self._resize_edge:
old_w = new_w
new_w = max(40, r.width() - delta.x())
# 从左边调整:需要向右移动,移动距离 = 宽度减少量
pos_delta_x = old_w - new_w
if "bottom" in self._resize_edge:
new_h = max(40, r.height() + delta.y())
elif "top" in self._resize_edge:
old_h = new_h
new_h = max(40, r.height() - delta.y())
# 从上边调整:需要向下移动,移动距离 = 高度减少量
pos_delta_y = old_h - new_h
# 更新矩形rect 是相对于 item 的局部坐标,从 0,0 开始)
self.setRect(QRectF(0, 0, new_w, new_h))
# 如果是从左边或上边调整,需要移动 item 的位置以保持视觉上的左上角不变
if pos_delta_x != 0 or pos_delta_y != 0:
current_pos = self.pos()
self.setPos(current_pos.x() + pos_delta_x, current_pos.y() + pos_delta_y)
self.on_resized()
event.accept()
return
super().mouseMoveEvent(event)
def hoverLeaveEvent(self, event):
"""鼠标离开组件时,恢复默认光标"""
self.unsetCursor()
super().hoverLeaveEvent(event)
def mouseReleaseEvent(self, event):
if self._resizing:
self._resizing = False
self._resize_edge = None
self._drag_start_rect = None
self._drag_start_pos = None
# 调整大小结束后强制更新,确保位置和大小变化被记录
self.update()
event.accept()
return
# 拖拽结束后也强制更新
super().mouseReleaseEvent(event)
self.update()
# 高亮选中效果
def paint(self, painter, option, widget=None):
if self.isSelected():
self.setPen(QPen(QColor(80, 160, 255), 2))
else:
self.setPen(QPen(QColor(120, 120, 120), 1))
super().paint(painter, option, widget)
def to_state(self) -> WidgetState:
"""将组件状态转换为 WidgetState使用精确的 pos() 和 rect(),而不是 sceneBoundingRect()"""
pos = self.pos()
r = self.rect()
return WidgetState(
widget_type=self._get_widget_type(),
x=float(pos.x()),
y=float(pos.y()),
w=float(r.width()),
h=float(r.height()),
z=float(self.zValue()),
config=self._get_config(),
)
def _get_widget_type(self) -> str:
"""子类需要重写,返回组件类型"""
raise NotImplementedError
def _get_config(self) -> Dict[str, Any]:
"""子类需要重写,返回配置字典"""
raise NotImplementedError
def apply_state(self, state: WidgetState):
self.setPos(state.x, state.y)
self.setRect(QRectF(0, 0, state.w, state.h))
# 分层z 值)
try:
self.setZValue(float(state.z))
except Exception:
self.setZValue(0.0)
self.on_resized()
def on_resized(self):
"""子类可重写:在几何变化后调整内部元素(例如 Web 视图大小、图片缩放等)"""
pass
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value):
"""位置变化时的处理"""
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
# 位置变化完成后强制更新
self.update()
return super().itemChange(change, value)
class ImageItem(DashboardItem):
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(x, y, w, h, parent)
# 图片组件背景色设为纯白色
self.setBrush(QBrush(QColor(255, 255, 255)))
self._image_path: str = ""
self._pixmap_item = QGraphicsPixmapItem(self)
self._pixmap_item.setPos(0, 0)
self._pixmap_item.setZValue(0)
self.on_resized()
def set_image(self, path: str):
self._image_path = path
if path and os.path.exists(path):
pm = QPixmap(path)
self._pixmap_item.setPixmap(pm)
self.on_resized()
def on_resized(self):
pm = self._pixmap_item.pixmap()
if pm.isNull():
return
# 按当前 rect 大致等比缩放填充
scale = min(
self.rect().width() / pm.width() if pm.width() else 1,
self.rect().height() / pm.height() if pm.height() else 1,
)
self._pixmap_item.setScale(scale)
def _get_widget_type(self) -> str:
return "image"
def _get_config(self) -> Dict[str, Any]:
return {"imagePath": self._image_path}
def apply_state(self, state: WidgetState):
super().apply_state(state)
path = state.config.get("imagePath", "")
if path:
self.set_image(path)
class LabelItem(DashboardItem):
"""独立的标签组件:显示一个字段的值,可单独拖拽/缩放"""
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(x, y, w, h, parent)
# 标签本身背景透明,默认浅灰边框(在编辑模式下)
self.setBrush(QBrush(Qt.GlobalColor.transparent))
self._field_name: str = ""
self._prefix: str = ""
self._suffix: str = ""
self._font_size: int = 16
self._color: str = "#FFFFFF"
self._edit_mode: bool = True # 由 MainWindow 控制,用于隐藏展示模式下的边框
self._text_item = QGraphicsTextItem(self)
self._text_item.setDefaultTextColor(QColor(self._color))
self._text_item.setFont(QFont("Arial", self._font_size))
self._update_preview_text()
self.on_resized()
def set_edit_mode(self, editing: bool):
"""编辑模式下显示浅灰/高亮边框,展示模式下完全隐藏边框"""
self._edit_mode = editing
self.update()
# 配置接口供属性面板调用
def set_label_config(
self,
field_name: Optional[str] = None,
prefix: Optional[str] = None,
suffix: Optional[str] = None,
font_size: Optional[int] = None,
color: Optional[str] = None,
):
if field_name is not None:
self._field_name = field_name
if prefix is not None:
self._prefix = prefix
if suffix is not None:
self._suffix = suffix
if font_size is not None:
self._font_size = int(font_size)
self._text_item.setFont(QFont("Arial", self._font_size))
if color is not None:
self._color = color
self._text_item.setDefaultTextColor(QColor(self._color))
self._update_preview_text()
self.on_resized()
def label_config(self) -> Dict[str, Any]:
return {
"fieldName": self._field_name,
"prefix": self._prefix,
"suffix": self._suffix,
"fontSize": self._font_size,
"color": self._color,
}
def _update_preview_text(self, value: Optional[Any] = None):
if value is None:
base = self._field_name or "label"
else:
# 所有 label如果是数值统一保留两位小数
num = None
if isinstance(value, (int, float)):
num = float(value)
else:
# 尝试把字符串解析成数字
try:
num = float(str(value))
except (TypeError, ValueError):
num = None
if num is not None:
base = f"{num:.2f}"
else:
base = str(value)
txt = f"{self._prefix}{base}{self._suffix}"
self._text_item.setPlainText(txt)
def on_resized(self):
# 文本居中到矩形内部
r = self.rect()
br = self._text_item.boundingRect()
x = max(0, (r.width() - br.width()) / 2)
y = max(0, (r.height() - br.height()) / 2)
self._text_item.setPos(x, y)
def apply_influx_data(self, data: dict):
"""后续全局 Influx 刷新时调用,根据 fieldName 更新显示"""
if self._field_name and self._field_name in data:
self._update_preview_text(data[self._field_name])
def _get_widget_type(self) -> str:
return "label"
def _get_config(self) -> Dict[str, Any]:
return self.label_config()
def apply_state(self, state: WidgetState):
super().apply_state(state)
cfg = state.config or {}
self.set_label_config(
field_name=str(cfg.get("fieldName", "")),
prefix=str(cfg.get("prefix", "")),
suffix=str(cfg.get("suffix", "")),
font_size=int(cfg.get("fontSize", 16)),
color=str(cfg.get("color", "#FFFFFF")),
)
def paint(self, painter, option, widget=None):
"""重写绘制:标签背景透明,编辑模式有边框,展示模式无边框"""
if not self._edit_mode:
# 展示模式:不画任何边框和背景,边界完全透明
# 直接返回,不调用父类的 paint避免绘制任何边框
return
# 编辑模式:选中时高亮强调色,否则使用边框色;背景依然透明
# 先设置 pen确保父类 paint 使用正确的边框颜色
if self.isSelected():
self.setPen(QPen(QColor(Colors.ACCENT), 2))
else:
self.setPen(QPen(QColor(Colors.BORDER), 1))
# 确保背景透明
self.setBrush(QBrush(Qt.GlobalColor.transparent))
super().paint(painter, option, widget)
class ArrowItem(DashboardItem):
"""箭头组件:用于在底图上标记流向/位置,本身不绑定数据,配合标签组件使用。"""
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(x, y, w, h, parent)
# 背景透明,只在编辑模式下画出虚线边框
self.setBrush(QBrush(Qt.GlobalColor.transparent))
self._color: str = "#00FF00"
self._width: int = 3
self._angle_deg: float = 0.0
self._edit_mode: bool = True
def set_edit_mode(self, editing: bool):
self._edit_mode = bool(editing)
self.update()
def set_arrow_config(
self,
color: Optional[str] = None,
width: Optional[int] = None,
angle_deg: Optional[float] = None,
):
if color is not None:
self._color = str(color)
if width is not None:
self._width = max(1, int(width))
if angle_deg is not None:
# 归一化到 [0, 360)
a = float(angle_deg) % 360.0
# 避免出现 360.0
if abs(a - 360.0) < 1e-9:
a = 0.0
self._angle_deg = a
self.update()
def arrow_config(self) -> Dict[str, Any]:
return {
"color": self._color,
"width": self._width,
"angle": self._angle_deg,
}
def _get_widget_type(self) -> str:
return "arrow"
def _get_config(self) -> Dict[str, Any]:
return self.arrow_config()
def apply_state(self, state: WidgetState):
super().apply_state(state)
cfg = state.config or {}
self.set_arrow_config(
color=str(cfg.get("color", "#00FF00")),
width=int(cfg.get("width", 3)),
angle_deg=float(cfg.get("angle", 0.0)),
)
def paint(self, painter, option, widget=None):
"""编辑模式:画虚线边框 + 两端圆点线段;展示模式:只画线段和圆点,不画边框。"""
r = self.rect()
if r.width() <= 1 or r.height() <= 1:
return
painter.setRenderHint(painter.RenderHint.Antialiasing, True)
# 编辑模式下的外框
if self._edit_mode:
border_color = QColor(80, 160, 255) if self.isSelected() else QColor(150, 150, 150)
border_pen = QPen(border_color, 1, Qt.PenStyle.DashLine)
painter.setPen(border_pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(r)
# 画线段和两端圆点(在 rect 内按角度绘制;不使用 item rotation避免影响边界/磁吸/约束)
arrow_color = QColor(self._color)
pen = QPen(arrow_color, self._width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(QBrush(arrow_color))
# 计算在矩形中可容纳的最长线段(以中心为基准,沿角度正负方向延伸)
margin = 10.0
cx, cy = r.center().x(), r.center().y()
rad = math.radians(self._angle_deg)
vx, vy = math.cos(rad), math.sin(rad)
# 防止除零
eps = 1e-6
ax = max(abs(vx), eps)
ay = max(abs(vy), eps)
t_max_x = (r.width() / 2 - margin) / ax
t_max_y = (r.height() / 2 - margin) / ay
t = max(5.0, min(t_max_x, t_max_y))
start = QPointF(cx - vx * t, cy - vy * t)
end = QPointF(cx + vx * t, cy + vy * t)
# 绘制连接线
painter.drawLine(start, end)
# 绘制终点圆点(箭头指向)
w = float(max(1, self._width))
dot_radius = max(4.0, min(12.0, 3.0 + w * 1.2))
painter.drawEllipse(end, dot_radius, dot_radius)
class WebItem(DashboardItem):
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(x, y, w, h, parent)
self._url: str = ""
self._locked: bool = True # True: 锁定(不能点击网页,只能拖拽组件)
self._proxy = QGraphicsProxyWidget(self)
# QWebEngineView 已在文件顶部导入(必须在 QApplication 创建前导入)
self._view = QWebEngineView()
self._proxy.setWidget(self._view)
self._proxy.setPos(0, 0)
self._proxy.setMinimumWidth(100)
self._proxy.setMinimumHeight(80)
# 初始为“锁定”:不让内嵌控件抢占鼠标点击,点击事件交给外层 DashboardItem用于选中/拖拽
self.set_locked(True)
self.on_resized()
def set_url(self, url: str):
self._url = url.strip()
if self._url and "://" not in self._url:
self._url = "https://" + self._url
# QWebEngineView 需要 QUrl 对象
self._view.setUrl(QUrl(self._url))
def set_locked(self, locked: bool):
"""锁定:不能点击网页内容,只能拖拽组件;解锁:可以点击网页内容"""
self._locked = bool(locked)
if self._locked:
self._proxy.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
else:
self._proxy.setAcceptedMouseButtons(Qt.MouseButton.AllButtons)
def is_locked(self) -> bool:
return self._locked
def _get_widget_type(self) -> str:
return "web"
def _get_config(self) -> Dict[str, Any]:
return {
"url": self._url,
"locked": self._locked,
}
def apply_state(self, state: WidgetState):
super().apply_state(state)
url = state.config.get("url", "")
if url:
self.set_url(url)
# 兼容旧数据:默认锁定
self.set_locked(bool(state.config.get("locked", True)))
def on_resized(self):
# 让内嵌的 Web 视图铺满当前组件矩形
r = self.rect()
self._proxy.setGeometry(r)
# -----------------------------
# Dashboard 场景 / 视图
# -----------------------------
class DashboardScene(QGraphicsScene):
selectionChangedEx = pyqtSignal(object) # 当前选中的 DashboardItem
def __init__(self, parent=None):
super().__init__(parent)
self.selectionChanged.connect(self._on_selection_changed)
self._canvas_item: Optional[QGraphicsRectItem] = None
self.reset_scene()
def _on_selection_changed(self):
items = self.selectedItems()
self.selectionChangedEx.emit(items[0] if items else None)
@property
def canvas_item(self) -> QGraphicsRectItem:
return self._canvas_item
def canvas_rect(self) -> QRectF:
return self._canvas_item.rect()
def set_canvas_rect(self, r: QRectF):
# canvas_item 用 rect 表示范围;其位置用 setPos 表示“画布坐标原点”
self._canvas_item.setRect(QRectF(0, 0, max(1.0, r.width()), max(1.0, r.height())))
self._canvas_item.setPos(r.x(), r.y())
self.setSceneRect(QRectF(r.x(), r.y(), r.width(), r.height()))
def canvas_state(self) -> Dict[str, float]:
r = self._canvas_item.rect()
p = self._canvas_item.pos()
return {"x": float(p.x()), "y": float(p.y()), "w": float(r.width()), "h": float(r.height())}
def set_canvas_edit_mode(self, edit_mode: bool):
# 编辑时显示边界;展示时隐藏边界
if edit_mode:
self._canvas_item.setPen(QPen(QColor(Colors.BORDER), 1, Qt.PenStyle.DashLine))
else:
self._canvas_item.setPen(QPen(Qt.GlobalColor.transparent))
def reset_scene(self):
"""清空所有 items并重建 canvas item保留信号连接"""
self.clear()
# 画布边界(用于定义“屏幕/展示区域”的坐标系与尺寸)
self._canvas_item = QGraphicsRectItem()
self._canvas_item.setZValue(-1e9)
# 画布使用白色配色
self._canvas_item.setPen(QPen(QColor(200, 200, 200), 1, Qt.PenStyle.DashLine)) # 浅灰色边框,在白色背景下可见
self._canvas_item.setBrush(QBrush(QColor(255, 255, 255))) # 纯白色背景
self._canvas_item.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable, False)
self._canvas_item.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable, False)
self.addItem(self._canvas_item)
# 默认画布0,0 1920x1080可点击画布空白处修改
self.set_canvas_rect(QRectF(0, 0, 1920, 1080))
class DashboardView(QGraphicsView):
canvasClicked = pyqtSignal(object) # QRectF
def __init__(self, parent=None):
super().__init__(parent)
self._scene = DashboardScene(self)
self.setScene(self._scene)
self.setRenderHints(self.renderHints())
# 使用深色主题背景
self.setBackgroundBrush(QColor(Colors.BG_PRIMARY))
# 取消自身的边框,避免全屏时出现白边
self.setFrameShape(QFrame.Shape.NoFrame)
self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
self._edit_mode = True
# 设置视口样式
self.setStyleSheet(f"""
DashboardView {{
background-color: {Colors.BG_PRIMARY};
border: none;
}}
""")
@property
def scene_obj(self) -> DashboardScene:
return self._scene
def set_edit_mode(self, edit_mode: bool):
self._edit_mode = bool(edit_mode)
self._scene.set_canvas_edit_mode(self._edit_mode)
def reset_scroll(self):
"""将滚动条复位到 (0,0) 位置(左上角)。"""
h = self.horizontalScrollBar()
v = self.verticalScrollBar()
if h is not None:
h.setValue(h.minimum())
if v is not None:
v.setValue(v.minimum())
def fit_canvas_to_view(self):
"""让画布完全适应视图(用于全屏展示模式)
关键:保持 1:1 比例,不做任何缩放!
只是隐藏滚动条,让画布完整显示。
如果视图大小与画布匹配,自然就能全屏显示。
"""
# 重置变换为 1:1绝对不能缩放否则 Web 组件会失真
self.resetTransform()
# 滚动条复位到左上角
self.reset_scroll()
# 隐藏滚动条
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
def reset_view_transform(self):
"""重置视图变换(用于退出全屏时恢复)"""
self.resetTransform()
self.reset_scroll()
def mousePressEvent(self, event):
# 点击画布空白处:选中“画布”,由右侧属性面板展示/编辑(仅编辑模式)
if self._edit_mode and event.button() == Qt.MouseButton.LeftButton:
it = self.itemAt(event.pos())
if it is None or it is self._scene.canvas_item:
r = self._scene.canvas_state()
# 清空当前 item 选中,让属性面板进入“画布模式”
self._scene.clearSelection()
self.canvasClicked.emit(QRectF(r["x"], r["y"], r["w"], r["h"]))
event.accept()
return
super().mousePressEvent(event)
# 工厂方法
def add_image_item(self) -> ImageItem:
# 创建新组件前,先清空旧选中,保证只选中新建的这一个
self._scene.clearSelection()
item = ImageItem(20, 20, 400, 300)
self._scene.addItem(item)
item.setSelected(True)
return item
def add_label_item(self) -> LabelItem:
self._scene.clearSelection()
item = LabelItem(50, 50, 160, 60)
self._scene.addItem(item)
item.setSelected(True)
return item
def add_web_item(self) -> WebItem:
self._scene.clearSelection()
item = WebItem(40, 40, 500, 350)
self._scene.addItem(item)
item.setSelected(True)
return item
def add_arrow_item(self) -> ArrowItem:
"""新增一个箭头组件,默认较长,用于指示方向。"""
self._scene.clearSelection()
item = ArrowItem(80, 80, 260, 40)
self._scene.addItem(item)
item.setSelected(True)
return item
# 布局持久化
def save_layout(self, path: str):
states = []
for it in self._scene.items():
if isinstance(it, DashboardItem):
state = it.to_state()
states.append(asdict(state))
with open(path, "w", encoding="utf-8") as f:
payload = {
"canvas": self._scene.canvas_state(),
"widgets": states,
}
json.dump(payload, f, indent=2, ensure_ascii=False)
def load_layout(self, path: str):
if path is None or not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# 兼容旧格式:如果不是列表,直接忽略,避免崩溃
canvas = None
if isinstance(data, dict):
canvas = data.get("canvas", None)
# {"widgets": [...]} 格式
data = data.get("widgets", [])
if not isinstance(data, list):
print("layout file format not recognized, ignore:", path)
return
self._scene.reset_scene()
if isinstance(canvas, dict):
try:
cx = float(canvas.get("x", 0.0))
cy = float(canvas.get("y", 0.0))
cw = float(canvas.get("w", 1920.0))
ch = float(canvas.get("h", 1080.0))
self._scene.set_canvas_rect(QRectF(cx, cy, cw, ch))
except Exception:
pass
for st in data:
if not isinstance(st, dict):
continue
try:
ws = WidgetState(**st)
except TypeError:
continue
if ws.widget_type == "image":
it: DashboardItem = self.add_image_item()
elif ws.widget_type == "web":
it = self.add_web_item()
elif ws.widget_type == "label":
it = self.add_label_item()
elif ws.widget_type == "arrow":
it = self.add_arrow_item()
else:
continue
it.apply_state(ws)
# -----------------------------
# 属性面板
# -----------------------------
class PropertyPanel(QWidget):
canvasGeometryChanged = pyqtSignal(float, float, float, float)
def __init__(self, parent=None):
super().__init__(parent)
self._current_item: Optional[DashboardItem] = None
# mode: "none" | "item" | "canvas"
self._mode: str = "none"
# 注意:不要在这里设置样式表,否则会覆盖全局样式
# 背景色通过父窗口或全局样式控制
self.x_spin = QSpinBox()
self.y_spin = QSpinBox()
self.w_spin = QSpinBox()
self.h_spin = QSpinBox()
self.z_spin = QSpinBox()
for s in (self.x_spin, self.y_spin, self.w_spin, self.h_spin):
s.setRange(-9999, 9999)
self.z_spin.setRange(-999999, 999999)
# 图片组件
self.image_path_edit = QLineEdit()
self.image_browse_btn = QPushButton("浏览...")
# 标签组件
self.label_field = QLineEdit()
self.label_prefix = QLineEdit()
self.label_suffix = QLineEdit()
self.label_font = QSpinBox()
self.label_font.setRange(8, 72)
self.btn_color = QPushButton("颜色...")
self._label_color = "#FFFFFF"
# Web 组件
self.url_edit = QLineEdit()
self.web_interactive_check = QCheckBox("允许点击网页(解锁)")
# 箭头组件
self.arrow_width = QSpinBox()
self.arrow_width.setRange(1, 20)
self.arrow_width.setValue(3)
self.arrow_angle = QSpinBox()
self.arrow_angle.setRange(0, 360)
self.arrow_angle.setValue(0)
self.btn_arrow_color = QPushButton("颜色...")
self._arrow_color = "#00FF00"
form = QFormLayout()
form.setSpacing(10)
form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
form.addRow("X 坐标:", self.x_spin)
form.addRow("Y 坐标:", self.y_spin)
form.addRow("宽度:", self.w_spin)
form.addRow("高度:", self.h_spin)
form.addRow("层级:", self.z_spin)
img_group = QGroupBox("🖼 图片组件")
img_layout = QVBoxLayout(img_group)
img_layout.addWidget(self.image_path_edit)
img_layout.addWidget(self.image_browse_btn)
self.img_group = img_group
label_group = QGroupBox("标签组件")
label_layout = QFormLayout(label_group)
label_layout.addRow("字段(fieldName):", self.label_field)
label_layout.addRow("前缀(prefix):", self.label_prefix)
label_layout.addRow("后缀(suffix):", self.label_suffix)
label_layout.addRow("字体大小:", self.label_font)
label_layout.addRow("颜色:", self.btn_color)
self.label_group = label_group
web_group = QGroupBox("Web 组件 URL")
web_box = QVBoxLayout(web_group)
web_box.addWidget(self.url_edit)
web_box.addWidget(self.web_interactive_check)
self.web_group = web_group
arrow_group = QGroupBox("箭头组件")
arrow_layout = QFormLayout(arrow_group)
arrow_layout.addRow("线宽:", self.arrow_width)
arrow_layout.addRow("角度(°):", self.arrow_angle)
arrow_layout.addRow("颜色:", self.btn_arrow_color)
self.arrow_group = arrow_group
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addSpacing(8)
layout.addWidget(img_group)
layout.addSpacing(8)
layout.addWidget(label_group)
layout.addSpacing(8)
layout.addWidget(web_group)
layout.addSpacing(8)
layout.addWidget(arrow_group)
layout.addStretch(1)
# 连接信号
self.x_spin.valueChanged.connect(self._on_geom_changed)
self.y_spin.valueChanged.connect(self._on_geom_changed)
self.w_spin.valueChanged.connect(self._on_geom_changed)
self.h_spin.valueChanged.connect(self._on_geom_changed)
self.z_spin.valueChanged.connect(self._on_geom_changed)
self.image_browse_btn.clicked.connect(self._on_browse_image)
self.image_path_edit.editingFinished.connect(self._on_image_path_changed)
self.url_edit.editingFinished.connect(self._on_url_changed)
self.web_interactive_check.toggled.connect(self._on_web_interactive_changed)
self.label_field.editingFinished.connect(self._on_label_changed)
self.label_prefix.editingFinished.connect(self._on_label_changed)
self.label_suffix.editingFinished.connect(self._on_label_changed)
self.label_font.valueChanged.connect(self._on_label_changed)
self.btn_color.clicked.connect(self._on_pick_color)
self.arrow_width.valueChanged.connect(self._on_arrow_changed)
self.arrow_angle.valueChanged.connect(self._on_arrow_changed)
self.btn_arrow_color.clicked.connect(self._on_pick_arrow_color)
self._update_enabled(False)
def _update_enabled(self, enabled: bool):
for w in (
self.x_spin,
self.y_spin,
self.w_spin,
self.h_spin,
self.z_spin,
self.image_path_edit,
self.image_browse_btn,
self.web_interactive_check,
self.label_field,
self.label_prefix,
self.label_suffix,
self.label_font,
self.btn_color,
self.url_edit,
self.arrow_width,
self.arrow_angle,
self.btn_arrow_color,
):
w.setEnabled(enabled)
def set_current_item(self, item: Optional[DashboardItem]):
self._current_item = item
if not item:
# 如果当前是 canvas 模式,则保持几何编辑可用;否则整体禁用
if self._mode != "canvas":
self._mode = "none"
self._update_enabled(False)
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
return
self._mode = "item"
self._update_enabled(True)
r = item.sceneBoundingRect()
self.x_spin.blockSignals(True)
self.y_spin.blockSignals(True)
self.w_spin.blockSignals(True)
self.h_spin.blockSignals(True)
self.z_spin.blockSignals(True)
self.x_spin.setValue(int(r.x()))
self.y_spin.setValue(int(r.y()))
self.w_spin.setValue(int(r.width()))
self.h_spin.setValue(int(r.height()))
self.z_spin.setValue(int(item.zValue()))
self.x_spin.blockSignals(False)
self.y_spin.blockSignals(False)
self.w_spin.blockSignals(False)
self.h_spin.blockSignals(False)
self.z_spin.blockSignals(False)
# 类型相关
if isinstance(item, ImageItem):
self.image_path_edit.setText(item._image_path)
self.img_group.setVisible(True)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
self.url_edit.setText("")
self.label_field.setText("")
self.label_prefix.setText("")
self.label_suffix.setText("")
elif isinstance(item, WebItem):
self.image_path_edit.setText("")
self.url_edit.setText(item._url)
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(True)
self.arrow_group.setVisible(False)
# 解锁=允许点击网页内容;锁定=像现在这样只拖拽组件
self.web_interactive_check.blockSignals(True)
self.web_interactive_check.setChecked(not item.is_locked())
self.web_interactive_check.blockSignals(False)
self.label_field.setText("")
self.label_prefix.setText("")
self.label_suffix.setText("")
elif isinstance(item, LabelItem):
self.image_path_edit.setText("")
self.url_edit.setText("")
cfg = item.label_config()
self.label_field.setText(cfg.get("fieldName", ""))
self.label_prefix.setText(cfg.get("prefix", ""))
self.label_suffix.setText(cfg.get("suffix", ""))
self.label_font.blockSignals(True)
self.label_font.setValue(int(cfg.get("fontSize", 16)))
self.label_font.blockSignals(False)
self._label_color = cfg.get("color", "#FFFFFF")
self.img_group.setVisible(False)
self.label_group.setVisible(True)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
elif isinstance(item, ArrowItem):
self.image_path_edit.setText("")
self.url_edit.setText("")
cfg = item.arrow_config()
self.arrow_width.blockSignals(True)
self.arrow_width.setValue(int(cfg.get("width", 3)))
self.arrow_width.blockSignals(False)
self.arrow_angle.blockSignals(True)
self.arrow_angle.setValue(int(float(cfg.get("angle", 0.0)) % 360))
self.arrow_angle.blockSignals(False)
self._arrow_color = cfg.get("color", "#00FF00")
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(True)
def _on_geom_changed(self):
x = self.x_spin.value()
y = self.y_spin.value()
w = max(40, self.w_spin.value())
h = max(40, self.h_spin.value())
z = self.z_spin.value()
if self._mode == "canvas":
# 画布几何变化:通知主窗口更新 scene 的 canvas_rect
self.canvasGeometryChanged.emit(float(x), float(y), float(w), float(h))
return
if not self._current_item:
return
self._current_item.setPos(x, y)
self._current_item.setRect(QRectF(0, 0, w, h))
self._current_item.setZValue(float(z))
# 调用 on_resized 确保内部元素(如图片)正确刷新
self._current_item.on_resized()
def set_canvas(self, rect: QRectF):
"""点击画布时调用右侧面板切换到“画布模式”X/Y/W/H 表示画布本身几何。"""
self._mode = "canvas"
self._current_item = None
# 只用到几何,隐藏组件类别配置
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
self._update_enabled(True)
self.x_spin.blockSignals(True)
self.y_spin.blockSignals(True)
self.w_spin.blockSignals(True)
self.h_spin.blockSignals(True)
self.z_spin.blockSignals(True)
self.x_spin.setValue(int(rect.x()))
self.y_spin.setValue(int(rect.y()))
self.w_spin.setValue(int(rect.width()))
self.h_spin.setValue(int(rect.height()))
# 画布不需要 z 值,固定为 0
self.z_spin.setValue(0)
self.x_spin.blockSignals(False)
self.y_spin.blockSignals(False)
self.w_spin.blockSignals(False)
self.h_spin.blockSignals(False)
self.z_spin.blockSignals(False)
def _on_browse_image(self):
if not isinstance(self._current_item, ImageItem):
return
path, _ = QFileDialog.getOpenFileName(
self,
"选择图片",
"",
"Images (*.png *.jpg *.jpeg *.bmp *.gif)",
)
if path:
self.image_path_edit.setText(path)
self._current_item.set_image(path)
def _on_image_path_changed(self):
if isinstance(self._current_item, ImageItem):
path = self.image_path_edit.text().strip()
if path:
self._current_item.set_image(path)
def _on_url_changed(self):
if isinstance(self._current_item, WebItem):
url = self.url_edit.text().strip()
if url:
self._current_item.set_url(url)
def _on_web_interactive_changed(self, checked: bool):
# checked=True 表示“允许点击网页”,即解锁
if isinstance(self._current_item, WebItem):
self._current_item.set_locked(not checked)
def _on_label_changed(self):
if isinstance(self._current_item, LabelItem):
self._current_item.set_label_config(
field_name=self.label_field.text().strip(),
prefix=self.label_prefix.text(),
suffix=self.label_suffix.text(),
font_size=self.label_font.value(),
color=self._label_color,
)
def _on_pick_color(self):
c = QColorDialog.getColor(QColor(self._label_color), self, "选择颜色")
if c.isValid():
self._label_color = c.name()
self._on_label_changed()
def _on_arrow_changed(self):
if isinstance(self._current_item, ArrowItem):
self._current_item.set_arrow_config(
width=self.arrow_width.value(),
color=self._arrow_color,
angle_deg=float(self.arrow_angle.value()),
)
def _on_pick_arrow_color(self):
c = QColorDialog.getColor(QColor(self._arrow_color), self, "选择箭头颜色")
if c.isValid():
self._arrow_color = c.name()
self._on_arrow_changed()
class InfluxConfigDialog(QDialog):
"""全局 InfluxDB 配置对话框"""
def __init__(self, settings: InfluxSettings, parent=None):
super().__init__(parent)
self.setWindowTitle("配置 InfluxDB")
self._settings = settings
self.url_edit = QLineEdit(settings.url)
self.token_edit = QLineEdit(settings.token)
self.token_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.org_edit = QLineEdit(settings.org)
self.bucket_edit = QLineEdit(settings.bucket)
self.interval_spin = QSpinBox()
self.interval_spin.setRange(200, 60_000)
self.interval_spin.setValue(settings.interval_ms)
self.query_edit = QPlainTextEdit(settings.query)
self.query_edit.setPlaceholderText("全局 Flux 查询语句,返回的字段名要和标签的 fieldName 对应")
form = QFormLayout()
form.addRow("URL:", self.url_edit)
form.addRow("Token:", self.token_edit)
form.addRow("Org:", self.org_edit)
form.addRow("Bucket:", self.bucket_edit)
form.addRow("刷新间隔(ms):", self.interval_spin)
form.addRow("查询(Query):", self.query_edit)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(buttons)
def result(self) -> InfluxSettings:
return InfluxSettings(
url=self.url_edit.text().strip(),
token=self.token_edit.text().strip(),
org=self.org_edit.text().strip(),
bucket=self.bucket_edit.text().strip(),
interval_ms=self.interval_spin.value(),
query=self.query_edit.toPlainText().strip(),
)
def _apply_global_influx_to_item(self, item: ImageItem):
# 未来如果需要按组件应用全局 Influx可以在这里扩展
pass
# -----------------------------
# UDP 命令监听器
# -----------------------------
class UDPCommandListener(QObject):
"""UDP 命令监听器,接收来自 PCM_Report 的命令"""
show_signal = pyqtSignal()
hide_signal = pyqtSignal()
load_layout_signal = pyqtSignal(str)
fullscreen_signal = pyqtSignal()
exit_signal = pyqtSignal()
def __init__(self, port=9876):
super().__init__()
self.port = port
self.running = False
self.socket = None
@staticmethod
def is_port_available(port: int) -> bool:
"""检测端口是否可用"""
test_socket = None
try:
test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
test_socket.bind(('127.0.0.1', port))
return True
except OSError:
return False
finally:
if test_socket:
test_socket.close()
def start(self):
"""在后台线程启动 UDP 监听"""
self.running = True
thread = threading.Thread(target=self._listen, daemon=True)
thread.start()
def _listen(self):
"""UDP 监听循环"""
try:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind(('127.0.0.1', self.port))
self.socket.settimeout(0.1) # 减少超时时间,加快关闭响应
print(f"[UDP] 监听端口 {self.port}...")
while self.running:
try:
data, addr = self.socket.recvfrom(4096)
message = data.decode('utf-8')
print(f"[UDP] 收到命令: {message}")
self._handle_command(message)
except socket.timeout:
continue
except Exception as e:
print(f"[UDP] 处理错误: {e}")
except Exception as e:
print(f"[UDP] 启动失败: {e}")
finally:
if self.socket:
self.socket.close()
def _handle_command(self, message: str):
"""处理接收到的命令"""
try:
cmd = json.loads(message)
action = cmd.get('action')
if action == 'show':
self.show_signal.emit()
elif action == 'hide':
self.hide_signal.emit()
elif action == 'load_layout':
path = cmd.get('path', '')
if path:
self.load_layout_signal.emit(path)
elif action == 'fullscreen':
self.fullscreen_signal.emit()
elif action == 'show_and_fullscreen':
path = cmd.get('path', '')
if path:
self.load_layout_signal.emit(path)
self.fullscreen_signal.emit()
elif action == 'exit':
self.exit_signal.emit()
except json.JSONDecodeError:
# 兼容简单文本命令
if message == 'show':
self.show_signal.emit()
elif message == 'hide':
self.hide_signal.emit()
elif message.startswith('load:'):
path = message[5:]
self.load_layout_signal.emit(path)
elif message == 'fullscreen':
self.fullscreen_signal.emit()
elif message == 'exit':
self.exit_signal.emit()
def stop(self):
"""停止监听"""
self.running = False
if self.socket:
self.socket.close()
def send_response(self, addr, message: str):
"""发送响应到指定地址"""
try:
self.socket.sendto(message.encode('utf-8'), addr)
except Exception as e:
print(f"[UDP] 发送响应失败: {e}")
# -----------------------------
# 主窗口
# -----------------------------
class MainWindow(QMainWindow):
def __init__(self, start_hidden=False, enable_edit=False):
super().__init__()
self.setWindowTitle("PCM Viewer - Widgets Dashboard")
self.resize(1400, 840)
# 设置焦点策略,确保能够接收键盘事件(特别是 ESC 键)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._enable_edit = enable_edit # 是否启用编辑模式
self._edit_mode = enable_edit # 默认根据enable_edit决定
# 全屏编辑标志(展示模式本身也会全屏)
self._fullscreen_edit = False
self._first_show = True # 用于在第一次显示时最大化并校正滚动条
self._start_hidden = start_hidden # 启动时是否隐藏窗口
self.view = DashboardView()
self.prop = PropertyPanel()
self.item_list = QListWidget()
self.item_list.setMinimumHeight(120)
self.item_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
self.influx_settings = InfluxSettings()
# 全局 Influx 客户端:延迟创建,只在需要时初始化(减少启动时间)
self.influx_client = None
# 系统托盘图标
self.tray_icon = None
self._setup_tray_icon()
self.splitter = QSplitter()
# 左侧:画布
self.splitter.addWidget(self.view)
# 右侧:属性面板 + 组件列表
self.right_panel = QWidget()
self.right_panel.setObjectName("right_panel")
self.right_panel.setStyleSheet(f"""
QWidget#right_panel {{
background-color: {Colors.BG_PRIMARY};
}}
""")
right_layout = QVBoxLayout(self.right_panel)
right_layout.setContentsMargins(12, 12, 12, 12)
right_layout.setSpacing(12)
right_layout.addWidget(self.prop)
group_list = QGroupBox("📋 当前组件列表")
glay = QVBoxLayout(group_list)
glay.setContentsMargins(12, 16, 12, 12)
glay.addWidget(self.item_list)
right_layout.addWidget(group_list)
self.splitter.addWidget(self.right_panel)
# 左侧画布权重大一些,右侧保持适中宽度
# 使用 setMinimumWidth 和 setMaximumWidth 来限制右侧面板,但让 splitter 自动管理比例
self.right_panel.setMinimumWidth(300)
self.right_panel.setMaximumWidth(500)
self.splitter.setStretchFactor(0, 4)
self.splitter.setStretchFactor(1, 1)
# 不设置固定 sizes让 splitter 根据窗口大小自动分配(但受 min/max 限制)
central = QWidget()
layout = QVBoxLayout(central)
# 取消外边距与间距,避免全屏时出现多余空白
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.toolbar_widget = QWidget()
self.toolbar_widget.setFixedHeight(52) # 增加工具栏高度
self.toolbar_widget.setStyleSheet(f"""
QWidget#toolbar_widget {{
background-color: {Colors.BG_SECONDARY};
border-bottom: 1px solid {Colors.BORDER};
}}
""")
self.toolbar_widget.setObjectName("toolbar_widget")
toolbar_layout = QHBoxLayout(self.toolbar_widget)
toolbar_layout.setContentsMargins(12, 8, 12, 8)
toolbar_layout.setSpacing(8)
# 保存按钮到成员,方便在其它方法(如键盘事件)中访问 / 控制可用性
# 所有按钮使用统一样式默认灰色样式与influx配置按钮一致
self.btn_mode = QPushButton("展示模式")
self.btn_influx = QPushButton("Influx配置")
self.btn_add_img = QPushButton("+ 图片")
self.btn_add_label = QPushButton("+ 标签")
self.btn_add_web = QPushButton("+ 曲线")
self.btn_add_arrow = QPushButton("+ 箭头")
self.btn_full_edit = QPushButton("全屏编辑")
self.btn_delete = QPushButton("删除")
self.btn_clone = QPushButton("复制")
self.btn_save = QPushButton("保存")
self.btn_load = QPushButton("加载")
# 当前文件路径显示标签
self.lbl_current_file = QLabel("未选择文件")
self.lbl_current_file.setStyleSheet(f"color: {Colors.TEXT_SECONDARY}; font-size: 11px;")
self.lbl_current_file.setMinimumWidth(200)
self.lbl_current_file.setMaximumWidth(400)
toolbar_layout.addWidget(self.btn_mode)
toolbar_layout.addWidget(self.btn_influx)
toolbar_layout.addWidget(self.btn_add_img)
toolbar_layout.addWidget(self.btn_add_label)
toolbar_layout.addWidget(self.btn_add_web)
toolbar_layout.addWidget(self.btn_add_arrow)
toolbar_layout.addWidget(self.btn_full_edit)
toolbar_layout.addStretch(1)
toolbar_layout.addWidget(self.lbl_current_file)
toolbar_layout.addStretch(1)
toolbar_layout.addWidget(self.btn_delete)
toolbar_layout.addWidget(self.btn_clone)
toolbar_layout.addWidget(self.btn_save)
toolbar_layout.addWidget(self.btn_load)
layout.addWidget(self.toolbar_widget)
layout.addWidget(self.splitter)
self.setCentralWidget(central)
self.view.scene_obj.selectionChangedEx.connect(self.prop.set_current_item)
self.view.canvasClicked.connect(self._on_canvas_clicked)
self.prop.canvasGeometryChanged.connect(self._on_canvas_changed)
self.item_list.itemDoubleClicked.connect(self._on_list_activated)
self.btn_influx.clicked.connect(self._on_influx_config)
self.btn_add_img.clicked.connect(self._on_add_image)
self.btn_add_label.clicked.connect(self._on_add_label)
self.btn_add_web.clicked.connect(self._on_add_web)
self.btn_add_arrow.clicked.connect(self._on_add_arrow)
self.btn_save.clicked.connect(self._on_save)
self.btn_load.clicked.connect(self._on_load)
self.btn_mode.clicked.connect(self._toggle_mode)
self.btn_full_edit.clicked.connect(self._on_fullscreen_edit)
self.btn_delete.clicked.connect(self._on_delete)
self.btn_clone.clicked.connect(self._on_clone)
# 快捷键Ctrl+D 复制当前组件
self._shortcut_clone = QShortcut(QKeySequence("Ctrl+D"), self)
self._shortcut_clone.activated.connect(self._on_clone)
# Influx 配置文件路径(固定位置)
# 支持打包后的单文件模式:配置文件保存在 exe 同目录
if getattr(sys, 'frozen', False):
# 打包后的可执行文件模式
base_dir = os.path.dirname(sys.executable)
else:
# 开发模式:使用脚本所在目录
base_dir = os.path.dirname(__file__)
self._layout_path = None # 初始为 None等待用户选择文件
self._settings_path = os.path.join(base_dir, "influx_settings.json")
# 先加载全局 Influx 配置,再加载布局
self._load_influx_settings()
self.view.load_layout(self._layout_path)
self.view.set_edit_mode(True)
self._apply_ui_mode()
self._refresh_item_list()
# 默认在右侧显示画布尺寸/位置,避免“未选中时全是 0”的困惑
cs = self.view.scene_obj.canvas_state()
self.prop.set_canvas(QRectF(cs["x"], cs["y"], cs["w"], cs["h"]))
def _update_window_fullscreen(self):
"""
根据当前模式决定是否全屏:
- 展示模式:始终全屏
- 全屏编辑:在编辑模式下也全屏
"""
want_full = (not self._edit_mode) or self._fullscreen_edit
if want_full:
# 真全屏:使用 Qt 的 showFullScreen退出时用 showMaximized 恢复普通 Windows 窗口
self.showFullScreen()
# 全屏后强制窗口到最上层
self.raise_()
self.activateWindow()
# 全屏后确保窗口获得焦点以便接收键盘事件ESC 键)
self.setFocus()
# 全屏时关闭滚动条,避免 1920x1080 屏幕上还出现滚动条
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 全屏时让画布完全填充视图(延迟执行,确保窗口大小已更新)
QTimer.singleShot(100, self.view.fit_canvas_to_view)
else:
# 恢复为标准的最大化窗口(带边框/任务栏)
self.showMaximized()
# 恢复焦点,确保能够接收键盘事件
self.setFocus()
# 恢复视图变换和滚动条
self.view.reset_view_transform()
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
def _toggle_mode(self):
self._edit_mode = not self._edit_mode
# 按钮文字:当前是编辑 -> 显示“展示模式”;当前是展示 -> 显示“编辑模式”
self.btn_mode.setText("展示模式" if self._edit_mode else "编辑模式")
# 编辑模式:可拖动缩放;展示模式:锁定并启动 Influx 刷新
for it in self.view.scene_obj.items():
if isinstance(it, DashboardItem):
it.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable, self._edit_mode)
it.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable, self._edit_mode)
# 标签 / 箭头 组件:根据模式控制边框是否可见
if isinstance(it, (LabelItem, ArrowItem)):
it.set_edit_mode(self._edit_mode)
# 根据模式启动/停止全局 Influx 刷新
s = self.influx_settings
if not self._edit_mode:
# 进入"展示模式":连接并开始按配置的间隔查询
if s.url and s.token and s.org and s.bucket and s.query:
# 延迟创建 Influx 客户端(只在需要时)
if self.influx_client is None:
from influxdb_wrapper import InfluxDBClient
self.influx_client = InfluxDBClient(self)
self.influx_client.dataReceived.connect(self._on_influx_data)
self.influx_client.errorOccurred.connect(self._on_influx_error)
self.influx_client.connect(s.url, s.token, s.org, s.bucket)
self.influx_client.setQuery(s.query)
self.influx_client.startQuery(s.interval_ms)
else:
# 回到编辑模式:停止查询
if self.influx_client is not None:
self.influx_client.stopQuery()
# 展示模式需要全屏 / 编辑模式下根据是否“全屏编辑”决定
self.view.set_edit_mode(self._edit_mode)
self._update_window_fullscreen()
self._apply_ui_mode()
def _on_fullscreen_edit(self):
"""
全屏编辑:不进入展示模式,只是把窗口拉到全屏,
并且隐藏右侧属性面板,让用户只通过拖拽/缩放来调整布局。
再次点击 / 按 ESC 退出全屏编辑。
"""
self._fullscreen_edit = not self._fullscreen_edit
# 确保在“编辑模式”下
if not self._edit_mode:
# 切回编辑模式
self._toggle_mode()
# 全屏编辑时隐藏属性面板,只保留画布
self.prop.setVisible(not self._fullscreen_edit)
self._update_window_fullscreen()
self._apply_ui_mode()
def _apply_ui_mode(self):
"""根据当前模式控制按钮可用性,保证"全屏编辑只拖拽/缩放"更纯粹。"""
if not self._edit_mode:
# 展示模式:禁用编辑相关,并隐藏右侧/工具栏,只保留全屏画布
for b in (
self.btn_influx,
self.btn_add_img,
self.btn_add_label,
self.btn_add_web,
self.btn_add_arrow,
self.btn_full_edit,
self.btn_delete,
self.btn_clone,
self.btn_save,
self.btn_load,
):
b.setEnabled(False)
# 如果启用了编辑模式,允许切换回编辑;否则隐藏按钮
self.btn_mode.setEnabled(self._enable_edit)
self.btn_mode.setVisible(self._enable_edit)
# 右侧和工具栏隐藏,达到"画布全屏"效果Esc 或按钮退出)
self.right_panel.setVisible(False)
self.toolbar_widget.setVisible(self._enable_edit)
# 全屏展示:去掉 splitter 句柄、关闭滚动条,避免白边/滚动条露出
self.splitter.setHandleWidth(0)
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 全屏模式间距设为0工具栏已隐藏画布紧贴顶部
central_layout = self.centralWidget().layout()
if central_layout:
central_layout.setSpacing(0)
return
# 编辑模式
if self._fullscreen_edit:
# 全屏编辑:只允许退出全屏编辑(以及关闭程序/ESC同时隐藏右侧与工具栏
for b in (
self.btn_mode,
self.btn_influx,
self.btn_add_img,
self.btn_add_label,
self.btn_add_web,
self.btn_add_arrow,
self.btn_delete,
self.btn_clone,
self.btn_save,
self.btn_load,
):
b.setEnabled(False)
self.btn_full_edit.setEnabled(True)
self.right_panel.setVisible(False)
self.toolbar_widget.setVisible(False)
self.splitter.setHandleWidth(0)
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 全屏编辑模式间距设为0工具栏已隐藏画布紧贴顶部
central_layout = self.centralWidget().layout()
if central_layout:
central_layout.setSpacing(0)
else:
# 普通编辑
for b in (
self.btn_mode,
self.btn_influx,
self.btn_add_img,
self.btn_add_label,
self.btn_add_web,
self.btn_add_arrow,
self.btn_full_edit,
self.btn_delete,
self.btn_clone,
self.btn_save,
self.btn_load,
):
b.setEnabled(True)
# 确保右侧面板和属性面板都可见
self.right_panel.setVisible(True)
self.prop.setVisible(True)
self.toolbar_widget.setVisible(True)
self.splitter.setHandleWidth(6)
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
# 普通编辑模式工具栏和画布之间添加间距像图2那样
central_layout = self.centralWidget().layout()
if central_layout:
central_layout.setSpacing(4)
def _on_canvas_clicked(self, rect: QRectF):
# 只允许在编辑模式下选中画布
if not self._edit_mode:
return
self.prop.set_canvas(rect)
def _on_canvas_changed(self, x: float, y: float, w: float, h: float):
self.view.scene_obj.set_canvas_rect(QRectF(x, y, w, h))
# ---------- 组件列表 ----------
def _describe_item(self, it: DashboardItem) -> str:
z = int(it.zValue())
if isinstance(it, ImageItem):
name = os.path.basename(getattr(it, "_image_path", "") or "图片")
return f"[图片 z={z}] {name}"
if isinstance(it, LabelItem):
cfg = it.label_config()
field = cfg.get("fieldName") or "未绑定"
return f"[标签 z={z}] {field}"
if isinstance(it, WebItem):
url = getattr(it, "_url", "") or ""
short = url if len(url) <= 32 else url[:29] + "..."
return f"[曲线 z={z}] {short}"
if isinstance(it, ArrowItem):
return f"[箭头 z={z}]"
return f"[组件 z={z}]"
def _refresh_item_list(self):
self.item_list.blockSignals(True)
self.item_list.clear()
for gitem in self.view.scene_obj.items():
if isinstance(gitem, DashboardItem):
text = self._describe_item(gitem)
li = QListWidgetItem(text)
li.setData(Qt.ItemDataRole.UserRole, gitem)
self.item_list.addItem(li)
self.item_list.blockSignals(False)
def _on_list_activated(self, item: QListWidgetItem):
gitem = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(gitem, DashboardItem):
return
scene = self.view.scene_obj
scene.clearSelection()
gitem.setSelected(True)
self.view.centerOn(gitem)
self.prop.set_current_item(gitem)
def _on_influx_config(self):
dlg = InfluxConfigDialog(self.influx_settings, self)
if dlg.exec() == QDialog.DialogCode.Accepted:
self.influx_settings = dlg.result()
self._save_influx_settings()
def _on_add_image(self):
item = self.view.add_image_item()
self.prop.set_current_item(item)
self._refresh_item_list()
def _on_add_label(self):
item = self.view.add_label_item()
self.prop.set_current_item(item)
self._refresh_item_list()
def _on_add_web(self):
item = self.view.add_web_item()
self.prop.set_current_item(item)
self._refresh_item_list()
def _on_add_arrow(self):
item = self.view.add_arrow_item()
self.prop.set_current_item(item)
self._refresh_item_list()
def _on_save(self):
"""保存布局到当前打开的文件,如果没有指定文件则弹出保存对话框"""
try:
# 如果没有指定文件路径,弹出保存对话框
if not self._layout_path or self._layout_path == os.path.join(os.path.dirname(__file__), "dashboard.json"):
file_path, _ = QFileDialog.getSaveFileName(
self,
"保存布局文件",
"",
"JSON Files (*.json);;All Files (*.*)"
)
if not file_path:
return # 用户取消了对话框
if not file_path.endswith('.json'):
file_path += '.json'
self._layout_path = file_path
self._update_current_file_label()
self.view.save_layout(self._layout_path)
QMessageBox.information(self, "保存布局", f"已保存到 {self._layout_path}")
except Exception as e:
QMessageBox.critical(self, "保存失败", str(e))
def _on_load(self):
"""通过文件对话框选择并加载 JSON 文件"""
try:
# 弹出文件选择对话框
file_path, _ = QFileDialog.getOpenFileName(
self,
"选择布局文件",
"",
"JSON Files (*.json);;All Files (*.*)"
)
if not file_path:
return # 用户取消了对话框
self._layout_path = file_path
self._update_current_file_label()
self.view.load_layout(self._layout_path)
self._refresh_item_list()
self.view.reset_scroll()
cs = self.view.scene_obj.canvas_state()
self.prop.set_canvas(QRectF(cs["x"], cs["y"], cs["w"], cs["h"]))
except Exception as e:
QMessageBox.critical(self, "加载失败", str(e))
def _update_current_file_label(self):
"""更新工具栏上显示当前文件路径的标签"""
if self._layout_path:
# 显示完整路径,如果太长会用省略号截断
self.lbl_current_file.setText(self._layout_path)
# 更新窗口标题显示文件名
file_name = os.path.basename(self._layout_path)
self.setWindowTitle(f"PCM Viewer - {file_name}")
else:
self.lbl_current_file.setText("未选择文件")
self.setWindowTitle("PCM Viewer - Widgets Dashboard")
def _on_delete(self):
# 删除当前选中的组件(支持多选)
scene = self.view.scene_obj
items = [it for it in scene.selectedItems() if isinstance(it, DashboardItem)]
if not items:
return
for it in items:
scene.removeItem(it)
self.prop.set_current_item(None)
self._refresh_item_list()
def _on_clone(self):
"""复制当前选中的组件(支持多选),完整复制配置并稍微偏移位置。"""
scene = self.view.scene_obj
selected = [it for it in scene.selectedItems() if isinstance(it, DashboardItem)]
if not selected:
return
clones: list[DashboardItem] = []
offset = 20.0
for it in selected:
st = it.to_state()
st.x += offset
st.y += offset
if isinstance(it, ImageItem):
new_it: DashboardItem = self.view.add_image_item()
elif isinstance(it, WebItem):
new_it = self.view.add_web_item()
elif isinstance(it, LabelItem):
new_it = self.view.add_label_item()
elif isinstance(it, ArrowItem):
new_it = self.view.add_arrow_item()
else:
continue
new_it.apply_state(st)
clones.append(new_it)
# 只选中新复制的一组中的最后一个,方便继续操作
if clones:
scene.clearSelection()
last = clones[-1]
last.setSelected(True)
self.prop.set_current_item(last)
self._refresh_item_list()
def keyPressEvent(self, event):
"""ESC 支持退出全屏展示 / 全屏编辑"""
if event.key() == Qt.Key.Key_Escape:
# 如果当前是展示模式
if not self._edit_mode:
# 如果启用了编辑模式ESC切回编辑否则隐藏窗口
if self._enable_edit:
self._toggle_mode()
else:
self.hide()
return
# 如果是全屏编辑:只退出全屏编辑,保留编辑模式
if self._fullscreen_edit:
self._fullscreen_edit = False
self._update_window_fullscreen()
self._apply_ui_mode()
return
super().keyPressEvent(event)
def resizeEvent(self, event):
"""窗口大小变化时,确保 splitter 正确分配空间"""
super().resizeEvent(event)
# 只在普通编辑模式下调整 splitter全屏编辑/展示模式时右侧面板已隐藏)
if self._edit_mode and not self._fullscreen_edit:
# 延迟一下,确保窗口大小已经更新
QTimer.singleShot(10, self._update_splitter_sizes)
def _update_splitter_sizes(self):
"""更新 splitter 的 sizes确保右侧面板在合理范围内"""
if not self._edit_mode or self._fullscreen_edit:
return
total_width = self.width()
if total_width < 100:
return # 窗口太小,不处理
# 右侧面板目标宽度:约 400px但限制在 300-500 之间
right_width = min(500, max(300, int(total_width * 0.2))) # 约 20% 宽度
left_width = total_width - right_width
if left_width > 0 and right_width > 0:
self.splitter.setSizes([left_width, right_width])
def showEvent(self, event):
"""第一次显示窗口时,默认最大化并把画布滚动条校正到 (0,0)。"""
super().showEvent(event)
if self._first_show:
self._first_show = False
self.showMaximized()
# 需要在布局/最大化完成后再复位滚动条和初始化 splitter避免被后续布局调整覆盖
def _init_after_maximize():
self.view.reset_scroll()
self._update_splitter_sizes()
QTimer.singleShot(100, _init_after_maximize)
# ---------- Influx 配置持久化 ----------
def _load_influx_settings(self):
"""从磁盘加载全局 Influx 配置(如果文件存在)"""
if not os.path.exists(self._settings_path):
return
try:
with open(self._settings_path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
self.influx_settings = InfluxSettings(
url=str(data.get("url", "")),
token=str(data.get("token", "")),
org=str(data.get("org", "")),
bucket=str(data.get("bucket", "")),
interval_ms=int(data.get("interval_ms", 1000)),
query=str(data.get("query", "")),
)
except Exception as e:
print("load influx_settings failed:", e)
def _save_influx_settings(self):
"""把当前全局 Influx 配置保存到磁盘"""
try:
with open(self._settings_path, "w", encoding="utf-8") as f:
json.dump(asdict(self.influx_settings), f, indent=2, ensure_ascii=False)
except Exception as e:
print("save influx_settings failed:", e)
def _on_influx_data(self, data: dict):
"""全局 Influx 查询结果下发到所有标签组件"""
for it in self.view.scene_obj.items():
if isinstance(it, LabelItem):
it.apply_influx_data(data)
def _on_influx_error(self, msg: str):
# 这里避免频繁弹窗,暂时只打印,也可以根据需要改成状态栏提示
print("Influx error:", msg)
def load_layout(self, path: str):
"""加载布局文件(代理调用 DashboardView 的 load_layout"""
if hasattr(self.view, 'load_layout'):
self.view.load_layout(path)
self._layout_path = path
self._update_current_file_label()
self._refresh_item_list() # 更新组件列表
def _setup_tray_icon(self):
"""设置系统托盘图标"""
if not QSystemTrayIcon.isSystemTrayAvailable():
print("[Tray] 系统托盘不可用")
return
self.tray_icon = QSystemTrayIcon(self)
# 使用默认图标(可以替换为自定义图标)
icon = self.style().standardIcon(self.style().StandardPixmap.SP_ComputerIcon)
self.tray_icon.setIcon(icon)
# 创建托盘菜单
tray_menu = QMenu()
show_action = QAction("显示窗口", self)
show_action.triggered.connect(self._show_window)
tray_menu.addAction(show_action)
hide_action = QAction("隐藏窗口", self)
hide_action.triggered.connect(self.hide)
tray_menu.addAction(hide_action)
tray_menu.addSeparator()
quit_action = QAction("退出", self)
quit_action.triggered.connect(QApplication.instance().quit)
tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu)
# 双击托盘图标显示窗口
self.tray_icon.activated.connect(self._on_tray_activated)
# 显示托盘图标
self.tray_icon.show()
self.tray_icon.setToolTip("PCM Viewer")
def _on_tray_activated(self, reason):
"""托盘图标被激活"""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self._show_window()
def _show_window(self):
"""显示窗口"""
# 先恢复窗口可见性,避免从最小化直接全屏导致显示不全
if self.isMinimized():
self.setWindowState(Qt.WindowState.WindowNoState)
self.show()
self.activateWindow()
self.raise_()
def _show_and_fullscreen(self, layout_path: str = None):
"""显示窗口并进入全屏展示模式"""
if layout_path:
self.load_layout(layout_path)
self._show_window()
# 确保进入展示模式会自动处理全屏和label边框等
if self._edit_mode:
self._toggle_mode()
else:
# 已经是展示模式,确保所有组件锁定且边框隐藏
for it in self.view.scene_obj.items():
if isinstance(it, DashboardItem):
it.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable, False)
it.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable, False)
if isinstance(it, (LabelItem, ArrowItem)):
it.set_edit_mode(False)
self.view.set_edit_mode(False)
self._update_window_fullscreen()
self._apply_ui_mode()
def closeEvent(self, event):
"""关闭事件:最小化到托盘而不是退出"""
if self.tray_icon and self.tray_icon.isVisible():
event.ignore()
self.hide()
self.tray_icon.showMessage(
"PCM Viewer",
"程序已最小化到系统托盘",
QSystemTrayIcon.MessageIcon.Information,
2000
)
else:
# 真正退出时,确保清理资源
if hasattr(self, '_udp_listener'):
self._udp_listener.stop()
event.accept()
def main():
# 抑制 Windows 上 QWebEngineView 的 DirectComposition 警告
os.environ.setdefault('QT_LOGGING_RULES', 'qt.webenginecontext.debug=false')
# 解析命令行参数
import argparse
parser = argparse.ArgumentParser(description='PCM Viewer - 全屏展示工具')
parser.add_argument('layout', nargs='?', help='布局JSON文件路径')
parser.add_argument('--fullscreen', '-f', action='store_true', help='启动时进入全屏模式')
parser.add_argument('--show', '-s', action='store_true', help='启动时显示窗口(默认隐藏)')
parser.add_argument('--edit', '-e', action='store_true', help='启用编辑模式(默认仅支持展示)')
parser.add_argument('--udp-port', '-p', type=int, default=9876, help='UDP监听端口默认9876')
args = parser.parse_args()
# 检测 UDP 端口是否可用
if not UDPCommandListener.is_port_available(args.udp_port):
print(f"[错误] UDP 端口 {args.udp_port} 已被占用,程序可能已在运行")
print(f"[错误] 请检查是否有其他实例正在运行,或使用 --udp-port 指定其他端口")
sys.exit(1)
# QWebEngineView 必须在 QApplication 创建之前导入
from PyQt6.QtWebEngineWidgets import QWebEngineView
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False) # 关闭窗口时不退出,保持托盘运行
# 应用全局样式
apply_styles(app)
# 创建主窗口(默认隐藏,除非指定 --show 或 --edit
start_hidden = not (args.show or args.edit) # --edit 或 --show 时显示窗口
win = MainWindow(start_hidden=start_hidden, enable_edit=args.edit)
# 如果提供了布局文件路径,加载它
if args.layout and os.path.exists(args.layout):
win.load_layout(args.layout)
# 启动 UDP 监听器
udp_listener = UDPCommandListener(port=args.udp_port)
udp_listener.show_signal.connect(win._show_window)
udp_listener.hide_signal.connect(win.hide)
udp_listener.load_layout_signal.connect(win.load_layout)
udp_listener.fullscreen_signal.connect(lambda: win._show_and_fullscreen())
udp_listener.exit_signal.connect(app.quit)
udp_listener.start()
# 保存到窗口对象,以便在关闭时清理
win._udp_listener = udp_listener
# 根据参数决定是否显示窗口
if args.fullscreen:
# 全屏模式:显示并进入展示模式
win._show_and_fullscreen()
elif args.show:
# 显示模式:显示窗口
win.show()
else:
# 默认隐藏模式:不显示窗口,仅托盘图标
print("[Main] 启动时隐藏窗口等待UDP命令...")
# 应用程序退出时清理
def on_quit():
udp_listener.stop()
app.aboutToQuit.connect(on_quit)
sys.exit(app.exec())
if __name__ == "__main__":
main()