PCM_Viewer/dashboard_model.py

337 lines
10 KiB
Python
Raw Permalink 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.

"""
Dashboard 组件布局模型
- 管理一组可视化组件widget的实例类型、位置、尺寸、层级、配置
- 支持:新增/删除/选中/移动/缩放/修改配置/保存加载(JSON)
说明:
该模型面向 QML使用 QAbstractListModel 暴露 role。
"""
from __future__ import annotations
import json
import os
import uuid
from dataclasses import dataclass, asdict, field
from typing import Any, Dict, List, Optional
from PyQt6.QtCore import (
QAbstractListModel,
QModelIndex,
Qt,
pyqtSignal,
pyqtSlot,
pyqtProperty,
)
@dataclass
class WidgetItem:
id: str
type: str # e.g. "image_overlay", "web_chart"
x: float = 0.0
y: float = 0.0
w: float = 400.0
h: float = 300.0
z: int = 0
locked: bool = False
props: Dict[str, Any] = field(default_factory=dict)
class DashboardModel(QAbstractListModel):
"""
QML 用的组件列表模型。
"""
selectedIndexChanged = pyqtSignal()
dirtyChanged = pyqtSignal()
countChanged = pyqtSignal()
# 重要role 名避免和 QML Item 的内置属性冲突x/y/width/height 等)
ROLE_UID = Qt.ItemDataRole.UserRole + 1
ROLE_WIDGET_TYPE = Qt.ItemDataRole.UserRole + 2
ROLE_POS_X = Qt.ItemDataRole.UserRole + 3
ROLE_POS_Y = Qt.ItemDataRole.UserRole + 4
ROLE_SIZE_W = Qt.ItemDataRole.UserRole + 5
ROLE_SIZE_H = Qt.ItemDataRole.UserRole + 6
ROLE_Z = Qt.ItemDataRole.UserRole + 7
ROLE_LOCKED = Qt.ItemDataRole.UserRole + 8
ROLE_PROPS = Qt.ItemDataRole.UserRole + 9
def __init__(self, parent=None):
super().__init__(parent)
self._items: List[WidgetItem] = []
self._selected_index: int = -1
self._dirty: bool = False
self._default_file: str = "dashboard.json"
# ---------- Qt model ----------
def rowCount(self, parent=QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._items)
@pyqtProperty(int, notify=countChanged)
def count(self) -> int:
return len(self._items)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
i = index.row()
if i < 0 or i >= len(self._items):
return None
item = self._items[i]
if role == self.ROLE_UID:
return item.id
if role == self.ROLE_WIDGET_TYPE:
return item.type
if role == self.ROLE_POS_X:
return float(item.x)
if role == self.ROLE_POS_Y:
return float(item.y)
if role == self.ROLE_SIZE_W:
return float(item.w)
if role == self.ROLE_SIZE_H:
return float(item.h)
if role == self.ROLE_Z:
return int(item.z)
if role == self.ROLE_LOCKED:
return bool(item.locked)
if role == self.ROLE_PROPS:
return item.props
return None
def roleNames(self):
return {
self.ROLE_UID: b"uid",
self.ROLE_WIDGET_TYPE: b"widgetType",
self.ROLE_POS_X: b"posX",
self.ROLE_POS_Y: b"posY",
self.ROLE_SIZE_W: b"sizeW",
self.ROLE_SIZE_H: b"sizeH",
# 避免和 QML Item.z 冲突(该属性在部分版本上是 FINAL不能在 delegate 里声明同名 required
self.ROLE_Z: b"layerZ",
self.ROLE_LOCKED: b"locked",
self.ROLE_PROPS: b"props",
}
# ---------- properties ----------
@pyqtProperty(int, notify=selectedIndexChanged)
def selectedIndex(self) -> int:
return self._selected_index
def _set_dirty(self, v: bool):
if self._dirty != v:
self._dirty = v
self.dirtyChanged.emit()
@pyqtProperty(bool, notify=dirtyChanged)
def dirty(self) -> bool:
return self._dirty
# ---------- helpers ----------
def _touch_item(self, row: int):
if 0 <= row < len(self._items):
model_index = self.index(row, 0)
self.dataChanged.emit(model_index, model_index, [])
self._set_dirty(True)
def _new_id(self) -> str:
return uuid.uuid4().hex
def _max_z(self) -> int:
if not self._items:
return 0
return max(x.z for x in self._items)
# ---------- QML slots ----------
@pyqtSlot(str, result=int)
def addWidget(self, widget_type: str) -> int:
"""
添加一个 widget返回 index。
widget_type: "image_overlay" | "web_chart" | ...
"""
defaults: Dict[str, Any] = {}
if widget_type == "image_overlay":
defaults = {
"imagePath": "",
# 存 DataModel 的 json仅 textItems
"textItemsJson": "",
# Influx 查询可按组件覆盖(可选)
"query": "",
}
elif widget_type == "web_chart":
defaults = {
"url": "",
}
item = WidgetItem(
id=self._new_id(),
type=widget_type,
x=20.0,
y=20.0,
w=500.0,
h=350.0,
z=len(self._items),
locked=False,
props=defaults,
)
self.beginInsertRows(QModelIndex(), len(self._items), len(self._items))
self._items.append(item)
self.endInsertRows()
self.countChanged.emit()
self.select(len(self._items) - 1)
self._set_dirty(True)
return len(self._items) - 1
@pyqtSlot(int)
def remove(self, index: int):
if 0 <= index < len(self._items):
self.beginRemoveRows(QModelIndex(), index, index)
self._items.pop(index)
self.endRemoveRows()
self.countChanged.emit()
if self._selected_index == index:
self._selected_index = -1
self.selectedIndexChanged.emit()
elif self._selected_index > index:
self._selected_index -= 1
self.selectedIndexChanged.emit()
self._set_dirty(True)
@pyqtSlot(int)
def select(self, index: int):
if index != self._selected_index:
if 0 <= index < len(self._items):
self._selected_index = index
else:
self._selected_index = -1
self.selectedIndexChanged.emit()
@pyqtSlot(int)
def bringToFront(self, index: int):
"""将指定组件置顶,避免拖拽/新建后层级混乱。"""
if 0 <= index < len(self._items):
max_z = self._max_z()
it = self._items[index]
if it.z < max_z:
it.z = max_z + 1
self._touch_item(index)
@pyqtSlot(int, float, float, float, float)
def setGeometry(self, index: int, x: float, y: float, w: float, h: float):
if 0 <= index < len(self._items):
it = self._items[index]
if it.locked:
return
it.x = float(x)
it.y = float(y)
it.w = max(20.0, float(w))
it.h = max(20.0, float(h))
self._touch_item(index)
@pyqtSlot(int, str, "QVariant")
def setProp(self, index: int, key: str, value):
if 0 <= index < len(self._items):
it = self._items[index]
it.props[key] = value
self._touch_item(index)
@pyqtSlot(int, str, result="QVariant")
def getProp(self, index: int, key: str):
if 0 <= index < len(self._items):
return self._items[index].props.get(key)
return None
@pyqtSlot(result=str)
def toJson(self) -> str:
data = {"widgets": [asdict(x) for x in self._items]}
return json.dumps(data, ensure_ascii=False, indent=2)
@pyqtSlot(str)
def fromJson(self, json_str: str):
try:
data = json.loads(json_str)
widgets = data.get("widgets", [])
parsed: List[WidgetItem] = []
for w in widgets:
parsed.append(
WidgetItem(
id=str(w.get("id") or self._new_id()),
type=str(w.get("type") or "image_overlay"),
x=float(w.get("x", 0.0)),
y=float(w.get("y", 0.0)),
w=float(w.get("w", 400.0)),
h=float(w.get("h", 300.0)),
z=int(w.get("z", 0)),
locked=bool(w.get("locked", False)),
props=dict(w.get("props") or {}),
)
)
self.beginResetModel()
self._items = parsed
self.endResetModel()
self.countChanged.emit()
self.select(-1)
self._set_dirty(False)
except Exception:
# ignore invalid json
return
@pyqtSlot(int, result="QVariant")
def getItem(self, index: int):
"""
便于 QML 属性面板读取当前条目(避免 role 名冲突/role id 取值问题)。
"""
if 0 <= index < len(self._items):
it = self._items[index]
return {
"uid": it.id,
"widgetType": it.type,
"posX": float(it.x),
"posY": float(it.y),
"sizeW": float(it.w),
"sizeH": float(it.h),
"z": int(it.z),
"locked": bool(it.locked),
"props": dict(it.props),
}
return None
@pyqtSlot(result=str)
def debugSummary(self) -> str:
return f"count={len(self._items)} selectedIndex={self._selected_index}"
@pyqtSlot(result=bool)
def load(self) -> bool:
return self.loadFromFile(self._default_file)
@pyqtSlot(result=bool)
def save(self) -> bool:
return self.saveToFile(self._default_file)
@pyqtSlot(str, result=bool)
def loadFromFile(self, path: str) -> bool:
try:
if not os.path.exists(path):
return False
with open(path, "r", encoding="utf-8") as f:
self.fromJson(f.read())
return True
except Exception:
return False
@pyqtSlot(str, result=bool)
def saveToFile(self, path: str) -> bool:
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.toJson())
self._set_dirty(False)
return True
except Exception:
return False