diff --git a/.gitignore b/.gitignore index 93526df..af5a0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ venv/ __pycache__/ +build/ +dist/ diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..9df979f --- /dev/null +++ b/BUILD.md @@ -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`:添加隐藏导入的模块 + + + diff --git a/PCM_Viewer.spec b/PCM_Viewer.spec new file mode 100644 index 0000000..487ca71 --- /dev/null +++ b/PCM_Viewer.spec @@ -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', +) diff --git a/STARTUP_OPTIMIZATION.md b/STARTUP_OPTIMIZATION.md new file mode 100644 index 0000000..38f1c7f --- /dev/null +++ b/STARTUP_OPTIMIZATION.md @@ -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. **使用启动画面** + - 即使启动慢,至少让用户知道程序在加载 + - 可以显示进度条或加载动画 + + + diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6baddef..0702c7d 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -56,3 +56,5 @@ pip install -r requirements.txt + + diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..e012d7b --- /dev/null +++ b/build.bat @@ -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 + + + diff --git a/build.py b/build.py new file mode 100644 index 0000000..b780823 --- /dev/null +++ b/build.py @@ -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-2,2 最高) + # 排除不需要的大型模块(减少打包体积和启动时间) + "--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() + diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..a70f6a4 --- /dev/null +++ b/build.sh @@ -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 + + + diff --git a/build_optimized.py b/build_optimized.py new file mode 100644 index 0000000..4b133a5 --- /dev/null +++ b/build_optimized.py @@ -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-2,2 最高) + # 排除不需要的大型模块(减少打包体积和启动时间) + "--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() + + + diff --git a/dashboard.json b/dashboard.json index f729764..cebc1c6 100644 --- a/dashboard.json +++ b/dashboard.json @@ -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" } } ] diff --git a/influx_settings.json b/influx_settings.json index fcd831d..99c57fa 100644 --- a/influx_settings.json +++ b/influx_settings.json @@ -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()" } \ No newline at end of file diff --git a/main.py b/main.py index 85a3dd0..e00b183 100644 --- a/main.py +++ b/main.py @@ -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()