diff --git a/dashboard.json b/dashboard.json deleted file mode 100644 index 709fd4c..0000000 --- a/dashboard.json +++ /dev/null @@ -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 - } - } -] \ No newline at end of file diff --git a/influx_settings.json b/influx_settings.json index bb4f223..f6aa709 100644 --- a/influx_settings.json +++ b/influx_settings.json @@ -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()" } \ No newline at end of file diff --git a/main.py b/main.py index 846ec13..18b21df 100644 --- a/main.py +++ b/main.py @@ -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 配置持久化 ----------