337 lines
10 KiB
Python
337 lines
10 KiB
Python
"""
|
||
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
|
||
|
||
|