""" 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