功能可用

main
risingLee 2026-02-07 01:28:41 +08:00
parent e4e5455761
commit 3b644a3acb
3 changed files with 647 additions and 84 deletions

View File

@ -1,25 +0,0 @@
[
{
"widget_type": "image",
"x": -244.5,
"y": -262.5,
"w": 401.0,
"h": 301.0,
"z": 0.0,
"config": {
"imagePath": ""
}
},
{
"widget_type": "web",
"x": -369.0,
"y": -404.0,
"w": 986.0,
"h": 634.0,
"z": 0.0,
"config": {
"url": "https://www.baidu.com",
"locked": true
}
}
]

View File

@ -4,5 +4,5 @@
"org": "MEASCON",
"bucket": "PCM",
"interval_ms": 1000,
"query": "from(bucket: \"PCM\")\n |> range(start: -1h)\n |> filter(fn: (r) => r._measurement == \"PCM_Measurement\")\n |> filter(fn: (r) => r.data_type == \"LSDAQ\")\n |> filter(fn: (r) =>\n r._field == \"主轴承#3\" or\n r._field == \"主轴承#4\"\n )\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])\n |> last()"
"query": "from(bucket: \"PCM\")\n |> range(start: -3h)\n |> filter(fn: (r) => r._measurement == \"PCM_Measurement\")\n |> filter(fn: (r) => r.data_type == \"LSDAQ\")\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])\n |> last()"
}

704
main.py
View File

@ -15,8 +15,8 @@ import sys
from dataclasses import dataclass, asdict, field
from typing import Optional, Dict, Any
from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont
from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer, QPointF
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
@ -36,6 +36,7 @@ from PyQt6.QtWidgets import (
QGraphicsPixmapItem,
QGraphicsTextItem,
QGraphicsProxyWidget,
QGraphicsItem,
QMessageBox,
QPlainTextEdit,
QListWidget,
@ -45,6 +46,7 @@ from PyQt6.QtWidgets import (
QDialog,
QDialogButtonBox,
QCheckBox,
QFrame,
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
@ -85,6 +87,9 @@ class InfluxSettings:
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))
@ -94,13 +99,17 @@ class DashboardItem(QGraphicsRectItem):
| QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsRectItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setBrush(QBrush(QColor(40, 40, 40)))
# 启用鼠标悬停事件,用于改变光标样式
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._snapping = False # 是否正在磁吸,避免递归调用
# 简单的右下角缩放
def mousePressEvent(self, event):
@ -119,6 +128,45 @@ class DashboardItem(QGraphicsRectItem):
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:
delta = event.pos() - self._drag_start_pos
@ -130,6 +178,11 @@ class DashboardItem(QGraphicsRectItem):
return
super().mouseMoveEvent(event)
def hoverLeaveEvent(self, event):
"""鼠标离开组件时,恢复默认光标"""
self.unsetCursor()
super().hoverLeaveEvent(event)
def mouseReleaseEvent(self, event):
if self._resizing:
self._resizing = False
@ -164,6 +217,147 @@ class DashboardItem(QGraphicsRectItem):
"""子类可重写:在几何变化后调整内部元素(例如 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 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):
@ -316,21 +510,19 @@ class LabelItem(DashboardItem):
def paint(self, painter, option, widget=None):
"""重写绘制:标签背景透明,编辑模式有边框,展示模式无边框"""
if not self._edit_mode:
# 展示模式:不画任何边框,只保持文本
pen = QPen(Qt.PenStyle.NoPen)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
QGraphicsRectItem.paint(self, painter, option, widget)
# 展示模式:不画任何边框和背景,边界完全透明
# 直接返回,不调用父类的 paint避免绘制任何边框
return
# 编辑模式:选中时高亮蓝色,否则浅灰色;背景依然透明
# 先设置 pen确保父类 paint 使用正确的边框颜色
if self.isSelected():
pen = QPen(QColor(80, 160, 255), 2)
self.setPen(QPen(QColor(80, 160, 255), 2))
else:
pen = QPen(QColor(170, 170, 170), 1)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
QGraphicsRectItem.paint(self, painter, option, widget)
self.setPen(QPen(QColor(170, 170, 170), 1))
# 确保背景透明
self.setBrush(QBrush(Qt.GlobalColor.transparent))
super().paint(painter, option, widget)
class WebItem(DashboardItem):
@ -405,38 +597,116 @@ class DashboardScene(QGraphicsScene):
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(90, 90, 90), 1, Qt.PenStyle.DashLine))
self._canvas_item.setBrush(QBrush(QColor(15, 15, 15)))
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(30, 30, 30))
# 取消自身的边框,避免全屏时出现白边
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 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)
@ -449,7 +719,11 @@ class DashboardView(QGraphicsView):
if isinstance(it, DashboardItem):
states.append(asdict(it.to_state()))
with open(path, "w", encoding="utf-8") as f:
json.dump(states, f, indent=2, ensure_ascii=False)
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):
@ -458,14 +732,25 @@ class DashboardView(QGraphicsView):
data = json.load(f)
# 兼容旧格式:如果不是列表,直接忽略,避免崩溃
canvas = None
if isinstance(data, dict):
# 可能是早期的 {"widgets": [...]} 格式
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.clear()
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
@ -491,9 +776,12 @@ class DashboardView(QGraphicsView):
# -----------------------------
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()
@ -599,12 +887,16 @@ class PropertyPanel(QWidget):
def set_current_item(self, item: Optional[DashboardItem]):
self._current_item = item
if not item:
self._update_enabled(False)
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
# 如果当前是 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)
return
self._mode = "item"
self._update_enabled(True)
r = item.sceneBoundingRect()
self.x_spin.blockSignals(True)
@ -662,17 +954,50 @@ class PropertyPanel(QWidget):
self.web_group.setVisible(False)
def _on_geom_changed(self):
if not self._current_item:
return
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))
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._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
@ -782,60 +1107,92 @@ class MainWindow(QMainWindow):
self.setWindowTitle("PCM Viewer - Widgets Dashboard")
self.resize(1400, 840)
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 客户端:查询结果分发给所有 LabelItem
self.influx_client = InfluxDBClient(self)
self.influx_client.dataReceived.connect(self._on_influx_data)
self.influx_client.errorOccurred.connect(self._on_influx_error)
splitter = QSplitter()
splitter.addWidget(self.view)
splitter.addWidget(self.prop)
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)
# 左侧画布权重大一些,右侧保持适中宽度
self.prop.setMaximumWidth(420)
splitter.setStretchFactor(0, 4)
splitter.setStretchFactor(1, 1)
splitter.setSizes([1000, 400])
# 使用 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)
toolbar_layout = QHBoxLayout()
btn_mode = QPushButton("展示模式")
btn_influx = QPushButton("Influx配置")
btn_add_img = QPushButton("新增图片组件")
btn_add_label = QPushButton("新增标签组件")
btn_add_web = QPushButton("新增曲线组件")
btn_delete = QPushButton("删除组件")
btn_save = QPushButton("保存布局")
btn_load = QPushButton("加载布局")
toolbar_layout.addWidget(btn_mode)
toolbar_layout.addWidget(btn_influx)
toolbar_layout.addWidget(btn_add_img)
toolbar_layout.addWidget(btn_add_label)
toolbar_layout.addWidget(btn_add_web)
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_full_edit = QPushButton("全屏编辑")
self.btn_delete = 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_full_edit)
toolbar_layout.addStretch(1)
toolbar_layout.addWidget(btn_delete)
toolbar_layout.addWidget(btn_save)
toolbar_layout.addWidget(btn_load)
toolbar_layout.addWidget(self.btn_delete)
toolbar_layout.addWidget(self.btn_save)
toolbar_layout.addWidget(self.btn_load)
layout.addLayout(toolbar_layout)
layout.addWidget(splitter)
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)
btn_influx.clicked.connect(self._on_influx_config)
btn_add_img.clicked.connect(self._on_add_image)
btn_add_label.clicked.connect(self._on_add_label)
btn_add_web.clicked.connect(self._on_add_web)
btn_save.clicked.connect(self._on_save)
btn_load.clicked.connect(self._on_load)
btn_mode.clicked.connect(lambda: self._toggle_mode(btn_mode))
btn_delete.clicked.connect(self._on_delete)
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_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)
# 布局 / Influx 配置文件路径
base_dir = os.path.dirname(__file__)
@ -845,10 +1202,36 @@ class MainWindow(QMainWindow):
# 先加载全局 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 _toggle_mode(self, btn: QPushButton):
def _update_window_fullscreen(self):
"""
根据当前模式决定是否全屏
- 展示模式始终全屏
- 全屏编辑在编辑模式下也全屏
"""
want_full = (not self._edit_mode) or self._fullscreen_edit
if want_full:
# 真全屏:使用 Qt 的 showFullScreen退出时用 showMaximized 恢复普通 Windows 窗口
self.showFullScreen()
# 全屏时关闭滚动条,避免 1920x1080 屏幕上还出现滚动条
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
else:
# 恢复为标准的最大化窗口(带边框/任务栏)
self.showMaximized()
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
def _toggle_mode(self):
self._edit_mode = not self._edit_mode
btn.setText("展示模式" if self._edit_mode else "编辑模式")
# 按钮文字:当前是编辑 -> 显示“展示模式”;当前是展示 -> 显示“编辑模式”
self.btn_mode.setText("展示模式" if self._edit_mode else "编辑模式")
# 编辑模式:可拖动缩放;展示模式:锁定并启动 Influx 刷新
for it in self.view.scene_obj.items():
if isinstance(it, DashboardItem):
@ -870,6 +1253,155 @@ class MainWindow(QMainWindow):
# 回到编辑模式:停止查询
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_full_edit,
self.btn_delete,
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_delete,
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_full_edit,
self.btn_delete,
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}"
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:
@ -879,14 +1411,17 @@ class MainWindow(QMainWindow):
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_save(self):
try:
@ -898,6 +1433,10 @@ class MainWindow(QMainWindow):
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))
@ -910,6 +1449,55 @@ class MainWindow(QMainWindow):
for it in items:
scene.removeItem(it)
self.prop.set_current_item(None)
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 配置持久化 ----------