PCM_Viewer/dashboard_model.py

337 lines
10 KiB
Python
Raw Normal View History

2026-02-06 22:49:52 +08:00
"""
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