diff --git a/README.md b/README.md
index f124321..2d4c1f3 100644
--- a/README.md
+++ b/README.md
@@ -1,79 +1,60 @@
# PyPack
-PyPack 是一个基于 PyQt5 开发的图形化 Python 脚本打包与构建辅助工具。它整合了 **PyInstaller**、**Nuitka** 和 **cx_Freeze** 三种主流打包引擎,旨在通过直观的 GUI 界面降低命令行打包的复杂度,并提供自动依赖分析与虚拟环境隔离等功能。
+PyPack 是一个为 Python 开发者设计的图形化打包配置工具。它将复杂的命令行打包流程(支持 **PyInstaller**、**Nuitka** 和 **cx_Freeze**)转化为直观、简易的 GUI 界面操作,无需记忆繁琐的终端命令,即可快速生成可执行文件(.exe)。
-
-
+
+
+
+
---
+## 核心设计:让打包变简单
-## 主要功能
+为了解决传统命令行打包参数多、依赖易冲突、配置繁琐的问题,PyPack 2.0 围绕“易用性”进行了重新设计:
-* **多引擎集成**:支持在界面中快速切换 PyInstaller、Nuitka 和 cx_Freeze 打包引擎。
-* **拖拽式操作**:提供支持拖放(Drag and Drop)的脚本装载区域,亦可手动浏览选择主脚本。
-* **自动依赖解析**:
- * 优先读取项目同级目录下的 `requirements.txt`。
- * 若无配置文件,则通过 Python 标准库中的 `ast` 模块静态分析脚本文件,提取第三方依赖(自动过滤标准库),同时提供基于正则表达式的备份扫描。
-* **隔离构建环境 (Venv)**:支持自动在临时目录下创建虚拟环境以隔离全局环境,降低依赖冲突的风险。
-* **自定义 PIP 镜像源**(**v1.0.1 新增**):高级选项中支持自定义设置 PIP 镜像源地址,默认提供清华大学镜像源以提升国内下载速度,修改后自动实时保存。
-* **元数据配置 (Windows)**:支持在界面配置版本号、发行公司、文件描述等元数据。对于 PyInstaller,会自动生成版本信息描述文件;对于 Nuitka,则自动映射为对应的编译器参数。
-* **多项细节优化**:
- * 支持控制台隐藏、单文件/单目录输出切换、UPX 压缩等。
- * 支持添加附加资源文件(格式为 `源路径:目标路径`),并提供交互式选择对话框。
- * 支持图标预览功能(可自动识别脚本目录下的 `icon.ico` / `logo.ico`)。
- * 采用 `QThread` 异步执行打包任务,保持 GUI 界面在打包过程中响应正常,并在日志窗口中实时输出控制台信息。
+### 1. 极简的交互流程
+* **拖拽即装载**:无需手动输入路径,直接将您的 `.py` 源代码文件拖入软件窗口,即可自动识别并准备构建。
+* **智能图标匹配**:脚本载入后,程序会自动检索同目录下的常用图标文件(如 `icon.ico` / `logo.ico`)并自动填充和预览。
+* **一键直达结果**:打包完成后,点击“打开输出目录”按钮即可自动弹出 Windows 资源管理器并定位到生成的程序。
+
+### 2. 自动化的依赖与环境处理
+* **智能沙盒隔离 (Venv)**:支持一键开启虚拟环境。工具会在临时目录下自动创建干净的沙盒环境,仅安装打包所需的库,有效减小最终生成的 `.exe` 文件的体积。
+* **多维度依赖扫描**:
+ * 自动检测并优先读取项目目录下的 `requirements.txt`。
+ * 提供辅助的 AST(静态语法树)扫描选项,自动分析代码中导入的第三方模块,帮助您查漏补缺隐式依赖。
+* **内置国内加速源**:预设清华大学等国内 PIP 镜像源,在沙盒中拉取打包工具或依赖库时自动加速。
+
+### 3. 可视化高级参数配置
+* **三引擎无缝切换**:在下拉菜单中即可在 PyInstaller、Nuitka 和 cx_Freeze 之间切换,相关的参数输入框会根据引擎特性自动启用或隐藏。
+* **可视化版本属性**:无需编写复杂的版本配置文件,直接在界面文本框中输入版本号、公司名和产品描述,工具会自动将其写入生成的 `.exe` 元数据中。
+* **直观的资源添加**:通过交互式对话框选择需要打包的图片、数据文件夹,自动生成跨平台兼容的路径映射。
---
+### Changelog
+2.02
+* 增加建构失败动画
+* 尝试修复打包后容易被WINDOWS安全拦截的问题
-# 更新日志 (Changelog)
-### [1.0.2] - 2026-06-3
-* **更新子进程处理机制,并改进数据处理以提升兼容性。
-* **隐藏打包过程中的命令窗口。
-
-### [1.0.1] - 2026-05-31
-#### 新增
-* **自定义 PIP 镜像源**:在“高级选项”界面中引入了 PIP 镜像源输入配置,默认使用清华大学开源软件镜像源(`https://pypi.tuna.tsinghua.edu.cn/simple`)。
-* **配置即时持久化**:PIP 镜像源输入支持修改即时生效,并利用 `QSettings` 自动存储于注册表/配置文件中,下次启动应用时自动读取。
-
-#### 优化
-* **构建逻辑统一**:优化了虚拟环境(Venv)初始化、打包引擎下载以及项目依赖解析时的命令行拼接逻辑,统一走用户配置的自定义 PIP 镜像源。
-
----
-## 运行环境
-
-* **操作系统**:Windows
-* **Python 版本**:Python 3.x
-* **核心依赖**:
- * `PyQt5`
- * (根据选择的打包引擎)`pyinstaller` / `nuitka` / `cx_Freeze` / `pillow`
-
+2.0.1
+* 修正 Nuitka 参数传递方式:将 cmd.extend(["--include-data-files", "src=dest"]) 修改为更符合 Nuitka 规范的单参数追加形式:cmd.append(f"--include-data-files={src}={dest}")。
+* 加入平台感知后缀:动态识别操作系统(Windows 使用 .exe 后缀,而 macOS/Linux 不使用),避免跨平台定位产物失败。
+* 安全过滤平台专有参数:对于 Windows 特有的元数据写入(如公司名称、描述、文件版本、ICO 图标),只有当 os.name == "nt" 时才追加对应指令。
---
## 快速上手
-### 1. 克隆或下载项目
-将包含该源码的 Python 文件保存为 `main.py`。
+只需三步,即可完成打包:
-### 2. 安装运行所需的必要依赖
-在运行该工具前,您需要先安装 `PyQt5`:
-```bash
-pip install PyQt5
-```
+1. **拖入脚本**:将您的 Python 脚本拖拽至主界面。
+2. **选择引擎**:点击右下角设置,在“基础打包”中选择您想使用的引擎(推荐默认的 PyInstaller 进行快速验证)。
+3. **开始构建**:返回主界面点击“开始构建”,并在下方展开的控制台中实时查看打包进度。
-### 3. 启动应用
-执行以下命令启动图形化界面:
-```bash
-python main.py
-```
+---
-### 或者使用打包好的二进制文件直接运行
-[GitHub Releases 下载链接](https://github.com/Qwejay/PyPack/releases)
+## 系统要求
-### 4. 打包您的项目
-1. **装载脚本**:将需要打包的 `.py` 文件拖拽至上方的拖拽区,或者点击主界面的“浏览...”按钮进行选择。
-2. **配置选项**:
- * 选择您要使用的**构建引擎**。
- * 根据需要调整**打包选项**(如是否开启虚拟环境、是否单文件输出、是否隐藏控制台等)。
- * 如有需要,在“高级选项”选项卡中配置 **PIP 镜像源**、隐式依赖、附加资源和版本元数据。
-3. **开始构建**:点击右下角的**“开始构建”**。下方日志窗口会实时更新编译进度。
-4. **提取产物**:构建成功后,点击“打开输出目录”按钮即可直达打包好的可执行文件位置。
+* **支持系统**:Windows (推荐), macOS, Linux
+* **Python 环境**:Python 3.8+
+* **基础依赖**:只需在您的本地系统提前安装好 `PyQt5` 即可运行:
+ ```bash
+ pip install PyQt5
diff --git a/main.py b/main.py
index 818f63b..4b080d2 100644
--- a/main.py
+++ b/main.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
-PyPack - 自动化 Python 构建与打包工具
+PyPack 2.0 - 自动化构建工具
支持引擎: PyInstaller / Nuitka / cx_Freeze
"""
@@ -14,71 +14,99 @@ import re
import time
import stat
import ast
+import locale
+import math
from pathlib import Path
-# 屏蔽 DirectWrite 遗留位图字体扫描警告
os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false"
-
-# 设置高 DPI 自动缩放
os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget,
- QPushButton, QLabel, QLineEdit, QFileDialog, QCheckBox, QTabWidget,
+ QPushButton, QLabel, QLineEdit, QFileDialog, QCheckBox,
QComboBox, QFrame, QStackedLayout, QFormLayout, QTextEdit,
- QGraphicsOpacityEffect, QGroupBox, QSizePolicy, QGridLayout,
- QMessageBox, QInputDialog)
-from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup
-from PyQt5.QtGui import QFont, QDragEnterEvent, QDropEvent, QTextCursor, QIcon, QPixmap
+ QGraphicsOpacityEffect, QGroupBox, QGridLayout, QTabWidget,
+ QMessageBox, QInputDialog, QFileIconProvider, QSizePolicy, QScrollArea,
+ QGraphicsDropShadowEffect)
+from PyQt5.QtCore import Qt, QThread, pyqtSignal, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileInfo, QVariantAnimation, QTimer, QPointF, QRectF, QRect
+from PyQt5.QtGui import QFont, QDragEnterEvent, QDropEvent, QTextCursor, QIcon, QPixmap, QPainter, QColor, QPen
+from PyQt5.QtSvg import QSvgRenderer
+
+# ======================== Material Design 内置 SVG 引擎 ========================
+MATERIAL_ICONS = {
+ 'settings': 'M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.73,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.43-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.49-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z',
+ 'refresh': 'M17.65,6.35C16.2,4.9,14.21,4,12,4c-4.42,0-7.99,3.58-7.99,8s3.57,8,7.99,8c3.73,0,6.84-2.55,7.73-6h-2.08 c-0.82,2.33-3.04,4-5.65,4c-3.31,0-6-2.69-6-6s2.69-6,6-6c1.66,0,3.14,0.69,4.22,1.78L13,11h7V4L17.65,6.35z',
+ 'play': 'M8 5v14l11-7z',
+ 'stop': 'M6 6h12v12H6z',
+ 'folder': 'M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z',
+ 'expand_more': 'M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z',
+ 'expand_less': 'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z',
+ 'check': 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
+ 'package': 'M20,2H4C3,2,2,2.9,2,4v3.01C2,7.73,2.43,8.35,3,8.7V20c0,1.1,1.1,2,2,2h14c0.9,0,2-0.9,2-2V8.7c0.57-0.35,1-0.97,1-1.69V4 C22,2.9,21,2,20,2z M19,20H5V9h14V20z M20,7H4V4h16V7z M9,12h6v2H9V12z',
+ 'back': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
+ 'info': 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
+ 'python': 'M12.06,1.48c-3.14,0-3.52,0.67-3.52,0.67l-0.01,2.44h3.63v0.52H7.43C5.12,5.11,4.5,6.58,4.5,8.81c0,2.34,0.38,3.48,2.3,3.48 h1.14v-1.62c0-1.48,1.23-2.65,2.7-2.65h3.69c1.47,0,2.66-1.19,2.66-2.65V3.88C16.99,1.83,14.67,1.48,12.06,1.48z M10.22,2.83 c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C9.49,3.16,9.82,2.83,10.22,2.83z M16.71,9.89 v1.62c0,1.48-1.23,2.65-2.7,2.65H10.3c-1.47,0-2.66,1.19-2.66,2.65v1.49c0,2.05,2.32,2.41,4.92,2.41c3.14,0,3.52-0.67,3.52-0.67 l0.01-2.44h-3.63v-0.52h4.73c2.31,0,2.93-1.47,2.93-3.7c0-2.34-0.38-3.48-2.3-3.48H16.71z M13.88,18.96c0.41,0,0.73,0.33,0.73,0.74 c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C13.15,19.29,13.48,18.96,13.88,18.96z',
+ 'close': 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'
+}
+
+def get_svg_icon(name, color="#5F6368", size=24):
+ path_data = MATERIAL_ICONS.get(name, "")
+ svg_str = f''
+ renderer = QSvgRenderer(svg_str.encode('utf-8'))
+ pixmap = QPixmap(size, size)
+ pixmap.fill(Qt.transparent)
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.Antialiasing)
+ renderer.render(painter)
+ painter.end()
+ return QIcon(pixmap)
+
+def get_svg_pixmap(name, color="#5F6368", size=64):
+ return get_svg_icon(name, color, size).pixmap(size, size)
-# ======================== 获取 Python 标准库集合 ========================
+# ======================== 工具与配置 ========================
def get_stdlib_names():
libs = {'os', 'sys', 're', 'math', 'time', 'datetime', 'json', 'urllib', 'sqlite3', 'csv',
- 'subprocess', 'shutil', 'threading', 'multiprocessing', 'queue', 'socket',
+ 'subprocess', 'shutil', 'threading', 'multprocessing', 'queue', 'socket',
'collections', 'itertools', 'functools', 'random', 'hashlib', 'base64',
'binascii', 'xml', 'logging', 'argparse', 'typing', 'pathlib', 'traceback',
'warnings', 'tempfile', 'platform', 'zipfile', 'tarfile', 'gzip', 'bz2',
'lzma', 'hmac', 'ssl', 'email', 'http', 'uuid', 'io', 'contextlib', 'winreg'}
- if sys.version_info >= (3, 10):
- libs.update(sys.stdlib_module_names)
+ if sys.version_info >= (3, 10): libs.update(sys.stdlib_module_names)
return libs
STD_LIBS = get_stdlib_names()
+KNOWN_MAPPINGS = {
+ 'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32', 'win32gui': 'pywin32',
+ 'cv2': 'opencv-python', 'PIL': 'pillow', 'bs4': 'beautifulsoup4', 'sklearn': 'scikit-learn',
+ 'yaml': 'pyyaml', 'fitz': 'pymupdf', 'dotenv': 'python-dotenv'
+}
+def get_resource_path(relative_path):
+ if hasattr(sys, '_MEIPASS'):
+ return os.path.join(sys._MEIPASS, relative_path)
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
def get_python_executable():
- """
- 获取宿主 Python 解释器路径。
- 若本打包工具已被打包为独立可执行文件运行,则在系统 PATH 中寻找 python 解释器。
- """
- if getattr(sys, 'frozen', False):
- return shutil.which("python") or shutil.which("python3") or "python"
+ if getattr(sys, 'frozen', False): return shutil.which("python") or shutil.which("python3") or "python"
return sys.executable
-
-# ======================== IO 异常处理与强制删除组件 ========================
def remove_readonly(func, path, exc_info):
- try:
- os.chmod(path, stat.S_IWRITE)
- func(path)
- except:
- pass
+ try: os.chmod(path, stat.S_IWRITE); func(path)
+ except: pass
def robust_rmtree(path: Path, retries=15, delay=0.8):
- if not path.exists():
- return True
+ if not path.exists(): return True
for _ in range(retries):
try:
shutil.rmtree(path, onerror=remove_readonly)
- if not path.exists():
- return True
- except:
- time.sleep(delay)
+ if not path.exists(): return True
+ except: time.sleep(delay)
return False
-# ======================== 界面动画组件 ========================
+# ======================== 界面动效组件 ========================
class AnimatedButton(QPushButton):
def __init__(self, text, parent=None):
super().__init__(text, parent)
@@ -88,14 +116,13 @@ class AnimatedButton(QPushButton):
self.animation_group = QParallelAnimationGroup()
self.pos_anim = QPropertyAnimation(self, b"geometry")
- self.pos_anim.setDuration(120)
+ self.pos_anim.setDuration(150)
self.pos_anim.setEasingCurve(QEasingCurve.OutCubic)
self.op_anim = QPropertyAnimation(self.opacity_effect, b"opacity")
- self.op_anim.setDuration(120)
+ self.op_anim.setDuration(200)
+ self.op_anim.setEasingCurve(QEasingCurve.InOutCubic)
- self.animation_group.addAnimation(self.pos_anim)
- self.animation_group.addAnimation(self.op_anim)
self.is_hovered = False
def enterEvent(self, event):
@@ -103,9 +130,9 @@ class AnimatedButton(QPushButton):
self.is_hovered = True
geom = self.geometry()
self.pos_anim.setStartValue(geom)
- self.pos_anim.setEndValue(geom.adjusted(0, -1, 0, -1))
+ self.pos_anim.setEndValue(geom.adjusted(0, -2, 0, -2))
self.op_anim.setStartValue(1.0)
- self.op_anim.setEndValue(0.88)
+ self.op_anim.setEndValue(0.85)
self.animation_group.start()
super().enterEvent(event)
@@ -114,65 +141,676 @@ class AnimatedButton(QPushButton):
self.is_hovered = False
geom = self.geometry()
self.pos_anim.setStartValue(geom)
- self.pos_anim.setEndValue(geom.adjusted(0, 1, 0, 1))
- self.op_anim.setStartValue(0.88)
+ self.pos_anim.setEndValue(geom.adjusted(0, 2, 0, 2))
+ self.op_anim.setStartValue(0.85)
self.op_anim.setEndValue(1.0)
self.animation_group.start()
super().leaveEvent(event)
+class TargetIconWidget(QWidget):
+ """自定义图标渲染组件,接管无损抗锯齿与复合重绘效果"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setFixedSize(200, 200)
+ self.pixmap = None
+ self.base_pixmap = None
+ self.file_pixmap = None # 用于专门记住当前加载文件的专属图标
+ self.current_size = 88
+
+ self.is_building = False
+ self.spin_angle = 0
+ self.pulse_value = 0
+
+ self.anim_timer = QTimer(self)
+ self.anim_timer.setInterval(16) # ~60fps
+ self.anim_timer.timeout.connect(self._update_frame)
+
+ self.success_effect = QGraphicsDropShadowEffect(self)
+ self.success_effect.setOffset(0, 0)
+ self.success_effect.setColor(QColor(0, 0, 0, 0))
+ self.success_effect.setBlurRadius(0)
+ self.setGraphicsEffect(self.success_effect)
+
+ self.burst_value = 0.0
+ self.burst_anim = QVariantAnimation(self)
+ self.burst_anim.setDuration(600)
+ self.burst_anim.setLoopCount(1)
+ self.burst_anim.setStartValue(0.0)
+ self.burst_anim.setEndValue(1.0)
+ self.burst_anim.setEasingCurve(QEasingCurve.OutQuad)
+ self.burst_anim.valueChanged.connect(self._animate_burst)
+
+ # 抖动动画配置(用于构建失败)
+ self.shake_offset = 0
+ self.shake_anim = QVariantAnimation(self)
+ self.shake_anim.setDuration(500)
+ self.shake_anim.setStartValue(0)
+ self.shake_anim.setEndValue(0)
+ self.shake_anim.setKeyValueAt(0.0, 0)
+ self.shake_anim.setKeyValueAt(0.1, -12)
+ self.shake_anim.setKeyValueAt(0.3, 12)
+ self.shake_anim.setKeyValueAt(0.5, -12)
+ self.shake_anim.setKeyValueAt(0.7, 12)
+ self.shake_anim.setKeyValueAt(0.9, -6)
+ self.shake_anim.setKeyValueAt(1.0, 0)
+ self.shake_anim.valueChanged.connect(self._animate_shake)
+
+ def set_default_pixmap(self, pixmap, size=88):
+ self.base_pixmap = pixmap
+ self.pixmap = pixmap
+ self.current_size = size
+ self.update()
+
+ def set_custom_pixmap(self, pixmap, size=88):
+ self.pixmap = pixmap
+ self.current_size = size
+ self.update()
+
+ def set_file_pixmap(self, pixmap, size=88):
+ self.file_pixmap = pixmap
+ self.pixmap = pixmap
+ self.current_size = size
+ self.update()
+
+ def start_building(self):
+ # 恢复之前保存的文件专属图标,覆盖掉可能残留的“成功”或“失败”图标
+ if getattr(self, 'file_pixmap', None) and not self.file_pixmap.isNull():
+ self.pixmap = self.file_pixmap
+ self.current_size = 88
+
+ self.is_building = True
+ self.spin_angle = 0
+ self.pulse_value = 0
+ self.burst_value = 0.0
+ self.shake_offset = 0
+ self.success_effect.setColor(QColor(0, 0, 0, 0))
+ self.burst_anim.stop()
+ self.shake_anim.stop()
+ self.anim_timer.start()
+
+ def stop_building(self):
+ self.is_building = False
+ self.anim_timer.stop()
+ self.update()
+
+ def start_success(self):
+ self.stop_building()
+ self.success_effect.setBlurRadius(40)
+ self.success_effect.setColor(QColor(255, 193, 7, 180))
+ self.burst_anim.start()
+
+ def start_failure(self):
+ self.stop_building()
+ self.success_effect.setBlurRadius(40)
+ self.success_effect.setColor(QColor(217, 48, 37, 180)) # 红色警告发光
+ self.shake_anim.start()
+
+ def reset(self):
+ self.stop_building()
+ self.burst_anim.stop()
+ self.shake_anim.stop()
+ self.burst_value = 0.0
+ self.shake_offset = 0
+ self.success_effect.setColor(QColor(0, 0, 0, 0))
+ self.file_pixmap = None # 清空文件专属图标
+ self.pixmap = self.base_pixmap
+ self.current_size = 88
+ self.update()
+
+ def _update_frame(self):
+ self.spin_angle = (self.spin_angle + 4) % 360
+ self.pulse_value += 0.05
+ self.update()
+
+ def _animate_burst(self, val):
+ self.burst_value = val
+ self.update()
+
+ def _animate_shake(self, val):
+ self.shake_offset = val
+ self.update()
+
+ def paintEvent(self, event):
+ if not self.pixmap or self.pixmap.isNull():
+ return
+
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setRenderHint(QPainter.SmoothPixmapTransform)
+
+ center = self.rect().center()
+ center_x = center.x() + int(self.shake_offset)
+ icon_center_y = center.y()
+ draw_size = self.current_size
+
+ if self.is_building:
+ radius = (self.current_size / 2) + 12
+ pen = QPen(QColor(26, 115, 232, 200))
+ pen.setWidth(4)
+ pen.setCapStyle(Qt.RoundCap)
+ painter.setPen(pen)
+
+ rect = QRectF(center_x - radius, center.y() - radius, radius * 2, radius * 2)
+ span_angle = int((140 + 60 * math.sin(self.pulse_value * 1.5)) * 16)
+ start_angle = int(-self.spin_angle * 16)
+ painter.drawArc(rect, start_angle, span_angle)
+
+ elif self.burst_value > 0.0:
+ pop_scale = 1.0 + math.sin(self.burst_value * math.pi) * 0.15
+ draw_size = int(self.current_size * pop_scale)
+
+ if self.burst_value < 1.0:
+ alpha = int(255 * (1.0 - self.burst_value))
+ painter.setPen(Qt.NoPen)
+
+ painter.setBrush(QColor(26, 115, 232, alpha))
+ burst_radius_1 = (self.current_size / 2) + 10 + self.burst_value * 40
+ dot_size_1 = 8 * (1.0 - self.burst_value)
+ for i in range(8):
+ angle = math.radians(i * 45)
+ dx = center_x + math.cos(angle) * burst_radius_1
+ dy = center.y() + math.sin(angle) * burst_radius_1
+ painter.drawEllipse(QPointF(dx, dy), dot_size_1, dot_size_1)
+
+ painter.setBrush(QColor(255, 193, 7, alpha))
+ burst_radius_2 = (self.current_size / 2) + self.burst_value * 65
+ dot_size_2 = 6 * (1.0 - self.burst_value)
+ for i in range(8):
+ angle = math.radians(i * 45 + 22.5)
+ dx = center_x + math.cos(angle) * burst_radius_2
+ dy = center.y() + math.sin(angle) * burst_radius_2
+ painter.drawEllipse(QPointF(dx, dy), dot_size_2, dot_size_2)
+
+ pix_rect = QRect(
+ int(center_x - draw_size / 2),
+ int(icon_center_y - draw_size / 2),
+ draw_size,
+ draw_size
+ )
+ scaled_pix = self.pixmap.scaled(draw_size, draw_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+ painter.drawPixmap(pix_rect, scaled_pix)
+ painter.end()
+
class DropArea(QFrame):
fileDropped = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
+ self.setObjectName("DropArea")
self.setAcceptDrops(True)
+ self.setFrameStyle(QFrame.NoFrame)
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet("""
- QFrame { background-color: #fafafa; border: 1px dashed #dee2e6; border-radius: 4px; }
- QFrame:hover { background-color: #f1f3f5; border-color: #ced4da; }
+ #DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; }
+ #DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }
""")
+ self.init_ui()
+
+ def _get_default_pixmap(self, size=88):
+ icon_path = get_resource_path("icon.ico")
+ if os.path.exists(icon_path):
+ pixmap = QIcon(icon_path).pixmap(size, size)
+ if not pixmap.isNull():
+ return pixmap
+ return get_svg_pixmap('python', color="#9AA0A6", size=size)
+
+ def init_ui(self):
layout = QVBoxLayout(self)
- layout.setContentsMargins(4, 4, 4, 4)
- self.tip_label = QLabel("📥 拖拽主脚本 (.py) 至此区域 ( 或点击浏览 )")
- self.tip_label.setAlignment(Qt.AlignCenter)
- self.tip_label.setStyleSheet("color: #6c757d; font-size: 9pt; font-weight: 500;")
- layout.addWidget(self.tip_label)
+ layout.setContentsMargins(10, 20, 10, 20)
+ layout.setSpacing(0)
+
+ layout.addStretch(1)
+
+ self.icon_widget = TargetIconWidget(self)
+ self.icon_widget.set_default_pixmap(self._get_default_pixmap(88))
+
+ h_layout = QHBoxLayout()
+ h_layout.addStretch(1)
+ h_layout.addWidget(self.icon_widget)
+ h_layout.addStretch(1)
+ layout.addLayout(h_layout)
+
+ layout.addSpacing(18)
+
+ self.label = QLabel("将脚本(.py)拖拽至此处\n或 点击浏览")
+ self.label.setAlignment(Qt.AlignCenter)
+ self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
+ layout.addWidget(self.label)
+
+ layout.addSpacing(8)
+
+ self.sub_label = QLabel("智能解析工程依赖、附件及隐藏模块")
+ self.sub_label.setAlignment(Qt.AlignCenter)
+ self.sub_label.setStyleSheet("QLabel { background: transparent; color: #9AA0A6; font-size: 13px; border: none; }")
+ layout.addWidget(self.sub_label)
+
+ layout.addStretch(1)
def dragEnterEvent(self, event: QDragEnterEvent):
- if event.mimeData().hasUrls():
- urls = event.mimeData().urls()
- if urls and urls[0].toLocalFile().lower().endswith('.py'):
- event.acceptProposedAction()
- self.setStyleSheet("QFrame { background-color: #e9ecef; border: 1px dashed #adb5bd; border-radius: 4px; }")
+ if event.mimeData().hasUrls() and event.mimeData().urls()[0].toLocalFile().lower().endswith('.py'):
+ event.acceptProposedAction()
+ self.setStyleSheet("#DropArea { background-color: #E8F0FE; border: 2px dashed #1A73E8; border-radius: 12px; }")
def dragLeaveEvent(self, event):
- self.setStyleSheet("QFrame { background-color: #fafafa; border: 1px dashed #dee2e6; border-radius: 4px; } QFrame:hover { background-color: #f1f3f5; border-color: #ced4da; }")
+ self.setStyleSheet("#DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; } #DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }")
def dropEvent(self, event: QDropEvent):
self.dragLeaveEvent(event)
urls = event.mimeData().urls()
- if urls:
- path = urls[0].toLocalFile()
- if path.lower().endswith('.py'):
- self.fileDropped.emit(path)
- self.tip_label.setText(f"✅ 已装载工程: {os.path.basename(path)}")
- self.tip_label.setStyleSheet("color: #198754; font-size: 9pt; font-weight: bold;")
+ if urls and urls[0].toLocalFile().lower().endswith('.py'):
+ self.fileDropped.emit(urls[0].toLocalFile())
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
fp, _ = QFileDialog.getOpenFileName(self, "选择 Python 源代码文件", "", "Python Scripts (*.py)")
- if fp:
- self.fileDropped.emit(fp)
- self.tip_label.setText(f"✅ 已装载工程: {os.path.basename(fp)}")
- self.tip_label.setStyleSheet("color: #198754; font-size: 9pt; font-weight: bold;")
+ if fp: self.fileDropped.emit(fp)
+ def set_success(self, filename, custom_icon_path=None):
+ pixmap = None
+ if custom_icon_path and Path(custom_icon_path).exists():
+ pixmap = QIcon(str(custom_icon_path)).pixmap(88, 88)
+ if pixmap.isNull():
+ pixmap = None
+
+ if not pixmap:
+ pixmap = get_svg_pixmap('package', color="#1A73E8", size=88)
+
+ # 使用 set_file_pixmap,避免覆盖 base_pixmap 导致复位失败
+ self.icon_widget.set_file_pixmap(pixmap, 88)
+
+ self.label.setText(f"已加载:{filename}")
+ self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
+ self.sub_label.setText("准备就绪,随时可开始构建")
+
+ def start_build_anim(self):
+ self.sub_label.setText("正在执行模块提取与打包...")
+ self.icon_widget.start_building()
+
+ def stop_build_anim(self):
+ self.icon_widget.stop_building()
+
+ def show_success(self, custom_icon_path=None):
+ size = 128
+ pixmap_set = False
+ if custom_icon_path and Path(custom_icon_path).exists():
+ pix = QIcon(str(custom_icon_path)).pixmap(size, size)
+ if not pix.isNull():
+ self.icon_widget.set_custom_pixmap(pix, size)
+ pixmap_set = True
+
+ if not pixmap_set:
+ if self.icon_widget.base_pixmap and not self.icon_widget.base_pixmap.isNull():
+ self.icon_widget.set_custom_pixmap(self.icon_widget.base_pixmap, size)
+ else:
+ self.icon_widget.set_custom_pixmap(get_svg_pixmap('check', color="#1E8E3E", size=size), size)
+
+ self.icon_widget.start_success()
+
+ self.label.setText("构建完成!")
+ self.label.setStyleSheet("QLabel { background: transparent; color: #1E8E3E; font-size: 20px; font-weight: bold; border: none; }")
+ self.sub_label.setText("可打开目录查看或重置工作区")
+
+ def show_failure(self):
+ size = 128
+ self.icon_widget.set_custom_pixmap(get_svg_pixmap('close', color="#D93025", size=size), size)
+ self.icon_widget.start_failure()
+
+ self.label.setText("构建失败!")
+ self.label.setStyleSheet("QLabel { background: transparent; color: #D93025; font-size: 20px; font-weight: bold; border: none; }")
+ self.sub_label.setText("请检查下方的日志诊断报告")
+
def reset(self):
- self.tip_label.setText("📥 拖拽主脚本 (.py) 至此区域 ( 或点击浏览 )")
- self.tip_label.setStyleSheet("color: #6c757d; font-size: 9pt; font-weight: 500;")
+ self.icon_widget.reset()
+ self.label.setText("将脚本(.py)拖拽至此处\n或 点击浏览")
+ self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
+ self.sub_label.setText("智能解析工程依赖、附件及隐藏模块")
-# ======================== 核心构建服务 ========================
+# ======================== 设置面板 ========================
+class SettingsPanel(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent_win = parent
+ self.setStyleSheet("""
+ SettingsPanel { background-color: white; }
+ QLabel { color: #3c4043; font-size: 13px; font-weight: bold; background: transparent; }
+ QComboBox, QLineEdit { color: #3c4043; font-size: 13px; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 6px; background: #fff; min-height: 24px; }
+ QComboBox:hover, QLineEdit:hover { border-color: #bdc1c6; }
+ QComboBox:focus, QLineEdit:focus { border-color: #1A73E8; }
+ QGroupBox { border: 1px solid #e8eaed; border-radius: 8px; margin-top: 15px; padding-top: 15px; background-color: #f8f9fa; }
+ QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 15px; top: 0px; color: #1A73E8; font-weight: bold; font-size: 13px; padding: 0 5px; background: transparent; }
+ QCheckBox { font-size: 13px; color: #3c4043; spacing: 8px; background: transparent; }
+ QCheckBox::indicator { width: 16px; height: 16px; border: 1px solid #bdc1c6; border-radius: 4px; background: white; }
+ QCheckBox::indicator:checked { background: #1A73E8; border-color: #1A73E8; image: url("data:image/svg+xml;utf8,"); }
+ QPushButton.ToolBtn { padding: 5px 12px; border: 1px solid #dadce0; border-radius: 6px; background: #f1f3f4; font-size: 12px; font-weight: bold; color: #5f6368;}
+ QPushButton.ToolBtn:hover { background: #e8eaed; color: #202124; }
+ QTabWidget::pane { border: 1px solid #e8eaed; border-radius: 8px; background: white; top: -1px; }
+ QTabBar::tab { background: #f1f3f4; border: 1px solid #e8eaed; padding: 10px 20px; margin-right: 4px; border-top-left-radius: 8px; border-top-right-radius: 8px; color: #5f6368; font-weight: bold; font-size: 13px; }
+ QTabBar::tab:selected { background: white; border-bottom-color: white; color: #1A73E8; }
+ QTabBar::tab:hover:!selected { background: #e8eaed; }
+ """)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setSpacing(15)
+ layout.setContentsMargins(20, 20, 20, 20)
+
+ self.tabs = QTabWidget()
+ self.tab_basic = QWidget()
+ self.tab_env = QWidget()
+ self.tab_adv = QWidget()
+ self.tab_meta = QWidget()
+
+ self.tabs.addTab(self.tab_basic, get_svg_icon('package', "#1A73E8"), " 基础打包")
+ self.tabs.addTab(self.tab_env, get_svg_icon('python', "#1A73E8"), " 环境与依赖")
+ self.tabs.addTab(self.tab_adv, get_svg_icon('settings', "#1A73E8"), " 高级选项")
+ self.tabs.addTab(self.tab_meta, get_svg_icon('info', "#1A73E8"), " 版本信息")
+
+ self.build_basic_tab()
+ self.build_env_tab()
+ self.build_adv_tab()
+ self.build_meta_tab()
+ layout.addWidget(self.tabs)
+
+ btn_lay = QHBoxLayout()
+ btn_lay.setContentsMargins(0, 5, 0, 0)
+ btn_lay.setSpacing(12)
+
+ self.btn_reset = AnimatedButton("")
+ self.btn_reset.setFixedSize(44, 44)
+ self.btn_reset.setIcon(get_svg_icon('refresh', "#5F6368"))
+ self.btn_reset.setToolTip("恢复默认配置")
+ self.btn_reset.setStyleSheet(self.parent_win.icon_btn_style)
+ self.btn_reset.clicked.connect(self.parent_win.reset_all)
+ btn_lay.addWidget(self.btn_reset)
+
+ self.btn_save = AnimatedButton(" 保存并返回工作区")
+ self.btn_save.setFixedHeight(44)
+ self.btn_save.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ self.btn_save.setIcon(get_svg_icon('check', "white"))
+ self.btn_save.setStyleSheet(self.parent_win.primary_btn_style)
+ self.btn_save.clicked.connect(self.parent_win.show_main)
+ btn_lay.addWidget(self.btn_save)
+
+ self.btn_back = AnimatedButton("")
+ self.btn_back.setFixedSize(44, 44)
+ self.btn_back.setIcon(get_svg_icon('back', "#5F6368"))
+ self.btn_back.setToolTip("放弃修改并返回")
+ self.btn_back.setStyleSheet(self.parent_win.icon_btn_style)
+ self.btn_back.clicked.connect(self.parent_win.show_main)
+ btn_lay.addWidget(self.btn_back)
+
+ layout.addLayout(btn_lay)
+
+ def build_basic_tab(self):
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ scroll.setStyleSheet("background: transparent;")
+
+ content = QWidget()
+ content.setStyleSheet("background: white;")
+ lay = QVBoxLayout(content)
+ lay.setContentsMargins(15, 15, 15, 15)
+
+ grp_core = QGroupBox("核心参数")
+ form_core = QFormLayout(grp_core)
+ form_core.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+ form_core.setContentsMargins(15, 20, 15, 15)
+ form_core.setVerticalSpacing(12)
+
+ self.engine_combo = QComboBox()
+ self.engine_combo.addItems(["PyInstaller", "Nuitka", "cx_Freeze"])
+ self.engine_combo.currentIndexChanged.connect(self.on_engine_changed)
+ form_core.addRow("构建引擎:", self.engine_combo)
+
+ self.name_edit = QLineEdit()
+ self.name_edit.setPlaceholderText("留空则与入口脚本同名")
+ form_core.addRow("输出名称:", self.name_edit)
+
+ self.icon_edit = QLineEdit()
+ self.icon_preview = QLabel()
+ self.icon_preview.setFixedSize(28, 28)
+ self.icon_preview.setScaledContents(True)
+ self.icon_edit.textChanged.connect(self.update_icon_preview)
+ btn_icon = QPushButton("浏览...")
+ btn_icon.setProperty("class", "ToolBtn")
+ btn_icon.clicked.connect(self.select_icon)
+ h_icon = QHBoxLayout()
+ h_icon.addWidget(self.icon_edit)
+ h_icon.addWidget(self.icon_preview)
+ h_icon.addWidget(btn_icon)
+ form_core.addRow("应用图标:", h_icon)
+ lay.addWidget(grp_core)
+
+ grp_mode = QGroupBox("输出模式与行为")
+ grid_mode = QGridLayout(grp_mode)
+ grid_mode.setContentsMargins(15, 20, 15, 15)
+ grid_mode.setVerticalSpacing(15)
+
+ self.onefile_check = QCheckBox("单文件模式 (OneFile)")
+ self.onefile_check.setChecked(True)
+ self.noconsole_check = QCheckBox("隐藏控制台 (GUI模式)")
+ self.noconsole_check.setChecked(True)
+ self.clean_all_check = QCheckBox("编译后自动清理缓存")
+ self.clean_all_check.setChecked(True)
+ self.auto_icon_check = QCheckBox("自动匹配同目录图标")
+ self.auto_icon_check.setChecked(True)
+
+ grid_mode.addWidget(self.onefile_check, 0, 0)
+ grid_mode.addWidget(self.noconsole_check, 0, 1)
+ grid_mode.addWidget(self.clean_all_check, 1, 0)
+ grid_mode.addWidget(self.auto_icon_check, 1, 1)
+ lay.addWidget(grp_mode)
+
+ lay.addStretch()
+ scroll.setWidget(content)
+ main_lay = QVBoxLayout(self.tab_basic)
+ main_lay.setContentsMargins(0,0,0,0)
+ main_lay.addWidget(scroll)
+
+ def build_env_tab(self):
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ scroll.setStyleSheet("background: transparent;")
+
+ content = QWidget()
+ content.setStyleSheet("background: white;")
+ lay = QVBoxLayout(content)
+ lay.setContentsMargins(15, 15, 15, 15)
+
+ grp_env = QGroupBox("隔离与网络")
+ form_env = QFormLayout(grp_env)
+ form_env.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+ form_env.setContentsMargins(15, 20, 15, 15)
+ form_env.setVerticalSpacing(12)
+
+ self.pip_source_edit = QLineEdit("https://pypi.tuna.tsinghua.edu.cn/simple")
+ form_env.addRow("PIP 镜像:", self.pip_source_edit)
+
+ self.venv_check = QCheckBox("启用独立虚拟环境 (推荐,保证依赖纯净)")
+ self.venv_check.setChecked(True)
+ form_env.addRow("", self.venv_check)
+ lay.addWidget(grp_env)
+
+ grp_dep = QGroupBox("包与依赖分析机制")
+ dep_lay = QVBoxLayout(grp_dep)
+ dep_lay.setContentsMargins(15, 20, 15, 15)
+ dep_lay.setSpacing(15)
+
+ grid_dep = QGridLayout()
+ self.reqs_check = QCheckBox("优先读取 requirements.txt")
+ self.reqs_check.setChecked(True)
+ self.pipreqs_check = QCheckBox("启用 pipreqs 深度代码分析")
+ self.pipreqs_check.setChecked(True)
+ grid_dep.addWidget(self.reqs_check, 0, 0)
+ grid_dep.addWidget(self.pipreqs_check, 0, 1)
+ dep_lay.addLayout(grid_dep)
+
+ form_dep = QFormLayout()
+ form_dep.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+ self.hidden_edit = QLineEdit()
+ self.hidden_edit.setPlaceholderText("例如: pandas, PyQt5 (逗号分隔)")
+ btn_scan = QPushButton("AST 扫描")
+ btn_scan.setProperty("class", "ToolBtn")
+ btn_scan.clicked.connect(self.auto_scan_hidden)
+ h_hid = QHBoxLayout()
+ h_hid.addWidget(self.hidden_edit)
+ h_hid.addWidget(btn_scan)
+ form_dep.addRow("隐式依赖:", h_hid)
+ dep_lay.addLayout(form_dep)
+
+ lay.addWidget(grp_dep)
+ lay.addStretch()
+
+ scroll.setWidget(content)
+ main_lay = QVBoxLayout(self.tab_env)
+ main_lay.setContentsMargins(0,0,0,0)
+ main_lay.addWidget(scroll)
+
+ def build_adv_tab(self):
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ scroll.setStyleSheet("background: transparent;")
+
+ content = QWidget()
+ content.setStyleSheet("background: white;")
+ lay = QVBoxLayout(content)
+ lay.setContentsMargins(15, 15, 15, 15)
+
+ grp_res = QGroupBox("附加资源与优化")
+ form_res = QFormLayout(grp_res)
+ form_res.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+ form_res.setContentsMargins(15, 20, 15, 15)
+ form_res.setVerticalSpacing(12)
+
+ self.add_data_edit = QLineEdit()
+ self.add_data_edit.setPlaceholderText("例如: data/*:data (跨平台自动处理路径)")
+ btn_add = QPushButton("添加...")
+ btn_add.setProperty("class", "ToolBtn")
+ btn_add.clicked.connect(self.add_resource)
+ h_res = QHBoxLayout()
+ h_res.addWidget(self.add_data_edit)
+ h_res.addWidget(btn_add)
+ form_res.addRow("附加资源:", h_res)
+
+ self.upx_check = QCheckBox("启用 UPX 压缩优化 (仅 PyInstaller)")
+ form_res.addRow("", self.upx_check)
+ lay.addWidget(grp_res)
+ lay.addStretch()
+
+ scroll.setWidget(content)
+ main_lay = QVBoxLayout(self.tab_adv)
+ main_lay.setContentsMargins(0,0,0,0)
+ main_lay.addWidget(scroll)
+
+ def build_meta_tab(self):
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ scroll.setStyleSheet("background: transparent;")
+
+ content = QWidget()
+ content.setStyleSheet("background: white;")
+ lay = QVBoxLayout(content)
+ lay.setContentsMargins(15, 15, 15, 15)
+
+ self.grp_meta = QGroupBox("版本元数据 (Metadata)")
+ meta_form = QFormLayout(self.grp_meta)
+ meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+ meta_form.setContentsMargins(15, 20, 15, 15)
+ meta_form.setVerticalSpacing(12)
+
+ self.ver_ver = QLineEdit("1.0.0")
+ self.ver_comp = QLineEdit("My Studio")
+ self.ver_desc = QLineEdit("Python Executable")
+ meta_form.addRow("版本序列:", self.ver_ver)
+ meta_form.addRow("发行公司:", self.ver_comp)
+ meta_form.addRow("文件描述:", self.ver_desc)
+ lay.addWidget(self.grp_meta)
+
+ tip = QLabel("注:仅 PyInstaller 和 Nuitka 引擎支持写入 EXE 版本信息。")
+ tip.setStyleSheet("color: #9AA0A6; font-size: 12px; font-weight: normal; background: transparent;")
+ lay.addWidget(tip)
+ lay.addStretch()
+
+ scroll.setWidget(content)
+ main_lay = QVBoxLayout(self.tab_meta)
+ main_lay.setContentsMargins(0,0,0,0)
+ main_lay.addWidget(scroll)
+
+ def on_engine_changed(self):
+ engine = self.engine_combo.currentText()
+ self.onefile_check.setVisible(engine in ("PyInstaller", "Nuitka"))
+ self.upx_check.setVisible(engine == "PyInstaller")
+ self.tabs.setTabEnabled(3, engine != "cx_Freeze")
+
+ def update_icon_preview(self, path):
+ if path and Path(path).exists():
+ pixmap = QPixmap(path)
+ if not pixmap.isNull():
+ self.icon_preview.setPixmap(pixmap.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation))
+ return
+ self.icon_preview.clear()
+
+ def select_icon(self):
+ p, _ = QFileDialog.getOpenFileName(self, "选择图标", "", "Icon Files (*.ico)")
+ if p: self.icon_edit.setText(Path(p).resolve().as_posix())
+
+ def auto_scan_hidden(self):
+ script_path = self.parent_win.script_path
+ if not script_path: return QMessageBox.warning(self, "提示", "请先在主界面加载脚本!")
+ try:
+ raw = Path(script_path).read_bytes()
+ if b"__CLOUDSYNC_ENC__" in raw[:1024]: return QMessageBox.critical(self, "错误", "脚本被云盘加密,请解密!")
+ try: code = raw.decode('utf-8-sig')
+ except: code = raw.decode('gbk', errors='ignore')
+
+ hidden = set()
+ try:
+ tree = ast.parse(code)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names: hidden.add(alias.name.split('.')[0])
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
+ hidden.add(node.module.split('.')[0])
+ except:
+ hidden = set(re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M))
+
+ hidden = [m for m in hidden if m not in STD_LIBS]
+ self.hidden_edit.setText(','.join(hidden))
+ QMessageBox.information(self, "扫描完成", f"捕获到 {len(hidden)} 项依赖。")
+ except Exception as e: QMessageBox.critical(self, "错误", f"扫描失败: {e}")
+
+ def add_resource(self):
+ choice = QMessageBox.question(self, "添加资源", "Yes=文件, No=文件夹", QMessageBox.Yes | QMessageBox.No)
+ if choice == QMessageBox.Yes:
+ files, _ = QFileDialog.getOpenFileNames(self, "选择文件", "", "All Files (*)")
+ for f in files:
+ f = Path(f).resolve().as_posix()
+ default_dest = Path(f).name
+ dest, ok = QInputDialog.getText(self, "相对路径", f"目标位置:", text=default_dest)
+ if ok:
+ dest = dest.strip().replace('\\', '/') if dest else default_dest
+ curr = self.add_data_edit.text().strip()
+ self.add_data_edit.setText(f"{curr}, {f}:{dest}" if curr else f"{f}:{dest}")
+ else:
+ folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
+ if folder:
+ folder = Path(folder).resolve().as_posix()
+ default_dest = Path(folder).name
+ dest, ok = QInputDialog.getText(self, "相对路径", f"目标位置:", text=default_dest)
+ if ok:
+ dest = dest.strip().replace('\\', '/') if dest else default_dest
+ curr = self.add_data_edit.text().strip()
+ self.add_data_edit.setText(f"{curr}, {folder}:{dest}" if curr else f"{folder}:{dest}")
+
+
+# ======================== 核心构建引擎 ========================
class PackingThread(QThread):
progress = pyqtSignal(str)
finished = pyqtSignal(bool, str)
@@ -190,793 +828,578 @@ class PackingThread(QThread):
self._is_cancelled = True
if self.process:
try:
- if os.name == "nt":
- # 在 Windows 下终止整个子进程树,防止编译器残留锁死文件 (增加 creationflags 隐藏结束进程的黑框)
- subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.process.pid)],
- capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
- else:
- self.process.kill()
- except:
- pass
+ if os.name == "nt": subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.process.pid)], capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
+ else: self.process.kill()
+ except: pass
- def run_cmd_realtime(self, cmd, cwd=None):
+ def run_cmd(self, cmd, cwd=None):
if self._is_cancelled: return False
try:
- kwargs = {
- "stdout": subprocess.PIPE,
- "stderr": subprocess.STDOUT,
- "text": True,
- "encoding": 'utf-8',
- "errors": 'replace',
- "cwd": cwd
- }
- # Windows 系统下静默执行,隐藏 CMD 黑窗口
- if os.name == 'nt':
- kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
-
+ kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, "cwd": cwd, "text": True, "errors": "replace"}
+ if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
+
self.process = subprocess.Popen(cmd, **kwargs)
- while True:
- line = self.process.stdout.readline()
- if not line and self.process.poll() is not None:
- break
- if line:
- self.progress.emit(line.strip())
+ for line in self.process.stdout:
+ if self._is_cancelled:
+ self.process.terminate()
+ return False
+ self.progress.emit(line.strip())
+
+ self.process.wait()
return self.process.returncode == 0
except Exception as e:
- self.progress.emit(f"[系统异常] 进程错误: {e}")
+ self.progress.emit(f"[系统异常] 进程执行失败: {e}")
return False
- def run(self):
- # 优雅解决新机器环境卡阻:在当前执行环境注册此环境变量,让所有版本的 Nuitka 自动同意下载依赖
- os.environ["NUITKA_ACCEPT_DOWNLOADS"] = "yes"
-
- engine = self.params['engine']
- pip_idx = self.params.get('pip_index_url', '').strip() or "https://pypi.tuna.tsinghua.edu.cn/simple"
+ def sanitize_script(self, orig_path: Path):
try:
- self.progress.emit("[预处理] 清理遗留缓存...")
+ raw = orig_path.read_bytes()
+ if b"__CLOUDSYNC_ENC__" in raw[:1024]:
+ return None, False, "目标脚本被云盘锁定加密!"
+ except Exception:
+ pass
+ # 移除了所有重命名及生成 pypack_temp_ 前缀文件的死代码
+ # 直接透传原始文件可防止 Nuitka 生成带有临时前缀的 DLL 触发 Windows 智能应用控制的启发式拦截
+ return orig_path, False, ""
+
+ def run(self):
+ os.environ["NUITKA_ACCEPT_DOWNLOADS"] = "yes"
+ engine = self.params['engine']
+ pip_idx = self.params.get('pip_index_url', '').strip()
+ is_temp = False
+ build_script_path = None
+ ext = ".exe" if os.name == "nt" else ""
+
+ try:
+ self.progress.emit("[清理] 初始化构建管线...")
robust_rmtree(Path.cwd() / "build")
robust_rmtree(Path.cwd() / "dist")
- script_path = self.params['script_path']
- script_dir = Path(script_path).parent
- if "OneDrive" in str(script_path):
- self.progress.emit("[警告] 工程位于 OneDrive 同步目录中,文件可能被锁定导致构建失败,建议将工程迁移至本地路径。")
+ script_path = Path(self.params['script_path']).resolve()
+ script_dir = script_path.parent
- # 虚拟环境
- if self.params['use_venv']:
- self.progress.emit("[环境预配置] 正在初始化 Python 虚拟环境...")
- self.venv_dir = Path(tempfile.mkdtemp(prefix="pypack_env_"))
- host_py = get_python_executable()
- if not self.run_cmd_realtime([host_py, "-m", "venv", str(self.venv_dir)]):
- self.finished.emit(False, "虚拟环境初始化失败,请检查系统权限。")
- return
- python_exe = str(self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))
- else:
- self.progress.emit("[环境警告] 虚拟环境已禁用,使用全局 Python。")
- python_exe = get_python_executable()
+ build_script_path, is_temp, err_msg = self.sanitize_script(script_path)
+ if not build_script_path and err_msg: return self.finished.emit(False, f"[安全拦截] {err_msg}")
+ script_posix = build_script_path.as_posix()
- # 安装引擎
- if engine == "Nuitka":
- engine_pkg = "nuitka[onefile]"
- elif engine == "cx_Freeze":
- engine_pkg = "cx_Freeze"
- else:
- engine_pkg = "pyinstaller"
- self.progress.emit(f"[环境配置] 正在通过镜像源 {pip_idx} 部署依赖包...")
- self.run_cmd_realtime([python_exe, "-m", "pip", "install", "-U", "pip", "-i", pip_idx, "-q"])
- self.run_cmd_realtime([python_exe, "-m", "pip", "install", engine_pkg, "-i", pip_idx, "-q"])
- if engine == "PyInstaller":
- self.run_cmd_realtime([python_exe, "-m", "pip", "install", "pillow", "-i", pip_idx, "-q"])
+ if self.params['use_venv']:
+ self.progress.emit("[环境] 初始化虚拟隔离沙盒...")
+ self.venv_dir = Path(tempfile.mkdtemp(prefix="pypack_env_")).resolve()
+ if not self.run_cmd([get_python_executable(), "-m", "venv", self.venv_dir.as_posix()]):
+ return self.finished.emit(False, "虚拟环境初始化失败。")
+ python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
+ else: python_exe = Path(get_python_executable()).resolve().as_posix()
+
+ pip_args = ["-i", pip_idx] if pip_idx else []
+ engine_pkg = "nuitka[onefile]" if engine == "Nuitka" else "cx_Freeze" if engine == "cx_Freeze" else "pyinstaller"
- # 依赖解析
+ self.progress.emit(f"[环境] 下载核心组件...")
+ core_pkgs = [engine_pkg]
+ if engine == "PyInstaller": core_pkgs.append("pillow")
+ self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + core_pkgs + pip_args)
+
+ req_success = False
if self.params.get('use_reqs'):
req_file = script_dir / "requirements.txt"
if req_file.exists():
- self.progress.emit(f"[依赖解析] 同步工程内 {req_file.name}...")
- self.run_cmd_realtime([python_exe, "-m", "pip", "install", "-r", str(req_file), "-i", pip_idx])
- else:
- self.progress.emit("[依赖解析] 未找到 requirements.txt,执行 AST 静态扫描...")
+ self.progress.emit("[依赖] 部署 requirements.txt...")
try:
- with open(script_path, encoding='utf-8', errors='ignore') as f:
- code = f.read()
- third_party = set()
- try:
- # 采用标准库 AST 分析,精准获取顶层依赖并过滤注释/伪导入
- tree = ast.parse(code, filename=str(script_path))
- for node in ast.walk(tree):
- if isinstance(node, ast.Import):
- for alias in node.names:
- third_party.add(alias.name.split('.')[0])
- elif isinstance(node, ast.ImportFrom):
- if node.level == 0 and node.module:
- third_party.add(node.module.split('.')[0])
- except Exception:
- # 语法不完备时回退到正则扫描
- imports = re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M)
- third_party = set(imports)
-
- third_party = {m for m in third_party if m not in STD_LIBS}
- if third_party:
- self.progress.emit(f"发现外部依赖模块: {', '.join(third_party)}")
- for pkg in third_party:
- if self._is_cancelled: break
- self.progress.emit(f"[自动同步] 正在安装 {pkg}...")
- self.run_cmd_realtime([python_exe, "-m", "pip", "install", pkg, "-i", pip_idx, "-q"])
- except Exception as e:
- self.progress.emit(f"[扫描异常] {e}")
+ raw_req = req_file.read_bytes()
+ if b"__CLOUDSYNC_ENC__" in raw_req[:1024]: raise ValueError("文件被锁定")
+ try: req_content = raw_req.decode('utf-8-sig')
+ except: req_content = raw_req.decode(locale.getpreferredencoding(), errors='ignore')
+ temp_req = script_dir / "pypack_temp_reqs.txt"
+ temp_req.write_text(req_content, encoding='utf-8')
+ if self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_req.as_posix()] + pip_args):
+ req_success = True
+ temp_req.unlink(missing_ok=True)
+ except Exception as e: self.progress.emit(f"[警告] {e}")
+ if self.params.get('use_pipreqs') and not req_success:
+ self.progress.emit("[依赖] 执行 pipreqs 深度推导...")
+ self.run_cmd([python_exe, "-m", "pip", "install", "pipreqs", "-q"] + pip_args)
+ temp_pipreqs = script_dir / "pypack_pipreqs_out.txt"
+ self.run_cmd([python_exe, "-m", "pipreqs.pipreqs", script_dir.as_posix(), "--encoding", "utf-8", "--force", "--savepath", temp_pipreqs.as_posix()])
+ if temp_pipreqs.exists():
+ if self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_pipreqs.as_posix()] + pip_args): req_success = True
+ temp_pipreqs.unlink(missing_ok=True)
+ if not req_success and (self.params.get('use_reqs') or self.params.get('use_pipreqs')):
+ self.progress.emit("[依赖] 执行 AST 语法树降级解析...")
+ try:
+ code = build_script_path.read_text(encoding='utf-8', errors='ignore')
+ third_party = set()
+ try:
+ for node in ast.walk(ast.parse(code)):
+ if isinstance(node, ast.Import):
+ for alias in node.names: third_party.add(alias.name.split('.')[0])
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
+ third_party.add(node.module.split('.')[0])
+ except: third_party = set(re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M))
+
+ pkgs_to_install = [KNOWN_MAPPINGS.get(m, m) for m in third_party if m not in STD_LIBS]
+ if pkgs_to_install:
+ self.progress.emit(f"[依赖] 同步外部模块: {', '.join(pkgs_to_install)}")
+ self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + pkgs_to_install + pip_args)
+ except Exception as e: self.progress.emit(f"[异常] {e}")
- if self._is_cancelled:
- self.finished.emit(False, "构建已被中断。")
- return
+ if self._is_cancelled: return self.finished.emit(False, "构建已被中断。")
- # 构建命令生成
- self.progress.emit(f"[编译阶段] 启动 {engine} 构建...")
+ self.progress.emit(f"[编译] 启动 {engine} 封装引擎...")
cmd = []
app_name = self.params['app_name']
- noconsole = self.params.get('noconsole')
+ icon_path = Path(self.params['icon']).resolve().as_posix() if self.params.get('icon') else None
if engine == "PyInstaller":
- self.temp_workpath = Path(tempfile.mkdtemp(prefix="pypack_build_"))
- cmd = [python_exe, "-m", "PyInstaller", "--clean", "-y",
- f"--workpath={self.temp_workpath}",
- f"--name={app_name}"]
+ self.temp_workpath = Path(tempfile.mkdtemp(prefix="pypack_build_")).resolve()
+ cmd = [python_exe, "-m", "PyInstaller", "--clean", "-y", f"--workpath={self.temp_workpath.as_posix()}", f"--name={app_name}"]
if self.params.get('onefile'): cmd.append("--onefile")
else: cmd.append("--onedir")
- if noconsole: cmd.append("--noconsole")
- if self.params.get('icon'): cmd.extend(["--icon", self.params['icon']])
- if self.params.get('version_file'): cmd.extend(["--version-file", self.params['version_file']])
+ if self.params.get('noconsole'): cmd.append("--noconsole")
- # 只有在本地存在 UPX 目录时才显式传参,否则让 PyInstaller 寻找环境变量
+ if icon_path:
+ cmd.extend(["--icon", icon_path])
+ sep = ';' if os.name == 'nt' else ':'
+ cmd.extend(["--add-data", f"{icon_path}{sep}."])
+
+ if self.params.get('version_file') and os.name == "nt": cmd.extend(["--version-file", self.params['version_file']])
if self.params.get('upx'):
- upx_dir = Path.cwd() / "upx"
- if upx_dir.exists():
- cmd.append(f"--upx-dir={upx_dir}")
+ upx_dir = (Path.cwd() / "upx").resolve()
+ if upx_dir.exists(): cmd.append(f"--upx-dir={upx_dir.as_posix()}")
for imp in self.params.get('hidden_imports', '').split(','):
- imp = imp.strip()
- if imp: cmd.extend(["--hidden-import", imp])
+ if imp.strip(): cmd.extend(["--hidden-import", imp.strip()])
for d in self.params.get('add_data', '').split(','):
d = d.strip()
if d:
- # 兼容 PyPack 统一的 ':' 语法,自动转换为 Windows PyInstaller 所需的 ';'
- if os.name == 'nt' and ':' in d:
- parts = d.rsplit(':', 1) # 从右拆分,防止破坏 C:\ 盘符
- if len(parts) == 2:
- d = f"{parts[0]};{parts[1]}"
+ d = d.replace('\\', '/')
+ if os.name == 'nt' and ':' in d and ';' not in d:
+ parts = d.rsplit(':', 1)
+ if len(parts) == 2: d = f"{parts[0]};{parts[1]}"
cmd.extend(["--add-data", d])
+
elif engine == "Nuitka":
- self.temp_out_dir = Path(tempfile.mkdtemp(prefix="nuitka_out_"))
+ self.temp_out_dir = Path(tempfile.mkdtemp(prefix="nuitka_out_")).resolve()
cmd = [python_exe, "-m", "nuitka", "--remove-output", "--assume-yes-for-downloads",
- f"--output-dir={self.temp_out_dir}",
- f"--output-filename={app_name}.exe"]
+ f"--output-dir={self.temp_out_dir.as_posix()}", f"--output-filename={app_name}{ext}"]
+
+ cmd.append(f"--jobs={os.cpu_count() or 4}")
+
if self.params.get('onefile'): cmd.append("--onefile")
else: cmd.append("--standalone")
- if noconsole: cmd.append("--windows-console-mode=disable")
-
- if self.params.get('icon'): cmd.append(f"--windows-icon-from-ico={self.params['icon']}")
- if self.params.get('ver_comp'): cmd.append(f"--company-name={self.params['ver_comp']}")
- if self.params.get('ver_desc'): cmd.append(f"--product-name={self.params['ver_desc']}")
- if self.params.get('ver_ver'): cmd.append(f"--file-version={self.params['ver_ver']}")
+ if self.params.get('noconsole'): cmd.append("--windows-console-mode=disable")
+ if icon_path:
+ if os.name == "nt":
+ cmd.append(f"--windows-icon-from-ico={icon_path}")
+ elif sys.platform == "darwin" and icon_path.endswith(".icns"):
+ cmd.append(f"--macos-app-icon={icon_path}")
+ # 使用精确的文件名作为目标部署位置,以避开 Nuitka '.' 校验致命错误
+ cmd.append(f"--include-data-files={icon_path}={Path(icon_path).name}")
+
+ if os.name == "nt":
+ if self.params.get('ver_comp'): cmd.append(f"--company-name={self.params['ver_comp']}")
+ if self.params.get('ver_desc'): cmd.append(f"--product-name={self.params['ver_desc']}")
+ if self.params.get('ver_ver'): cmd.append(f"--file-version={self.params['ver_ver']}")
+
try:
- with open(script_path, encoding='utf-8', errors='ignore') as f:
- if 'PyQt5' in f.read():
- cmd.append("--enable-plugin=pyqt5")
- self.progress.emit("[自动检测] 已启用 PyQt5 插件")
+ if 'PyQt5' in build_script_path.read_text(encoding='utf-8', errors='ignore'):
+ cmd.append("--enable-plugin=pyqt5")
except: pass
+
for imp in self.params.get('hidden_imports', '').split(','):
- imp = imp.strip()
- if imp: cmd.append(f"--include-module={imp}")
+ if imp.strip(): cmd.append(f"--include-module={imp.strip()}")
for d in self.params.get('add_data', '').split(','):
d = d.strip()
if d:
+ d = d.replace('\\', '/')
parts = d.split(':', 1)
- if len(parts) == 2:
- # 修复:Nuitka 要求 --include-data-files 参数必须是完整字符串(由 = 拼接),不可分离
+ if len(parts) == 2:
cmd.append(f"--include-data-files={parts[0]}={parts[1]}")
- else:
- self.progress.emit(f"[警告] 附加资源格式错误,应为 src:dest,忽略: {d}")
-
+
elif engine == "cx_Freeze":
- cmd = [python_exe, "-m", "cx_Freeze", str(script_path),
- f"--target-dir=dist/{app_name}",
- f"--target-name={app_name}.exe"]
- if noconsole:
- cmd.append("--base=Win32GUI")
- else:
- cmd.append("--base=Console")
- if self.params.get('icon'): cmd.extend(["--icon", self.params['icon']])
- for imp in self.params.get('hidden_imports', '').split(','):
- imp = imp.strip()
- if imp: cmd.extend(["--include-modules", imp])
+ cmd = [python_exe, "-m", "cx_Freeze", script_posix, f"--target-dir=dist/{app_name}", f"--target-name={app_name}{ext}"]
+ if self.params.get('noconsole'): cmd.append("--base=Win32GUI")
+ else: cmd.append("--base=Console")
+
add_data_list = []
+ if icon_path:
+ cmd.extend(["--icon", icon_path])
+ add_data_list.append(f"{icon_path}={Path(icon_path).name}")
+ for imp in self.params.get('hidden_imports', '').split(','):
+ if imp.strip(): cmd.extend(["--include-modules", imp.strip()])
for d in self.params.get('add_data', '').split(','):
d = d.strip()
if d:
+ d = d.replace('\\', '/')
parts = d.split(':', 1)
- if len(parts) == 2:
- add_data_list.append(f"{parts[0]}={parts[1]}")
- else:
- self.progress.emit(f"[警告] 附加资源格式错误,应为 src:dest,忽略: {d}")
- if add_data_list:
- cmd.extend(["--include-files", ";".join(add_data_list)])
+ if len(parts) == 2: add_data_list.append(f"{parts[0]}={parts[1]}")
+ if add_data_list: cmd.append(f"--include-files={','.join(add_data_list)}")
- if engine in ["PyInstaller", "Nuitka"]:
- cmd.append(str(script_path))
+ if engine in ["PyInstaller", "Nuitka"]: cmd.append(script_posix)
- success = self.run_cmd_realtime(cmd, cwd=str(Path.cwd()))
- if self._is_cancelled:
- self.finished.emit(False, "构建已被中断。")
- return
+ success = self.run_cmd(cmd, cwd=Path.cwd().resolve().as_posix())
+ if self._is_cancelled: return self.finished.emit(False, "构建已被中断。")
- # 归档产物
- self.progress.emit("[阶段收尾] 归档产物...")
- if engine == "PyInstaller":
- src_out = Path.cwd() / "dist" / (f"{app_name}.exe" if self.params['onefile'] else app_name)
- elif engine == "Nuitka":
- src_out = self.temp_out_dir / (f"{app_name}.exe" if self.params['onefile'] else f"{app_name}.dist")
- elif engine == "cx_Freeze":
- # cx_Freeze 的产物是整个目录。若单独移出 exe 将缺失运行库。
- src_out = Path.cwd() / "dist" / app_name
+ self.progress.emit("[收尾] 归档可执行文件...")
+ cwd = Path.cwd().resolve()
+ if engine == "PyInstaller": src_out = cwd / "dist" / (f"{app_name}{ext}" if self.params['onefile'] else app_name)
+ elif engine == "Nuitka": src_out = self.temp_out_dir / (f"{app_name}{ext}" if self.params['onefile'] else f"{app_name}.dist")
+ elif engine == "cx_Freeze": src_out = cwd / "dist" / app_name
final_out = script_dir / src_out.name
if success and src_out.exists():
try:
- self.progress.emit(f"[产物归档] 提取至: {final_out.resolve()}")
if final_out.exists():
- robust_rmtree(final_out) if final_out.is_dir() else final_out.unlink(missing_ok=True)
- shutil.move(str(src_out), str(final_out))
- except Exception as e:
- self.progress.emit(f"[归档异常] {e}")
- else:
- self.progress.emit(f"[错误] 未找到产物: {src_out}")
-
- if self.params.get('clean_all'):
- self.progress.emit("[清理] 删除临时文件...")
- for p in [self.venv_dir, self.temp_workpath, self.temp_out_dir]:
- if p and p.exists(): robust_rmtree(p)
- robust_rmtree(Path.cwd() / "dist")
- for p in ["build", "__pycache__", f"{app_name}.build", f"{app_name}.dist", f"{app_name}.onefile-build"]:
- robust_rmtree(Path.cwd() / p)
- Path(f"{app_name}.spec").unlink(missing_ok=True)
- if self.params.get('version_file'):
- Path(self.params['version_file']).unlink(missing_ok=True)
-
- if success and final_out.exists():
- self.finished.emit(True, f"[构建成功] 应用程序已交付。\n路径: {final_out.resolve()}")
- else:
- self.finished.emit(False, "[构建失败] 请检查日志排查错误。")
+ if final_out.is_dir(): shutil.rmtree(final_out, ignore_errors=True)
+ else: final_out.unlink(missing_ok=True)
+ shutil.move(src_out.as_posix(), final_out.as_posix())
+ except Exception as e: self.progress.emit(f"[归档异常] 程序被占用: {e}")
+ else: self.progress.emit(f"[错误] 未找到程序: {src_out}")
+ if success and final_out.exists():
+ self.progress.emit("[收尾] 执行收尾指令...")
+ self.finished.emit(True, f"[构建成功] 程序路径: {final_out.resolve().as_posix()}")
+ else:
+ self.finished.emit(False, "[构建失败] 请检查日志。")
except Exception as e:
self.finished.emit(False, f"[系统异常] {str(e)}")
+ finally:
+ if is_temp and build_script_path and build_script_path.exists():
+ try: build_script_path.unlink()
+ except: pass
+
+ if self.params.get('clean_all'):
+ self.progress.emit("[清理] 抹除环境与沙盒...")
+ for p in [self.venv_dir, self.temp_workpath, self.temp_out_dir]:
+ if p and p.exists(): robust_rmtree(p)
+
+ cwd = Path.cwd().resolve()
+ app_name = self.params.get('app_name', 'app')
+ robust_rmtree(cwd / "dist")
+ for p in ["build", "__pycache__", f"{app_name}.build", f"{app_name}.dist", f"{app_name}.onefile-build"]:
+ robust_rmtree(cwd / p)
+ Path(cwd / f"{app_name}.spec").unlink(missing_ok=True)
+ if self.params.get('version_file'): Path(self.params['version_file']).unlink(missing_ok=True)
-# ======================== 界面核心控制层 ========================
+# ======================== 界面主窗口 ========================
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
- self.settings = QSettings("PyPack", "Universal")
self.script_path = ""
self.thread = None
+ self.current_state = "idle"
self.init_style()
- self._load_settings()
self.init_ui()
- self._load_window_icon()
def init_style(self):
- self.setWindowTitle("PyPack 1.0.1 - 自动化构建工具")
- self.setStyleSheet("""
- QMainWindow { background-color: #fdfdfd; }
- QLabel { color: #343a40; font-size: 9pt; }
- QGroupBox { border: 1px solid #e9ecef; border-radius: 4px; margin-top: 6px; padding: 10px 8px 6px 8px; color: #212529; }
- QGroupBox::title { subcontrol-origin: margin; left: 8px; padding: 0 3px; color: #6c757d; font-size: 9pt; }
-
- /* 统一输入控件与下拉框基础样式 */
- QLineEdit, QTextEdit, QComboBox {
- padding: 3px 6px;
- border: 1px solid #dee2e6;
- border-radius: 4px;
- font-size: 9pt;
- background: #ffffff;
- color: #212529;
- }
- QLineEdit:focus, QTextEdit:focus, QComboBox:focus {
- border-color: #86b7fe;
- }
-
- /* 下拉框细节美化,去除原生自带的灰色按钮边框与渐变 */
- QComboBox {
- padding-right: 22px;
- }
- QComboBox::drop-down {
- subcontrol-origin: padding;
- subcontrol-position: top right;
- width: 20px;
- border-left-width: 0px;
- border-top-right-radius: 4px;
- border-bottom-right-radius: 4px;
- }
- /* 采用优雅的 SVG 箭头代替原生灰色小三角 */
- QComboBox::down-arrow {
- image: url("data:image/svg+xml;utf8,");
- width: 10px;
- height: 10px;
- }
- /* 统一下拉选项菜单(QAbstractItemView)的边框与悬浮高亮样式 */
- QComboBox QAbstractItemView {
- border: 1px solid #dee2e6;
- border-radius: 4px;
- background-color: #ffffff;
- selection-background-color: #0d6efd;
- selection-color: #ffffff;
- outline: none;
- }
-
- QCheckBox { font-size: 9pt; padding: 1px 0; color: #343a40; spacing: 5px; }
- QCheckBox::indicator { width: 13px; height: 13px; border: 1px solid #adb5bd; border-radius: 2px; background: white; }
- QCheckBox::indicator:checked { background: #0d6efd; border-color: #0d6efd; image: url("data:image/svg+xml;utf8,"); }
- QCheckBox::indicator:hover { border-color: #6c757d; }
- QTabWidget::pane { border: 1px solid #e9ecef; background: #ffffff; border-radius: 4px; top: -1px; }
- QTabBar::tab { background: #f8f9fa; border: 1px solid #e9ecef; padding: 4px 10px; margin-right: 2px; border-top-left-radius: 4px; border-top-right-radius: 4px; color: #6c757d; font-size: 9pt; }
- QTabBar::tab:selected { background: #ffffff; border-bottom-color: #ffffff; color: #0d6efd; font-weight: bold; }
- """)
-
- def _load_settings(self):
- if self.settings.value("geometry"):
- self.restoreGeometry(self.settings.value("geometry"))
- else:
- self.setGeometry(150, 100, 620, 520)
-
- def closeEvent(self, event):
- if self.thread and self.thread.isRunning():
- self.thread.cancel()
- self.thread.wait()
- self.settings.setValue("geometry", self.saveGeometry())
- event.accept()
-
- def _load_window_icon(self):
- if getattr(sys, 'frozen', False):
- base_dir = Path(sys.executable).parent
- else:
- base_dir = Path(__file__).resolve().parent
+ self.setWindowTitle("PyPack 2.0")
+ self.setMinimumSize(580, 520)
+ self.resize(600, 560)
- # 多后缀不区分大小写检索,提高跨平台抗风险能力
- for name in ["ICON.ICO", "icon.ico", "logo.ico", "ICON.PNG", "icon.png"]:
- icon_path = base_dir / name
- if icon_path.exists():
- self.setWindowIcon(QIcon(str(icon_path)))
- break
+ icon_path = get_resource_path("icon.ico")
+ if os.path.exists(icon_path):
+ self.setWindowIcon(QIcon(icon_path))
+ elif getattr(sys, 'frozen', False):
+ provider = QFileIconProvider()
+ exe_icon = provider.icon(QFileInfo(sys.executable))
+ if not exe_icon.isNull(): self.setWindowIcon(exe_icon)
- def update_icon_preview(self, path):
- if path and Path(path).exists():
- pixmap = QPixmap(path)
- if not pixmap.isNull():
- self.icon_preview.setPixmap(pixmap.scaled(24, 24, Qt.KeepAspectRatio, Qt.SmoothTransformation))
- return
- self.icon_preview.clear()
+ self.setStyleSheet("""
+ QMainWindow { background-color: #ffffff; }
+ QTextEdit { border: 1px solid #e8eaed; border-radius: 8px; background-color: #f8f9fa; font-family: Consolas, monospace; font-size: 13px; color: #3c4043; padding: 10px; }
+ QStatusBar { background-color: #f8f9fa; color: #5f6368; border-top: 1px solid #e8eaed; padding: 5px; }
+ QStatusBar QLabel { color: #5f6368; font-size: 13px; padding: 2px; background: transparent; }
+ """)
+
+ self.icon_btn_style = """
+ QPushButton { background-color: #f1f3f4; border: 1px solid transparent; border-radius: 8px; }
+ QPushButton:hover { background-color: #e8eaed; }
+ QPushButton:pressed { background-color: #dadce0; }
+ """
+ self.primary_btn_style = """
+ QPushButton { background-color: #1A73E8; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; }
+ QPushButton:hover { background-color: #1B66C9; } QPushButton:pressed { background-color: #174EA6; }
+ """
+ self.danger_btn_style = """
+ QPushButton { background-color: #D93025; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; }
+ QPushButton:hover { background-color: #C5221F; } QPushButton:pressed { background-color: #A50E0E; }
+ """
+ self.success_btn_style = """
+ QPushButton { background-color: #1E8E3E; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; }
+ QPushButton:hover { background-color: #188038; } QPushButton:pressed { background-color: #137333; }
+ """
def init_ui(self):
- self.setMinimumSize(500, 440)
central = QWidget()
self.setCentralWidget(central)
- self.stacked = QStackedLayout(central)
+ self.stacked_layout = QStackedLayout(central)
self.main_panel = QWidget()
- main_lay = QVBoxLayout(self.main_panel)
- main_lay.setContentsMargins(10, 10, 10, 10)
- main_lay.setSpacing(6)
+ layout = QVBoxLayout(self.main_panel)
+ layout.setSpacing(15)
+ layout.setContentsMargins(20, 20, 20, 20)
self.drop_area = DropArea(self)
- self.drop_area.setFixedHeight(65)
self.drop_area.fileDropped.connect(self.on_script_selected)
- main_lay.addWidget(self.drop_area)
-
- self.tabs = QTabWidget()
- basic = QWidget()
- adv = QWidget()
- self.tabs.addTab(basic, "常规配置")
- self.tabs.addTab(adv, "高级选项")
-
- self.setup_basic(basic)
- self.setup_adv(adv)
- main_lay.addWidget(self.tabs)
+ layout.addWidget(self.drop_area, stretch=1)
+ self.log_container = QWidget()
+ log_lay = QVBoxLayout(self.log_container)
+ log_lay.setContentsMargins(0, 0, 0, 0)
self.log = QTextEdit()
self.log.setReadOnly(True)
- self.log.setStyleSheet("""
- QTextEdit {
- background-color: #f8f9fa;
- color: #495057;
- font-family: Consolas, "Courier New", monospace;
- font-size: 8.5pt;
- border: 1px solid #e9ecef;
- border-radius: 4px;
- padding: 4px;
- line-height: 1.2;
- }
- """)
- self.log.setFixedHeight(100)
- main_lay.addWidget(self.log)
+ self.log.setFixedHeight(120)
+ log_lay.addWidget(self.log)
+ self.log_container.hide()
+ layout.addWidget(self.log_container)
- btn_lay = QHBoxLayout()
- self.reset_btn = AnimatedButton("重置")
- self.reset_btn.setStyleSheet("QPushButton { background: #f8f9fa; color: #495057; border: 1px solid #dee2e6; padding: 4px 10px; border-radius: 4px; font-size: 9pt; } QPushButton:hover { background: #e9ecef; }")
- self.reset_btn.clicked.connect(self.reset_all)
+ btn_layout = QHBoxLayout()
+ btn_layout.setSpacing(12)
+ btn_layout.setContentsMargins(0, 5, 0, 0)
+
+ self.btn_left = AnimatedButton("")
+ self.btn_left.setFixedSize(44, 44)
+ self.btn_left.setStyleSheet(self.icon_btn_style)
+ self.btn_left.clicked.connect(self.on_left_btn_clicked)
+ btn_layout.addWidget(self.btn_left)
+
+ self.btn_main = AnimatedButton("")
+ self.btn_main.setFixedHeight(44)
+ self.btn_main.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ self.btn_main.clicked.connect(self.on_main_btn_clicked)
+ btn_layout.addWidget(self.btn_main)
+
+ self.btn_right = AnimatedButton("")
+ self.btn_right.setFixedSize(44, 44)
+ self.btn_right.setIcon(get_svg_icon('settings', "#5F6368"))
+ self.btn_right.setToolTip("构建配置")
+ self.btn_right.setStyleSheet(self.icon_btn_style)
+ self.btn_right.clicked.connect(self.show_settings)
+ btn_layout.addWidget(self.btn_right)
+
+ layout.addLayout(btn_layout)
+ self.stacked_layout.addWidget(self.main_panel)
+
+ self.settings_panel = SettingsPanel(self)
+ self.stacked_layout.addWidget(self.settings_panel)
+ self.stacked_layout.setCurrentWidget(self.main_panel)
- self.open_btn = AnimatedButton("打开输出目录")
- self.open_btn.setStyleSheet("QPushButton { background: #198754; color: white; border: none; padding: 4px 10px; border-radius: 4px; font-weight: bold; font-size: 9pt; } QPushButton:hover { background: #157347; }")
- self.open_btn.clicked.connect(self.open_dist)
- self.open_btn.setVisible(False)
-
- self.pack_btn = AnimatedButton("开始构建")
- self.pack_btn.setStyleSheet("QPushButton { background: #0d6efd; color: white; border: none; padding: 5px 16px; border-radius: 4px; font-weight: bold; font-size: 9pt; } QPushButton:hover { background: #0b5ed7; }")
- self.pack_btn.clicked.connect(self.toggle_pack)
-
- btn_lay.addWidget(self.reset_btn)
- btn_lay.addStretch()
- btn_lay.addWidget(self.open_btn)
- btn_lay.addWidget(self.pack_btn)
- main_lay.addLayout(btn_lay)
-
- self.stacked.addWidget(self.main_panel)
-
self.statusBar = self.statusBar()
- self.status_label = QLabel("就绪")
+ self.status_label = QLabel(" 状态: 就绪")
self.statusBar.addWidget(self.status_label)
- self.on_engine_changed(0)
+ self.update_ui_state("idle")
- def setup_basic(self, tab):
- lay = QVBoxLayout(tab)
- lay.setContentsMargins(10, 10, 10, 10)
- lay.setSpacing(6)
-
- form = QFormLayout()
- form.setSpacing(5)
- form.setContentsMargins(0, 0, 0, 0)
-
- self.engine_combo = QComboBox()
- self.engine_combo.addItems(["PyInstaller", "Nuitka", "cx_Freeze"])
- self.engine_combo.currentIndexChanged.connect(self.on_engine_changed)
- form.addRow("构建引擎:", self.engine_combo)
-
- # 添加入口脚本浏览按钮,极大程度改善无拖拽习惯时的选包体验
- self.script_edit = QLineEdit()
- self.script_edit.setPlaceholderText("可直接拖拽文件至上方或点击右侧浏览")
- script_btn = QPushButton("浏览...")
- script_btn.setCursor(Qt.PointingHandCursor)
- script_btn.setStyleSheet("QPushButton { padding: 3px 8px; border: 1px solid #dee2e6; border-radius: 4px; background: #f8f9fa; font-size: 9pt; } QPushButton:hover { background: #e9ecef; }")
- script_btn.clicked.connect(self.select_script)
- h_script = QHBoxLayout()
- h_script.setSpacing(6)
- h_script.addWidget(self.script_edit)
- h_script.addWidget(script_btn)
- form.addRow("入口脚本:", h_script)
-
- self.name_edit = QLineEdit()
- self.name_edit.setPlaceholderText("[可选] 留空则与入口脚本同名")
- form.addRow("输出名称:", self.name_edit)
-
- self.icon_edit = QLineEdit()
- self.icon_edit.textChanged.connect(self.update_icon_preview)
- self.icon_preview = QLabel()
- self.icon_preview.setFixedSize(24, 24)
- self.icon_preview.setScaledContents(True)
- self.icon_preview.setStyleSheet("border: 1px solid #dee2e6; border-radius: 2px;")
+ def update_ui_state(self, state):
+ self.current_state = state
- icon_btn = QPushButton("浏览...")
- icon_btn.setCursor(Qt.PointingHandCursor)
- icon_btn.setStyleSheet("QPushButton { padding: 3px 8px; border: 1px solid #dee2e6; border-radius: 4px; background: #f8f9fa; font-size: 9pt; } QPushButton:hover { background: #e9ecef; }")
- icon_btn.clicked.connect(self.select_icon)
+ self.btn_right.setEnabled(state != "building")
+ self.drop_area.setAcceptDrops(state != "building")
- h = QHBoxLayout()
- h.setSpacing(6)
- h.addWidget(self.icon_edit)
- h.addWidget(self.icon_preview)
- h.addWidget(icon_btn)
- form.addRow("应用图标:", h)
-
- lay.addLayout(form)
-
- checks_group = QGroupBox("打包选项")
- grid = QGridLayout(checks_group)
- grid.setContentsMargins(8, 8, 8, 8)
- grid.setSpacing(6)
- grid.setColumnStretch(0, 1)
- grid.setColumnStretch(1, 1)
-
- self.venv_check = QCheckBox("使用虚拟环境 (Venv)")
- self.venv_check.setChecked(True)
- self.onefile_check = QCheckBox("单文件模式 (OneFile)")
- self.onefile_check.setChecked(True)
- self.noconsole_check = QCheckBox("隐藏控制台 (GUI)")
- self.noconsole_check.setChecked(True)
- self.reqs_check = QCheckBox("自动解析依赖")
- self.reqs_check.setChecked(True)
- self.clean_all_check = QCheckBox("清理临时文件并归档")
- self.clean_all_check.setChecked(True)
-
- grid.addWidget(self.venv_check, 0, 0)
- grid.addWidget(self.noconsole_check, 0, 1)
- grid.addWidget(self.onefile_check, 1, 0)
- grid.addWidget(self.reqs_check, 1, 1)
- grid.addWidget(self.clean_all_check, 2, 0, 1, 2)
-
- lay.addWidget(checks_group)
- lay.addStretch()
-
- def setup_adv(self, tab):
- lay = QVBoxLayout(tab)
- lay.setContentsMargins(10, 10, 10, 10)
- lay.setSpacing(6)
-
- form = QFormLayout()
- form.setSpacing(5)
- form.setContentsMargins(0, 0, 0, 0)
-
- # PIP 源配置(修改马上通过 QSettings 保存,在打包时生效)
- self.pip_source_edit = QLineEdit()
- self.pip_source_edit.setPlaceholderText("默认: https://pypi.tuna.tsinghua.edu.cn/simple")
- self.pip_source_edit.setText(self.settings.value("pip_index_url", "https://pypi.tuna.tsinghua.edu.cn/simple"))
- self.pip_source_edit.textChanged.connect(lambda text: self.settings.setValue("pip_index_url", text))
- form.addRow("PIP 镜像源:", self.pip_source_edit)
-
- self.hidden_edit = QLineEdit()
- self.hidden_edit.setPlaceholderText("例如: pandas, PyQt5 (逗号分隔)")
- scan_btn = QPushButton("AST 扫描")
- scan_btn.setCursor(Qt.PointingHandCursor)
- scan_btn.setStyleSheet("QPushButton { padding: 3px 8px; border: 1px solid #dee2e6; border-radius: 4px; background: #f8f9fa; font-size: 9pt; } QPushButton:hover { background: #e9ecef; }")
- scan_btn.clicked.connect(self.auto_scan_hidden)
- h = QHBoxLayout()
- h.setSpacing(6)
- h.addWidget(self.hidden_edit)
- h.addWidget(scan_btn)
- form.addRow("隐式依赖:", h)
-
- self.add_data_edit = QLineEdit()
- self.add_data_edit.setPlaceholderText("源:目标 (例如: data/*:data)")
- add_res_btn = QPushButton("添加")
- add_res_btn.setCursor(Qt.PointingHandCursor)
- add_res_btn.setStyleSheet("QPushButton { padding: 3px 8px; border: 1px solid #dee2e6; border-radius: 4px; background: #f8f9fa; font-size: 9pt; } QPushButton:hover { background: #e9ecef; }")
- add_res_btn.clicked.connect(self.add_resource)
- h_res = QHBoxLayout()
- h_res.setSpacing(6)
- h_res.addWidget(self.add_data_edit)
- h_res.addWidget(add_res_btn)
- form.addRow("附加资源:", h_res)
-
- self.upx_check = QCheckBox("启用 UPX 压缩 (仅 PyInstaller)")
- form.addRow("", self.upx_check)
-
- self.info_group = QGroupBox("版本元数据 (Metadata)")
- info_lay = QFormLayout()
- info_lay.setContentsMargins(8, 8, 8, 8)
- info_lay.setSpacing(5)
- self.ver_ver = QLineEdit("1.0.1")
- self.ver_comp = QLineEdit("Developer Studio")
- self.ver_desc = QLineEdit("Windows Executable Application")
- info_lay.addRow("版本序列:", self.ver_ver)
- info_lay.addRow("发行公司:", self.ver_comp)
- info_lay.addRow("文件描述:", self.ver_desc)
- self.info_group.setLayout(info_lay)
-
- lay.addLayout(form)
- lay.addWidget(self.info_group)
- lay.addStretch()
-
- def on_engine_changed(self, index):
- engine = self.engine_combo.currentText()
- self.onefile_check.setVisible(engine in ("PyInstaller", "Nuitka"))
- self.upx_check.setVisible(engine == "PyInstaller")
- self.info_group.setVisible(engine != "cx_Freeze")
- if engine == "Nuitka":
- self.show_status("提示:Nuitka 编译可能触发杀软拦截,建议将路径加入白名单。", "info")
- else:
- self.show_status("引擎已切换", "info")
-
- def auto_scan_hidden(self):
- if not self.script_path:
- self.show_status("请先选择脚本", "error")
- return
- try:
- with open(self.script_path, encoding='utf-8', errors='ignore') as f:
- code = f.read()
+ if state in ("idle", "ready"):
+ is_log_open = self.log_container.isVisible()
+ icon_name = 'expand_less' if is_log_open else 'expand_more'
+ self.btn_left.setIcon(get_svg_icon(icon_name, "#5F6368"))
+ self.btn_left.setToolTip("显示/隐藏执行日志")
- # 使用 AST 解析依赖
- hidden = set()
- try:
- tree = ast.parse(code, filename=self.script_path)
- for node in ast.walk(tree):
- if isinstance(node, ast.Import):
- for alias in node.names:
- hidden.add(alias.name.split('.')[0])
- elif isinstance(node, ast.ImportFrom):
- if node.level == 0 and node.module:
- hidden.add(node.module.split('.')[0])
- except Exception:
- # 兼容未完成编写的代码
- imports = re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M)
- hidden = set(imports)
+ self.btn_main.setText(" 开始构建")
+ self.btn_main.setIcon(get_svg_icon('play', "white"))
+ self.btn_main.setStyleSheet(self.primary_btn_style)
+
+ elif state == "building":
+ is_log_open = self.log_container.isVisible()
+ icon_name = 'expand_less' if is_log_open else 'expand_more'
+ self.btn_left.setIcon(get_svg_icon(icon_name, "#5F6368"))
+ self.btn_left.setToolTip("显示/隐藏执行日志")
+
+ self.btn_main.setText(" 停止构建")
+ self.btn_main.setIcon(get_svg_icon('stop', "white"))
+ self.btn_main.setStyleSheet(self.danger_btn_style)
+
+ elif state in ("done", "failed"):
+ self.btn_left.setIcon(get_svg_icon('refresh', "#5F6368"))
+ self.btn_left.setToolTip("重置工作区")
+
+ if state == "done":
+ self.btn_main.setText(" 打开输出目录")
+ self.btn_main.setIcon(get_svg_icon('folder', "white"))
+ self.btn_main.setStyleSheet(self.success_btn_style)
+ else:
+ self.btn_main.setText(" 重新构建")
+ self.btn_main.setIcon(get_svg_icon('refresh', "white"))
+ self.btn_main.setStyleSheet(self.danger_btn_style)
- hidden = [m for m in hidden if m not in STD_LIBS]
- self.hidden_edit.setText(','.join(hidden))
- self.show_status(f"已捕获 {len(hidden)} 项外部模块", "success")
- except Exception as e:
- self.show_status(f"扫描失败: {e}", "error")
+ def on_left_btn_clicked(self):
+ if self.current_state in ("done", "failed"):
+ self.reset_all()
+ else:
+ self.toggle_log()
- def select_script(self):
- fp, _ = QFileDialog.getOpenFileName(self, "选择 Python 源代码文件", "", "Python Scripts (*.py)")
- if fp:
- self.on_script_selected(fp)
+ def on_main_btn_clicked(self):
+ if self.current_state in ("idle", "ready", "failed"):
+ self.start_pack()
+ elif self.current_state == "building":
+ self.cancel_pack()
+ elif self.current_state == "done":
+ self.open_dist()
+
+ def toggle_log(self):
+ if self.log_container.isVisible():
+ self.log_container.hide()
+ else:
+ self.log_container.show()
+ self.update_ui_state(self.current_state)
+
+ def show_settings(self):
+ self._animate_switch(self.settings_panel)
+
+ def show_main(self):
+ self._animate_switch(self.main_panel)
+
+ def _animate_switch(self, target_widget):
+ self.anim = QPropertyAnimation(self.stacked_layout.currentWidget(), b"geometry")
+ self.anim.setDuration(250)
+ self.anim.setEasingCurve(QEasingCurve.OutCubic)
+ self.anim.setStartValue(self.stacked_layout.currentWidget().geometry())
+ self.stacked_layout.setCurrentWidget(target_widget)
+ self.anim.setEndValue(target_widget.geometry())
+ self.anim.start()
def on_script_selected(self, path):
+ path = Path(path).resolve().as_posix()
+ try:
+ if b"__CLOUDSYNC_ENC__" in Path(path).read_bytes()[:1024]:
+ QMessageBox.critical(self, "加载失败", "脚本已被锁定!")
+ return
+ except: pass
+
self.script_path = path
- self.script_edit.setText(path)
- self.name_edit.setText(Path(path).stem)
- script_dir = Path(path).parent
+ self.settings_panel.name_edit.setText(Path(path).stem)
- # 支持不区分大小写的 icon 自动加载
+ script_dir = Path(path).parent
auto_icon = None
- for name in ["ICON.ICO", "icon.ico", "logo.ico"]:
- trial = script_dir / name
- if trial.exists():
- auto_icon = trial
- break
+
+ if self.settings_panel.auto_icon_check.isChecked():
+ for name in ["ICON.ICO", "icon.ico", "logo.ico", "icon.svg", "logo.svg"]:
+ trial = script_dir / name
+ if trial.exists():
+ auto_icon = trial
+ curr = self.settings_panel.icon_edit.text()
+ if not curr or not Path(curr).exists():
+ self.settings_panel.icon_edit.setText(trial.resolve().as_posix())
+ break
- if auto_icon:
- current_icon = self.icon_edit.text().strip()
- if not current_icon or not Path(current_icon).exists():
- self.icon_edit.setText(str(auto_icon))
-
- self.show_status(f"当前工程: {Path(path).name}", "success")
- self.drop_area.tip_label.setText(f"✅ 已装载工程: {os.path.basename(path)}")
- self.drop_area.tip_label.setStyleSheet("color: #198754; font-size: 9pt; font-weight: bold;")
+ self.drop_area.set_success(Path(path).name, custom_icon_path=auto_icon)
+ self.status_label.setText(f" 状态: 已锁定源文件 {Path(path).name}")
+
+ if not self.log_container.isVisible(): self.toggle_log()
+ self.log.clear()
+ self.append_log(f"已识别脚本: {path}")
+ self.update_ui_state("ready")
- def select_icon(self):
- p, _ = QFileDialog.getOpenFileName(self, "选择图标", "", "Icon Files (*.ico)")
- if p:
- self.icon_edit.setText(p)
-
- def add_resource(self):
- choice = QMessageBox.question(self, "添加资源", "请选择要添加的资源类型:\n“是” - 选择文件\n“否” - 选择文件夹",
- QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
- if choice == QMessageBox.Yes:
- files, _ = QFileDialog.getOpenFileNames(self, "选择资源文件", "", "All Files (*)")
- for f in files:
- default_dest = Path(f).name
- dest, ok = QInputDialog.getText(self, "目标路径", f"输入目标相对路径(默认为 {default_dest}):", text=default_dest)
- if ok:
- dest = dest.strip() if dest else default_dest
- src_dest = f"{f}:{dest}"
- current = self.add_data_edit.text().strip()
- if current:
- self.add_data_edit.setText(current + ", " + src_dest)
- else:
- self.add_data_edit.setText(src_dest)
- else:
- folder = QFileDialog.getExistingDirectory(self, "选择资源文件夹")
- if folder:
- default_dest = Path(folder).name
- dest, ok = QInputDialog.getText(self, "目标路径", f"输入目标相对路径(默认为 {default_dest}):", text=default_dest)
- if ok:
- dest = dest.strip() if dest else default_dest
- src_dest = f"{folder}:{dest}"
- current = self.add_data_edit.text().strip()
- if current:
- self.add_data_edit.setText(current + ", " + src_dest)
- else:
- self.add_data_edit.setText(src_dest)
-
- def toggle_pack(self):
+ def cancel_pack(self):
if self.thread and self.thread.isRunning():
self.thread.cancel()
- self.pack_btn.setText("开始构建")
- self.pack_btn.setStyleSheet("QPushButton { background: #0d6efd; color: white; border: none; padding: 5px 16px; border-radius: 4px; font-weight: bold; font-size: 9pt; } QPushButton:hover { background: #0b5ed7; }")
- self.show_status("已终止构建进程。", "error")
- else:
- self.start_pack()
+ self.status_label.setText(" 状态: 已被用户终止")
+ self.drop_area.stop_build_anim()
+ self.update_ui_state("ready")
def start_pack(self):
- # 兼容手动在文本框中直接填写路径的情况
- manual_path = self.script_edit.text().strip()
- if manual_path and Path(manual_path).exists():
- self.script_path = manual_path
-
if not self.script_path or not Path(self.script_path).exists():
- self.show_status("脚本路径无效", "error")
+ QMessageBox.warning(self, "提示", "请先加载需要打包的 Python 脚本!")
return
- app_name = self.name_edit.text().strip() or Path(self.script_path).stem
- engine = self.engine_combo.currentText()
+ sp = self.settings_panel
+ app_name = sp.name_edit.text().strip() or Path(self.script_path).stem
+ engine = sp.engine_combo.currentText()
- # 生成 PyInstaller 版本文件
version_file = None
- if engine == "PyInstaller" and self.ver_ver.text().strip():
+ if engine == "PyInstaller" and sp.ver_ver.text().strip():
try:
- content = f'''VSVersionInfo(ffi=FixedFileInfo(filevers=(1,0,0,0),prodvers=(1,0,0,0),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)),kids=[StringFileInfo([StringTable('040904B0',[StringStruct('CompanyName','{self.ver_comp.text()}'),StringStruct('FileDescription','{self.ver_desc.text()}'),StringStruct('FileVersion','{self.ver_ver.text()}'),StringStruct('ProductVersion','{self.ver_ver.text()}'),StringStruct('OriginalFilename','{app_name}.exe')])])])'''
- version_file = Path(f"{app_name}_version.txt")
+ content = f'''VSVersionInfo(ffi=FixedFileInfo(filevers=(1,0,0,0),prodvers=(1,0,0,0),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)),kids=[StringFileInfo([StringTable('040904B0',[StringStruct('CompanyName','{sp.ver_comp.text()}'),StringStruct('FileDescription','{sp.ver_desc.text()}'),StringStruct('FileVersion','{sp.ver_ver.text()}'),StringStruct('ProductVersion','{sp.ver_ver.text()}'),StringStruct('OriginalFilename','{app_name}.exe')])])])'''
+ version_file = Path(tempfile.gettempdir()) / f"pypack_{app_name}_version.txt"
version_file.write_text(content, encoding='utf-8')
- except:
- pass
+ except: pass
params = {
'engine': engine,
- 'script_path': Path(self.script_path),
+ 'script_path': self.script_path,
'app_name': app_name,
- 'onefile': self.onefile_check.isChecked() if engine != "cx_Freeze" else False,
- 'noconsole': self.noconsole_check.isChecked(),
- 'icon': self.icon_edit.text().strip(),
- 'use_reqs': self.reqs_check.isChecked(),
- 'hidden_imports': self.hidden_edit.text(),
- 'add_data': self.add_data_edit.text(),
- 'upx': self.upx_check.isChecked() if engine == "PyInstaller" else False,
- 'use_venv': self.venv_check.isChecked(),
- 'clean_all': self.clean_all_check.isChecked(),
- 'version_file': str(version_file) if version_file else None,
- 'ver_comp': self.ver_comp.text() if engine != "cx_Freeze" else "",
- 'ver_desc': self.ver_desc.text() if engine != "cx_Freeze" else "",
- 'ver_ver': self.ver_ver.text() if engine != "cx_Freeze" else "",
- 'pip_index_url': self.pip_source_edit.text().strip()
+ 'onefile': sp.onefile_check.isChecked() if engine != "cx_Freeze" else False,
+ 'noconsole': sp.noconsole_check.isChecked(),
+ 'icon': sp.icon_edit.text().strip(),
+ 'use_reqs': sp.reqs_check.isChecked(),
+ 'use_pipreqs': sp.pipreqs_check.isChecked(),
+ 'hidden_imports': sp.hidden_edit.text(),
+ 'add_data': sp.add_data_edit.text(),
+ 'upx': sp.upx_check.isChecked() if engine == "PyInstaller" else False,
+ 'use_venv': sp.venv_check.isChecked(),
+ 'clean_all': sp.clean_all_check.isChecked(),
+ 'version_file': version_file.as_posix() if version_file else None,
+ 'ver_comp': sp.ver_comp.text() if engine != "cx_Freeze" else "",
+ 'ver_desc': sp.ver_desc.text() if engine != "cx_Freeze" else "",
+ 'ver_ver': sp.ver_ver.text() if engine != "cx_Freeze" else "",
+ 'pip_index_url': sp.pip_source_edit.text().strip()
}
self.log.clear()
- self.open_btn.setVisible(False)
- self.pack_btn.setText("终止构建")
- self.pack_btn.setStyleSheet("QPushButton { background: #dc3545; color: white; border: none; padding: 5px 16px; border-radius: 4px; font-weight: bold; font-size: 9pt; } QPushButton:hover { background: #bb2d3b; }")
-
+ if not self.log_container.isVisible(): self.toggle_log()
+
self.thread = PackingThread(params)
self.thread.progress.connect(self.append_log)
self.thread.finished.connect(self.on_pack_finished)
self.thread.start()
- self.show_status(f"[{engine}] 构建任务执行中...", "info")
+
+ self.status_label.setText(f" 状态: 正在使用 {engine} 执行构建...")
+ self.update_ui_state("building")
+ self.drop_area.start_build_anim()
def on_pack_finished(self, success, msg):
- self.pack_btn.setText("开始构建")
- self.pack_btn.setStyleSheet("QPushButton { background: #0d6efd; color: white; border: none; padding: 5px 16px; border-radius: 4px; font-weight: bold; font-size: 9pt; } QPushButton:hover { background: #0b5ed7; }")
- self.append_log("\n" + "━"*60 + "\n" + msg)
+ self.append_log("\n" + "━"*50 + "\n" + msg)
+ self.drop_area.stop_build_anim()
if success:
- self.show_status("构建完成", "success")
- self.open_btn.setVisible(True)
+ icon_path = self.settings_panel.icon_edit.text().strip()
+ self.drop_area.show_success(icon_path)
+ self.status_label.setText(" 状态: 构建完成 ✅")
+ self.update_ui_state("done")
else:
- self.show_status("构建失败", "error")
+ self.drop_area.show_failure()
+ self.status_label.setText(" 状态: 构建失败 ❌")
+ self.update_ui_state("failed")
def open_dist(self):
- if self.clean_all_check.isChecked() and self.script_path:
- target_dir = Path(self.script_path).parent
- else:
- target_dir = Path("dist")
- if target_dir.exists():
+ if self.settings_panel.clean_all_check.isChecked() and self.script_path:
+ target = Path(self.script_path).parent
+ else: target = Path.cwd() / "dist"
+ if target.exists():
try:
- os.startfile(target_dir)
+ if os.name == 'nt': os.startfile(target)
+ elif sys.platform == 'darwin': subprocess.call(('open', target.as_posix()))
+ else: subprocess.call(('xdg-open', target.as_posix()))
except: pass
def reset_all(self):
self.script_path = ""
- for w in [self.script_edit, self.name_edit, self.icon_edit, self.hidden_edit, self.add_data_edit]:
- w.clear()
- self.icon_preview.clear()
+ self.settings_panel.name_edit.clear()
+ self.settings_panel.icon_edit.clear()
+ self.settings_panel.hidden_edit.clear()
+ self.settings_panel.add_data_edit.clear()
self.log.clear()
+ if self.log_container.isVisible(): self.toggle_log()
self.drop_area.reset()
- self.open_btn.setVisible(False)
- self.show_status("工作区已重置", "success")
-
- def show_status(self, text, typ="info"):
- colors = {"success":"#198754", "error":"#dc3545", "info":"#6c757d"}
- self.status_label.setStyleSheet(f"color: {colors.get(typ, '#6c757d')}; font-weight: bold;")
- self.status_label.setText(text)
+ self.status_label.setText(" 状态: 工作区已重置")
+ self.update_ui_state("idle")
def append_log(self, msg):
self.log.append(msg)
- cursor = self.log.textCursor()
- cursor.movePosition(QTextCursor.End)
- self.log.setTextCursor(cursor)
+ self.log.moveCursor(QTextCursor.End)
-def main():
- if hasattr(Qt, 'AA_EnableHighDpiScaling'):
- QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
- if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
- QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
+if __name__ == "__main__":
+ if hasattr(Qt, 'HighDpiScaleFactorRoundingPolicy'):
+ QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
+ QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
+ QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
app = QApplication(sys.argv)
app.setStyle("Fusion")
@@ -984,6 +1407,3 @@ def main():
win = MainWindow()
win.show()
sys.exit(app.exec_())
-
-if __name__ == "__main__":
- main()