定稿1
parent
44c582487e
commit
44a53520a4
|
|
@ -1,2 +1,4 @@
|
|||
venv/
|
||||
__pycache__/
|
||||
build/
|
||||
dist/
|
||||
|
|
|
|||
|
|
@ -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`:添加隐藏导入的模块
|
||||
|
||||
|
||||
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
|
@ -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. **使用启动画面**
|
||||
- 即使启动慢,至少让用户知道程序在加载
|
||||
- 可以显示进度条或加载动画
|
||||
|
||||
|
||||
|
||||
|
|
@ -56,3 +56,5 @@ pip install -r requirements.txt
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
405
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue