963 lines
34 KiB
Python
963 lines
34 KiB
Python
"""
|
||
PCM Viewer - Widgets 版 Dashboard 入口
|
||
|
||
技术栈:PyQt6 Widgets + QGraphicsView/QGraphicsScene
|
||
功能(当前版本,先实现基础骨架):
|
||
- 左侧画布:可拖拽的组件(图片组件 / Web 组件)
|
||
- 右侧面板:显示并编辑当前选中组件的几何和基本配置
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
from dataclasses import dataclass, asdict, field
|
||
from typing import Optional, Dict, Any
|
||
|
||
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()
|