diff --git a/BUILD.md b/BUILD.md index bafceae..59481cd 100644 --- a/BUILD.md +++ b/BUILD.md @@ -115,3 +115,4 @@ A: 确保打包时包含了 `PyQt6.QtWebEngineWidgets`,打包脚本已自动 + diff --git a/PCM_Viewer.spec b/PCM_Viewer.spec index 487ca71..40303f5 100644 --- a/PCM_Viewer.spec +++ b/PCM_Viewer.spec @@ -26,13 +26,16 @@ pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, + a.binaries, + a.datas, [('O', None, 'OPTION'), ('O', None, 'OPTION')], - exclude_binaries=True, name='PCM_Viewer', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, + upx_exclude=[], + runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, @@ -40,12 +43,3 @@ exe = EXE( 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 index 79539df..8d2e41e 100644 --- a/STARTUP_OPTIMIZATION.md +++ b/STARTUP_OPTIMIZATION.md @@ -117,3 +117,4 @@ Measure-Command { Start-Process -FilePath "dist\PCM_Viewer.exe" -Wait } + diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index a4a28bf..8a9c373 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -60,3 +60,4 @@ pip install -r requirements.txt + diff --git a/build.bat b/build.bat index 325fba7..a73c3ad 100644 --- a/build.bat +++ b/build.bat @@ -29,3 +29,4 @@ pause + diff --git a/build.sh b/build.sh index cd59a3b..8aaa50c 100644 --- a/build.sh +++ b/build.sh @@ -27,3 +27,4 @@ python build.py + diff --git a/build_optimized.py b/build_optimized.py index 1b81472..52d809e 100644 --- a/build_optimized.py +++ b/build_optimized.py @@ -96,3 +96,4 @@ if __name__ == "__main__": + diff --git a/main.py b/main.py index 51345ff..5915710 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,8 @@ from __future__ import annotations import json import os import sys +import socket +import threading from dataclasses import dataclass, asdict, field from typing import Optional, Dict, Any import math @@ -24,10 +26,12 @@ os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "0" from PyQt6.QtCore import Qt, QRectF, pyqtSignal, QObject, QTimer, QPointF, QSizeF from PyQt6.QtWidgets import QSizePolicy from PyQt6.QtGui import QFontMetrics -from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor, QShortcut, QKeySequence +from PyQt6.QtGui import QBrush, QColor, QPen, QPixmap, QFont, QCursor, QShortcut, QKeySequence, QIcon from PyQt6.QtWidgets import ( QApplication, QMainWindow, + QSystemTrayIcon, + QMenu, QWidget, QHBoxLayout, QVBoxLayout, @@ -48,6 +52,7 @@ from PyQt6.QtWidgets import ( QMessageBox, QPlainTextEdit, QListWidget, + QAction, QListWidgetItem, QColorDialog, QGroupBox, @@ -1451,12 +1456,115 @@ class InfluxConfigDialog(QDialog): pass +# ----------------------------- +# UDP 命令监听器 +# ----------------------------- + +class UDPCommandListener(QObject): + """UDP 命令监听器,接收来自 PCM_Report 的命令""" + show_signal = pyqtSignal() + hide_signal = pyqtSignal() + load_layout_signal = pyqtSignal(str) + fullscreen_signal = pyqtSignal() + exit_signal = pyqtSignal() + + def __init__(self, port=9876): + super().__init__() + self.port = port + self.running = False + self.socket = None + + def start(self): + """在后台线程启动 UDP 监听""" + self.running = True + thread = threading.Thread(target=self._listen, daemon=True) + thread.start() + + def _listen(self): + """UDP 监听循环""" + try: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind(('127.0.0.1', self.port)) + self.socket.settimeout(1.0) + + print(f"[UDP] 监听端口 {self.port}...") + + while self.running: + try: + data, addr = self.socket.recvfrom(4096) + message = data.decode('utf-8') + print(f"[UDP] 收到命令: {message}") + self._handle_command(message) + except socket.timeout: + continue + except Exception as e: + print(f"[UDP] 处理错误: {e}") + + except Exception as e: + print(f"[UDP] 启动失败: {e}") + finally: + if self.socket: + self.socket.close() + + def _handle_command(self, message: str): + """处理接收到的命令""" + try: + cmd = json.loads(message) + action = cmd.get('action') + + if action == 'show': + self.show_signal.emit() + elif action == 'hide': + self.hide_signal.emit() + elif action == 'load_layout': + path = cmd.get('path', '') + if path: + self.load_layout_signal.emit(path) + elif action == 'fullscreen': + self.fullscreen_signal.emit() + elif action == 'show_and_fullscreen': + path = cmd.get('path', '') + if path: + self.load_layout_signal.emit(path) + self.show_signal.emit() + self.fullscreen_signal.emit() + elif action == 'exit': + self.exit_signal.emit() + except json.JSONDecodeError: + # 兼容简单文本命令 + if message == 'show': + self.show_signal.emit() + elif message == 'hide': + self.hide_signal.emit() + elif message.startswith('load:'): + path = message[5:] + self.load_layout_signal.emit(path) + elif message == 'fullscreen': + self.fullscreen_signal.emit() + elif message == 'exit': + self.exit_signal.emit() + + def stop(self): + """停止监听""" + self.running = False + if self.socket: + self.socket.close() + + def send_response(self, addr, message: str): + """发送响应到指定地址""" + try: + self.socket.sendto(message.encode('utf-8'), addr) + except Exception as e: + print(f"[UDP] 发送响应失败: {e}") + + # ----------------------------- # 主窗口 # ----------------------------- class MainWindow(QMainWindow): - def __init__(self): + def __init__(self, start_hidden=False): super().__init__() self.setWindowTitle("PCM Viewer - Widgets Dashboard") self.resize(1400, 840) @@ -1466,6 +1574,7 @@ class MainWindow(QMainWindow): # 全屏编辑标志(展示模式本身也会全屏) self._fullscreen_edit = False self._first_show = True # 用于在第一次显示时最大化并校正滚动条 + self._start_hidden = start_hidden # 启动时是否隐藏窗口 self.view = DashboardView() self.prop = PropertyPanel() @@ -1475,6 +1584,10 @@ class MainWindow(QMainWindow): self.influx_settings = InfluxSettings() # 全局 Influx 客户端:延迟创建,只在需要时初始化(减少启动时间) self.influx_client = None + + # 系统托盘图标 + self.tray_icon = None + self._setup_tray_icon() self.splitter = QSplitter() # 左侧:画布 @@ -2051,23 +2164,144 @@ class MainWindow(QMainWindow): # 这里避免频繁弹窗,暂时只打印,也可以根据需要改成状态栏提示 print("Influx error:", msg) + def load_layout(self, path: str): + """加载布局文件(代理调用 DashboardView 的 load_layout)""" + if hasattr(self.view, 'load_layout'): + self.view.load_layout(path) + self._layout_path = path + self._update_current_file_label() + + def _setup_tray_icon(self): + """设置系统托盘图标""" + if not QSystemTrayIcon.isSystemTrayAvailable(): + print("[Tray] 系统托盘不可用") + return + + self.tray_icon = QSystemTrayIcon(self) + + # 使用默认图标(可以替换为自定义图标) + icon = self.style().standardIcon(self.style().StandardPixmap.SP_ComputerIcon) + self.tray_icon.setIcon(icon) + + # 创建托盘菜单 + tray_menu = QMenu() + + show_action = QAction("显示窗口", self) + show_action.triggered.connect(self._show_window) + tray_menu.addAction(show_action) + + hide_action = QAction("隐藏窗口", self) + hide_action.triggered.connect(self.hide) + tray_menu.addAction(hide_action) + + tray_menu.addSeparator() + + quit_action = QAction("退出", self) + quit_action.triggered.connect(QApplication.instance().quit) + tray_menu.addAction(quit_action) + + self.tray_icon.setContextMenu(tray_menu) + + # 双击托盘图标显示窗口 + self.tray_icon.activated.connect(self._on_tray_activated) + + # 显示托盘图标 + self.tray_icon.show() + self.tray_icon.setToolTip("PCM Viewer") + + def _on_tray_activated(self, reason): + """托盘图标被激活""" + if reason == QSystemTrayIcon.ActivationReason.DoubleClick: + self._show_window() + + def _show_window(self): + """显示窗口""" + self.show() + self.activateWindow() + self.raise_() + + def _show_and_fullscreen(self, layout_path: str = None): + """显示窗口并进入全屏展示模式""" + if layout_path: + self.load_layout(layout_path) + self._show_window() + # 进入展示模式 + if self._edit_mode: + self._toggle_mode() + # 窗口全屏 + self.showFullScreen() + + def closeEvent(self, event): + """关闭事件:最小化到托盘而不是退出""" + if self.tray_icon and self.tray_icon.isVisible(): + event.ignore() + self.hide() + self.tray_icon.showMessage( + "PCM Viewer", + "程序已最小化到系统托盘", + QSystemTrayIcon.MessageIcon.Information, + 2000 + ) + else: + event.accept() + def main(): # 抑制 Windows 上 QWebEngineView 的 DirectComposition 警告 - import os os.environ.setdefault('QT_LOGGING_RULES', 'qt.webenginecontext.debug=false') + # 解析命令行参数 + import argparse + parser = argparse.ArgumentParser(description='PCM Viewer - 全屏展示工具') + parser.add_argument('layout', nargs='?', help='布局JSON文件路径') + parser.add_argument('--fullscreen', '-f', action='store_true', help='启动时进入全屏模式') + parser.add_argument('--hidden', '-H', action='store_true', help='启动时隐藏窗口(仅显示托盘图标)') + parser.add_argument('--udp-port', '-p', type=int, default=9876, help='UDP监听端口(默认9876)') + args = parser.parse_args() + # QWebEngineView 必须在 QApplication 创建之前导入 - # 已经在文件顶部导入,这里确保环境变量已设置 - from PyQt6.QtWebEngineWidgets import QWebEngineView # 确保已导入 + from PyQt6.QtWebEngineWidgets import QWebEngineView app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) # 关闭窗口时不退出,保持托盘运行 # 应用全局样式 apply_styles(app) - win = MainWindow() - win.show() + # 创建主窗口(根据参数决定是否启动时隐藏) + start_hidden = args.hidden or args.fullscreen # 全屏模式也先隐藏,等待UDP命令 + win = MainWindow(start_hidden=start_hidden) + + # 如果提供了布局文件路径,加载它 + if args.layout and os.path.exists(args.layout): + win.load_layout(args.layout) + + # 启动 UDP 监听器 + udp_listener = UDPCommandListener(port=args.udp_port) + udp_listener.show_signal.connect(win._show_window) + udp_listener.hide_signal.connect(win.hide) + udp_listener.load_layout_signal.connect(win.load_layout) + udp_listener.fullscreen_signal.connect(lambda: win._show_and_fullscreen()) + udp_listener.exit_signal.connect(app.quit) + udp_listener.start() + + # 根据参数决定是否显示窗口 + if args.fullscreen: + # 全屏模式:显示并进入展示模式 + win._show_and_fullscreen() + elif args.hidden: + # 隐藏模式:不显示窗口,仅托盘图标 + print("[Main] 启动时隐藏窗口,等待UDP命令...") + else: + # 正常模式:显示窗口 + win.show() + + # 应用程序退出时清理 + def on_quit(): + udp_listener.stop() + + app.aboutToQuit.connect(on_quit) + sys.exit(app.exec())