PCM_Viewer/main.py

1965 lines
76 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
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()