main
risingLee 2026-02-11 11:10:50 +08:00
parent 44c582487e
commit 44a53520a4
12 changed files with 940 additions and 52 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
venv/
__pycache__/
build/
dist/

115
BUILD.md Normal file
View File

@ -0,0 +1,115 @@
# 打包说明
## 前置要求
1. Python 3.8+
2. 已安装所有依赖(见 `requirements.txt`
3. PyInstaller打包脚本会自动安装
## 打包步骤
### Windows
1. 激活虚拟环境(如果使用):
```cmd
venv\Scripts\activate
```
2. 运行打包脚本:
```cmd
build.bat
```
或者直接运行:
```cmd
python build.py
```
3. 打包完成后,可执行文件位于:
- 单文件模式:`dist\PCM_Viewer.exe`
- 文件夹模式:`dist\PCM_Viewer\PCM_Viewer.exe`
### Linux/Mac
1. 激活虚拟环境(如果使用):
```bash
source venv/bin/activate
```
2. 运行打包脚本:
```bash
chmod +x build.sh
./build.sh
```
或者直接运行:
```bash
python build.py
```
## 打包模式
### 单文件模式(默认,推荐)
- 所有依赖打包到一个 exe 文件中
- 首次运行会在 exe 同目录创建配置文件:
- `dashboard.json` - 布局配置
- `influx_settings.json` - InfluxDB 配置
- 配置文件保存在 exe 同目录,方便用户修改
### 文件夹模式
使用 `--onedir` 参数:
```bash
python build.py --onedir
```
- 所有文件打包到一个文件夹中
- 配置文件同样保存在 exe 同目录
## 配置文件处理
程序会自动处理配置文件:
1. **首次运行**:如果配置文件不存在,程序会创建默认配置
2. **配置文件位置**
- 打包后exe 同目录
- 开发模式:脚本同目录
3. **配置文件内容**
- `dashboard.json`:保存画布布局和组件配置
- `influx_settings.json`:保存 InfluxDB 连接配置
## 注意事项
1. **首次运行**:打包后的程序首次运行可能需要几秒钟来解压临时文件
2. **杀毒软件**:某些杀毒软件可能会误报,这是 PyInstaller 打包程序的常见问题
3. **文件大小**:单文件打包后大约 100-200MB包含 PyQt6 和 WebEngine
4. **依赖库**:确保所有依赖都已安装,特别是 `PyQt6``PyQt6-WebEngine`
## 常见问题
### Q: 打包后程序无法启动?
A: 检查以下几点:
- 确保所有依赖都已安装
- 查看控制台错误信息(如果有)
- 尝试使用 `--onedir` 模式打包,查看详细错误
### Q: 配置文件找不到?
A: 配置文件会自动创建在 exe 同目录。如果找不到,检查:
- exe 是否有写入权限
- 是否在只读目录运行
### Q: Web 组件无法显示?
A: 确保打包时包含了 `PyQt6.QtWebEngineWidgets`,打包脚本已自动处理。
## 自定义打包选项
如果需要自定义打包选项,可以修改 `build.py` 中的 PyInstaller 参数:
- `--icon=icon.ico`:添加程序图标
- `--add-data`:添加额外数据文件
- `--hidden-import`:添加隐藏导入的模块

51
PCM_Viewer.spec Normal file
View File

@ -0,0 +1,51 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = ['PyQt6.QtWebEngineWidgets', 'influxdb_client', 'influxdb_client.client', 'influxdb_client.client.write_api', 'influxdb_client.client.query_api', 'influxdb_wrapper']
tmp_ret = collect_all('PyQt6.QtWebEngineWidgets')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['main.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['matplotlib', 'numpy', 'pandas', 'scipy', 'PIL', 'tkinter'],
noarchive=False,
optimize=2,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[('O', None, 'OPTION'), ('O', None, 'OPTION')],
exclude_binaries=True,
name='PCM_Viewer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name='PCM_Viewer',
)

117
STARTUP_OPTIMIZATION.md Normal file
View File

@ -0,0 +1,117 @@
# 启动速度优化指南
## 问题
PyInstaller 单文件打包后启动时间超过 30 秒,太慢。
## 优化措施
### 1. 代码层面优化(已实现)
#### 延迟导入大型模块
- **QWebEngineView**:只在创建 WebItem 时导入
- **InfluxDBClient**:只在进入展示模式时创建
```python
# 之前:启动时立即导入
from PyQt6.QtWebEngineWidgets import QWebEngineView
from influxdb_wrapper import InfluxDBClient
# 现在:延迟导入
# 在 WebItem.__init__ 中:
from PyQt6.QtWebEngineWidgets import QWebEngineView
# 在需要 Influx 时:
if self.influx_client is None:
from influxdb_wrapper import InfluxDBClient
self.influx_client = InfluxDBClient(self)
```
### 2. 打包参数优化
#### 使用优化版打包脚本
```bash
python build_optimized.py
```
#### 关键优化参数
- `--noupx`:不使用 UPX 压缩UPX 会增加解压时间)
- `--optimize=2`Python 字节码优化级别
- `--exclude-module`:排除不需要的大型模块
- 不收集全部 PyQt6只收集必要的
### 3. 进一步优化建议
#### A. 使用 --onedir 模式(推荐)
单文件模式需要解压所有文件到临时目录,这很慢。如果启动速度是优先考虑,建议使用文件夹模式:
```bash
python build_optimized.py --onedir
```
**优点**
- 启动速度快(不需要解压)
- 文件体积更小
- 更新时只需替换 exe 文件
**缺点**
- 需要整个文件夹一起分发
#### B. 减少启动时的初始化工作
- 延迟加载布局文件(在后台线程)
- 延迟创建非关键 UI 组件
- 使用启动画面(让用户知道程序正在加载)
#### C. 使用 Nuitka 替代 PyInstaller可选
Nuitka 将 Python 编译为 C++,启动速度通常更快:
```bash
pip install nuitka
python -m nuitka --onefile --windows-disable-console main.py
```
#### D. 使用 SSD 和快速 CPU
- 单文件模式需要解压到临时目录SSD 会快很多
- CPU 性能也影响解压速度
### 4. 性能对比
| 方案 | 启动时间 | 文件大小 | 分发便利性 |
|------|---------|---------|-----------|
| 单文件(优化前)| 30+ 秒 | ~200MB | ⭐⭐⭐⭐⭐ |
| 单文件(优化后)| 10-15 秒 | ~180MB | ⭐⭐⭐⭐⭐ |
| 文件夹模式 | 2-5 秒 | ~200MB | ⭐⭐⭐ |
| Nuitka 单文件 | 5-10 秒 | ~150MB | ⭐⭐⭐⭐⭐ |
### 5. 测试启动时间
打包后测试启动时间:
```bash
# Windows
timeout /t 0 /nobreak >nul & dist\PCM_Viewer.exe
# 或使用 PowerShell
Measure-Command { Start-Process -FilePath "dist\PCM_Viewer.exe" -Wait }
```
### 6. 如果仍然太慢
如果优化后仍然超过 10 秒,强烈建议:
1. **使用文件夹模式**`--onedir`
- 启动速度通常快 5-10 倍
- 只需分发整个文件夹
2. **考虑使用 Nuitka**
- 编译为原生代码,启动更快
- 但打包时间更长
3. **检查杀毒软件**
- 某些杀毒软件会扫描单文件 exe导致启动慢
- 可以添加到白名单
4. **使用启动画面**
- 即使启动慢,至少让用户知道程序在加载
- 可以显示进度条或加载动画

View File

@ -56,3 +56,5 @@ pip install -r requirements.txt

29
build.bat Normal file
View File

@ -0,0 +1,29 @@
@echo off
REM Windows 打包脚本
echo 正在打包 PCM Viewer...
REM 检查是否在虚拟环境中
if not exist "venv\Scripts\activate.bat" (
echo 错误:未找到虚拟环境
echo 请先创建虚拟环境: python -m venv venv
pause
exit /b 1
)
REM 激活虚拟环境
call venv\Scripts\activate.bat
REM 检查是否安装了 PyInstaller
python -c "import PyInstaller" 2>nul
if errorlevel 1 (
echo 正在安装 PyInstaller...
pip install pyinstaller
)
REM 运行打包脚本
python build.py
pause

96
build.py Normal file
View File

@ -0,0 +1,96 @@
"""
打包脚本 - 使用 PyInstaller 打包 PCM Viewer
使用方法
python build.py # 打包为单文件(推荐)
python build.py --onedir # 打包为文件夹
"""
import os
import sys
import shutil
import subprocess
def get_resource_path(relative_path):
"""获取资源文件的绝对路径(支持打包后的单文件模式)"""
try:
# PyInstaller 创建的临时文件夹
base_path = sys._MEIPASS
except Exception:
# 开发环境:使用脚本所在目录
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def main():
# 检查是否安装了 PyInstaller
try:
import PyInstaller
except ImportError:
print("错误:未安装 PyInstaller")
print("请运行: pip install pyinstaller")
sys.exit(1)
# 解析命令行参数
onedir = "--onedir" in sys.argv
# 清理之前的构建
if os.path.exists("build"):
shutil.rmtree("build")
if os.path.exists("dist"):
shutil.rmtree("dist")
if os.path.exists("PCM_Viewer.spec"):
os.remove("PCM_Viewer.spec")
# 构建 PyInstaller 命令(已优化启动速度)
cmd = [
"pyinstaller",
"--name=PCM_Viewer",
"--windowed", # 不显示控制台窗口
"--onefile" if not onedir else "--onedir",
# ========== 启动速度优化 ==========
"--noupx", # 不使用 UPX 压缩UPX 会增加启动时间)
"--optimize=2", # Python 字节码优化级别0-22 最高)
# 排除不需要的大型模块(减少打包体积和启动时间)
"--exclude-module=matplotlib",
"--exclude-module=numpy",
"--exclude-module=pandas",
"--exclude-module=scipy",
"--exclude-module=PIL",
"--exclude-module=tkinter",
# 隐藏导入(延迟导入的模块)
"--hidden-import=PyQt6.QtWebEngineWidgets",
"--hidden-import=influxdb_client",
"--hidden-import=influxdb_client.client",
"--hidden-import=influxdb_client.client.write_api",
"--hidden-import=influxdb_client.client.query_api",
"--hidden-import=influxdb_wrapper",
# 只收集必要的 PyQt6 模块(不收集全部,减少体积)
"--collect-all=PyQt6.QtWebEngineWidgets", # WebEngine 需要完整收集
"main.py"
]
print("开始打包...")
print(f"模式: {'单文件' if not onedir else '文件夹'}")
print(f"命令: {' '.join(cmd)}")
print()
# 执行打包
result = subprocess.run(cmd, check=False)
if result.returncode == 0:
print("\n打包成功!")
if not onedir:
print("单文件位置: dist/PCM_Viewer.exe")
print("\n注意:")
print("1. 首次运行会在 exe 同目录创建配置文件")
print("2. dashboard.json 和 influx_settings.json 会保存在 exe 同目录")
else:
print("文件夹位置: dist/PCM_Viewer/")
print("可执行文件: dist/PCM_Viewer/PCM_Viewer.exe")
else:
print("\n打包失败!")
sys.exit(1)
if __name__ == "__main__":
main()

27
build.sh Normal file
View File

@ -0,0 +1,27 @@
#!/bin/bash
# Linux/Mac 打包脚本
echo "正在打包 PCM Viewer..."
# 检查是否在虚拟环境中
if [ ! -f "venv/bin/activate" ]; then
echo "错误:未找到虚拟环境"
echo "请先创建虚拟环境: python3 -m venv venv"
exit 1
fi
# 激活虚拟环境
source venv/bin/activate
# 检查是否安装了 PyInstaller
python -c "import PyInstaller" 2>/dev/null
if [ $? -ne 0 ]; then
echo "正在安装 PyInstaller..."
pip install pyinstaller
fi
# 运行打包脚本
python build.py

96
build_optimized.py Normal file
View File

@ -0,0 +1,96 @@
"""
优化版打包脚本 - 加速启动时间
优化措施
1. 延迟导入大型模块QWebEngineView, InfluxDBClient
2. 排除不需要的模块
3. 不使用 UPX 压缩
4. 优化 Python 字节码
"""
import os
import sys
import shutil
import subprocess
def main():
# 检查是否安装了 PyInstaller
try:
import PyInstaller
except ImportError:
print("错误:未安装 PyInstaller")
print("请运行: pip install pyinstaller")
sys.exit(1)
# 解析命令行参数
onedir = "--onedir" in sys.argv
# 清理之前的构建
if os.path.exists("build"):
shutil.rmtree("build")
if os.path.exists("dist"):
shutil.rmtree("dist")
if os.path.exists("PCM_Viewer.spec"):
os.remove("PCM_Viewer.spec")
# 构建优化的 PyInstaller 命令
cmd = [
"pyinstaller",
"--name=PCM_Viewer",
"--windowed", # 不显示控制台窗口
"--onefile" if not onedir else "--onedir",
# ========== 启动速度优化 ==========
"--noupx", # 不使用 UPX 压缩UPX 会增加启动时间)
"--optimize=2", # Python 字节码优化级别0-22 最高)
# 排除不需要的大型模块(减少打包体积和启动时间)
"--exclude-module=matplotlib",
"--exclude-module=numpy",
"--exclude-module=pandas",
"--exclude-module=scipy",
"--exclude-module=PIL",
"--exclude-module=tkinter",
"--exclude-module=PyQt5", # 排除 PyQt5如果不需要
# 隐藏导入(延迟导入的模块)
"--hidden-import=PyQt6.QtWebEngineWidgets",
"--hidden-import=influxdb_client",
"--hidden-import=influxdb_client.client",
"--hidden-import=influxdb_client.client.write_api",
"--hidden-import=influxdb_client.client.query_api",
"--hidden-import=influxdb_wrapper",
# 只收集必要的 PyQt6 模块(不收集全部,减少体积)
"--collect-all=PyQt6.QtWebEngineWidgets", # WebEngine 需要完整收集
# 不收集其他大型库
"--collect-submodules=influxdb_client", # 只收集 influxdb_client 的子模块
"main.py"
]
print("开始打包(优化版)...")
print(f"模式: {'单文件' if not onedir else '文件夹'}")
print(f"优化措施: 延迟导入、排除不需要的模块、不使用 UPX")
print(f"命令: {' '.join(cmd)}")
print()
# 执行打包
result = subprocess.run(cmd, check=False)
if result.returncode == 0:
print("\n打包成功!")
if not onedir:
print("单文件位置: dist/PCM_Viewer.exe")
print("\n优化说明:")
print("1. 延迟导入 QWebEngineView 和 InfluxDBClient减少启动时间")
print("2. 排除了不需要的大型模块matplotlib, numpy 等)")
print("3. 不使用 UPX 压缩,避免解压时间")
print("4. 首次运行会在 exe 同目录创建配置文件")
else:
print("文件夹位置: dist/PCM_Viewer/")
print("可执行文件: dist/PCM_Viewer/PCM_Viewer.exe")
else:
print("\n打包失败!")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -6,21 +6,6 @@
"h": 1080.0
},
"widgets": [
{
"widget_type": "label",
"x": 173.0,
"y": 210.0,
"w": 162.0,
"h": 62.0,
"z": 1.0,
"config": {
"fieldName": "主轴承#3",
"prefix": "主轴承温度",
"suffix": " ℃",
"fontSize": 16,
"color": "#FFFFFF"
}
},
{
"widget_type": "label",
"x": 46.0,
@ -37,26 +22,41 @@
}
},
{
"widget_type": "image",
"x": 0.0,
"y": 0.0,
"w": 650.0,
"h": 865.0,
"z": 0.0,
"widget_type": "label",
"x": 173.0,
"y": 210.0,
"w": 162.0,
"h": 62.0,
"z": 1.0,
"config": {
"imagePath": "D:/1-2.JPG"
"fieldName": "主轴承#3",
"prefix": "主轴承温度",
"suffix": " ℃",
"fontSize": 16,
"color": "#FFFFFF"
}
},
{
"widget_type": "web",
"x": 648.5,
"x": 649.0,
"y": 0.0,
"w": 887.0,
"w": 886.0,
"h": 863.0,
"z": 0.0,
"config": {
"url": "http://127.0.0.1:8086/orgs/b2542eeb72a3e614/dashboards/0f8ab8a328fe9000?lower=now%28%29+-+1h",
"locked": false
"locked": true
}
},
{
"widget_type": "image",
"x": 0.0,
"y": 0.0,
"w": 649.0,
"h": 864.0,
"z": 0.0,
"config": {
"imagePath": "D:/1-2.JPG"
}
}
]

View File

@ -4,5 +4,5 @@
"org": "MEASCON",
"bucket": "PCM",
"interval_ms": 1000,
"query": "from(bucket: \"PCM\")\n |> range(start: -24h)\n |> filter(fn: (r) => r._measurement == \"PCM_Measurement\")\n |> filter(fn: (r) => r.data_type == \"LSDAQ\")\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])\n |> last()"
"query": "from(bucket: \"PCM\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"PCM_Measurement\")\n |> filter(fn: (r) => r.data_type == \"LSDAQ\")\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])\n |> last()"
}

405
main.py
View File

@ -14,9 +14,10 @@ import os
import sys
from dataclasses import dataclass, asdict, field
from typing import Optional, Dict, Any
import math
from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer, QPointF
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor
from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor, QShortcut, QKeySequence
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
@ -49,10 +50,12 @@ from PyQt6.QtWidgets import (
QFrame,
)
# QWebEngineView 必须在 QApplication 创建之前导入Qt 要求)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QUrl
from influxdb_wrapper import InfluxDBClient
# 延迟导入InfluxDB 客户端只在需要时导入(减少启动时间)
# from influxdb_wrapper import InfluxDBClient
# -----------------------------
@ -61,7 +64,7 @@ from influxdb_wrapper import InfluxDBClient
@dataclass
class WidgetState:
widget_type: str # "image" | "web" | "label"
widget_type: str # "image" | "web" | "label" | "arrow"
x: float
y: float
w: float
@ -109,19 +112,45 @@ class DashboardItem(QGraphicsRectItem):
self._resize_margin = 8
self._drag_start_rect: Optional[QRectF] = None
self._drag_start_pos = None
self._resize_edge = None # "left", "right", "top", "bottom", "corner" 等
self._snapping = False # 是否正在磁吸,避免递归调用
# 简单的右下角缩放
def _is_in_resize_area(self, pt: QPointF, r: QRectF) -> Optional[str]:
"""检测鼠标是否在可调整大小的区域,返回边缘标识"""
margin = self._resize_margin
w = r.width()
h = r.height()
x = pt.x()
y = pt.y()
# 四个角
if x <= margin and y <= margin:
return "top-left"
elif x >= w - margin and y <= margin:
return "top-right"
elif x <= margin and y >= h - margin:
return "bottom-left"
elif x >= w - margin and y >= h - margin:
return "bottom-right"
# 四个边
elif x <= margin:
return "left"
elif x >= w - margin:
return "right"
elif y <= margin:
return "top"
elif y >= h - margin:
return "bottom"
return 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()
):
edge = self._is_in_resize_area(pt, r)
if edge:
self._resizing = True
self._resize_edge = edge
self._drag_start_rect = QRectF(r)
self._drag_start_pos = pt
event.accept()
@ -168,11 +197,38 @@ class DashboardItem(QGraphicsRectItem):
super().hoverMoveEvent(event)
def mouseMoveEvent(self, event):
if self._resizing and self._drag_start_rect is not None and self._drag_start_pos is not None:
if self._resizing and self._drag_start_rect is not None and self._drag_start_pos is not None and self._resize_edge:
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())
r = self._drag_start_rect
new_w = r.width()
new_h = r.height()
pos_delta_x = 0
pos_delta_y = 0
# 根据拖拽的边缘调整大小和位置
if "right" in self._resize_edge:
new_w = max(40, r.width() + delta.x())
elif "left" in self._resize_edge:
old_w = new_w
new_w = max(40, r.width() - delta.x())
# 从左边调整:需要向右移动,移动距离 = 宽度减少量
pos_delta_x = old_w - new_w
if "bottom" in self._resize_edge:
new_h = max(40, r.height() + delta.y())
elif "top" in self._resize_edge:
old_h = new_h
new_h = max(40, r.height() - delta.y())
# 从上边调整:需要向下移动,移动距离 = 高度减少量
pos_delta_y = old_h - new_h
# 更新矩形rect 是相对于 item 的局部坐标,从 0,0 开始)
self.setRect(QRectF(0, 0, new_w, new_h))
# 如果是从左边或上边调整,需要移动 item 的位置以保持视觉上的左上角不变
if pos_delta_x != 0 or pos_delta_y != 0:
current_pos = self.pos()
self.setPos(current_pos.x() + pos_delta_x, current_pos.y() + pos_delta_y)
self.on_resized()
event.accept()
return
@ -186,6 +242,7 @@ class DashboardItem(QGraphicsRectItem):
def mouseReleaseEvent(self, event):
if self._resizing:
self._resizing = False
self._resize_edge = None
self._drag_start_rect = None
self._drag_start_pos = None
event.accept()
@ -276,6 +333,10 @@ class DashboardItem(QGraphicsRectItem):
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value):
"""重写 itemChange 以实现磁吸功能(画布边缘 + 组件间吸附)"""
# 箭头组件和标签组件不进行边界限制和磁吸,直接返回
if isinstance(self, (ArrowItem, LabelItem)):
return super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and not self._snapping:
# 位置变化时,检测是否需要磁吸
new_pos: QPointF = value
@ -480,7 +541,21 @@ class LabelItem(DashboardItem):
if value is None:
base = self._field_name or "label"
else:
base = str(value)
# 所有 label如果是数值统一保留两位小数
num = None
if isinstance(value, (int, float)):
num = float(value)
else:
# 尝试把字符串解析成数字
try:
num = float(str(value))
except (TypeError, ValueError):
num = None
if num is not None:
base = f"{num:.2f}"
else:
base = str(value)
txt = f"{self._prefix}{base}{self._suffix}"
self._text_item.setPlainText(txt)
@ -532,12 +607,130 @@ class LabelItem(DashboardItem):
super().paint(painter, option, widget)
class ArrowItem(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._color: str = "#00FF00"
self._width: int = 3
self._angle_deg: float = 0.0
self._edit_mode: bool = True
def set_edit_mode(self, editing: bool):
self._edit_mode = bool(editing)
self.update()
def set_arrow_config(
self,
color: Optional[str] = None,
width: Optional[int] = None,
angle_deg: Optional[float] = None,
):
if color is not None:
self._color = str(color)
if width is not None:
self._width = max(1, int(width))
if angle_deg is not None:
# 归一化到 [0, 360)
a = float(angle_deg) % 360.0
# 避免出现 360.0
if abs(a - 360.0) < 1e-9:
a = 0.0
self._angle_deg = a
self.update()
def arrow_config(self) -> Dict[str, Any]:
return {
"color": self._color,
"width": self._width,
"angle": self._angle_deg,
}
def _get_widget_type(self) -> str:
return "arrow"
def _get_config(self) -> Dict[str, Any]:
return self.arrow_config()
def apply_state(self, state: WidgetState):
super().apply_state(state)
cfg = state.config or {}
self.set_arrow_config(
color=str(cfg.get("color", "#00FF00")),
width=int(cfg.get("width", 3)),
angle_deg=float(cfg.get("angle", 0.0)),
)
def paint(self, painter, option, widget=None):
"""编辑模式:画虚线边框 + 箭头;展示模式:只画箭头,不画边框。"""
r = self.rect()
if r.width() <= 1 or r.height() <= 1:
return
painter.setRenderHint(painter.RenderHint.Antialiasing, True)
# 编辑模式下的外框
if self._edit_mode:
border_color = QColor(80, 160, 255) if self.isSelected() else QColor(150, 150, 150)
border_pen = QPen(border_color, 1, Qt.PenStyle.DashLine)
painter.setPen(border_pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(r)
# 画箭头(在 rect 内按角度绘制;不使用 item rotation避免影响边界/磁吸/约束)
arrow_color = QColor(self._color)
pen = QPen(arrow_color, self._width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(QBrush(arrow_color))
# 计算在矩形中可容纳的最长线段(以中心为基准,沿角度正负方向延伸)
margin = 10.0
cx, cy = r.center().x(), r.center().y()
rad = math.radians(self._angle_deg)
vx, vy = math.cos(rad), math.sin(rad)
# 防止除零
eps = 1e-6
ax = max(abs(vx), eps)
ay = max(abs(vy), eps)
t_max_x = (r.width() / 2 - margin) / ax
t_max_y = (r.height() / 2 - margin) / ay
t = max(5.0, min(t_max_x, t_max_y))
start = QPointF(cx - vx * t, cy - vy * t)
end = QPointF(cx + vx * t, cy + vy * t)
painter.drawLine(start, end)
# 箭头三角形
dx = end.x() - start.x()
dy = end.y() - start.y()
length = math.hypot(dx, dy) or 1.0
ux, uy = dx / length, dy / length
# 箭头头部大小随线宽缩放(并限制范围)
w = float(max(1, self._width))
arrow_len = max(8.0, min(28.0, 6.0 + w * 3.0))
half_width = max(4.0, min(14.0, 3.5 + w * 1.6))
back = QPointF(end.x() - ux * arrow_len, end.y() - uy * arrow_len)
# 垂直方向
px, py = -uy, ux
p1 = QPointF(back.x() + px * half_width, back.y() + py * half_width)
p2 = QPointF(back.x() - px * half_width, back.y() - py * half_width)
# PyQt6 支持直接传入 QPointF 列表,无需 QPolygonF
painter.drawPolygon([end, p1, p2])
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)
# QWebEngineView 已在文件顶部导入(必须在 QApplication 创建前导入)
self._view = QWebEngineView()
self._proxy.setWidget(self._view)
self._proxy.setPos(0, 0)
@ -636,9 +829,9 @@ class DashboardScene(QGraphicsScene):
# 画布边界(用于定义“屏幕/展示区域”的坐标系与尺寸)
self._canvas_item = QGraphicsRectItem()
self._canvas_item.setZValue(-1e9)
# 画布用更暗的颜色,与组件形成对比
self._canvas_item.setPen(QPen(QColor(90, 90, 90), 1, Qt.PenStyle.DashLine))
self._canvas_item.setBrush(QBrush(QColor(15, 15, 15)))
# 画布背景色:纯白色
self._canvas_item.setPen(QPen(QColor(200, 200, 200), 1, Qt.PenStyle.DashLine)) # 浅灰色边框,在白色背景下可见
self._canvas_item.setBrush(QBrush(QColor(255, 255, 255))) # 纯白色背景
self._canvas_item.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable, False)
self._canvas_item.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable, False)
self.addItem(self._canvas_item)
@ -654,7 +847,7 @@ class DashboardView(QGraphicsView):
self._scene = DashboardScene(self)
self.setScene(self._scene)
self.setRenderHints(self.renderHints())
self.setBackgroundBrush(QColor(30, 30, 30))
self.setBackgroundBrush(QColor(255, 255, 255)) # 纯白色背景
# 取消自身的边框,避免全屏时出现白边
self.setFrameShape(QFrame.Shape.NoFrame)
self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
@ -676,6 +869,17 @@ class DashboardView(QGraphicsView):
h.setValue(h.minimum())
if v is not None:
v.setValue(v.minimum())
def fit_canvas_to_view(self):
"""让画布按1:1显示用于全屏模式"""
# 直接使用1:1显示不进行任何缩放
self.resetTransform()
self.reset_scroll()
def reset_view_transform(self):
"""重置视图变换(用于退出全屏时恢复)"""
self.resetTransform()
self.reset_scroll()
def mousePressEvent(self, event):
# 点击画布空白处:选中“画布”,由右侧属性面板展示/编辑(仅编辑模式)
@ -713,6 +917,14 @@ class DashboardView(QGraphicsView):
item.setSelected(True)
return item
def add_arrow_item(self) -> ArrowItem:
"""新增一个箭头组件,默认较长,用于指示方向。"""
self._scene.clearSelection()
item = ArrowItem(80, 80, 260, 40)
self._scene.addItem(item)
item.setSelected(True)
return item
# 布局持久化
def save_layout(self, path: str):
states = []
@ -793,6 +1005,8 @@ class DashboardView(QGraphicsView):
it = self.add_web_item()
elif ws.widget_type == "label":
it = self.add_label_item()
elif ws.widget_type == "arrow":
it = self.add_arrow_item()
else:
continue
it.apply_state(ws)
@ -857,6 +1071,16 @@ class PropertyPanel(QWidget):
self.url_edit = QLineEdit()
self.web_interactive_check = QCheckBox("允许点击网页(解锁)")
# 箭头组件
self.arrow_width = QSpinBox()
self.arrow_width.setRange(1, 20)
self.arrow_width.setValue(3)
self.arrow_angle = QSpinBox()
self.arrow_angle.setRange(0, 360)
self.arrow_angle.setValue(0)
self.btn_arrow_color = QPushButton("颜色...")
self._arrow_color = "#00FF00"
form = QFormLayout()
form.addRow("X:", self.x_spin)
form.addRow("Y:", self.y_spin)
@ -885,6 +1109,13 @@ class PropertyPanel(QWidget):
web_box.addWidget(self.web_interactive_check)
self.web_group = web_group
arrow_group = QGroupBox("箭头组件")
arrow_layout = QFormLayout(arrow_group)
arrow_layout.addRow("线宽:", self.arrow_width)
arrow_layout.addRow("角度(°):", self.arrow_angle)
arrow_layout.addRow("颜色:", self.btn_arrow_color)
self.arrow_group = arrow_group
layout = QVBoxLayout(self)
layout.addWidget(QLabel("当前选中组件"))
layout.addLayout(form)
@ -894,6 +1125,8 @@ class PropertyPanel(QWidget):
layout.addWidget(label_group)
layout.addSpacing(10)
layout.addWidget(web_group)
layout.addSpacing(10)
layout.addWidget(arrow_group)
layout.addStretch(1)
# 连接信号
@ -911,6 +1144,9 @@ class PropertyPanel(QWidget):
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.arrow_width.valueChanged.connect(self._on_arrow_changed)
self.arrow_angle.valueChanged.connect(self._on_arrow_changed)
self.btn_arrow_color.clicked.connect(self._on_pick_arrow_color)
self._update_enabled(False)
@ -930,6 +1166,9 @@ class PropertyPanel(QWidget):
self.label_font,
self.btn_color,
self.url_edit,
self.arrow_width,
self.arrow_angle,
self.btn_arrow_color,
):
w.setEnabled(enabled)
@ -943,6 +1182,7 @@ class PropertyPanel(QWidget):
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
return
self._mode = "item"
@ -970,6 +1210,7 @@ class PropertyPanel(QWidget):
self.img_group.setVisible(True)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
self.url_edit.setText("")
self.label_field.setText("")
self.label_prefix.setText("")
@ -980,6 +1221,7 @@ class PropertyPanel(QWidget):
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(True)
self.arrow_group.setVisible(False)
# 解锁=允许点击网页内容;锁定=像现在这样只拖拽组件
self.web_interactive_check.blockSignals(True)
self.web_interactive_check.setChecked(not item.is_locked())
@ -1001,6 +1243,22 @@ class PropertyPanel(QWidget):
self.img_group.setVisible(False)
self.label_group.setVisible(True)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
elif isinstance(item, ArrowItem):
self.image_path_edit.setText("")
self.url_edit.setText("")
cfg = item.arrow_config()
self.arrow_width.blockSignals(True)
self.arrow_width.setValue(int(cfg.get("width", 3)))
self.arrow_width.blockSignals(False)
self.arrow_angle.blockSignals(True)
self.arrow_angle.setValue(int(float(cfg.get("angle", 0.0)) % 360))
self.arrow_angle.blockSignals(False)
self._arrow_color = cfg.get("color", "#00FF00")
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(True)
def _on_geom_changed(self):
x = self.x_spin.value()
@ -1019,6 +1277,8 @@ class PropertyPanel(QWidget):
self._current_item.setPos(x, y)
self._current_item.setRect(QRectF(0, 0, w, h))
self._current_item.setZValue(float(z))
# 调用 on_resized 确保内部元素(如图片)正确刷新
self._current_item.on_resized()
def set_canvas(self, rect: QRectF):
"""点击画布时调用右侧面板切换到“画布模式”X/Y/W/H 表示画布本身几何。"""
@ -1028,6 +1288,7 @@ class PropertyPanel(QWidget):
self.img_group.setVisible(False)
self.label_group.setVisible(False)
self.web_group.setVisible(False)
self.arrow_group.setVisible(False)
self._update_enabled(True)
self.x_spin.blockSignals(True)
@ -1093,6 +1354,20 @@ class PropertyPanel(QWidget):
self._label_color = c.name()
self._on_label_changed()
def _on_arrow_changed(self):
if isinstance(self._current_item, ArrowItem):
self._current_item.set_arrow_config(
width=self.arrow_width.value(),
color=self._arrow_color,
angle_deg=float(self.arrow_angle.value()),
)
def _on_pick_arrow_color(self):
c = QColorDialog.getColor(QColor(self._arrow_color), self, "选择箭头颜色")
if c.isValid():
self._arrow_color = c.name()
self._on_arrow_changed()
class InfluxConfigDialog(QDialog):
"""全局 InfluxDB 配置对话框"""
@ -1168,10 +1443,8 @@ class MainWindow(QMainWindow):
self.item_list.setMinimumHeight(120)
self.item_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
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)
# 全局 Influx 客户端:延迟创建,只在需要时初始化(减少启动时间)
self.influx_client = None
self.splitter = QSplitter()
# 左侧:画布
@ -1211,8 +1484,10 @@ class MainWindow(QMainWindow):
self.btn_add_img = QPushButton("新增图片组件")
self.btn_add_label = QPushButton("新增标签组件")
self.btn_add_web = QPushButton("新增曲线组件")
self.btn_add_arrow = QPushButton("新增箭头组件")
self.btn_full_edit = QPushButton("全屏编辑")
self.btn_delete = QPushButton("删除组件")
self.btn_clone = QPushButton("复制组件")
self.btn_save = QPushButton("保存布局")
self.btn_load = QPushButton("加载布局")
toolbar_layout.addWidget(self.btn_mode)
@ -1220,9 +1495,11 @@ class MainWindow(QMainWindow):
toolbar_layout.addWidget(self.btn_add_img)
toolbar_layout.addWidget(self.btn_add_label)
toolbar_layout.addWidget(self.btn_add_web)
toolbar_layout.addWidget(self.btn_add_arrow)
toolbar_layout.addWidget(self.btn_full_edit)
toolbar_layout.addStretch(1)
toolbar_layout.addWidget(self.btn_delete)
toolbar_layout.addWidget(self.btn_clone)
toolbar_layout.addWidget(self.btn_save)
toolbar_layout.addWidget(self.btn_load)
@ -1239,14 +1516,26 @@ class MainWindow(QMainWindow):
self.btn_add_img.clicked.connect(self._on_add_image)
self.btn_add_label.clicked.connect(self._on_add_label)
self.btn_add_web.clicked.connect(self._on_add_web)
self.btn_add_arrow.clicked.connect(self._on_add_arrow)
self.btn_save.clicked.connect(self._on_save)
self.btn_load.clicked.connect(self._on_load)
self.btn_mode.clicked.connect(self._toggle_mode)
self.btn_full_edit.clicked.connect(self._on_fullscreen_edit)
self.btn_delete.clicked.connect(self._on_delete)
self.btn_clone.clicked.connect(self._on_clone)
# 快捷键Ctrl+D 复制当前组件
self._shortcut_clone = QShortcut(QKeySequence("Ctrl+D"), self)
self._shortcut_clone.activated.connect(self._on_clone)
# 布局 / Influx 配置文件路径
base_dir = os.path.dirname(__file__)
# 支持打包后的单文件模式:配置文件保存在 exe 同目录
if getattr(sys, 'frozen', False):
# 打包后的可执行文件模式
base_dir = os.path.dirname(sys.executable)
else:
# 开发模式:使用脚本所在目录
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")
@ -1275,11 +1564,15 @@ class MainWindow(QMainWindow):
# 全屏时关闭滚动条,避免 1920x1080 屏幕上还出现滚动条
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 全屏时让画布完全填充视图(延迟执行,确保窗口大小已更新)
QTimer.singleShot(100, self.view.fit_canvas_to_view)
else:
# 恢复为标准的最大化窗口(带边框/任务栏)
self.showMaximized()
# 恢复焦点,确保能够接收键盘事件
self.setFocus()
# 恢复视图变换和滚动条
self.view.reset_view_transform()
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
@ -1292,21 +1585,28 @@ class MainWindow(QMainWindow):
if isinstance(it, DashboardItem):
it.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable, self._edit_mode)
it.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable, self._edit_mode)
# 标签组件:根据模式控制边框是否可见
if isinstance(it, LabelItem):
# 标签 / 箭头 组件:根据模式控制边框是否可见
if isinstance(it, (LabelItem, ArrowItem)):
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:
# 延迟创建 Influx 客户端(只在需要时)
if self.influx_client is None:
from influxdb_wrapper import InfluxDBClient
self.influx_client = InfluxDBClient(self)
self.influx_client.dataReceived.connect(self._on_influx_data)
self.influx_client.errorOccurred.connect(self._on_influx_error)
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()
if self.influx_client is not None:
self.influx_client.stopQuery()
# 展示模式需要全屏 / 编辑模式下根据是否“全屏编辑”决定
self.view.set_edit_mode(self._edit_mode)
@ -1340,8 +1640,10 @@ class MainWindow(QMainWindow):
self.btn_add_img,
self.btn_add_label,
self.btn_add_web,
self.btn_add_arrow,
self.btn_full_edit,
self.btn_delete,
self.btn_clone,
self.btn_save,
self.btn_load,
):
@ -1369,7 +1671,9 @@ class MainWindow(QMainWindow):
self.btn_add_img,
self.btn_add_label,
self.btn_add_web,
self.btn_add_arrow,
self.btn_delete,
self.btn_clone,
self.btn_save,
self.btn_load,
):
@ -1392,8 +1696,10 @@ class MainWindow(QMainWindow):
self.btn_add_img,
self.btn_add_label,
self.btn_add_web,
self.btn_add_arrow,
self.btn_full_edit,
self.btn_delete,
self.btn_clone,
self.btn_save,
self.btn_load,
):
@ -1434,6 +1740,8 @@ class MainWindow(QMainWindow):
url = getattr(it, "_url", "") or ""
short = url if len(url) <= 32 else url[:29] + "..."
return f"[曲线 z={z}] {short}"
if isinstance(it, ArrowItem):
return f"[箭头 z={z}]"
return f"[组件 z={z}]"
def _refresh_item_list(self):
@ -1478,6 +1786,11 @@ class MainWindow(QMainWindow):
self.prop.set_current_item(item)
self._refresh_item_list()
def _on_add_arrow(self):
item = self.view.add_arrow_item()
self.prop.set_current_item(item)
self._refresh_item_list()
def _on_save(self):
try:
self.view.save_layout(self._layout_path)
@ -1506,6 +1819,42 @@ class MainWindow(QMainWindow):
self.prop.set_current_item(None)
self._refresh_item_list()
def _on_clone(self):
"""复制当前选中的组件(支持多选),完整复制配置并稍微偏移位置。"""
scene = self.view.scene_obj
selected = [it for it in scene.selectedItems() if isinstance(it, DashboardItem)]
if not selected:
return
clones: list[DashboardItem] = []
offset = 20.0
for it in selected:
st = it.to_state()
st.x += offset
st.y += offset
if isinstance(it, ImageItem):
new_it: DashboardItem = self.view.add_image_item()
elif isinstance(it, WebItem):
new_it = self.view.add_web_item()
elif isinstance(it, LabelItem):
new_it = self.view.add_label_item()
elif isinstance(it, ArrowItem):
new_it = self.view.add_arrow_item()
else:
continue
new_it.apply_state(st)
clones.append(new_it)
# 只选中新复制的一组中的最后一个,方便继续操作
if clones:
scene.clearSelection()
last = clones[-1]
last.setSelected(True)
self.prop.set_current_item(last)
self._refresh_item_list()
def keyPressEvent(self, event):
"""ESC 支持退出全屏展示 / 全屏编辑"""
if event.key() == Qt.Key.Key_Escape:
@ -1599,6 +1948,10 @@ def main():
import os
os.environ.setdefault('QT_LOGGING_RULES', 'qt.webenginecontext.debug=false')
# QWebEngineView 必须在 QApplication 创建之前导入
# 已经在文件顶部导入,这里确保环境变量已设置
from PyQt6.QtWebEngineWidgets import QWebEngineView # 确保已导入
app = QApplication(sys.argv)
win = MainWindow()
win.show()