PCM_Viewer/main.py

963 lines
34 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
from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QHBoxLayout,
QVBoxLayout,
QSplitter,
QPushButton,
QLabel,
QLineEdit,
QSpinBox,
QFileDialog,
QFormLayout,
QGraphicsView,
QGraphicsScene,
QGraphicsRectItem,
QGraphicsPixmapItem,
QGraphicsTextItem,
QGraphicsProxyWidget,
QMessageBox,
QPlainTextEdit,
QListWidget,
QListWidgetItem,
QColorDialog,
QGroupBox,
QDialog,
QDialogButtonBox,
QCheckBox,
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QUrl
from influxdb_wrapper import InfluxDBClient
# -----------------------------
# 数据结构(用于保存/加载布局)
# -----------------------------
@dataclass
class WidgetState:
widget_type: str # "image" | "web" | "label"
x: float
y: float
w: float
h: float
z: float = 0.0
config: Dict[str, Any] = field(default_factory=dict)
@dataclass
class InfluxSettings:
url: str = ""
token: str = ""
org: str = ""
bucket: str = ""
interval_ms: int = 1000
query: str = ""
# -----------------------------
# 画布组件基类
# -----------------------------
class DashboardItem(QGraphicsRectItem):
"""可拖拽、可简单缩放的组件基类"""
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(parent)
self.setRect(QRectF(0, 0, w, h))
self.setPos(x, y)
self.setFlags(
QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsRectItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setBrush(QBrush(QColor(40, 40, 40)))
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
# 简单的右下角缩放
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
r = self.rect()
# PyQt6 的 QGraphicsSceneMouseEvent 使用 pos() 返回局部坐标
pt = event.pos()
if (
r.width() - self._resize_margin <= pt.x() <= r.width()
and r.height() - self._resize_margin <= pt.y() <= r.height()
):
self._resizing = True
self._drag_start_rect = QRectF(r)
self._drag_start_pos = pt
event.accept()
return
super().mousePressEvent(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
new_w = max(40, self._drag_start_rect.width() + delta.x())
new_h = max(40, self._drag_start_rect.height() + delta.y())
self.setRect(QRectF(0, 0, new_w, new_h))
self.on_resized()
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self._resizing:
self._resizing = False
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:
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
class ImageItem(DashboardItem):
def __init__(self, x: float, y: float, w: float, h: float, parent=None):
super().__init__(x, y, w, h, parent)
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 to_state(self) -> WidgetState:
r = self.sceneBoundingRect()
return WidgetState(
widget_type="image",
x=r.x(),
y=r.y(),
w=r.width(),
h=r.height(),
z=float(self.zValue()),
config={"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:
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 to_state(self) -> WidgetState:
r = self.sceneBoundingRect()
return WidgetState(
widget_type="label",
x=r.x(),
y=r.y(),
w=r.width(),
h=r.height(),
z=float(self.zValue()),
config=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:
# 展示模式:不画任何边框,只保持文本
pen = QPen(Qt.PenStyle.NoPen)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
QGraphicsRectItem.paint(self, painter, option, widget)
return
# 编辑模式:选中时高亮蓝色,否则浅灰色;背景依然透明
if self.isSelected():
pen = 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)
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)
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 to_state(self) -> WidgetState:
r = self.sceneBoundingRect()
return WidgetState(
widget_type="web",
x=r.x(),
y=r.y(),
w=r.width(),
h=r.height(),
z=float(self.zValue()),
config={
"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)
def _on_selection_changed(self):
items = self.selectedItems()
self.selectionChangedEx.emit(items[0] if items else None)
class DashboardView(QGraphicsView):
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))
@property
def scene_obj(self) -> DashboardScene:
return self._scene
# 工厂方法
def add_image_item(self) -> ImageItem:
item = ImageItem(20, 20, 400, 300)
self._scene.addItem(item)
item.setSelected(True)
return item
def add_label_item(self) -> LabelItem:
item = LabelItem(50, 50, 160, 60)
self._scene.addItem(item)
item.setSelected(True)
return item
def add_web_item(self) -> WebItem:
item = WebItem(40, 40, 500, 350)
self._scene.addItem(item)
item.setSelected(True)
return item
# 布局持久化
def save_layout(self, path: str):
states = []
for it in self._scene.items():
if isinstance(it, DashboardItem):
states.append(asdict(it.to_state()))
with open(path, "w", encoding="utf-8") as f:
json.dump(states, 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)
# 兼容旧格式:如果不是列表,直接忽略,避免崩溃
if isinstance(data, dict):
# 可能是早期的 {"widgets": [...]} 格式
data = data.get("widgets", [])
if not isinstance(data, list):
print("layout file format not recognized, ignore:", path)
return
self._scene.clear()
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()
else:
continue
it.apply_state(ws)
# -----------------------------
# 属性面板
# -----------------------------
class PropertyPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._current_item: Optional[DashboardItem] = 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("允许点击网页(解锁)")
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
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.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._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,
):
w.setEnabled(enabled)
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)
return
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.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.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)
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()
self._current_item.setPos(x, y)
self._current_item.setRect(QRectF(0, 0, w, h))
self._current_item.setZValue(float(z))
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()
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)
self._edit_mode = True
self.view = DashboardView()
self.prop = PropertyPanel()
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.prop.setMaximumWidth(420)
splitter.setStretchFactor(0, 4)
splitter.setStretchFactor(1, 1)
splitter.setSizes([1000, 400])
central = QWidget()
layout = QVBoxLayout(central)
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)
toolbar_layout.addStretch(1)
toolbar_layout.addWidget(btn_delete)
toolbar_layout.addWidget(btn_save)
toolbar_layout.addWidget(btn_load)
layout.addLayout(toolbar_layout)
layout.addWidget(splitter)
self.setCentralWidget(central)
self.view.scene_obj.selectionChangedEx.connect(self.prop.set_current_item)
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)
# 布局 / Influx 配置文件路径
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)
def _toggle_mode(self, btn: QPushButton):
self._edit_mode = not self._edit_mode
btn.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):
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:
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:
# 回到编辑模式:停止查询
self.influx_client.stopQuery()
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)
def _on_add_label(self):
item = self.view.add_label_item()
self.prop.set_current_item(item)
def _on_add_web(self):
item = self.view.add_web_item()
self.prop.set_current_item(item)
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)
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)
# ---------- 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():
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()