""" Model Team Tools - 主程序入口 应用程序主窗口的初始化和配置模块 使用 PySide6 构建的桌面应用程序框架 主要功能: - 初始化 Qt 主窗口 - 加载 Qt Material 样式表 - 配置菜单栏和状态栏 - 提供统一的日志记录接口 """ import os import sys import logging import subprocess import runtime_hook # noqa: F401 启动时把 plugins/*/ 加进 sys.path,必须在 core 之前 import core import traceback from typing import Optional from PySide6.QtCore import QTimer from PySide6.QtGui import QIcon,QAction from PySide6.QtWidgets import QApplication, QMainWindow, QMenuBar, QStatusBar from qt_material import apply_stylesheet from main_ui import Ui_MainWindow # 获取模块日志记录器 logger = logging.getLogger(__name__) # 版本号常量 MAJOR_VER: int = 0 # 主版本号 MINOR_VER: int = 0 # 次版本号 MICRO_VER: int = 1 # 修订版本号 APP_NAME: str = "Model Team Tools" APP_VERSION: str = f"{MAJOR_VER}.{MINOR_VER}.{MICRO_VER}" class MainWindow(QMainWindow, Ui_MainWindow): """ 主窗口类 继承自 QMainWindow(Qt 主窗口基类)和 Ui_MainWindow(Qt Designer 生成的 UI 界面) 负责应用程序主窗口的初始化、样式配置和界面布局 """ def __init__(self) -> None: super().__init__() self.setupUi(self) self.plugin_registry = core.PluginRegistry() self.plugins = core.Plugins(self.plugin_registry) self.plugin_registry.plugins_loader_signal.connect(self.on_plugins_loader) self.plugin_registry.uninstall_completed_signal.connect(self.on_plugin_uninstalled) # 工具栏:插件名称 → QAction 映射 self.tool_actions: dict = {} # 内容区:插件名称 → QWidget 映射 self.plugin_widgets: dict = {} self.initUI() def initUI(self) -> None: """ 初始化用户界面组件 执行以下初始化操作: - 加载 Qt 样式表和自定义样式 - 设置窗口标题 - 配置菜单栏 - 配置状态栏 - 记录初始化日志 注意:此方法在主窗口创建后调用 """ self.load_stylesheet() self.setWindowTitle(APP_NAME) self.setWindowIcon(QIcon('resources/main.ico')) self.menubar: Optional[QMenuBar] = self.menuBar() self.statusbar: QStatusBar = self.statusBar() self.statusbar.showMessage(f"Version: {APP_VERSION}") self.action_tools.triggered.connect(self.on_tools) def load_stylesheet(self) -> None: """ 加载 Qt 样式表和自定义样式文件 使用 qt_material 库加载暗色主题,并应用自定义 QSS 样式表 失败时记录错误日志,但不影响应用继续运行(降级策略) 异常处理: - FileNotFoundError: 样式文件不存在 - RuntimeError: 主题加载失败 - 其他异常: 记录警告但不中断应用 """ try: apply_stylesheet(self, style=None,theme='dark_teal.xml', css_file='resources/my.qss') except FileNotFoundError as e: logger.warning(f"样式文件未找到,使用默认主题: {e}") except RuntimeError as e: logger.warning(f"主题加载失败,使用默认主题: {e}") except Exception as e: logger.warning(f"样式加载失败,使用默认主题: {e}") def on_tools(self) -> None: """打开插件管理窗口。 包含完整的错误处理机制,确保任何异常都不会导致主程序崩溃。 """ try: self.plugins.show() except Exception as e: logger.error(f"无法打开工具箱窗口: {e}") logger.error(traceback.format_exc()) def on_plugins_loader(self, plugins: dict) -> None: """加载已安装插件到主窗口工具栏和堆栈页面。 为每个插件创建 QAction 并添加到工具栏,同时将插件 QWidget 添加到 stackedWidget 中,点击 action 时切换对应页面。 """ if not plugins: return for name in plugins.keys(): action = QAction(name,self) self.toolBar.addAction(action) action.triggered.connect(lambda checked, action=action: self.on_action_triggered(action)) widget = plugins[name]['obj'] self.stackedWidget.addWidget(widget) self.tool_actions[name] = action self.plugin_widgets[name] = widget logger.info(f"已加载 {len(plugins)} 个插件到工具栏: {list(plugins.keys())}") def on_action_triggered(self, action: QAction) -> None: """工具栏按钮点击:切换到对应插件的 stack 页面。""" self.stackedWidget.setCurrentWidget(self.plugin_registry.plugins[action.text()]['obj']) def on_plugin_uninstalled(self, name: str) -> None: """卸载完成后清理主窗口工具栏与堆栈窗口中的对应项,并自动重启。 .pyd 已被进程加载,运行时无法删除(Windows 文件锁),因此重启进程: 新进程启动时会先执行待删清单,在 import 之前删除 .pyd 文件。 """ logger.info(f"从主窗口移除插件: {name}") action = self.tool_actions.pop(name, None) if action is not None: self.toolBar.removeAction(action) action.deleteLater() widget = self.plugin_widgets.pop(name, None) if widget is not None: self.stackedWidget.removeWidget(widget) widget.deleteLater() self.statusbar.showMessage(f"{name} 已卸载,应用即将重启以完成清理...", 1500) QTimer.singleShot(1000, self._restart_app) def _restart_app(self) -> None: """启动新进程并退出当前进程。 开发模式:python.exe main.py 打包模式:main.exe(Nuitka standalone) 用 subprocess.Popen + DETACHED_PROCESS 直接调 Win32 CreateProcess。 Nuitka standalone 下 sys.executable 指向不存在的原始 python.exe, 此时改用 sys.argv[0](即 main.exe 自身)。 """ work_dir = os.path.dirname(os.path.abspath(sys.argv[0])) args = sys.argv[:] if '--restarting' not in args: args.append('--restarting') # Nuitka standalone 下 sys.executable 指向编译机器的 python.exe(分发环境不存在), # 直接用 os.path.isfile 检测,不存在时回退到 sys.argv[0](main.exe 自身)。 if os.path.isfile(sys.executable): cmd = [sys.executable] + args else: cmd = [os.path.abspath(sys.argv[0])] + args[1:] try: proc = subprocess.Popen( cmd, cwd=work_dir, creationflags=subprocess.DETACHED_PROCESS, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) logger.info(f"新进程已启动 (PID: {proc.pid}),当前进程即将退出") QTimer.singleShot(100, QApplication.quit) except Exception as e: logger.error(f"启动新进程失败: {e}") if __name__ == '__main__': print(f"{APP_NAME} v{APP_VERSION} 启动中...") app = QApplication(sys.argv) main: MainWindow = MainWindow() app.aboutToQuit.connect(lambda: logger.info(f"{APP_NAME} 退出")) app.aboutToQuit.connect(main.plugin_registry.shutdown) main.show() sys.exit(app.exec())