1965 lines
76 KiB
Python
1965 lines
76 KiB
Python
"""
|
||
PCM Viewer - Widgets 版 Dashboard 入口
|
||
|
||
技术栈:PyQt6 Widgets + QGraphicsView/QGraphicsScene
|
||
功能(当前版本,先实现基础骨架):
|
||
- 左侧画布:可拖拽的组件(图片组件 / Web 组件)
|
||
- 右侧面板:显示并编辑当前选中组件的几何和基本配置
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
from dataclasses import dataclass, asdict, field
|
||
from typing import Optional, Dict, Any
|
||
import math
|
||
|
||
from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer, QPointF
|
||
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor, QShortcut, QKeySequence
|
||
from PyQt6.QtWidgets import (
|
||
QApplication,
|
||
QMainWindow,
|
||
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
|
||
|
||
|
||
# -----------------------------
|
||
# 数据结构(用于保存/加载布局)
|
||
# -----------------------------
|
||
|
||
@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):
|
||
"""可拖拽、可简单缩放的组件基类"""
|
||
|
||
# 磁吸阈值:距离边缘多少像素时触发磁吸
|
||
SNAP_THRESHOLD = 10.0
|
||
|
||
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 # "left", "right", "top", "bottom", "corner" 等
|
||
self._snapping = False # 是否正在磁吸,避免递归调用
|
||
|
||
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
|
||
event.accept()
|
||
return
|
||
super().mouseReleaseEvent(event)
|
||
|
||
# 高亮选中效果
|
||
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 _get_canvas_bounds(self) -> Optional[QRectF]:
|
||
"""获取画布的边界矩形(scene 坐标)"""
|
||
scene = self.scene()
|
||
if not scene or not isinstance(scene, DashboardScene):
|
||
return None
|
||
canvas_item = scene.canvas_item
|
||
if not canvas_item:
|
||
return None
|
||
# 画布的实际边界 = canvas_item 的位置 + rect
|
||
canvas_pos = canvas_item.pos()
|
||
canvas_rect = canvas_item.rect()
|
||
return QRectF(
|
||
canvas_pos.x(),
|
||
canvas_pos.y(),
|
||
canvas_rect.width(),
|
||
canvas_rect.height()
|
||
)
|
||
|
||
def _get_other_items_bounds(self) -> list[QRectF]:
|
||
"""获取场景中其他组件的边界矩形列表(scene 坐标)"""
|
||
scene = self.scene()
|
||
if not scene:
|
||
return []
|
||
bounds = []
|
||
for item in scene.items():
|
||
if (isinstance(item, DashboardItem) and
|
||
item is not self and
|
||
item is not scene.canvas_item):
|
||
pos = item.pos()
|
||
rect = item.rect()
|
||
bounds.append(QRectF(
|
||
pos.x(),
|
||
pos.y(),
|
||
rect.width(),
|
||
rect.height()
|
||
))
|
||
return bounds
|
||
|
||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value):
|
||
"""重写 itemChange 以实现磁吸功能(画布边缘 + 组件间吸附)"""
|
||
# 箭头组件和标签组件不进行边界限制和磁吸,直接返回
|
||
if isinstance(self, (ArrowItem, LabelItem)):
|
||
return super().itemChange(change, value)
|
||
|
||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and not self._snapping:
|
||
# 位置变化时,检测是否需要磁吸
|
||
new_pos: QPointF = value
|
||
r = self.rect()
|
||
# 组件在 scene 坐标下的边界
|
||
item_left = new_pos.x()
|
||
item_top = new_pos.y()
|
||
item_right = item_left + r.width()
|
||
item_bottom = item_top + r.height()
|
||
|
||
final_x = new_pos.x()
|
||
final_y = new_pos.y()
|
||
|
||
# 1. 检测画布边缘磁吸 + 边界限制
|
||
canvas_bounds = self._get_canvas_bounds()
|
||
canvas_left = None
|
||
canvas_top = None
|
||
canvas_right = None
|
||
canvas_bottom = None
|
||
|
||
if canvas_bounds:
|
||
canvas_left = canvas_bounds.x()
|
||
canvas_top = canvas_bounds.y()
|
||
canvas_right = canvas_left + canvas_bounds.width()
|
||
canvas_bottom = canvas_top + canvas_bounds.height()
|
||
|
||
# 左边缘磁吸
|
||
if abs(item_left - canvas_left) < self.SNAP_THRESHOLD:
|
||
final_x = canvas_left
|
||
# 右边缘磁吸
|
||
elif abs(item_right - canvas_right) < self.SNAP_THRESHOLD:
|
||
final_x = canvas_right - r.width()
|
||
# 上边缘磁吸
|
||
if abs(item_top - canvas_top) < self.SNAP_THRESHOLD:
|
||
final_y = canvas_top
|
||
# 下边缘磁吸
|
||
elif abs(item_bottom - canvas_bottom) < self.SNAP_THRESHOLD:
|
||
final_y = canvas_bottom - r.height()
|
||
|
||
# 2. 检测其他组件的磁吸(组件间对齐)
|
||
other_bounds = self._get_other_items_bounds()
|
||
for other_rect in other_bounds:
|
||
other_left = other_rect.x()
|
||
other_top = other_rect.y()
|
||
other_right = other_left + other_rect.width()
|
||
other_bottom = other_top + other_rect.height()
|
||
|
||
# 左边缘对齐(当前组件的左边缘对齐到其他组件的左边缘)
|
||
if abs(item_left - other_left) < self.SNAP_THRESHOLD:
|
||
final_x = other_left
|
||
# 右边缘对齐(当前组件的右边缘对齐到其他组件的右边缘)
|
||
elif abs(item_right - other_right) < self.SNAP_THRESHOLD:
|
||
final_x = other_right - r.width()
|
||
# 当前组件的左边缘对齐到其他组件的右边缘(相邻)
|
||
elif abs(item_left - other_right) < self.SNAP_THRESHOLD:
|
||
final_x = other_right
|
||
# 当前组件的右边缘对齐到其他组件的左边缘(相邻)
|
||
elif abs(item_right - other_left) < self.SNAP_THRESHOLD:
|
||
final_x = other_left - r.width()
|
||
|
||
# 上边缘对齐
|
||
if abs(item_top - other_top) < self.SNAP_THRESHOLD:
|
||
final_y = other_top
|
||
# 下边缘对齐
|
||
elif abs(item_bottom - other_bottom) < self.SNAP_THRESHOLD:
|
||
final_y = other_bottom - r.height()
|
||
# 当前组件的上边缘对齐到其他组件的下边缘(相邻)
|
||
elif abs(item_top - other_bottom) < self.SNAP_THRESHOLD:
|
||
final_y = other_bottom
|
||
# 当前组件的下边缘对齐到其他组件的上边缘(相邻)
|
||
elif abs(item_bottom - other_top) < self.SNAP_THRESHOLD:
|
||
final_y = other_top - r.height()
|
||
|
||
# 3. 限制组件不能超出画布边界(在磁吸之后再次检查,确保不会超出)
|
||
if canvas_bounds and canvas_left is not None:
|
||
# 重新计算组件边界(因为 final_x/final_y 可能已经被磁吸调整过)
|
||
item_left = final_x
|
||
item_top = final_y
|
||
item_right = item_left + r.width()
|
||
item_bottom = item_top + r.height()
|
||
|
||
# 限制在画布范围内
|
||
if item_left < canvas_left:
|
||
final_x = canvas_left
|
||
if item_top < canvas_top:
|
||
final_y = canvas_top
|
||
if item_right > canvas_right:
|
||
final_x = canvas_right - r.width()
|
||
if item_bottom > canvas_bottom:
|
||
final_y = canvas_bottom - r.height()
|
||
|
||
# 如果位置被调整了,返回调整后的位置
|
||
if final_x != new_pos.x() or final_y != new_pos.y():
|
||
self._snapping = True
|
||
return QPointF(final_x, final_y)
|
||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||
# 位置变化完成后,重置磁吸标志
|
||
self._snapping = False
|
||
|
||
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(80, 160, 255), 2))
|
||
else:
|
||
self.setPen(QPen(QColor(170, 170, 170), 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)
|
||
|
||
# 箭头三角形
|
||
dx = end.x() - start.x()
|
||
dy = end.y() - start.y()
|
||
length = math.hypot(dx, dy) or 1.0
|
||
ux, uy = dx / length, dy / length
|
||
|
||
# 箭头头部大小随线宽缩放(并限制范围)
|
||
w = float(max(1, self._width))
|
||
arrow_len = max(8.0, min(28.0, 6.0 + w * 3.0))
|
||
half_width = max(4.0, min(14.0, 3.5 + w * 1.6))
|
||
back = QPointF(end.x() - ux * arrow_len, end.y() - uy * arrow_len)
|
||
# 垂直方向
|
||
px, py = -uy, ux
|
||
p1 = QPointF(back.x() + px * half_width, back.y() + py * half_width)
|
||
p2 = QPointF(back.x() - px * half_width, back.y() - py * half_width)
|
||
|
||
# PyQt6 支持直接传入 QPointF 列表,无需 QPolygonF
|
||
painter.drawPolygon([end, p1, p2])
|
||
|
||
|
||
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(140, 140, 140), 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(255, 255, 255)) # 纯白色背景
|
||
# 取消自身的边框,避免全屏时出现白边
|
||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||
self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
||
self._edit_mode = True
|
||
|
||
@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显示,不进行任何缩放
|
||
self.resetTransform()
|
||
self.reset_scroll()
|
||
|
||
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 = []
|
||
canvas_bounds = self._scene.canvas_rect()
|
||
canvas_pos = self._scene.canvas_item.pos()
|
||
canvas_left = canvas_pos.x()
|
||
canvas_top = canvas_pos.y()
|
||
canvas_right = canvas_left + canvas_bounds.width()
|
||
canvas_bottom = canvas_top + canvas_bounds.height()
|
||
|
||
for it in self._scene.items():
|
||
if isinstance(it, DashboardItem):
|
||
# 获取组件状态
|
||
state = it.to_state()
|
||
state_dict = asdict(state)
|
||
|
||
# 修正超出边界的位置数据(直接修改字典,不移动组件)
|
||
item_left = state_dict["x"]
|
||
item_top = state_dict["y"]
|
||
item_w = state_dict["w"]
|
||
item_h = state_dict["h"]
|
||
|
||
# 修正超出边界的位置数据
|
||
final_x = max(canvas_left, min(item_left, canvas_right - item_w))
|
||
final_y = max(canvas_top, min(item_top, canvas_bottom - item_h))
|
||
|
||
# 更新状态数据
|
||
state_dict["x"] = final_x
|
||
state_dict["y"] = final_y
|
||
|
||
states.append(state_dict)
|
||
|
||
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 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)
|
||
|
||
# 加载后也应用边界限制,确保组件不超出画布
|
||
canvas_bounds = self._scene.canvas_rect()
|
||
canvas_pos = self._scene.canvas_item.pos()
|
||
canvas_left = canvas_pos.x()
|
||
canvas_top = canvas_pos.y()
|
||
canvas_right = canvas_left + canvas_bounds.width()
|
||
canvas_bottom = canvas_top + canvas_bounds.height()
|
||
|
||
pos = it.pos()
|
||
r = it.rect()
|
||
item_left = pos.x()
|
||
item_top = pos.y()
|
||
item_right = item_left + r.width()
|
||
item_bottom = item_top + r.height()
|
||
|
||
# 修正超出边界的位置
|
||
final_x = max(canvas_left, min(item_left, canvas_right - r.width()))
|
||
final_y = max(canvas_top, min(item_top, canvas_bottom - r.height()))
|
||
|
||
if final_x != item_left or final_y != item_top:
|
||
it.setPos(final_x, final_y)
|
||
|
||
|
||
# -----------------------------
|
||
# 属性面板
|
||
# -----------------------------
|
||
|
||
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.addRow("X:", self.x_spin)
|
||
form.addRow("Y:", self.y_spin)
|
||
form.addRow("W:", self.w_spin)
|
||
form.addRow("H:", self.h_spin)
|
||
form.addRow("Z:", 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.addWidget(QLabel("当前选中组件"))
|
||
layout.addLayout(form)
|
||
layout.addSpacing(10)
|
||
layout.addWidget(img_group)
|
||
layout.addSpacing(10)
|
||
layout.addWidget(label_group)
|
||
layout.addSpacing(10)
|
||
layout.addWidget(web_group)
|
||
layout.addSpacing(10)
|
||
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
|
||
|
||
|
||
# -----------------------------
|
||
# 主窗口
|
||
# -----------------------------
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("PCM Viewer - Widgets Dashboard")
|
||
self.resize(1400, 840)
|
||
# 设置焦点策略,确保能够接收键盘事件(特别是 ESC 键)
|
||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||
self._edit_mode = True
|
||
# 全屏编辑标志(展示模式本身也会全屏)
|
||
self._fullscreen_edit = False
|
||
self._first_show = True # 用于在第一次显示时最大化并校正滚动条
|
||
|
||
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.splitter = QSplitter()
|
||
# 左侧:画布
|
||
self.splitter.addWidget(self.view)
|
||
|
||
# 右侧:属性面板 + 组件列表
|
||
self.right_panel = QWidget()
|
||
right_layout = QVBoxLayout(self.right_panel)
|
||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||
right_layout.addWidget(self.prop)
|
||
group_list = QGroupBox("当前组件列表")
|
||
glay = QVBoxLayout(group_list)
|
||
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(40) # 固定工具栏高度,避免与画布之间出现多余间距
|
||
toolbar_layout = QHBoxLayout(self.toolbar_widget)
|
||
# 保存按钮到成员,方便在其它方法(如键盘事件)中访问 / 控制可用性
|
||
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("加载布局")
|
||
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.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 = os.path.join(base_dir, "dashboard.json")
|
||
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()
|
||
# 全屏后确保窗口获得焦点,以便接收键盘事件(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(True) # 允许退出展示
|
||
# 右侧和工具栏隐藏,达到“画布全屏”效果(Esc 或按钮退出)
|
||
self.right_panel.setVisible(False)
|
||
self.toolbar_widget.setVisible(False)
|
||
# 全屏展示:去掉 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:
|
||
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):
|
||
try:
|
||
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 _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:
|
||
# 如果当前是展示模式:ESC 直接切回编辑模式(并退出全屏)
|
||
if not self._edit_mode:
|
||
self._toggle_mode()
|
||
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 main():
|
||
# 抑制 Windows 上 QWebEngineView 的 DirectComposition 警告
|
||
import os
|
||
os.environ.setdefault('QT_LOGGING_RULES', 'qt.webenginecontext.debug=false')
|
||
|
||
# QWebEngineView 必须在 QApplication 创建之前导入
|
||
# 已经在文件顶部导入,这里确保环境变量已设置
|
||
from PyQt6.QtWebEngineWidgets import QWebEngineView # 确保已导入
|
||
|
||
app = QApplication(sys.argv)
|
||
win = MainWindow()
|
||
win.show()
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|