#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Python脚本打包工具 - PyQt6完整版 """
import sys, json, time, ast, shutil, subprocess, threading, multiprocessing
import tempfile, webbrowser, fnmatch, glob, ctypes, platform as sys_platform
import urllib.request, zipfile, traceback
from pathlib import Path
from datetime import datetime
from collections import Counter
import os, re, stat,unicodedata,textwrap,difflib,io,functools,psutil,datetime
from contextlib import redirect_stdout, redirect_stderr
from PyQt6.QtCore import *
from PyQt6.QtGui import *
from PyQt6.QtWidgets import *
try:
from PyQt6.QtMultimedia import QMediaPlayer
from PyQt6.QtMultimediaWidgets import QVideoWidget
MEDIA_AVAILABLE = True
except (ImportError, AttributeError):
MEDIA_AVAILABLE = False
VERSION = "7.0.0"
BUILD_DATE = datetime.datetime.now().strftime("%Y-%m-%d")
AUTHOR = "wcj6376"
# ==================== 常量定义 ====================
MIRROR = "https://pypi.tuna.tsinghua.edu.cn/simple"
# 国内镜像源列表
MIRRORS = ["https://pypi.tuna.tsinghua.edu.cn/simple","https://mirrors.aliyun.com/pypi/simple/","https://pypi.mirrors.ustc.edu.cn/simple/"]
STANDARD_LIBS = frozenset({
'abc','argparse','array','ast','asyncio','atexit','base64','bdb','binascii',
'bisect','builtins','bz2','calendar','cgi','cgitb','chunk','cmath','cmd',
'code','codecs','codeop','collections','colorsys','compileall','concurrent',
'configparser','contextlib','contextvars','copy','copyreg','csv','ctypes',
'dataclasses','datetime','dbm','decimal','difflib','dis','email','encodings',
'enum','faulthandler','fnmatch','fractions','ftplib','functools','fcntl','gc',
'getopt','getpass','gettext','glob','graphlib','gzip','hashlib','heapq',
'hmac','html','http','idlelib','imaplib','imghdr','importlib','inspect',
'io','ipaddress','itertools','json','keyword','linecache','locale','logging',
'lzma','mailbox','mailcap','marshal','math','mimetypes','mmap','modulefinder','msvcrt',
'multiprocessing','netrc','nis','nntplib','numbers','operator','optparse',
'os','ossaudiodev','pathlib','pdb','pickle','pickletools','pipes','pkgutil',
'platform','plistlib','poplib','posix','posixpath','pprint','profile','pstats',
'pty','pwd','py_compile','pyclbr','pydoc','queue','quopri','random','re',
'readline','reprlib','resource','rlcompleter','runpy','sched','secrets',
'select','selectors','shelve','shlex','shutil','signal','site','smtpd',
'smtplib','sndhdr','socket','socketserver','spwd','sqlite3','ssl','stat',
'statistics','string','stringprep','struct','subprocess','sunau','symtable',
'sys','sysconfig','syslog','tabnanny','tarfile','telnetlib','tempfile',
'termios','test','textwrap','threading','time','timeit','tkinter','token',
'tokenize','trace','traceback','tracemalloc','tty','turtle','types','typing',
'unicodedata','unittest','urllib','uu','uuid','venv','warnings','wave',
'weakref','webbrowser','winreg','wsgiref','xml','xmlrpc','zipapp','zipfile',
'zipimport','zlib','_thread','__future__','zoneinfo','tomllib','typing_extensions'
})
MODULE_TO_PACKAGE = {
'cv2':'opencv-python','PIL':'Pillow','skimage':'scikit-image','sklearn':'scikit-learn',
'bs4':'beautifulsoup4','yaml':'PyYAML','Image':'Pillow','ImageDraw':'Pillow',
'pyautogui':'PyAutoGUI','wx':'wxPython','qtpy':'QtPy','PySide2':'PySide2',
'PySide6':'PySide6','PyQt5':'PyQt5','PyQt6':'PyQt6','dateutil':'python-dateutil',
'dotenv':'python-dotenv','jwt':'PyJWT','lxml':'lxml','OpenGL':'PyOpenGL',
'redis':'redis','requests':'requests','selenium':'selenium','sqlalchemy':'SQLAlchemy',
'matplotlib':'matplotlib','numpy':'numpy','pandas':'pandas','scipy':'scipy',
'torch':'torch','tensorflow':'tensorflow','flask':'Flask','django':'Django',
'fastapi':'fastapi','tornado':'tornado','aiohttp':'aiohttp','grpc':'grpcio',
'protobuf':'protobuf','pydantic':'pydantic','typer':'typer','rich':'rich',
'click':'click','jinja2':'Jinja2','markupsafe':'MarkupSafe','werkzeug':'Werkzeug',
'itsdangerous':'itsdangerous','win32com': 'pywin32','LibreHardwareMonitor': 'PyLibreHardwareMonitor',
}
EXCLUDE_PACKAGES = frozenset({
'_pytest',
'astroid', 'asttokens', 'autopep8',
'backcall', 'black', 'build',
'charset_normalizer', 'coverage', 'Cython', 'cython',
'debugpy', 'decorator', 'distribute',
'executing',
'fancycompleter', 'flake8',
'ipykernel', 'ipython', 'ipywidgets', 'isort',
'jedi', 'jupyter', 'jupyter_client', 'jupyter_core', 'jupyterlab',
'matplotlib-inline', 'mccabe', 'mock', 'module', 'mypy', 'mypy_extensions', 'mypyc',
'nbconvert', 'nbformat', 'nose', 'notebook','numpy',
'packaging', 'pathspec', 'pdbpp', 'pep517', 'pip', 'pkg_resources', 'platformdirs',
'pluggy', 'prompt_toolkit', 'ptpython', 'pure_eval', 'py', 'pycodestyle', 'pyflakes',
'pygments', 'pyi_hooks', 'pyi_hooks_contrib', 'pyinstaller', 'pyinstaller-hooks-contrib',
'pylint', 'pyproject_hooks', 'pywin32_ctypes', 'pytest','pywin32'
'qtconsole',
'setuptools', 'stack_data',
'test', 'tests', 'tox', 'traitlets', 'typed_ast', 'typeshed_client',
'unittest2',
'venv', 'virtualenv',
'wcwidth', 'wheel', 'wmctrl','win32', 'win32con'
'yaml', 'pyyaml',
})
class PythonInstallWorker(QThread):
"""后台静默安装 Python - 完全异步版"""
finished_signal = pyqtSignal(bool, str)
def __init__(self):
super().__init__()
self._cancel = False
self._progress = 0
def cancel(self):
self._cancel = True
def run(self):
import urllib.request
import ssl
import tempfile
import os
import subprocess
import time
import sys
try:
version = "3.12.10"
temp_dir = tempfile.gettempdir()
has_python, version_str = check_python_installed()
if has_python:
self.finished_signal.emit(True, f"Python {version_str} 已安装")
return
if sys.platform == 'win32':
filename = f"python-{version}-amd64.exe"
mirrors = [
f"https://www.python.org/ftp/python/{version}/python-{version}-amd64.exe",
f"https://mirrors.tuna.tsinghua.edu.cn/python/{version}/python-{version}-amd64.exe",
f"https://mirrors.aliyun.com/python/{version}/python-{version}-amd64.exe",
f"https://mirrors.ustc.edu.cn/python/{version}/python-{version}-amd64.exe",
]
elif sys.platform == 'darwin':
filename = f"python-{version}-macos11.pkg"
mirrors = [
f"https://www.python.org/ftp/python/{version}/python-{version}-macos11.pkg",
f"https://mirrors.tuna.tsinghua.edu.cn/python/{version}/python-{version}-macos11.pkg",
]
else:
self.finished_signal.emit(False, "Linux请使用包管理器安装Python")
return
installer_path = os.path.join(temp_dir, filename)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
downloaded = False
last_error = ""
for i, url in enumerate(mirrors):
if self._cancel:
self.finished_signal.emit(False, "已取消")
return
try:
self.finished_signal.emit(False, f"下载中... 尝试镜像 {i+1}/{len(mirrors)}")
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
with urllib.request.urlopen(req, context=ssl_context, timeout=60) as response:
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
if os.path.exists(installer_path):
if total_size > 0 and os.path.getsize(installer_path) == total_size:
downloaded = True
break
with open(installer_path, 'wb') as f:
while not self._cancel:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
downloaded_size += len(chunk)
if total_size > 0:
progress = int(downloaded_size * 100 / total_size)
if progress != self._progress:
self._progress = progress
self.finished_signal.emit(False, f"下载中... {progress}%")
if os.path.exists(installer_path):
file_size = os.path.getsize(installer_path)
if file_size > 5 * 1024 * 1024:
downloaded = True
self.finished_signal.emit(False, f"下载完成 ({file_size // 1024 // 1024}MB)")
break
else:
os.remove(installer_path)
last_error = f"文件太小 ({file_size} bytes)"
except Exception as e:
last_error = str(e)
continue
if self._cancel:
self.finished_signal.emit(False, "已取消")
return
if not downloaded:
self.finished_signal.emit(False, f"下载失败: {last_error}")
return
self.finished_signal.emit(False, "安装中... 请稍候")
if sys.platform == 'win32':
cmd = [
installer_path,
'/quiet',
'InstallAllUsers=1',
'PrependPath=1',
'Include_doc=0',
'Include_tcltk=0',
'Include_test=0',
'Include_tools=0',
'Include_pip=1',
'Include_setuptools=1',
'Include_symbols=0',
'Include_debug=0',
'InstallLauncherAllUsers=1',
]
elif sys.platform == 'darwin':
cmd = ['sudo', 'installer', '-pkg', installer_path, '-target', '/']
else:
self.finished_signal.emit(False, "不支持的操作系统")
return
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
timeout_count = 0
while process.poll() is None:
if self._cancel:
process.terminate()
self.finished_signal.emit(False, "已取消")
return
timeout_count += 1
if timeout_count > 600:
process.terminate()
self.finished_signal.emit(False, "安装超时")
return
if timeout_count % 30 == 0:
progress = min(95, 50 + timeout_count // 6)
self.finished_signal.emit(False, f"安装中... {progress}%")
self.msleep(500)
try:
os.remove(installer_path)
except:
pass
if process.returncode == 0:
self.finished_signal.emit(False, "验证安装...")
time.sleep(3)
for attempt in range(5):
has_python, version_str = check_python_installed()
if has_python:
self.finished_signal.emit(True, f"Python {version_str} 安装成功!")
return
time.sleep(2)
self.finished_signal.emit(False, "安装完成但验证失败,请手动安装")
else:
self.finished_signal.emit(False, f"安装失败 (返回码: {process.returncode})")
except Exception as e:
import traceback
self.finished_signal.emit(False, f"错误: {str(e)}")
def check_python_installed():
"""检测系统是否安装了 Python"""
import subprocess
import sys
import os
import shutil
import glob
IS_FROZEN = getattr(sys, 'frozen', False)
def is_valid_system_python(path):
if not path or not os.path.exists(path):
return False
if IS_FROZEN:
try:
if os.path.samefile(path, sys.executable):
return False
except:
if path.lower() == sys.executable.lower():
return False
path_lower = path.lower()
if '_mei' in path_lower or 'onefile_' in path_lower or 'nuitka' in path_lower:
return False
try:
if sys.platform == 'win32':
result = subprocess.run(
[path, '--version'],
capture_output=True, text=True, timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW
)
else:
result = subprocess.run(
[path, '--version'],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
output = result.stdout + result.stderr
return 'Python' in output
except:
pass
return False
def get_python_version(path):
"""获取Python版本"""
try:
if sys.platform == 'win32':
result = subprocess.run(
[path, '--version'],
capture_output=True, text=True, timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW
)
else:
result = subprocess.run(
[path, '--version'],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
output = result.stdout.strip() or result.stderr.strip()
return output
except:
pass
return None
if sys.platform == 'win32':
try:
result = subprocess.run(
['py', '--version'],
capture_output=True, text=True, timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW
)
if result.returncode == 0:
version = result.stdout.strip() or result.stderr.strip()
return True, version
except:
pass
try:
result = subprocess.run(
['py', '-c', 'import sys; print(sys.executable)'],
capture_output=True, text=True, timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW
)
if result.returncode == 0:
py_path = result.stdout.strip()
if py_path and is_valid_system_python(py_path):
version = get_python_version(py_path)
if version:
return True, version
except:
pass
try:
result = subprocess.run(
['where', 'python'],
capture_output=True, text=True, timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
py_path = line.strip()
if is_valid_system_python(py_path):
version = get_python_version(py_path)
if version:
return True, version
except:
pass
try:
import winreg
for root in [winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE]:
try:
key = winreg.OpenKey(root, r'Software\Python\PythonCore')
i = 0
while True:
try:
version_key = winreg.EnumKey(key, i)
if version_key.startswith('3.'):
try:
install_key = winreg.OpenKey(key, fr'{version_key}\InstallPath')
install_path, _ = winreg.QueryValueEx(install_key, '')
if install_path:
py_path = os.path.join(install_path, 'python.exe')
if os.path.exists(py_path) and is_valid_system_python(py_path):
version = get_python_version(py_path)
if version:
return True, version
except:
pass
i += 1
except WindowsError:
break
except:
pass
except:
pass
for cmd in ['python3', 'python']:
py_path = shutil.which(cmd)
if py_path and is_valid_system_python(py_path):
version = get_python_version(py_path)
if version:
return True, version
if sys.platform == 'win32':
username = os.environ.get('USERNAME', '')
search_patterns = [
r'C:\Python3*',
r'C:\Python3*\python.exe',
rf'C:\Users\{username}\AppData\Local\Programs\Python\Python3*\python.exe',
r'C:\Program Files\Python3*\python.exe',
r'C:\Program Files (x86)\Python3*\python.exe',
]
for pattern in search_patterns:
for path in glob.glob(pattern):
if os.path.isfile(path) and path.endswith('python.exe'):
if is_valid_system_python(path):
version = get_python_version(path)
if version:
return True, version
elif os.path.isdir(path):
exe_path = os.path.join(path, 'python.exe')
if os.path.exists(exe_path) and is_valid_system_python(exe_path):
version = get_python_version(exe_path)
if version:
return True, version
elif sys.platform == 'darwin':
paths_to_check = [
'/usr/local/bin/python3',
'/usr/bin/python3',
'/opt/homebrew/bin/python3',
'/Library/Frameworks/Python.framework/Versions/3.*/bin/python3',
]
for pattern in paths_to_check:
for path in glob.glob(pattern):
if os.path.exists(path) and is_valid_system_python(path):
version = get_python_version(path)
if version:
return True, version
else:
paths_to_check = [
'/usr/bin/python3',
'/usr/local/bin/python3',
'/usr/bin/python',
'/opt/python3/bin/python3',
]
for path in paths_to_check:
if os.path.exists(path) and is_valid_system_python(path):
version = get_python_version(path)
if version:
return True, version
return False, None
def ensure_python_on_startup():
"""启动时检测 Python"""
import sys
import os
if not getattr(sys, 'frozen', False):
return True
has_python, version = check_python_installed()
if has_python:
print(f"[Main] 检测到系统Python: {version}")
return True
print("[Main] 未检测到系统Python,后台静默安装...")
if sys.platform == "win32":
try:
import ctypes
if not ctypes.windll.shell32.IsUserAnAdmin():
print("[Main] 警告: 非管理员模式,安装可能失败")
except:
pass
from PyQt6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
app.setStyle('Fusion')
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton
from PyQt6.QtCore import QTimer, Qt
tip_widget = QWidget(None, Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint)
tip_widget.setWindowTitle("安装 Python")
tip_widget.setFixedSize(380, 140)
tip_widget.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
layout = QVBoxLayout(tip_widget)
layout.setSpacing(8)
status_label = QLabel("⏳ 正在后台静默安装 Python 3.12.10...")
status_label.setStyleSheet("font-size: 13px; font-weight: bold;")
status_label.setWordWrap(True)
layout.addWidget(status_label)
progress_bar = QProgressBar()
progress_bar.setRange(0, 100)
progress_bar.setValue(0)
progress_bar.setTextVisible(True)
layout.addWidget(progress_bar)
btn_layout = QHBoxLayout()
btn_layout.addStretch()
hide_btn = QPushButton("最小化到托盘")
hide_btn.setFixedWidth(100)
btn_layout.addWidget(hide_btn)
cancel_btn = QPushButton("取消")
cancel_btn.setFixedWidth(60)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
tip_widget.show()
worker = PythonInstallWorker()
install_success = False
def on_progress(success, msg):
nonlocal install_success
if success:
install_success = True
status_label.setText("✅ " + msg)
progress_bar.setValue(100)
hide_btn.setEnabled(False)
cancel_btn.setEnabled(False)
cancel_btn.setText("重启")
cancel_btn.clicked.connect(lambda: restart_app())
QTimer.singleShot(3000, tip_widget.close)
else:
if "下载" in msg or "安装" in msg:
status_label.setText("⏳ " + msg)
import re
match = re.search(r'(\d+)%', msg)
if match:
progress_bar.setValue(int(match.group(1)))
else:
status_label.setText("⏳ " + msg)
cancel_btn.setText("关闭")
cancel_btn.clicked.connect(tip_widget.close)
def on_hide():
"""最小化到托盘或隐藏窗口"""
tip_widget.hide()
print("[Main] Python安装已在后台继续...")
def on_cancel():
"""取消安装"""
if worker.isRunning():
worker.cancel()
worker.wait()
tip_widget.close()
def restart_app():
"""重启程序"""
import subprocess
import sys
tip_widget.close()
subprocess.Popen([sys.executable] + sys.argv)
sys.exit(0)
worker.finished_signal.connect(on_progress)
hide_btn.clicked.connect(on_hide)
cancel_btn.clicked.connect(on_cancel)
worker.start()
return True
def patch_subprocess_hide_window():
"""Monkey Patch: 所有 subprocess 调用默认隐藏 cmd 窗口(Windows)"""
import subprocess
import sys
if sys.platform == "win32":
_orig_run = subprocess.run
_orig_popen = subprocess.Popen
_orig_call = subprocess.call
_orig_check_call = subprocess.check_call
_orig_check_output = subprocess.check_output
CREATE_NO_WINDOW = 0x08000000
def _patched_run(*args, **kwargs):
if 'creationflags' not in kwargs:
kwargs['creationflags'] = CREATE_NO_WINDOW
return _orig_run(*args, **kwargs)
def _patched_popen(*args, **kwargs):
if 'creationflags' not in kwargs:
kwargs['creationflags'] = CREATE_NO_WINDOW
return _orig_popen(*args, **kwargs)
def _patched_call(*args, **kwargs):
if 'creationflags' not in kwargs:
kwargs['creationflags'] = CREATE_NO_WINDOW
return _orig_call(*args, **kwargs)
def _patched_check_call(*args, **kwargs):
if 'creationflags' not in kwargs:
kwargs['creationflags'] = CREATE_NO_WINDOW
return _orig_check_call(*args, **kwargs)
def _patched_check_output(*args, **kwargs):
if 'creationflags' not in kwargs:
kwargs['creationflags'] = CREATE_NO_WINDOW
return _orig_check_output(*args, **kwargs)
subprocess.run = _patched_run
subprocess.Popen = _patched_popen
subprocess.call = _patched_call
subprocess.check_call = _patched_check_call
subprocess.check_output = _patched_check_output
def get_short_path(self, path):
"""获取 Windows 短路径(8.3格式),如果失败则返回原路径"""
if sys.platform != 'win32':
return path
try:
import ctypes
GetShortPathName = ctypes.windll.kernel32.GetShortPathNameW
buffer_len = GetShortPathName(path, None, 0)
if buffer_len == 0:
return path
buffer = ctypes.create_unicode_buffer(buffer_len)
GetShortPathName(path, buffer, buffer_len)
return buffer.value if buffer.value else path
except Exception:
return path
def show_msg(parent, title, text, timeout=0, buttons='ok'):
"""
通用消息框(PyQt6)调用示例:
show_msg(self, "提示", "操作完成")
show_msg(self, "完成", "打包成功!", 3)
if show_msg(self, "确认", "是否继续?", 3, 'yes_no'):
self.do_continue() # 点击"是" 或 超时 → 执行
else:
print("取消") # 点击"否" → 跳过
if show_msg(self, "提示", "确定要删除吗?", 5, 'ok_cancel'):
self.do_delete() # 点击"确定" 或 超时 → 执行
else:
print("取消删除")
show_msg(None, "后台任务", "完成!", 2)
QMessageBox.critical(self, "错误", "打包失败!")
"""
from PyQt6.QtWidgets import QMessageBox
from PyQt6.QtCore import QTimer
btn_map = {
'ok': QMessageBox.StandardButton.Ok,
'ok_cancel': QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
'yes_no': QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
}
msg = QMessageBox(parent)
msg.setWindowTitle(title)
msg.setText(text)
msg.setIcon(QMessageBox.Icon.Information)
msg.setStandardButtons(btn_map.get(buttons, QMessageBox.StandardButton.Ok))
if timeout > 0:
remaining = timeout
msg.setText(f"{text}\n\n⏱️ {remaining}秒后自动...")
timer = QTimer(msg)
def update():
nonlocal remaining
remaining -= 1
if remaining > 0:
msg.setText(f"{text}\n\n⏱️ {remaining}秒后自动...")
else:
timer.stop()
timer.timeout.connect(update)
timer.start(1000)
QTimer.singleShot(timeout * 1000, msg.accept)
result = msg.exec()
if buttons == 'ok':
return True
elif buttons == 'ok_cancel':
return result == QMessageBox.StandardButton.Ok
elif buttons == 'yes_no':
return result == QMessageBox.StandardButton.Yes
return False
class PythonHighlighter(QSyntaxHighlighter):
"""Python 语法高亮器"""
def __init__(self, parent=None):
super().__init__(parent)
keyword_format = QTextCharFormat()
keyword_format.setForeground(QColor("#569CD6"))
keyword_format.setFontWeight(QFont.Weight.Bold)
keywords = [
'def', 'class', 'import', 'from', 'return', 'if', 'elif', 'else', 'for',
'while', 'try', 'except', 'finally', 'with', 'as', 'pass', 'break',
'continue', 'lambda', 'yield', 'assert', 'raise', 'del', 'global',
'nonlocal', 'True', 'False', 'None', 'and', 'or', 'not', 'is', 'in',
'async', 'await', '__init__', '__name__', '__main__', '__file__'
]
self.rules = []
for kw in keywords:
pattern = QRegularExpression(r'\b' + kw + r'\b')
self.rules.append((pattern, keyword_format))
string_format = QTextCharFormat()
string_format.setForeground(QColor("#CE9178"))
self.rules.append((QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"'), string_format))
self.rules.append((QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'"), string_format))
comment_format = QTextCharFormat()
comment_format.setForeground(QColor("#6A9955"))
self.rules.append((QRegularExpression(r'#.*'), comment_format))
number_format = QTextCharFormat()
number_format.setForeground(QColor("#B5CEA8"))
self.rules.append((QRegularExpression(r'\b[0-9]+\b'), number_format))
def highlightBlock(self, text):
for pattern, fmt in self.rules:
iterator = pattern.globalMatch(text)
while iterator.hasNext():
match = iterator.next()
self.setFormat(match.capturedStart(), match.capturedLength(), fmt)
class CodePreviewDialog(QDialog):
"""代码修复预览对话框 - 左右双栏对比"""
skip_preview = False
def __init__(self, parent, original_content, new_content, changes, file_path, backup_path):
super().__init__(parent)
self.setWindowTitle("代码修复预览")
self.setModal(True)
# ===== 获取屏幕尺寸并设置窗口大小 =====
screen = QApplication.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
window_width = int(screen_width * 0.9)
window_height = int(screen_height * 0.8)
self.setMinimumSize(int(screen_width * 0.5), int(screen_height * 0.5))
self.resize(window_width, window_height)
self.original_content = original_content
self.new_content = new_content
self.changes = changes
self.file_path = file_path
self.backup_path = backup_path
self.left_modified = False
self.right_modified = False
self._setup_ui()
self._show_diff()
self.setStyleSheet("")
def _setup_ui(self):
layout = QVBoxLayout(self)
# 顶部信息
info = QHBoxLayout()
info.addWidget(QLabel(f"📄 文件: {os.path.basename(self.file_path)}"))
info.addStretch()
info.addWidget(QLabel(f"📝 修改: {len(self.changes)} 处"))
layout.addLayout(info)
# 对比区域
splitter = QSplitter(Qt.Orientation.Horizontal)
# === 左侧:原始代码 ===
left_widget = QWidget()
left_layout = QVBoxLayout(left_widget)
left_title = QLabel("🔴 原始代码")
left_title.setStyleSheet("font-weight: bold; color: #c0392b; font-size: 12px;")
left_layout.addWidget(left_title)
self.left_edit = QPlainTextEdit()
self.left_edit.setFont(QFont("Consolas", 11))
self.left_edit.setStyleSheet("""
QPlainTextEdit {
background-color: #fdf2f2;
color: #1a0000;
border: 2px solid #e74c3c;
border-radius: 6px;
padding: 8px;
selection-background-color: #ffcccc;
}
""")
self.left_edit.textChanged.connect(lambda: self._on_edit("left"))
left_layout.addWidget(self.left_edit)
splitter.addWidget(left_widget)
# === 右侧:修复后代码 ===
right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_title = QLabel("🟢 修复后代码")
right_title.setStyleSheet("font-weight: bold; color: #27ae60; font-size: 12px;")
right_layout.addWidget(right_title)
self.right_edit = QPlainTextEdit()
self.right_edit.setFont(QFont("Consolas", 11))
self.right_edit.setStyleSheet("""
QPlainTextEdit {
background-color: #f0faf0;
color: #001a00;
border: 2px solid #2ecc71;
border-radius: 6px;
padding: 8px;
selection-background-color: #a8e6cf;
}
""")
self.right_edit.textChanged.connect(lambda: self._on_edit("right"))
right_layout.addWidget(self.right_edit)
splitter.addWidget(right_widget)
splitter.setSizes([450, 450])
layout.addWidget(splitter, stretch=1)
# 修改列表
layout.addWidget(QLabel("📋 修改详情:"))
self.changes_list = QListWidget()
self.changes_list.setMaximumHeight(120)
self.changes_list.setStyleSheet("""
QListWidget {
background-color: #f8f9fa;
color: #212529;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 11px;
}
QListWidget::item {
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
}
QListWidget::item:selected {
background-color: #007bff;
color: white;
}
""")
for change in self.changes:
self.changes_list.addItem(change)
layout.addWidget(self.changes_list)
# 底部按钮
btn_layout = QHBoxLayout()
btn_layout.setSpacing(8)
left_btns = QHBoxLayout()
self.save_left_btn = QPushButton("💾 保存左侧")
self.save_left_btn.setEnabled(False)
self.save_left_btn.setStyleSheet("background: #e74c3c; color: white; font-weight: bold; padding: 6px 14px; border-radius: 4px;")
self.save_left_btn.clicked.connect(lambda: self._save_side("left"))
left_btns.addWidget(self.save_left_btn)
self.revert_left_btn = QPushButton("↩️ 还原左侧")
self.revert_left_btn.setStyleSheet("background: #95a5a6; color: white; padding: 6px 14px; border-radius: 4px;")
self.revert_left_btn.clicked.connect(lambda: self._revert_side("left"))
left_btns.addWidget(self.revert_left_btn)
btn_layout.addLayout(left_btns)
btn_layout.addStretch()
right_btns = QHBoxLayout()
self.save_right_btn = QPushButton("💾 保存右侧")
self.save_right_btn.setEnabled(False)
self.save_right_btn.setStyleSheet("background: #27ae60; color: white; font-weight: bold; padding: 6px 14px; border-radius: 4px;")
self.save_right_btn.clicked.connect(lambda: self._save_side("right"))
right_btns.addWidget(self.save_right_btn)
self.revert_right_btn = QPushButton("↩️ 还原右侧")
self.revert_right_btn.setStyleSheet("background: #95a5a6; color: white; padding: 6px 14px; border-radius: 4px;")
self.revert_right_btn.clicked.connect(lambda: self._revert_side("right"))
right_btns.addWidget(self.revert_right_btn)
btn_layout.addLayout(right_btns)
btn_layout.addStretch()
self.btn_apply = QPushButton("✅ 应用修复")
self.btn_apply.setStyleSheet("background: #2ecc71; color: white; font-weight: bold; padding: 8px 24px; border-radius: 6px; font-size: 12px;")
self.btn_apply.clicked.connect(self.accept)
btn_layout.addWidget(self.btn_apply)
self.btn_cancel = QPushButton("❌ 取消")
self.btn_cancel.setStyleSheet("background: #e74c3c; color: white; padding: 8px 24px; border-radius: 6px;")
self.btn_cancel.clicked.connect(self.reject)
btn_layout.addWidget(self.btn_cancel)
layout.addLayout(btn_layout)
# 备份提示
bottom_info = QHBoxLayout()
bottom_info.addWidget(QLabel("💡 备份文件 (.bak.py) 将保留,可手动恢复"))
bottom_info.addStretch()
layout.addLayout(bottom_info)
self.status_label = QLabel("就绪")
self.status_label.setStyleSheet("border: 1px solid #ddd; padding: 2px 8px; background: #f8f9fa; border-radius: 4px;")
layout.addWidget(self.status_label)
self._update_status()
def _show_diff(self):
self.left_edit.setPlainText(self.original_content)
self.right_edit.setPlainText(self.new_content)
def _on_edit(self, side):
if side == "left":
self.left_modified = True
self.save_left_btn.setEnabled(True)
else:
self.right_modified = True
self.save_right_btn.setEnabled(True)
self._update_status()
def _save_side(self, side):
content = self.left_edit.toPlainText() if side == "left" else self.right_edit.toPlainText()
with open(self.backup_path, 'w', encoding='utf-8') as f:
f.write(content)
if side == "left":
self.left_modified = False
self.save_left_btn.setEnabled(False)
else:
self.right_modified = False
self.save_right_btn.setEnabled(False)
self._update_status()
self.safe_log(f"✅ 已保存{'左侧' if side == 'left' else '右侧'}到备份")
def _revert_side(self, side):
if side == "left":
self.left_edit.setPlainText(self.original_content)
self.left_modified = False
self.save_left_btn.setEnabled(False)
else:
self.right_edit.setPlainText(self.new_content)
self.right_modified = False
self.save_right_btn.setEnabled(False)
self._update_status()
def _update_status(self):
self.status_label.setText(f"左侧: {'已修改' if self.left_modified else '未修改'} | 右侧: {'已修改' if self.right_modified else '未修改'}")
def _log(self, msg):
if self.parent() and hasattr(self.parent(), 'safe_log'):
self.parent().safe_log(msg)
def accept(self):
"""用户确认应用修复"""
self.safe_log(f"✅ 修复已应用,备份保留: {os.path.basename(self.backup_path)}")
super().accept()
def reject(self):
"""用户取消,从备份还原(备份保留)"""
if os.path.exists(self.backup_path):
try:
shutil.copy2(self.backup_path, self.file_path)
self.safe_log(f"↩️ 已从备份还原: {os.path.basename(self.file_path)}")
self.safe_log(f"💡 备份保留: {os.path.basename(self.backup_path)}")
except Exception as e:
self.safe_log(f"⚠️ 还原失败: {e}")
super().reject()
class CodeCompareDialog(QDialog):
"""增强版代码对比 - 4窗口 + 函数列表 + 同步滚动 + 模糊匹配 + 互覆盖"""
def __init__(self, parent, left_file, right_file):
super().__init__(parent)
self.setWindowTitle(f"代码对比 - {os.path.basename(left_file)} ↔ {os.path.basename(right_file)}")
self.resize(1200, 800)
self.setMinimumSize(800, 600)
self.setModal(True)
self.left_file = left_file
self.right_file = right_file
self.view_mode = "func"
self.current_func = None
self.sync_mode = False
self.left_modified = False
self.right_modified = False
self.highlight_active = False
self.fuzzy_match_enabled = False
self._load_files()
self._extract_functions()
self._setup_ui()
self._display_func_mode()
self._update_status()
self.sync_mode = True
self._bind_sync()
self.sync_btn.setText("🔗 滚动 (开)")
self.sync_btn.setStyleSheet("background: #2ecc71; color: white;")
if self.common_funcs:
self._select_func(self.common_funcs[0])
def _load_files(self):
with open(self.left_file, 'r', encoding='utf-8') as f:
self.left_lines = f.readlines()
with open(self.right_file, 'r', encoding='utf-8') as f:
self.right_lines = f.readlines()
self.left_original = self.left_lines.copy()
self.right_original = self.right_lines.copy()
def _extract_functions(self):
self.left_funcs = {}
self.right_funcs = {}
def extract(lines, func_dict):
i = 0
while i < len(lines):
line = lines.strip()
if line.startswith('def '):
match = re.match(r'def\s+(\w+)\s*\(', line)
if match:
name = match.group(1)
start = i
j = i + 1
indent = len(lines) - len(lines.lstrip())
while j < len(lines):
if lines[j].strip():
curr_indent = len(lines[j]) - len(lines[j].lstrip())
if curr_indent <= indent and lines[j].strip().startswith('def '):
break
j += 1
end = j - 1
body = ''.join(lines[start:end+1])
func_dict[name] = {'start': start, 'end': end, 'body': body}
i = j
else:
i += 1
else:
i += 1
extract(self.left_lines, self.left_funcs)
extract(self.right_lines, self.right_funcs)
self.common_funcs = sorted(set(self.left_funcs.keys()) & set(self.right_funcs.keys()))
def showEvent(self, event):
"""窗口显示时调整大小"""
super().showEvent(event)
try:
screen = QApplication.primaryScreen().availableGeometry()
width = int(screen.width() * 0.8)
height = int(screen.height() * 0.8)
self.resize(width, height)
self.setMinimumSize(int(screen.width() * 0.6), int(screen.height() * 0.4))
except:
pass
def _setup_ui(self):
layout = QVBoxLayout(self)
# 工具栏
toolbar = QWidget()
tb = QHBoxLayout(toolbar)
tb.setContentsMargins(0,0,0,0)
self.func_mode_btn = QPushButton("函数")
self.func_mode_btn.setStyleSheet("background: #2ecc71; color: white;")
self.func_mode_btn.clicked.connect(self._switch_to_func_mode)
tb.addWidget(self.func_mode_btn)
self.line_mode_btn = QPushButton("逐行")
self.line_mode_btn.setStyleSheet("background: #95a5a6; color: white;")
self.line_mode_btn.clicked.connect(self._switch_to_line_mode)
tb.addWidget(self.line_mode_btn)
tb.addWidget(QLabel("|"))
# ===== 模糊匹配开关 =====
self.fuzzy_match_cb = QCheckBox("🔍 模糊")
self.fuzzy_match_cb.setChecked(False)
self.fuzzy_match_cb.setToolTip("开启后,精确匹配不到的函数会尝试相似度匹配")
self.fuzzy_match_cb.stateChanged.connect(self._refresh_func_mode)
tb.addWidget(self.fuzzy_match_cb)
tb.addWidget(QLabel("|"))
# ===== 互覆盖按钮 =====
self.copy_left_to_right_btn = QPushButton("左→右")
self.copy_left_to_right_btn.setStyleSheet("background: #e74c3c; color: white;")
self.copy_left_to_right_btn.setToolTip("将左侧选中的行覆盖到右侧对应位置")
self.copy_left_to_right_btn.clicked.connect(self._copy_selected_left_to_right)
tb.addWidget(self.copy_left_to_right_btn)
self.copy_right_to_left_btn = QPushButton("右→左")
self.copy_right_to_left_btn.setStyleSheet("background: #3498db; color: white;")
self.copy_right_to_left_btn.setToolTip("将右侧选中的行覆盖到左侧对应位置")
self.copy_right_to_left_btn.clicked.connect(self._copy_selected_right_to_left)
tb.addWidget(self.copy_right_to_left_btn)
tb.addWidget(QLabel("|"))
self.highlight_btn = QPushButton("高亮")
self.highlight_btn.clicked.connect(self._on_highlight_clicked)
self.highlight_btn.setStyleSheet("background: #e67e22; color: white;")
self.highlight_btn.setToolTip("在当前模式下高亮显示差异")
tb.addWidget(self.highlight_btn)
# === 清除高亮按钮(点击后变色) ===
self.clear_highlight_btn = QPushButton("清除")
self.clear_highlight_btn.clicked.connect(self._clear_highlights)
self.clear_highlight_btn.setStyleSheet("background: #95a5a6; color: white;")
self.clear_highlight_btn.setToolTip("清除所有差异高亮")
tb.addWidget(self.clear_highlight_btn)
tb.addWidget(QLabel("|"))
self.sync_btn = QPushButton("🔗 滚动")
self.sync_btn.clicked.connect(self._toggle_sync)
self.sync_btn.setStyleSheet("background: #3498db; color: white;")
tb.addWidget(self.sync_btn)
tb.addStretch()
self.save_left_btn = QPushButton("💾 存左")
self.save_left_btn.setEnabled(False)
self.save_left_btn.clicked.connect(lambda: self._save_side("left"))
tb.addWidget(self.save_left_btn)
self.revert_left_btn = QPushButton("↩️ 还左")
self.revert_left_btn.clicked.connect(lambda: self._revert_side("left"))
tb.addWidget(self.revert_left_btn)
self.save_right_btn = QPushButton("💾 存右")
self.save_right_btn.setEnabled(False)
self.save_right_btn.clicked.connect(lambda: self._save_side("right"))
tb.addWidget(self.save_right_btn)
self.revert_right_btn = QPushButton("↩️ 还右")
self.revert_right_btn.clicked.connect(lambda: self._revert_side("right"))
tb.addWidget(self.revert_right_btn)
tb.addWidget(QLabel("|"))
self.export_btn = QPushButton("导出")
self.export_btn.clicked.connect(self._export_report)
self.export_btn.setStyleSheet("background: #1abc9c; color: white;")
tb.addWidget(self.export_btn)
self.reload_btn = QPushButton("加载")
self.reload_btn.clicked.connect(self._reload_files)
self.reload_btn.setStyleSheet("background: #f39c12; color: white;")
tb.addWidget(self.reload_btn)
self.select_btn = QPushButton("选择")
self.select_btn.clicked.connect(self._select_files)
self.select_btn.setStyleSheet("background: #9b59b6; color: white;")
tb.addWidget(self.select_btn)
layout.addWidget(toolbar)
# 主分割:左侧函数列表 + 右侧4窗口
main_splitter = QSplitter(Qt.Orientation.Horizontal)
# 左侧函数列表
left_frame = QWidget()
left_layout = QVBoxLayout(left_frame)
left_layout.addWidget(QLabel("📋 函数列表"))
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("搜索函数...")
self.search_edit.textChanged.connect(self._filter_func_list)
left_layout.addWidget(self.search_edit)
self.func_list = QListWidget()
self.func_list.itemClicked.connect(self._on_func_clicked)
left_layout.addWidget(self.func_list)
main_splitter.addWidget(left_frame)
main_splitter.setSizes([200, 800])
# 右侧4窗口
right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_layout.setContentsMargins(0,0,0,0)
top_bottom = QSplitter(Qt.Orientation.Vertical)
# 预览区
preview_splitter = QSplitter(Qt.Orientation.Horizontal)
left_preview_frame = QFrame()
left_preview_frame.setFrameStyle(QFrame.Shape.StyledPanel)
left_preview_layout = QVBoxLayout(left_preview_frame)
left_preview_layout.addWidget(QLabel("📄 源文件 (预览)"))
self.left_preview = QPlainTextEdit()
self.left_preview.setReadOnly(True)
self.left_preview.setFont(QFont("Consolas", 10))
self.left_preview.setStyleSheet("background: #1e1e1e; color: #d4d4d4;")
PythonHighlighter(self.left_preview.document())
left_preview_layout.addWidget(self.left_preview)
preview_splitter.addWidget(left_preview_frame)
right_preview_frame = QFrame()
right_preview_frame.setFrameStyle(QFrame.Shape.StyledPanel)
right_preview_layout = QVBoxLayout(right_preview_frame)
right_preview_layout.addWidget(QLabel("📄 编译文件 (预览)"))
self.right_preview = QPlainTextEdit()
self.right_preview.setReadOnly(True)
self.right_preview.setFont(QFont("Consolas", 10))
self.right_preview.setStyleSheet("background: #1e1e1e; color: #d4d4d4;")
PythonHighlighter(self.right_preview.document())
right_preview_layout.addWidget(self.right_preview)
preview_splitter.addWidget(right_preview_frame)
preview_splitter.setSizes([400,400])
top_bottom.addWidget(preview_splitter)
# 编辑区
edit_splitter = QSplitter(Qt.Orientation.Horizontal)
left_edit_frame = QFrame()
left_edit_frame.setFrameStyle(QFrame.Shape.StyledPanel)
left_edit_layout = QVBoxLayout(left_edit_frame)
self.left_edit_title = QLabel("✏️ 源函数: 未选中")
left_edit_layout.addWidget(self.left_edit_title)
self.left_edit = QPlainTextEdit()
self.left_edit.setFont(QFont("Consolas", 10))
self.left_edit.setStyleSheet("background: #2b2b2b; color: #d4d4d4;")
self.left_edit.textChanged.connect(lambda: self._on_edit("left"))
PythonHighlighter(self.left_edit.document())
left_edit_layout.addWidget(self.left_edit)
edit_splitter.addWidget(left_edit_frame)
right_edit_frame = QFrame()
right_edit_frame.setFrameStyle(QFrame.Shape.StyledPanel)
right_edit_layout = QVBoxLayout(right_edit_frame)
self.right_edit_title = QLabel("✏️ 编译函数: 未选中")
right_edit_layout.addWidget(self.right_edit_title)
self.right_edit = QPlainTextEdit()
self.right_edit.setFont(QFont("Consolas", 10))
self.right_edit.setStyleSheet("background: #2b2b2b; color: #d4d4d4;")
self.right_edit.textChanged.connect(lambda: self._on_edit("right"))
PythonHighlighter(self.right_edit.document())
right_edit_layout.addWidget(self.right_edit)
edit_splitter.addWidget(right_edit_frame)
edit_splitter.setSizes([400,400])
top_bottom.addWidget(edit_splitter)
top_bottom.setSizes([400,400])
right_layout.addWidget(top_bottom)
main_splitter.addWidget(right_widget)
layout.addWidget(main_splitter, stretch=1)
# 状态栏
status_frame = QFrame()
status_frame.setFrameStyle(QFrame.Shape.StyledPanel)
status_layout = QHBoxLayout(status_frame)
self.status_label = QLabel("就绪")
status_layout.addWidget(self.status_label)
status_layout.addStretch()
self.status_func = QLabel("函数: 无")
status_layout.addWidget(self.status_func)
layout.addWidget(status_frame)
def _refresh_func_mode(self):
"""刷新函数模式(切换模糊匹配后重新显示)"""
if self.view_mode == "func":
self._display_func_mode()
def _find_matching_functions(self, left_name, right_names):
"""找到匹配的右侧函数名(优先精确匹配)"""
import difflib
if left_name in right_names:
return left_name, "精确匹配"
if not self.fuzzy_match_cb.isChecked():
return None, None
lower_map = {name.lower(): name for name in right_names}
if left_name.lower() in lower_map:
return lower_map[left_name.lower()], "忽略大小写"
left_clean = left_name.strip('_')
for right_name in right_names:
right_clean = right_name.strip('_')
if left_clean == right_clean:
return right_name, "去前后缀"
if left_clean.endswith(right_clean) or right_clean.endswith(left_clean):
return right_name, "包含匹配"
matches = difflib.get_close_matches(left_name, right_names, n=1, cutoff=0.7)
if matches:
return matches[0], f"相似度 {difflib.SequenceMatcher(None, left_name, matches[0]).ratio():.2f}"
return None, None
def _count_diff_lines(self, left_text, right_text):
"""统计两个文本的差异行数"""
import difflib
differ = difflib.SequenceMatcher(None, left_text.splitlines(), right_text.splitlines())
added = 0
removed = 0
for tag, i1, i2, j1, j2 in differ.get_opcodes():
if tag == 'replace':
added += j2 - j1
removed += i2 - i1
elif tag == 'delete':
removed += i2 - i1
elif tag == 'insert':
added += j2 - j1
return added, removed
def _get_display_funcs(self):
"""获取当前显示的匹配函数列表"""
result = []
left_names = list(self.left_funcs.keys())
right_names = list(self.right_funcs.keys())
for name in left_names:
matched, _ = self._find_matching_functions(name, right_names)
if matched:
result.append(name)
return sorted(result)
def _get_selected_range(self, edit_widget):
"""获取选中区域的行范围"""
cursor = edit_widget.textCursor()
start = cursor.selectionStart()
end = cursor.selectionEnd()
if start == end:
return None, None
doc = edit_widget.document()
start_block = doc.findBlock(start)
end_block = doc.findBlock(end)
return start_block.blockNumber(), end_block.blockNumber()
def _copy_selected_left_to_right(self):
"""将左侧选中的行覆盖到右侧对应位置"""
start, end = self._get_selected_range(self.left_edit)
if start is None:
show_msg(self, "提示", "请先在左侧选中要覆盖的行",1)
return
cursor = self.left_edit.textCursor()
selected_text = cursor.selectedText()
reply = QMessageBox.question(
self, "确认覆盖",
f"将左侧选中的 {end - start + 1} 行覆盖到右侧对应位置?\n\n右侧原有内容将被替换!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
right_cursor = self.right_edit.textCursor()
right_cursor.setPosition(self.right_edit.document().findBlockByNumber(start).position())
right_cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
for _ in range(end - start):
right_cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor)
right_cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
right_cursor.insertText(selected_text)
self.right_modified = True
self.save_right_btn.setEnabled(True)
self._update_status()
if self.parent() and hasattr(self.parent(), 'safe_log'):
self.parent().safe_log(f"✅ 已从左侧覆盖 {end - start + 1} 行到右侧")
def _copy_selected_right_to_left(self):
"""将右侧选中的行覆盖到左侧对应位置"""
start, end = self._get_selected_range(self.right_edit)
if start is None:
show_msg(self, "提示", "请先在右侧选中要覆盖的行",1)
return
cursor = self.right_edit.textCursor()
selected_text = cursor.selectedText()
reply = QMessageBox.question(
self, "确认覆盖",
f"将右侧选中的 {end - start + 1} 行覆盖到左侧对应位置?\n\n左侧原有内容将被替换!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
left_cursor = self.left_edit.textCursor()
left_cursor.setPosition(self.left_edit.document().findBlockByNumber(start).position())
left_cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
for _ in range(end - start):
left_cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor)
left_cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
left_cursor.insertText(selected_text)
self.left_modified = True
self.save_left_btn.setEnabled(True)
self._update_status()
if self.parent() and hasattr(self.parent(), 'safe_log'):
self.parent().safe_log(f"✅ 已从右侧覆盖 {end - start + 1} 行到左侧")
def _populate_func_list(self):
self.func_list.clear()
keyword = self.search_edit.text().strip().lower()
display_funcs = self._get_display_funcs()
for name in display_funcs:
if keyword == "" or keyword in name.lower():
self.func_list.addItem(name)
def _on_func_clicked(self, item):
func_name = item.text()
right_names = list(self.right_funcs.keys())
matched, _ = self._find_matching_functions(func_name, right_names)
if matched:
self._select_func(func_name, matched)
else:
self._select_func(func_name, None)
def _select_func(self, left_name, right_name=None):
"""选择并显示函数对比"""
self.current_func = left_name
if right_name is None:
right_name = left_name
matched, _ = self._find_matching_functions(left_name, list(self.right_funcs.keys()))
if matched:
right_name = matched
self.left_edit_title.setText(f"✏️ 源函数: {left_name}")
self.right_edit_title.setText(f"✏️ 编译函数: {right_name}")
self.left_edit.clear()
self.right_edit.clear()
if left_name in self.left_funcs:
self.left_edit.setPlainText(self.left_funcs[left_name]['body'])
if right_name in self.right_funcs:
self.right_edit.setPlainText(self.right_funcs[right_name]['body'])
self.left_modified = False
self.right_modified = False
self.save_left_btn.setEnabled(False)
self.save_right_btn.setEnabled(False)
self._update_status()
if self.highlight_active:
self._highlight_diffs()
def _display_func_mode(self):
self.view_mode = "func"
self.func_mode_btn.setStyleSheet("background: #2ecc71; color: white;")
self.line_mode_btn.setStyleSheet("background: #95a5a6; color: white;")
self.left_preview.clear()
self.right_preview.clear()
left_names = list(self.left_funcs.keys())
right_names = list(self.right_funcs.keys())
matched_pairs = []
used_right = set()
for name in left_names:
matched, match_type = self._find_matching_functions(name, right_names)
if matched and matched not in used_right:
matched_pairs.append((name, matched, match_type))
used_right.add(matched)
elif matched and matched in used_right:
matched_pairs.append((name, None, "已被其他函数匹配"))
for name, matched, match_type in matched_pairs:
if matched:
left_body = self.left_funcs[name]['body']
right_body = self.right_funcs[matched]['body']
left_num = len(left_body.splitlines())
right_num = len(right_body.splitlines())
added, removed = self._count_diff_lines(left_body, right_body)
diff_info = f" [+{added} -{removed}]"
label = f"函数: {name} ↔ {matched}{diff_info}"
if match_type != "精确匹配":
label += f" [{match_type}]"
self.left_preview.appendPlainText(f"\n{'='*60}\n{label}\n行数: {left_num}\n{'='*60}")
self.left_preview.appendPlainText(left_body)
self.right_preview.appendPlainText(f"\n{'='*60}\n{label}\n行数: {right_num}\n{'='*60}")
self.right_preview.appendPlainText(right_body)
else:
self.left_preview.appendPlainText(f"\n{'='*60}\n函数: {name} (无匹配)\n{'='*60}")
self.left_preview.appendPlainText(self.left_funcs[name]['body'])
self.right_preview.appendPlainText(f"\n{'='*60}\n函数: {name} (无匹配)\n{'='*60}")
self._populate_func_list()
matched_count = len([p for p in matched_pairs if p[1] is not None])
total_left = len(left_names)
self.status_label.setText(f"匹配: {matched_count}/{total_left} 个函数")
if self.highlight_active:
self._highlight_diffs()
def _switch_to_func_mode(self):
if self.view_mode != "func":
self._display_func_mode()
if self.current_func:
right_names = list(self.right_funcs.keys())
matched, _ = self._find_matching_functions(self.current_func, right_names)
if matched:
self._select_func(self.current_func, matched)
else:
self._select_func(self.current_func, None)
def _display_line_mode(self):
self.view_mode = "line"
self.func_mode_btn.setStyleSheet("background: #95a5a6; color: white;")
self.line_mode_btn.setStyleSheet("background: #2ecc71; color: white;")
self.left_preview.clear()
self.right_preview.clear()
differ = difflib.SequenceMatcher(None, self.left_lines, self.right_lines)
for tag, i1, i2, j1, j2 in differ.get_opcodes():
if tag == 'equal':
for i in range(i1, i2):
self.left_preview.appendPlainText(self.left_lines.rstrip())
self.right_preview.appendPlainText(self.right_lines[j1 + (i-i1)].rstrip())
elif tag == 'replace':
for i in range(i1, i2):
self.left_preview.appendPlainText(" " + self.left_lines.rstrip())
for j in range(j1, j2):
self.right_preview.appendPlainText(" " + self.right_lines[j].rstrip())
elif tag == 'delete':
for i in range(i1, i2):
self.left_preview.appendPlainText("- " + self.left_lines.rstrip())
elif tag == 'insert':
for j in range(j1, j2):
self.right_preview.appendPlainText("+ " + self.right_lines[j].rstrip())
# 逐行模式下也支持高亮
if self.highlight_active:
self._highlight_diffs()
def _switch_to_func_mode(self):
if self.view_mode != "func":
self._display_func_mode()
if self.current_func:
self._select_func(self.current_func)
def _switch_to_line_mode(self):
if self.view_mode != "line":
self._display_line_mode()
def _on_edit(self, side):
if side == "left":
self.left_modified = True
self.save_left_btn.setEnabled(True)
else:
self.right_modified = True
self.save_right_btn.setEnabled(True)
self._update_status()
# 编辑后自动清除高亮状态
if self.highlight_active:
self.highlight_active = False
self.highlight_btn.setStyleSheet("background: #e67e22; color: white;")
def _save_side(self, side):
if side == "left":
content = self.left_edit.toPlainText()
if self.current_func and self.current_func in self.left_funcs:
old = self.left_funcs[self.current_func]
new_lines = content.splitlines(True)
if new_lines and not new_lines[-1].endswith('\n'):
new_lines[-1] += '\n'
self.left_lines[old['start']:old['end']+1] = new_lines
with open(self.left_file, 'w', encoding='utf-8') as f:
f.writelines(self.left_lines)
self.left_funcs[self.current_func]['body'] = content
self.left_funcs[self.current_func]['end'] = old['start'] + len(new_lines) - 1
self.left_modified = False
self.save_left_btn.setEnabled(False)
self._refresh_preview()
else:
content = self.right_edit.toPlainText()
if self.current_func and self.current_func in self.right_funcs:
old = self.right_funcs[self.current_func]
new_lines = content.splitlines(True)
if new_lines and not new_lines[-1].endswith('\n'):
new_lines[-1] += '\n'
self.right_lines[old['start']:old['end']+1] = new_lines
with open(self.right_file, 'w', encoding='utf-8') as f:
f.writelines(self.right_lines)
self.right_funcs[self.current_func]['body'] = content
self.right_funcs[self.current_func]['end'] = old['start'] + len(new_lines) - 1
self.right_modified = False
self.save_right_btn.setEnabled(False)
self._refresh_preview()
self._update_status()
def _revert_side(self, side):
if side == "left" and self.current_func in self.left_funcs:
old = self.left_funcs[self.current_func]
original = ''.join(self.left_original[old['start']:old['end']+1])
self.left_edit.setPlainText(original.rstrip())
self.left_funcs[self.current_func]['body'] = original.rstrip()
self.left_lines[old['start']:old['end']+1] = self.left_original[old['start']:old['end']+1].copy()
self.left_modified = False
self.save_left_btn.setEnabled(False)
elif side == "right" and self.current_func in self.right_funcs:
old = self.right_funcs[self.current_func]
original = ''.join(self.right_original[old['start']:old['end']+1])
self.right_edit.setPlainText(original.rstrip())
self.right_funcs[self.current_func]['body'] = original.rstrip()
self.right_lines[old['start']:old['end']+1] = self.right_original[old['start']:old['end']+1].copy()
self.right_modified = False
self.save_right_btn.setEnabled(False)
self._update_status()
if self.view_mode == "func":
self._refresh_preview()
# 还原后重新高亮
if self.highlight_active:
self._highlight_diffs()
def _refresh_preview(self):
if self.view_mode != "func":
return
self.left_preview.clear()
self.right_preview.clear()
left_names = list(self.left_funcs.keys())
right_names = list(self.right_funcs.keys())
for name in left_names:
matched, match_type = self._find_matching_functions(name, right_names)
if matched:
left_body = self.left_funcs[name]['body']
right_body = self.right_funcs[matched]['body']
left_num = len(left_body.splitlines())
right_num = len(right_body.splitlines())
label = f"函数: {name} ↔ {matched}"
if match_type != "精确匹配":
label += f" [{match_type}]"
self.left_preview.appendPlainText(f"\n{'='*60}\n{label}\n行数: {left_num}\n{'='*60}")
self.left_preview.appendPlainText(left_body)
self.right_preview.appendPlainText(f"\n{'='*60}\n{label}\n行数: {right_num}\n{'='*60}")
self.right_preview.appendPlainText(right_body)
else:
self.left_preview.appendPlainText(f"\n{'='*60}\n函数: {name} (无匹配)\n{'='*60}")
self.left_preview.appendPlainText(self.left_funcs[name]['body'])
self.right_preview.appendPlainText(f"\n{'='*60}\n函数: {name} (无匹配)\n{'='*60}")
def _on_highlight_clicked(self):
"""高亮差异按钮点击 - 切换高亮状态"""
if self.highlight_active:
self._clear_highlights()
self.highlight_active = False
self.highlight_btn.setStyleSheet("background: #e67e22; color: white;")
else:
self._highlight_diffs()
self.highlight_active = True
self.highlight_btn.setStyleSheet("background: #e67e22; color: white; font-weight: bold; border: 2px solid #ff6b00;")
# 确保清除高亮按钮样式恢复
self.clear_highlight_btn.setStyleSheet("background: #95a5a6; color: white;")
def _clear_highlights(self):
self.left_edit.setExtraSelections([])
self.right_edit.setExtraSelections([])
self.status_label.setText("已清除高亮")
self.highlight_active = False
self.highlight_btn.setStyleSheet("background: #e67e22; color: white;")
self.clear_highlight_btn.setStyleSheet("background: #f44336; color: white; font-weight: bold;")
# 延迟恢复清除按钮样式
QTimer.singleShot(300, lambda: self.clear_highlight_btn.setStyleSheet("background: #95a5a6; color: white;"))
def _highlight_diffs(self):
"""高亮差异 - 使用 ExtraSelections"""
if self.view_mode == "func":
self._highlight_func_diff()
else:
self._highlight_line_diff()
def _highlight_func_diff(self):
"""函数模式下的高亮 - 只高亮当前选中的函数"""
if not self.current_func:
return
right_name = self.current_func
matched, _ = self._find_matching_functions(self.current_func, list(self.right_funcs.keys()))
if matched:
right_name = matched
if self.current_func not in self.left_funcs or right_name not in self.right_funcs:
return
left_body = self.left_funcs[self.current_func]['body'].splitlines()
right_body = self.right_funcs[right_name]['body'].splitlines()
differ = difflib.SequenceMatcher(None, left_body, right_body)
left_selections = []
right_selections = []
for tag, i1, i2, j1, j2 in differ.get_opcodes():
if tag == 'equal':
continue
if tag in ('delete', 'replace'):
for i in range(i1, i2):
if i < self.left_edit.document().blockCount():
cursor = self.left_edit.textCursor()
cursor.setPosition(self.left_edit.document().findBlockByNumber(i).position())
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
fmt = QTextCharFormat()
fmt.setBackground(QColor("#5a2a2a"))
selection = QTextEdit.ExtraSelection()
selection.cursor = cursor
selection.format = fmt
left_selections.append(selection)
if tag in ('insert', 'replace'):
for j in range(j1, j2):
if j < self.right_edit.document().blockCount():
cursor = self.right_edit.textCursor()
cursor.setPosition(self.right_edit.document().findBlockByNumber(j).position())
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
fmt = QTextCharFormat()
fmt.setBackground(QColor("#2a5a2a"))
selection = QTextEdit.ExtraSelection()
selection.cursor = cursor
selection.format = fmt
right_selections.append(selection)
self.left_edit.setExtraSelections(left_selections)
self.right_edit.setExtraSelections(right_selections)
if left_selections or right_selections:
self.status_label.setText(f"✅ 差异: 左侧 {len(left_selections)} 处, 右侧 {len(right_selections)} 处")
self.status_func.setText(f"函数: {self.current_func} | 差异: L{len(left_selections)} R{len(right_selections)}")
def _highlight_line_diff(self):
"""逐行模式下的高亮 - 高亮全文件差异"""
differ = difflib.SequenceMatcher(None, self.left_lines, self.right_lines)
left_selections = []
right_selections = []
for tag, i1, i2, j1, j2 in differ.get_opcodes():
if tag == 'equal':
continue
if tag in ('delete', 'replace'):
for i in range(i1, i2):
if i < self.left_edit.document().blockCount():
cursor = self.left_edit.textCursor()
cursor.setPosition(self.left_edit.document().findBlockByNumber(i).position())
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
fmt = QTextCharFormat()
fmt.setBackground(QColor("#5a2a2a"))
selection = QTextEdit.ExtraSelection()
selection.cursor = cursor
selection.format = fmt
left_selections.append(selection)
if tag in ('insert', 'replace'):
for j in range(j1, j2):
if j < self.right_edit.document().blockCount():
cursor = self.right_edit.textCursor()
cursor.setPosition(self.right_edit.document().findBlockByNumber(j).position())
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
fmt = QTextCharFormat()
fmt.setBackground(QColor("#2a5a2a"))
selection = QTextEdit.ExtraSelection()
selection.cursor = cursor
selection.format = fmt
right_selections.append(selection)
self.left_edit.setExtraSelections(left_selections)
self.right_edit.setExtraSelections(right_selections)
if left_selections or right_selections:
self.status_label.setText(f"✅ 差异: 左侧 {len(left_selections)} 处, 右侧 {len(right_selections)} 处")
def _toggle_sync(self):
self.sync_mode = not self.sync_mode
if self.sync_mode:
self._bind_sync()
self.sync_btn.setText("🔗 滚动 (开)")
self.sync_btn.setStyleSheet("background: #2ecc71; color: white;")
else:
self._unbind_sync()
self.sync_btn.setText("🔗 滚动 (关)")
self.sync_btn.setStyleSheet("background: #3498db; color: white;")
def _bind_sync(self):
all_texts = [self.left_preview, self.right_preview, self.left_edit, self.right_edit]
for text in all_texts:
text.wheelEvent = lambda e, t=text: self._on_wheel(e, t)
def _unbind_sync(self):
all_texts = [self.left_preview, self.right_preview, self.left_edit, self.right_edit]
for text in all_texts:
text.wheelEvent = None
def _on_wheel(self, event, source):
if not self.sync_mode:
return
delta = event.angleDelta().y()
if delta == 0:
return
current = source.verticalScrollBar().value()
new_pos = current - delta // 4
all_texts = [self.left_preview, self.right_preview, self.left_edit, self.right_edit]
for text in all_texts:
text.verticalScrollBar().setValue(new_pos)
event.accept()
def _reload_files(self):
self._load_files()
self._extract_functions()
if self.view_mode == "func":
self._display_func_mode()
if self.current_func:
matched, _ = self._find_matching_functions(self.current_func, list(self.right_funcs.keys()))
if matched:
self._select_func(self.current_func, matched)
else:
self._select_func(self.current_func, None)
else:
self._display_line_mode()
self.left_modified = False
self.right_modified = False
self._update_status()
def _select_files(self):
left = QFileDialog.getOpenFileName(self, "选择源文件", "", "Python文件 (*.py)")[0]
if not left:
return
right = QFileDialog.getOpenFileName(self, "选择编译文件", "", "Python文件 (*.py)")[0]
if not right:
return
self.left_file = left
self.right_file = right
self.setWindowTitle(f"代码对比 - {os.path.basename(left)} ↔ {os.path.basename(right)}")
self._reload_files()
def _export_report(self):
path = QFileDialog.getSaveFileName(self, "导出报告", "", "文本文件 (*.txt)")[0]
if not path:
return
report = ["=" * 60]
report.append("代码对比报告")
report.append(f"源文件: {os.path.basename(self.left_file)}")
report.append(f"编译文件: {os.path.basename(self.right_file)}")
report.append(f"时间: {datetime.now()}")
report.append("=" * 60)
left_names = list(self.left_funcs.keys())
right_names = list(self.right_funcs.keys())
matched_count = 0
report.append("")
report.append("📊 函数匹配统计:")
report.append("-" * 40)
for name in left_names:
matched, match_type = self._find_matching_functions(name, right_names)
if matched:
matched_count += 1
left_body = self.left_funcs[name]['body']
right_body = self.right_funcs[matched]['body']
ratio = difflib.SequenceMatcher(None, left_body, right_body).ratio() * 100
added, removed = self._count_diff_lines(left_body, right_body)
status = f"相似度: {ratio:.1f}% [+{added} -{removed}]"
if match_type != "精确匹配":
status += f" [{match_type}]"
report.append(f" {name} ↔ {matched}")
report.append(f" {status}")
report.append("")
report.append(f"匹配率: {matched_count}/{len(left_names)} ({matched_count/len(left_names)*100:.1f}%)" if left_names else "0/0")
with open(path, 'w', encoding='utf-8') as f:
f.write('\n'.join(report))
self.status_label.setText(f"✅ 报告已导出: {os.path.basename(path)}")
def _filter_func_list(self):
self._populate_func_list()
def _update_status(self):
self.status_label.setText(f"函数: {self.current_func or '无'} | 左侧修改: {'是' if self.left_modified else '否'} | 右侧修改: {'是' if self.right_modified else '否'}")
self.status_func.setText(f"函数: {self.current_func or '无'}")
class SystemMonitorThread(QThread):
"""系统资源监控线程 - 低频更新,温度显示一位小数"""
status_updated = pyqtSignal(float, float, float, float, str)
def __init__(self):
super().__init__()
self._is_running = True
self._interval = 3000
self._lhm_computer = None
self._init_lhm()
def _init_lhm(self):
"""初始化 LibreHardwareMonitor(Windows 最佳方案)"""
if sys.platform != 'win32':
return
try:
import clr
dll_paths = [
'LibreHardwareMonitorLib.dll',
'./LibreHardwareMonitorLib.dll',
'../LibreHardwareMonitorLib.dll',
os.path.join(os.path.dirname(__file__), 'LibreHardwareMonitorLib.dll'),
]
dll_loaded = False
for dll_path in dll_paths:
if os.path.exists(dll_path):
clr.AddReference(dll_path)
dll_loaded = True
break
if not dll_loaded:
return
from LibreHardwareMonitor.Hardware import Computer, HardwareType, SensorType
self._lhm_computer = Computer()
self._lhm_computer.IsCpuEnabled = True
self._lhm_computer.IsGpuEnabled = True
self._lhm_computer.IsMotherboardEnabled = True
self._lhm_computer.Open()
except Exception:
self._lhm_computer = None
def _get_temp_lhm(self):
"""通过 LibreHardwareMonitor 获取温度(返回 float)"""
if not self._lhm_computer:
return None
try:
from LibreHardwareMonitor.Hardware import HardwareType, SensorType
for hardware in self._lhm_computer.Hardware:
if hardware.HardwareType == HardwareType.Cpu:
hardware.Update()
for sensor in hardware.Sensors:
if (sensor.SensorType == SensorType.Temperature and
'package' in sensor.Name.lower()):
val = sensor.Value
if val is not None and -10 < val < 120:
return float(val)
for sensor in hardware.Sensors:
if sensor.SensorType == SensorType.Temperature:
val = sensor.Value
if val is not None and -10 < val < 120:
return float(val)
except Exception:
pass
return None
def _get_temp_psutil(self):
"""通过 psutil 获取温度(Linux/macOS 有效,返回 float)"""
try:
import psutil
temps = psutil.sensors_temperatures()
if not temps:
return None
priority_keys = ['coretemp', 'k10temp', 'zenpower', 'cpu_thermal',
'acpitz', 'pch_skylake']
for key in priority_keys:
if key in temps:
entries = temps[key]
if entries:
for entry in entries:
if entry.current and 10 < entry.current < 120:
return float(entry.current)
for name, entries in temps.items():
if entries:
for entry in entries:
if entry.current and 10 < entry.current < 120:
return float(entry.current)
except Exception:
pass
return None
def _get_temp_wmic(self):
"""通过 wmic 命令获取温度(Windows 备用,返回 float)"""
if sys.platform != 'win32':
return None
try:
import subprocess
result = subprocess.run(
['wmic', r'/namespace:\\root\wmi',
'PATH', 'MSAcpi_ThermalZoneTemperature',
'GET', 'CurrentTemperature'],
capture_output=True, text=True, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW
)
lines = [l.strip() for l in result.stdout.split('\n')
if l.strip().replace('.', '').isdigit()]
if lines:
temp_k = float(lines[0]) / 10.0
temp_c = temp_k - 273.15
if -10 < temp_c < 120:
return float(temp_c)
except Exception:
pass
return None
def _get_temperature(self):
"""获取温度:按优先级尝试多种方案"""
temp = self._get_temp_lhm()
if temp is not None:
return f"{temp:.1f}°C"
temp = self._get_temp_psutil()
if temp is not None:
return f"{temp:.1f}°C"
temp = self._get_temp_wmic()
if temp is not None:
return f"{temp:.1f}°C"
return ""
def _get_resource_color(self, percent, is_temp=False):
"""获取资源颜色(三色:绿→橙→红)"""
if is_temp:
if percent > 80:
return "#F44336"
elif percent > 65:
return "#FF9800"
else:
return "#4CAF50"
else:
if percent > 80:
return "#F44336"
elif percent > 60:
return "#FF9800"
else:
return "#4CAF50"
def run(self):
try:
import psutil
except ImportError:
return
while self._is_running:
try:
cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
temp_str = self._get_temperature()
self.status_updated.emit(
cpu_percent,
memory.percent,
memory.used / (1024 ** 3),
memory.total / (1024 ** 3),
temp_str
)
except Exception:
pass
if self._is_running:
self.msleep(self._interval)
def stop(self):
self._is_running = False
self.wait(1000)
if self._lhm_computer:
try:
self._lhm_computer.Close()
except Exception:
pass
class PackThread(QThread):
progress_signal = pyqtSignal(int)
log_signal = pyqtSignal(str)
finished_signal = pyqtSignal(bool, str)
def __init__(self, config):
super().__init__()
self.config = config
self._is_running = True
self.process = None
self._mock_progress = 0
self._mock_timer = None
self._real_progress_received = False
self._output_buffer = []
def run(self):
process = None
try:
self.log_signal.emit("🚀 开始打包...")
cmd = self._build_command()
process = self._popen_hidden(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
startupinfo=get_startupinfo(),
bufsize=1,
universal_newlines=True
)
self.process = process
self._real_progress_received = False
self._mock_progress = 0
self._start_mock_progress()
import select
import sys
while self._is_running and process.poll() is None:
try:
if sys.platform == 'win32':
# Windows使用timeout轮询
import time
time.sleep(0.1)
line = process.stdout.readline()
if line:
self._process_output_line(line)
else:
import select
if select.select([process.stdout], [], [], 0.1)[0]:
line = process.stdout.readline()
if line:
self._process_output_line(line)
except Exception as e:
if self._is_running:
self.log_signal.emit(f"读取输出异常: {e}")
break
if not self._is_running:
break
if self._is_running:
for line in process.stdout:
if not self._is_running:
break
self._process_output_line(line)
if process.poll() is None:
process.wait(timeout=30)
returncode = process.returncode if process.poll() is not None else -1
self._stop_mock_progress()
if returncode == 0:
self.progress_signal.emit(100)
self.finished_signal.emit(True, "打包完成!")
else:
self.finished_signal.emit(False, f"返回码: {returncode}")
except subprocess.TimeoutExpired:
self._stop_mock_progress()
self.finished_signal.emit(False, "打包超时")
except Exception as e:
self._stop_mock_progress()
self.finished_signal.emit(False, str(e))
finally:
self._cleanup_process(process)
def _process_output_line(self, line):
"""处理输出行"""
if not line:
return
line = line.rstrip()
if line:
self.log_signal.emit(line)
p = self._parse_progress(line)
if p is not None:
if not self._real_progress_received:
self._real_progress_received = True
self._stop_mock_progress()
self.progress_signal.emit(p)
def _start_mock_progress(self):
"""启动模拟进度"""
self._mock_progress = 0
self._real_progress_received = False
self._mock_timer = QTimer()
self._mock_timer.timeout.connect(self._update_mock_progress)
self._mock_timer.start(2000)
def _update_mock_progress(self):
"""更新模拟进度(更慢的增长)"""
if self._real_progress_received:
self._stop_mock_progress()
return
if self._mock_progress < 5:
self._mock_progress += 1
self.progress_signal.emit(self._mock_progress)
def _stop_mock_progress(self):
"""停止模拟进度"""
if self._mock_timer:
self._mock_timer.stop()
self._mock_timer.deleteLater()
self._mock_timer = None
def _cleanup_process(self, process):
"""彻底清理进程"""
if process:
try:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
if process.stdout:
process.stdout.close()
if process.stderr:
process.stderr.close()
except Exception as e:
pass
finally:
self.process = None
import gc
gc.collect()
def stop(self):
"""停止打包(改进版)"""
self._is_running = False
self._stop_mock_progress()
if self.process:
try:
if sys.platform == 'win32':
self.process.terminate()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.kill()
else:
self.process.terminate()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.kill()
except Exception as e:
pass
finally:
self.process = None
class AboutDialog(QDialog):
"""关于对话框"""
def __init__(self, parent=None):
super().__init__(parent)
self.parent = parent
self.setWindowTitle("关于 - Python代码跨平台打包工具")
# 获取屏幕大小并设置为80%
screen = QApplication.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
window_width = int(screen_width * 0.8)
window_height = int(screen_height * 0.8)
self.setMinimumSize(window_width, window_height)
self.setModal(True)
self.loader = None
self.setStyleSheet("""
QDialog {
background-color: #f5f5f5;
}
QTextEdit {
background-color: white;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 8px;
}
QScrollBar:vertical {
border: none;
background: #f0f0f0;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background: #c0c0c0;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background: #a0a0a0;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton#closeBtn {
background-color: #f44336;
}
QPushButton#closeBtn:hover {
background-color: #da190b;
}
QPushButton#editBtn {
background-color: #ff9800;
}
QPushButton#editBtn:hover {
background-color: #e68900;
}
QLabel#titleLabel {
color: #2196F3;
}
QSplitter::handle {
background-color: #d0d0d0;
width: 3px;
}
QSplitter::handle:hover {
background-color: #2196F3;
}
""")
self.init_ui()
self.load_content_async()
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
def init_ui(self):
"""初始化界面"""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(25, 25, 25, 25)
main_layout.setSpacing(15)
# 顶部标题区域
title_widget = QWidget()
title_layout = QVBoxLayout(title_widget)
title_layout.setSpacing(8)
# 主标题
title_label = QLabel("🐍 Python 代码跨平台打包工具")
title_label.setObjectName("titleLabel")
title_label.setFont(QFont("Microsoft YaHei", 22, QFont.Weight.Bold))
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_layout.addWidget(title_label)
# 版本信息
version_label = QLabel(f"版本: {VERSION} | 编译日期: {BUILD_DATE} | 作者: {AUTHOR}")
version_label.setFont(QFont("Microsoft YaHei", 10))
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
version_label.setStyleSheet("color: #666;")
title_layout.addWidget(version_label)
# 开发环境提示(条件显示)
if self.should_show_dev_mode():
dev_label = QLabel("💡 开发模式:编辑 CHANGELOG.txt 和 TUTORIAL.txt 可更新内容")
dev_label.setFont(QFont("Microsoft YaHei", 8))
dev_label.setStyleSheet("color: #ff6b00; background-color: #fff3e0; padding: 4px 10px; border-radius: 4px;")
dev_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_layout.addWidget(dev_label)
main_layout.addWidget(title_widget)
# 分割线
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFrameShadow(QFrame.Shadow.Sunken)
separator.setStyleSheet("background-color: #e0e0e0; max-height: 2px;")
main_layout.addWidget(separator)
# ========== 左右分栏(扩大中间区域) ==========
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setHandleWidth(5)
# ----- 左侧:更新日志 -----
left_widget = QWidget()
left_layout = QVBoxLayout(left_widget)
left_layout.setContentsMargins(0, 0, 10, 0)
left_layout.setSpacing(8)
# 左侧标题栏
left_header = QWidget()
left_header_layout = QHBoxLayout(left_header)
left_header_layout.setContentsMargins(0, 0, 0, 0)
left_title = QLabel("📝 更新日志")
left_title.setFont(QFont("Microsoft YaHei", 13, QFont.Weight.Bold))
left_title.setStyleSheet("color: #2196F3;")
left_header_layout.addWidget(left_title)
left_header_layout.addStretch()
if self.should_show_dev_mode():
refresh_btn = QPushButton("🔄")
refresh_btn.setFixedSize(30, 30)
refresh_btn.setToolTip("重新加载内容")
refresh_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #666;
border: 1px solid #d0d0d0;
border-radius: 15px;
font-size: 14px;
}
QPushButton:hover {
background-color: #e3f2fd;
border-color: #2196F3;
}
""")
refresh_btn.clicked.connect(self.refresh_content)
left_header_layout.addWidget(refresh_btn)
left_layout.addWidget(left_header)
# 左侧文本编辑框
self.changelog_text = QTextEdit()
self.changelog_text.setFont(QFont("Consolas", 10))
self.changelog_text.setReadOnly(True)
self.changelog_text.setFrameShape(QFrame.Shape.NoFrame)
self.changelog_text.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.changelog_text.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.changelog_text.setStyleSheet("""
QTextEdit {
background-color: white;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 10px;
line-height: 1.6;
}
""")
# 显示加载中
self.changelog_text.setText("⏳ 正在加载更新日志...")
left_layout.addWidget(self.changelog_text)
splitter.addWidget(left_widget)
# ----- 右侧:使用教程 -----
right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_layout.setContentsMargins(10, 0, 0, 0)
right_layout.setSpacing(8)
right_title = QLabel("📖 使用教程")
right_title.setFont(QFont("Microsoft YaHei", 13, QFont.Weight.Bold))
right_title.setStyleSheet("color: #4CAF50;")
right_layout.addWidget(right_title)
# 右侧文本编辑框
self.tutorial_text = QTextEdit()
self.tutorial_text.setFont(QFont("Microsoft YaHei", 10))
self.tutorial_text.setReadOnly(True)
self.tutorial_text.setFrameShape(QFrame.Shape.NoFrame)
self.tutorial_text.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.tutorial_text.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.tutorial_text.setStyleSheet("""
QTextEdit {
background-color: white;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 10px;
line-height: 1.8;
}
""")
# 显示加载中
self.tutorial_text.setText("⏳ 正在加载使用教程...")
right_layout.addWidget(self.tutorial_text)
splitter.addWidget(right_widget)
# 设置左右比例(各占一半,让内容区更大)
splitter.setSizes([600, 600])
main_layout.addWidget(splitter, 1) # stretch=1 让splitter占据更多空间
# ========== 底部按钮区域 ==========
btn_widget = QWidget()
btn_widget.setStyleSheet("""
QWidget {
background-color: white;
border-top: 1px solid #e0e0e0;
padding: 10px 0;
}
""")
btn_layout = QHBoxLayout(btn_widget)
btn_layout.setSpacing(12)
btn_layout.setContentsMargins(5, 5, 5, 5)
# 左侧按钮组
left_btn_layout = QHBoxLayout()
left_btn_layout.setSpacing(10)
# 项目主页
home_btn = QPushButton("🌐 项目主页")
home_btn.setFixedWidth(130)
home_btn.setStyleSheet("""
QPushButton {
background-color: #2196F3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #1976D2;
}
""")
home_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://github.com/wcj6376")))
left_btn_layout.addWidget(home_btn)
feedback_btn = QPushButton("📧 反馈问题")
feedback_btn.setFixedWidth(130)
feedback_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #45a049;
}
""")
feedback_btn.clicked.connect(self.send_email)
left_btn_layout.addWidget(feedback_btn)
# 编辑内容按钮 - 始终显示(源码运行时)
if self.should_show_dev_mode():
edit_btn = QPushButton("📁 编辑内容")
edit_btn.setObjectName("editBtn")
edit_btn.setFixedWidth(130)
edit_btn.setStyleSheet("""
QPushButton {
background-color: #FF9800;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #F57C00;
}
""")
edit_btn.clicked.connect(self.open_about_files)
left_btn_layout.addWidget(edit_btn)
btn_layout.addLayout(left_btn_layout)
btn_layout.addStretch()
# 右侧关闭按钮
close_btn = QPushButton("✕ 关闭")
close_btn.setObjectName("closeBtn")
close_btn.setFixedWidth(130)
close_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #D32F2F;
}
""")
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
main_layout.addWidget(btn_widget)
def should_show_dev_mode(self):
"""判断是否显示开发模式 - 源码运行时返回True"""
import sys
import os
if hasattr(sys, '_MEIPASS'):
return False
if '__compiled__' in globals():
return False
if getattr(sys, 'frozen', False):
return False
exe_path = sys.argv[0]
if exe_path.endswith('.exe') and not exe_path.endswith('.py.exe'):
py_file = exe_path[:-4] + '.py'
if not os.path.exists(py_file):
return False
return True
def load_content_async(self):
"""异步加载内容"""
if self.loader and self.loader.isRunning():
return
self.loader = ContentLoader()
self.loader.finished.connect(self.on_content_loaded)
self.loader.start()
def on_content_loaded(self, changelog, tutorial):
"""内容加载完成"""
self.changelog_text.setText(changelog)
self.tutorial_text.setText(tutorial)
self.changelog_text.moveCursor(self.changelog_text.textCursor().MoveOperation.Start)
self.tutorial_text.moveCursor(self.tutorial_text.textCursor().MoveOperation.Start)
def refresh_content(self):
"""刷新内容"""
self.changelog_text.setText("⏳ 正在重新加载更新日志...")
self.tutorial_text.setText("⏳ 正在重新加载使用教程...")
self.load_content_async()
def send_email(self):
"""发送邮件"""
try:
webbrowser.open("mailto:your-email@example.com?subject=Python打包工具反馈")
except Exception as e:
QMessageBox.warning(self, "错误", f"无法打开邮件客户端: {str(e)}")
def open_about_files(self):
"""打开编辑内容文件"""
try:
if not os.path.exists("CHANGELOG.txt"):
with open("CHANGELOG.txt", "w", encoding="utf-8") as f:
f.write(ContentLoader.get_default_changelog(ContentLoader))
show_msg(self, "提示", "已创建 CHANGELOG.txt 示例文件",1)
if not os.path.exists("TUTORIAL.txt"):
with open("TUTORIAL.txt", "w", encoding="utf-8") as f:
f.write(ContentLoader.get_default_tutorial(ContentLoader))
show_msg(self, "提示", "已创建 TUTORIAL.txt 示例文件",1)
# 打开文件
if sys_platform.system() == 'Windows':
if os.path.exists("CHANGELOG.txt"):
os.startfile("CHANGELOG.txt")
if os.path.exists("TUTORIAL.txt"):
os.startfile("TUTORIAL.txt")
else:
import subprocess
if os.path.exists("CHANGELOG.txt"):
self._popen_hidden(["xdg-open", "CHANGELOG.txt"])
if os.path.exists("TUTORIAL.txt"):
self._popen_hidden(["xdg-open", "TUTORIAL.txt"])
except Exception as e:
QMessageBox.warning(self, "错误", f"无法打开文件: {str(e)}")
def closeEvent(self, event):
"""关闭事件 - 清理资源"""
if hasattr(self, 'loader') and self.loader is not None:
if self.loader.isRunning():
self.loader.quit()
self.loader.wait()
self.loader = None
event.accept()
def get_startupinfo():
"""获取启动信息,隐藏控制台窗口"""
if sys.platform == 'win32':
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = subprocess.SW_HIDE
return si
return None
def get_exe_directory():
if getattr(sys, 'frozen', False) or os.environ.get('NUITKA_ONEFILE_PARENT') is not None:
return os.path.dirname(os.path.abspath(sys.argv[0]))
return os.path.dirname(os.path.abspath(__file__))
APP_BASE_PATH = get_exe_directory()
def _get_real_python():
"""获取真实的Python解释器路径(绝不返回exe自身)"""
import os, subprocess, sys, shutil
# 源码模式:直接返回当前Python
if not getattr(sys, 'frozen', False):
return sys.executable
def is_same_file(path1, path2):
try:
return os.path.samefile(path1, path2)
except Exception:
return os.path.abspath(path1) == os.path.abspath(path2)
def is_valid_python(path):
"""检查是否是有效的Python且不是当前exe"""
if not path or not os.path.exists(path):
return False
if is_same_file(path, sys.executable):
return False
try:
result = subprocess.run(
[path, '--version'],
capture_output=True, text=True, timeout=3,
startupinfo=get_startupinfo()
)
return result.returncode == 0 and ('Python' in (result.stdout + result.stderr))
except Exception:
return False
def run_hidden(args, **kw):
"""隐藏窗口运行命令"""
if sys.platform == 'win32':
kw.setdefault('startupinfo', get_startupinfo())
kw.setdefault('creationflags', subprocess.CREATE_NO_WINDOW)
return subprocess.run(args, capture_output=True, text=True, timeout=5, **kw)
# 1. 尝试 py 启动器 (Windows)
if sys.platform == 'win32':
try:
r = run_hidden(['py', '-c', 'import sys; print(sys.executable)'])
if r.returncode == 0 and r.stdout.strip():
py_path = r.stdout.strip()
if is_valid_python(py_path):
return py_path
except Exception:
pass
# 2. 尝试 python3 / python 命令
for py_name in ['python3', 'python']:
try:
r = run_hidden([py_name, '-c', 'import sys; print(sys.executable)'])
if r.returncode == 0 and r.stdout.strip():
py_path = r.stdout.strip()
if is_valid_python(py_path):
return py_path
except Exception:
pass
# 3. 常见安装路径
if sys.platform == 'win32':
username = os.environ.get('USERNAME', '')
default_paths = [
r'C:\Python314\python.exe', r'C:\Python313\python.exe',
r'C:\Python312\python.exe', r'C:\Python311\python.exe',
r'C:\Python310\python.exe', r'C:\Python39\python.exe',
rf'C:\Users\{username}\AppData\Local\Programs\Python\Python314\python.exe',
rf'C:\Users\{username}\AppData\Local\Programs\Python\Python313\python.exe',
rf'C:\Users\{username}\AppData\Local\Programs\Python\Python312\python.exe',
rf'C:\Users\{username}\AppData\Local\Programs\Python\Python311\python.exe',
rf'C:\Users\{username}\AppData\Local\Programs\Python\Python310\python.exe',
]
elif sys.platform == 'darwin':
default_paths = [
'/usr/local/bin/python3', '/usr/bin/python3', '/opt/homebrew/bin/python3',
]
else:
default_paths = [
'/usr/bin/python3', '/usr/local/bin/python3', '/usr/bin/python',
]
for p in default_paths:
if is_valid_python(p):
return p
# 4. 在 MEIPASS 附近搜索 (PyInstaller打包后)
if hasattr(sys, '_MEIPASS'):
meipass = sys._MEIPASS
search_dir = os.path.dirname(meipass)
try:
for root, dirs, files in os.walk(search_dir):
for f in files:
if f.lower() in ('python.exe', 'python3.exe', 'python'):
p = os.path.join(root, f)
if is_valid_python(p):
return p
if len(root) > len(search_dir) + 100:
break
except Exception:
pass
# 5. PATH 中搜索
for cmd in ['python3', 'python', 'py']:
p = shutil.which(cmd)
if p and is_valid_python(p):
return p
return None
def get_cache_dir():
"""获取可写的缓存目录"""
exe_dir = get_exe_directory()
if os.access(exe_dir, os.W_OK):
return exe_dir
import tempfile
fallback = os.path.join(tempfile.gettempdir(), "PyPackTool")
os.makedirs(fallback, exist_ok=True)
return fallback
CACHE_FILE = os.path.join(get_exe_directory(), "global_cache.json")
# ===== 全局内存缓存 =====
_memory_cache = None
def load_cache():
"""加载缓存 - 优先内存"""
global _memory_cache
if _memory_cache is not None:
return _memory_cache
try:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
_memory_cache = json.load(f)
return _memory_cache
except:
pass
_memory_cache = {}
return _memory_cache
def save_cache(cache):
"""保存缓存到文件并更新内存"""
global _memory_cache
_memory_cache = cache
try:
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
except:
pass
def now_str():
"""返回当前时间的字符串格式 HH:MM:SS.mmm"""
import datetime
now = datetime.datetime.now()
return now.strftime("%H:%M:%S") + f".{now.microsecond//1000:03d}"
class StripedProgressBar(QProgressBar):
"""彩色条纹进度条 - 色彩丰富"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(20)
self.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 10px;
background-color: #f0f2f5;
text-align: center;
color: #1a1a2e;
font-weight: bold;
font-size: 10px;
}
QProgressBar::chunk {
border-radius: 10px;
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 #ff6b6b,
stop: 0.2 #feca57,
stop: 0.4 #48dbfb,
stop: 0.6 #1dd1a1,
stop: 0.8 #5f27cd,
stop: 1 #ff6b6b
);
}
""")
self.setValue(0)
def setValue(self, value):
super().setValue(value)
if value < 30:
self.setFormat(f"🔍 分析中... {value}%")
elif value < 60:
self.setFormat(f"⚙️ 编译中... {value}%")
elif value < 90:
self.setFormat(f"🚀 打包中... {value}%")
else:
self.setFormat(f"✅ 完成! {value}%")
class EmojiProgressBar(QProgressBar):
"""表情动画进度条 - 简洁风格"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(24)
self.setStyleSheet("""
QProgressBar {
border: 2px solid #0984e3;
border-radius: 12px;
background-color: #dfe6e9;
text-align: center;
color: #2d3436;
font-weight: bold;
font-size: 11px;
}
QProgressBar::chunk {
border-radius: 10px;
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 #00cec9,
stop: 1 #0984e3
);
}
""")
self.setValue(0)
def setValue(self, value):
super().setValue(value)
if value < 20:
self.setFormat(f"🌙 初始化... {value}%")
elif value < 40:
self.setFormat(f"📦 收集依赖... {value}%")
elif value < 60:
self.setFormat(f"⚡ 编译中... {value}%")
elif value < 80:
self.setFormat(f"🎯 打包中... {value}%")
else:
self.setFormat(f"✨ 完成! {value}%")
class WaveProgressBar(QWidget):
"""波浪进度条"""
def __init__(self, parent=None):
super().__init__(parent)
self._value = 0
self.setFixedHeight(60)
self.offset = 0
self.timer = QTimer()
self.timer.timeout.connect(self._animate)
self.timer.start(50)
self._format = ""
self.setValue(0)
def setValue(self, value):
self._value = value
def value(self):
return self._value
def setMinimum(self, value):
"""兼容 QProgressBar 接口"""
pass
def setMaximum(self, value):
pass
def setFormat(self, format_str):
self._format = format_str
if '%' in format_str:
self._format = format_str.replace('%', f'{self._value}%')
self.update()
def setMinimumHeight(self, height):
self.setFixedHeight(height)
return self
def _animate(self):
self.offset += 5
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
painter.fillRect(0, 0, w, h, QColor(240, 240, 240))
wave_width = self._value / 100 * w
path = QPainterPath()
path.moveTo(0, h)
for x in range(0, int(wave_width) + 10, 10):
y = h - 20 + 10 * (x / 30 + self.offset / 20)
path.lineTo(x, y)
path.lineTo(wave_width, h)
path.closeSubpath()
gradient = QLinearGradient(0, 0, wave_width, 0)
gradient.setColorAt(0, QColor(76, 217, 100))
gradient.setColorAt(1, QColor(52, 199, 89))
painter.fillPath(path, gradient)
painter.setFont(QFont("Arial", 12, QFont.Weight.Bold))
painter.setPen(QColor(51, 51, 51))
if self._format:
display_text = self._format
else:
display_text = f"{self._value}%"
painter.drawText(0, 0, w, h, Qt.AlignmentFlag.AlignCenter, display_text)
class DotProgressBar(QWidget):
"""点阵进度条"""
def __init__(self, parent=None):
super().__init__(parent)
self._value = 0
self.setFixedHeight(30)
self._format = ""
self.setValue(0)
def setValue(self, value):
self._value = value
self.update()
def value(self):
return self._value
def setMinimum(self, value):
pass
def setMaximum(self, value):
pass
def setFormat(self, format_str):
self._format = format_str
self.update()
def setMinimumHeight(self, height):
self.setFixedHeight(height)
return self
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
# ===== 根据宽度自适应点阵数量 =====
dot_radius = 6
dot_spacing = dot_radius * 3 # 点间距 = 半径 * 3
dot_count = max(10, int((w - 20) / dot_spacing)) # 最少10个点,根据宽度计算
# 重新计算实际间距,让点均匀分布
total_width = w - 20
actual_spacing = total_width / max(dot_count, 1)
start_x = 10 # 起始X坐标
filled = int(self._value / 100 * dot_count)
for i in range(dot_count):
x = start_x + i * actual_spacing + actual_spacing / 2
y = h / 2
radius = dot_radius
if i < filled:
gradient = QRadialGradient(x, y, radius)
gradient.setColorAt(0, QColor(76, 217, 100))
gradient.setColorAt(1, QColor(52, 199, 89))
painter.setBrush(gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(x, y), radius, radius)
else:
painter.setBrush(QColor(200, 200, 200))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(x, y), radius, radius)
# 显示百分比(靠右)
painter.setFont(QFont("Arial", 9))
painter.setPen(QColor(51, 51, 51))
class GreenProgressBar(QProgressBar):
"""薄荷绿进度条"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(20)
self.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 10px;
background-color: #c8e6c9;
text-align: center;
color: #1b5e20;
font-weight: bold;
}
QProgressBar::chunk {
border-radius: 10px;
background-color: #4caf50;
}
""")
self.setValue(0)
def setValue(self, value):
super().setValue(value)
self.setFormat(f"🌿 {value}%")
class PinkProgressBar(QProgressBar):
"""樱花粉进度条"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(20)
self.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 10px;
background-color: #f8bbd0;
text-align: center;
color: #880e4f;
font-weight: bold;
}
QProgressBar::chunk {
border-radius: 10px;
background-color: #e91e63;
}
""")
def setValue(self, value):
super().setValue(value)
self.setFormat(f"🌸 {value}%")
class PurpleProgressBar(QProgressBar):
"""星际紫进度条"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(20)
self.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 10px;
background-color: #e9d5ff;
text-align: center;
color: #4a1a7a;
font-weight: bold;
}
QProgressBar::chunk {
border-radius: 10px;
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 #9333ea,
stop: 1 #a855f7
);
}
""")
self.setValue(0)
def setValue(self, value):
super().setValue(value)
self.setFormat(f"🌌 {value}%")
class BlueProgressBar(QProgressBar):
"""深海蓝进度条"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(20)
self.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 10px;
background-color: #bbdefb;
text-align: center;
color: #0d47a1;
font-weight: bold;
}
QProgressBar::chunk {
border-radius: 10px;
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 #2196f3,
stop: 1 #42a5f5
);
}
""")
self.setValue(0)
def setValue(self, value):
super().setValue(value)
self.setFormat(f"🌊 {value}%")
class CollapsibleBox(QWidget):
def __init__(self, title="", parent=None):
super().__init__(parent)
self.is_collapsed = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0,0,0,0)
layout.setSpacing(0)
self.header = QFrame()
self.header.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.header.setStyleSheet("QFrame{background-color:#f0f0f0;border:1px solid #ccc;border-radius:4px}")
hl = QHBoxLayout(self.header)
hl.setContentsMargins(8,4,8,4)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(f"▶ {title}")
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.toggle_btn.clicked.connect(self.toggle)
hl.addWidget(self.toggle_btn)
hl.addStretch()
layout.addWidget(self.header)
self.content_area = QWidget()
self.content_layout = QVBoxLayout(self.content_area)
self.content_layout.setContentsMargins(8,4,8,4)
self.content_layout.setSpacing(4)
layout.addWidget(self.content_area)
def toggle(self):
self.is_collapsed = not self.is_collapsed
self.content_area.setVisible(not self.is_collapsed)
t = self.toggle_btn.text().replace("▶ ","").replace("▼ ","")
self.toggle_btn.setText(f"{'▼' if not self.is_collapsed else '▶'} {t}")
def add_widget(self, w): self.content_layout.addWidget(w)
def add_layout(self, l): self.content_layout.addLayout(l)
class EmojiButton(QPushButton):
def __init__(self, text="", parent=None):
super().__init__(text, parent)
font_name = "Segoe UI Emoji" if sys.platform == "win32" else (
"Apple Color Emoji" if sys.platform == "darwin" else "Noto Color Emoji")
self.setFont(QFont(font_name, 10))
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.setMinimumWidth(60)
self._tooltip_text = ""
self.setMouseTracking(True)
def setToolTip(self, text):
self._tooltip_text = text
super().setToolTip(text)
def enterEvent(self, event):
super().enterEvent(event)
if self._tooltip_text:
# 立即显示,不延迟
QToolTip.showText(event.globalPosition().toPoint(), self._tooltip_text, self)
def leaveEvent(self, event):
super().leaveEvent(event)
QToolTip.hideText()
class DragDropLineEdit(QLineEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setPlaceholderText("可拖拽文件到此处...")
self._drag_enabled = False
def enable_drag_drop(self):
"""延迟启用拖拽功能"""
if not self._drag_enabled:
self.setAcceptDrops(True)
self._drag_enabled = True
def dragEnterEvent(self, e):
if self._drag_enabled and e.mimeData().hasUrls():
e.acceptProposedAction()
def dropEvent(self, e):
if not self._drag_enabled:
return
urls = e.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
normalized = os.path.normpath(path)
self.setText(normalized)
self.textChanged.emit(normalized)
class LogTextEdit(QPlainTextEdit):
files_dropped = pyqtSignal(list)
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
self.setAcceptDrops(True)
self.setPlaceholderText("可将数据文件拖拽到此区域自动添加\n\n打包日志将显示在这里...")
# ========== 添加滚动条 ==========
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
# 可选:设置滚动条样式
self.setStyleSheet("""
QPlainTextEdit {
font-family: Consolas;
font-size: 12px;
}
QScrollBar:vertical {
background: #f0f0f0;
width: 12px;
}
QScrollBar::handle:vertical {
background: #c0c0c0;
min-height: 20px;
border-radius: 6px;
}
""")
def append_log(self, msg):
"""添加日志到GUI(线程安全)"""
try:
self.appendPlainText(msg)
scrollbar = self.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
QApplication.processEvents()
except Exception as e:
pass
def dragEnterEvent(self, e):
if e.mimeData().hasUrls():
e.acceptProposedAction()
else:
super().dragEnterEvent(e)
def dragMoveEvent(self, e):
if e.mimeData().hasUrls():
e.acceptProposedAction()
else:
super().dragMoveEvent(e)
def dropEvent(self, e):
urls = e.mimeData().urls()
if urls:
files = []
for u in urls:
path = u.toLocalFile()
if path and os.path.exists(path):
files.append(path)
self.append_log(f"📎 添加文件: {os.path.basename(path)}")
if files:
self.files_dropped.emit(files)
e.acceptProposedAction()
else:
super().dropEvent(e)
class PackageWorker(QThread):
log_signal = pyqtSignal(str)
progress_signal = pyqtSignal(int)
time_signal = pyqtSignal(str)
finished_signal = pyqtSignal(bool, str)
def __init__(self, config):
super().__init__()
self.config = config
self._is_running = True
self.start_time = None
self.process = None
self._use_msvc = False # 添加MSVC标志
self._downloading = False # 添加下载标志
# 日志标志,防止重复打印
self._cache_logged = False
self._mingw_logged = False
self._local_logged = False
self._incomplete_logged = False
self._msvc_logged = False
self._msvc_ok_logged = False
self._download_logged = False
def safe_log(self, msg):
"""兼容方法,发送日志信号"""
self.log_signal.emit(msg)
def run(self):
process = None
packer = self.config.get('packer', 'PyInstaller')
try:
self.log_signal.emit("🚀 开始打包...")
'''
if packer == 'Nuitka':
self._run_nuitka_pack(self.config)
return
'''
cmd = self._build_command()
target_python = self.config.get('target_python', sys.executable)
use_venv = self.config.get('use_venv', False)
# ===== 构建环境变量 =====
if use_venv:
# ===== 虚拟环境模式:彻底隔离,只用自己的 =====
env = {}
# 1. 系统变量
system_keys = [
'SYSTEMROOT', 'TEMP', 'TMP', 'USERPROFILE',
'HOMEDRIVE', 'HOMEPATH', 'COMSPEC', 'WINDIR',
'ProgramFiles', 'CommonProgramFiles', 'ALLUSERSPROFILE'
]
for key in system_keys:
if key in os.environ:
env[key] = os.environ[key]
# 2. PATH:虚拟环境自己的目录
python_dir = os.path.dirname(target_python)
path_dirs = [
python_dir, # 虚拟环境根目录
os.path.join(python_dir, 'Scripts'), # 虚拟环境Scripts
]
# 加上Windows系统目录(必要的)
system_paths = [
r'C:\Windows\System32',
r'C:\Windows',
r'C:\Windows\System32\Wbem',
r'C:\Windows\System32\WindowsPowerShell\v1.0',
]
for p in system_paths:
if os.path.exists(p) and p not in path_dirs:
path_dirs.append(p)
env['PATH'] = os.pathsep.join(path_dirs)
# 3. 构建 PYTHONPATH(只包含虚拟环境自己的路径,不包含系统)
pythonpath_dirs = []
venv_site_packages = self.config.get('venv_site_packages')
if venv_site_packages and os.path.exists(venv_site_packages):
pythonpath_dirs.append(venv_site_packages)
# Python 标准库 Lib(虚拟环境自己的)
python_lib = os.path.join(python_dir, 'Lib')
if os.path.exists(python_lib) and python_lib not in pythonpath_dirs:
pythonpath_dirs.append(python_lib)
# Python DLLs(虚拟环境自己的)
python_dlls = os.path.join(python_dir, 'DLLs')
if os.path.exists(python_dlls) and python_dlls not in pythonpath_dirs:
pythonpath_dirs.append(python_dlls)
# Python 根目录(虚拟环境自己的)
if os.path.exists(python_dir) and python_dir not in pythonpath_dirs:
pythonpath_dirs.append(python_dir)
if pythonpath_dirs:
env['PYTHONPATH'] = os.pathsep.join(pythonpath_dirs)
# 4. 阻止访问系统
env['PYTHONNOUSERSITE'] = '1'
env['PYTHONSAFEPATH'] = '1'
# 5. 清除系统Python变量
for key in ['PYTHONHOME', 'VIRTUAL_ENV', 'PYTHONPATH_OLD',
'PYTHONSTARTUP', 'PYTHONEXECUTABLE']:
env.pop(key, None)
# 6. 编码
env['PYTHONIOENCODING'] = 'utf-8'
env['PYTHONUTF8'] = '1'
if sys.platform == 'win32':
env['PYTHONLEGACYWINDOWSSTDIO'] = 'utf-8'
self.log_signal.emit(f"📁 使用虚拟环境隔离模式: {target_python}")
else:
# ===== 非虚拟环境:使用当前环境,只清理干扰项 =====
env = os.environ.copy()
env['PYTHONIOENCODING'] = 'utf-8'
env['PYTHONUTF8'] = '1'
if sys.platform == 'win32':
env['PYTHONLEGACYWINDOWSSTDIO'] = 'utf-8'
env.pop('PYTHONHOME', None)
# 打印命令
if 'response_file' in self.config and self.config['response_file']:
try:
with open(self.config['response_file'], 'r', encoding='utf-8') as f:
content = f.read()
except:
pass
startupinfo = get_startupinfo()
process = self._popen_hidden(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
startupinfo=startupinfo,
bufsize=1,
universal_newlines=True,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0,
env=env
)
self.process = process
for line in iter(process.stdout.readline, ""):
if not self._is_running:
process.terminate()
import time
time.sleep(0.3)
if process.poll() is None:
process.kill()
return
if line:
line = line.rstrip()
if line:
try:
self.log_signal.emit(line)
except:
self.log_signal.emit(
line.encode('utf-8', errors='replace').decode('utf-8', errors='replace'))
try:
p = self._parse_progress(line)
if p is not None:
self.progress_signal.emit(p)
except:
pass
process.wait()
returncode = process.returncode
if returncode == 0:
try:
upx_result = self._manual_upx_compress()
if upx_result:
self.log_signal.emit(f"✅ {upx_result}")
except Exception as e:
self.log_signal.emit(f"⚠️ UPX压缩异常: {e}")
self.finished_signal.emit(True, "打包完成!")
else:
self.finished_signal.emit(False, f"返回码: {returncode}")
except Exception as e:
self.finished_signal.emit(False, str(e))
finally:
if hasattr(self, '_temp_packers_installed') and self._temp_packers_installed:
venv_python = self.config.get('venv_python')
if venv_python and os.path.exists(venv_python):
for pkg in self._temp_packers_installed:
if pkg in ['pyinstaller', 'nuitka']:
continue
self._uninstall_temp_packer(venv_python, pkg)
self._temp_packers_installed = []
if process:
try:
if process.poll() is None:
process.terminate()
import time
time.sleep(0.5)
if process.poll() is None:
process.kill()
time.sleep(0.2)
except:
pass
try:
if process.stdout:
process.stdout.close()
except:
pass
try:
if process.stderr:
process.stderr.close()
except:
pass
self.process = None
import gc
gc.collect()
def _manual_upx_compress(self):
try:
upx_path = self.config.get('upx_path', '')
if not upx_path or not os.path.exists(upx_path):
return None
compress_level = self.config.get('compress_level', '默认')
if compress_level == '不压':
return None
# 获取exe路径
exe_path = self._get_exe_path()
if not exe_path or not os.path.exists(exe_path):
return None
# ===== 确保UPX在PATH中 =====
upx_dir = os.path.dirname(upx_path)
current_path = os.environ.get('PATH', '')
if upx_dir not in current_path:
os.environ['PATH'] = upx_dir + os.pathsep + current_path
# 清空环境变量
os.environ.pop('UPX', None)
os.environ.pop('UPX_FLAGS', None)
# UPX 参数
upx_args = {
'最快': '-1',
'默认': '-7',
'最好': '--best',
'极致': '--ultra-brute'
}.get(compress_level, '-7')
upx_args = f'{upx_args} --force'
original_size = os.path.getsize(exe_path)
try:
self._run_hidden(
[upx_path, '-d', exe_path],
capture_output=True, timeout=60,
startupinfo=get_startupinfo()
)
except:
pass
# ===== 执行压缩 =====
result = self._run_hidden(
[upx_path] + upx_args.split() + [exe_path],
capture_output=True, text=True, timeout=300,
startupinfo=get_startupinfo()
)
if result.returncode == 0:
new_size = os.path.getsize(exe_path)
saved = original_size - new_size
saved_percent = (saved / original_size * 100) if original_size > 0 else 0
else:
return None
except subprocess.TimeoutExpired:
return None
except Exception as e:
self.log_signal.emit(f"⚠️ UPX异常: {e}")
return None
def __del__(self):
"""析构函数,确保进程被清理"""
if hasattr(self, 'process') and self.process:
try:
if self.process.poll() is None:
self.process.terminate()
import time
time.sleep(0.3)
if self.process.poll() is None:
self.process.kill()
if self.process.stdout:
self.process.stdout.close()
if self.process.stderr:
self.process.stderr.close()
except:
pass
self.process = None
import gc
gc.collect()
def _run_hidden(self, args, **kwargs):
"""隐藏窗口运行命令(兼容所有调用)"""
if sys.platform == 'win32':
if 'startupinfo' not in kwargs:
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = subprocess.SW_HIDE
kwargs['startupinfo'] = si
if 'creationflags' not in kwargs:
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
return subprocess.run(args, **kwargs)
def _popen_hidden(self, args, **kwargs):
"""隐藏窗口运行命令(Popen)- 支持编码"""
if sys.platform == 'win32':
if 'startupinfo' not in kwargs:
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = subprocess.SW_HIDE
kwargs['startupinfo'] = si
if 'creationflags' not in kwargs:
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
if 'text' not in kwargs and 'encoding' not in kwargs:
kwargs['text'] = True
kwargs['encoding'] = 'utf-8'
kwargs['errors'] = 'replace'
return subprocess.Popen(args, **kwargs)
def _get_exe_path(self):
"""获取生成的 exe 路径"""
try:
script = self.config.get('script', '')
if not script:
return None
project_name = os.path.splitext(os.path.basename(script))[0]
output_dir = self.config.get('output', os.path.join(os.path.dirname(script), 'dist'))
possible_paths = [
os.path.join(output_dir, f'{project_name}.exe'),
os.path.join(output_dir, project_name, f'{project_name}.exe'),
os.path.join(os.path.dirname(script), 'dist', f'{project_name}.exe'),
os.path.join(os.path.dirname(script), 'dist', project_name, f'{project_name}.exe'),
]
for path in possible_paths:
if os.path.exists(path):
return path
return None
except:
return None
def _format_size(self, size):
"""格式化大小显示"""
try:
if size <= 0:
return "0 B"
if size < 1024:
return f"{int(size)} B"
elif size < 1024 * 1024:
return f"{size / 1024:.2f} KB ({int(size)} B)"
elif size < 1024 * 1024 * 1024:
return f"{size / (1024 * 1024):.2f} MB ({int(size / 1024):,} KB)"
else:
return f"{size / (1024 * 1024 * 1024):.3f} GB ({int(size / (1024 * 1024)):,} MB)"
except Exception:
return "0 B"
def _build_command(self):
cfg = self.config
cmd = []
packer = cfg.get('packer', 'PyInstaller')
script = cfg.get('script', '')
use_venv = cfg.get('use_venv', False)
target_python = cfg.get('target_python', sys.executable)
venv_python = cfg.get('venv_python')
version_file = cfg.get('version_file')
version_info = cfg.get('version_info', {})
# ===== 获取所有选项 =====
platform = cfg.get('platform', 'current')
log_level = cfg.get('log_level', 'INFO')
collect = cfg.get('collect', '')
copy_metadata = cfg.get('copy_metadata', '')
# ===== 虚拟模式:处理临时打包器 =====
if venv_python and os.path.exists(venv_python):
packer_map = {
'PyInstaller-spec': 'pyinstaller',
'PyInstaller-cmd': 'pyinstaller',
'Nuitka': 'nuitka',
'PyApp': 'pyapp',
'Py2exe': 'py2exe',
'Cx_Freeze': 'cx-freeze',
'Pynsist': 'pynsist',
'PyOxidizer': 'pyoxidizer',
'Py2app': 'py2app',
}
packer_name = packer_map.get(packer)
if packer_name and packer_name not in ['pyinstaller', 'nuitka']:
self._install_temp_packer(venv_python, packer_name)
if not hasattr(self, '_temp_packers_installed'):
self._temp_packers_installed = []
if packer_name not in self._temp_packers_installed:
self._temp_packers_installed.append(packer_name)
def quote_path(p):
if not p:
return p
if ' ' in p:
return f'"{p}"'
return p
# ========== PyInstaller-spec 模式 ==========
if packer == 'PyInstaller-spec' or script.lower().endswith('.spec'):
spec_dir = os.path.dirname(script)
build_dir = os.path.join(spec_dir, 'build')
if use_venv:
cmd = [target_python, '-S', '-m', 'PyInstaller',
'--distpath', spec_dir,
'--workpath', build_dir]
else:
cmd = [target_python, '-m', 'PyInstaller',
'--distpath', spec_dir,
'--workpath', build_dir]
compress_level = cfg.get('compress_level', '默认')
upx_path = cfg.get('upx_path', '')
if upx_path and os.path.exists(upx_path) and compress_level != '不压':
upx_dir = os.path.dirname(upx_path)
cmd.append('--upx-dir')
cmd.append(upx_dir)
current_path = os.environ.get('PATH', '')
if upx_dir not in current_path:
os.environ['PATH'] = upx_dir + os.pathsep + current_path
UPX_FLAGS = ''
if compress_level == '最快':
os.environ['UPX_FLAGS'] = '-1'
elif compress_level == '默认':
os.environ['UPX_FLAGS'] = '-7'
elif compress_level == '最好':
os.environ['UPX_FLAGS'] = '--best'
elif compress_level == '极致':
os.environ['UPX_FLAGS'] = '--ultra-brute'
self.log_signal.emit(f"🗜️ UPX压缩: {compress_level}模式 {os.environ['UPX_FLAGS']} ")
if cfg.get('clean', False):
cmd.append('--clean')
if cfg.get('extra_args'):
extra = cfg['extra_args']
if isinstance(extra, str):
cmd.extend(extra.split())
else:
cmd.extend(extra)
cmd.append(script)
return cmd
# ========== Nuitka 模式 ==========
if packer == 'Nuitka':
cmd = [target_python, '-m', 'nuitka']
if version_info:
if version_info.get('product_name'):
cmd.append(f'--product-name={version_info["product_name"]}')
if version_info.get('company'):
cmd.append(f'--company-name={version_info["company"]}')
if version_info.get('file_version'):
cmd.append(f'--file-version={version_info["file_version"]}')
if version_info.get('product_version'):
cmd.append(f'--product-version={version_info["product_version"]}')
if version_info.get('product_name'):
cmd.append(f'--file-description={version_info["product_name"]}')
if version_info.get('company'):
cmd.append(f'--copyright=Copyright (c) {datetime.datetime.now().year} {version_info["company"]}')
#self.safe_log(f"📋 已应用版本信息: {version_info.get('product_name', '')} v{version_info.get('product_version', '')}")
compat_mode = cfg.get('nuitka_compat', False)
backend = cfg.get('backend', 'auto')
mingw_path = cfg.get('mingw_path', '')
msvc_path = cfg.get('msvc_path', '')
has_mingw = cfg.get('has_mingw', False)
has_msvc = cfg.get('has_msvc', False)
optimize = cfg.get('optimize', '平衡')
disable_ccache = cfg.get('disable_ccache', False)
if not disable_ccache:
ccache_path = self._find_best_ccache()
if ccache_path:
os.environ['NUITKA_CCACHE_BINARY'] = ccache_path
self.safe_log(f"✅ ccache: {ccache_path}")
else:
self.safe_log("⚠️ 未找到ccache")
else:
self.safe_log("🚫 已禁用ccache")
# ===== 【直接使用 cfg 中的 excludes】 =====
hidden_imports = cfg.get('hidden_imports', [])
exclude_list = cfg.get('excludes', [])
if exclude_list:
# 用逗号分隔所有排除的包
cmd.append(f'--nofollow-import-to={",".join(exclude_list)}')
#self.safe_log(f"🚫 排除 {len(exclude_list)} 个包")
# ===== 输出模式 =====
if cfg.get('onefile', True):
if compat_mode:
cmd.append('--standalone')
cmd.append('--onefile')
else:
cmd.append('--onefile')
else:
cmd.append('--standalone')
# ===== 控制台 =====
if not cfg.get('debug', False):
if compat_mode:
cmd.append('--windows-console-mode=disable')
else:
cmd.append('--disable-console')
else:
if compat_mode:
cmd.append('--windows-console-mode=attach')
# ===== 名称和输出 =====
if cfg.get('name'):
cmd.append(f'--output-filename={cfg["name"]}')
if cfg.get('output'):
cmd.append(f'--output-dir={cfg["output"]}')
# ===== 图标 =====
if cfg.get('icon'):
icon_path = cfg['icon']
icon_name = os.path.basename(icon_path)
if compat_mode:
cmd.append(f'--windows-icon-from-ico={icon_path}')
else:
cmd.append(f'--icon={icon_path}')
cmd.append(f'--include-data-file={icon_path}={icon_name}')
# ===== 并行编译 =====
jobs = cfg.get('jobs', 'auto')
if jobs == 'auto':
import multiprocessing
auto_jobs = max(1, multiprocessing.cpu_count())
cmd.append(f'--jobs={auto_jobs}')
self.safe_log(f"🔧 自动并行编译: {auto_jobs} 核")
else:
cmd.append(f'--jobs={jobs}')
self.safe_log(f"🔧 并行编译: {jobs} 核")
# ===== 编译器后端 =====
backend = cfg.get('backend', 'auto')
if backend == 'auto':
if has_mingw:
cmd.append('--mingw64')
elif has_msvc:
cmd.append('--msvc=latest')
else:
cmd.append('--mingw64')
elif backend == 'MinGW64':
cmd.append('--mingw64')
elif backend == 'MSVC':
cmd.append('--msvc=latest')
# ===== GUI插件 =====
plugin = cfg.get('gui_plugin', 'auto')
# if plugin != 'auto' and plugin != 'none':
# cmd.append(f'--enable-plugin={plugin}')
# ===== LTO =====
lto = cfg.get('lto', 'no')
if optimize == "速度优先":
if lto == 'yes' or lto == 'thin':
self.safe_log("⚡ 速度优先模式:禁用LTO")
lto = 'no'
if lto == 'yes':
cmd.append('--lto=yes')
self.safe_log("🔗 已启用LTO 优化")
elif lto == 'thin':
cmd.append('--lto=thin')
self.safe_log("🔗 已启用Thin LTO 优化")
else:
cmd.append('--lto=no')
self.safe_log("🔗 LTO 已禁用")
# ===== 隐藏导入自动检测插件 =====
has_qt = any(mod.lower() in ['pyqt6', 'pyqt5', 'pyside6', 'pyside2'] for mod in hidden_imports)
has_sf = any(mod.lower() in ['torch', 'numpy', 'pandas', 'matplotlib', 'tensorflow'] for mod in hidden_imports)
has_tk = any(mod.lower() == 'tkinter' for mod in hidden_imports)
has_wx = any(mod.lower() == 'wx' for mod in hidden_imports)
plugin_args = set()
if has_sf:
for mod in hidden_imports:
if mod.lower() == 'torch':
plugin_args.add('--enable-plugin=torch')
elif mod.lower() == 'tensorflow':
plugin_args.add('--enable-plugin=tensorflow')
elif mod.lower() == 'numpy' or mod.lower() == 'pandas' or mod.lower() == 'matplotlib' :
plugin_args.add('--enable-plugin=numpy')
if has_qt:
plugin_args.add('--include-qt-plugins=platforms,styles,imageformats')
for mod in hidden_imports:
if mod.lower() == 'pyqt6':
plugin_args.add('--enable-plugin=pyqt6')
elif mod.lower() == 'pyqt5':
plugin_args.add('--enable-plugin=pyqt5')
elif mod.lower() == 'pyside6':
plugin_args.add('--enable-plugin=pyside6')
elif mod.lower() == 'pyside2':
plugin_args.add('--enable-plugin=pyside2')
if has_tk:
plugin_args.add('--enable-plugin=tk-inter')
if has_wx:
plugin_args.add('--enable-plugin=wx-python')
cmd.extend(list(plugin_args))
# ===== UPX压缩 =====
upx_path = cfg.get('upx_path', '')
compress_level = cfg.get('compress_level', '默认')
if optimize == "速度优先":
upx_enabled = False
self.safe_log("⚡ 速度优先模式:禁用UPX压缩")
else:
upx_enabled = upx_path and os.path.exists(upx_path) and compress_level != '不压'
if upx_enabled:
if 'UPX' in os.environ:
del os.environ['UPX']
if sys.platform == 'win32':
try:
import ctypes
GetShortPathName = ctypes.windll.kernel32.GetShortPathNameW
GetShortPathName.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32]
buffer = ctypes.create_unicode_buffer(260)
GetShortPathName(upx_path, buffer, 260)
short_path = buffer.value
if short_path:
upx_path = short_path
except:
pass
cmd.append('--enable-plugin=upx')
cmd.append(f'--upx-binary={upx_path}')
if compress_level == '最好':
cmd.append('--optimize=1')
elif compress_level == '极致':
cmd.append('--optimize=2')
elif compress_level == '最快':
cmd.append('--optimize=0')
self.safe_log(f"🗜️ UPX压缩: {compress_level}模式")
# ===== 兼容模式 =====
if compat_mode:
cmd.append('--assume-yes-for-downloads')
#进度\内存
#cmd.append('--show-progress')
#cmd.append('--show-memory')
# ===== 去除符号 =====
if cfg.get('strip', True):
if optimize == "速度优先":
self.safe_log("⚡ 速度优先模式:保留调试符号以加快链接")
else:
cmd.append('--remove-output')
else:
if not compat_mode:
cmd.append('--unstripped')
# ===== 低内存 =====
if cfg.get('low_memory', False):
cmd.append('--low-memory')
self.safe_log("🧠 已启用低内存模式")
# ===== 实验性 =====
if cfg.get('experimental', False):
cmd.append('--experimental')
# ===== 缓存目录(如果配置中有) =====
cache_dir = cfg.get('cache_dir')
if cache_dir:
os.environ['NUITKA_CACHE_DIR'] = cache_dir
# ===== 额外参数 =====
if cfg.get('extra_args'):
extra = cfg['extra_args']
if isinstance(extra, str):
cmd.extend(extra.split())
else:
cmd.extend(extra)
# ===== 脚本 =====
cmd.append(script)
# ===== 打包外部脚本 =====
pack_scripts = cfg.get('pack_scripts', [])
for script_path in pack_scripts:
if os.path.exists(script_path):
script_name = os.path.basename(script_path)
cmd.append(f'--include-data-file={script_path}={script_name}')
self.safe_log(f"📦 打包外部脚本: {script_name}")
return cmd
# ========== PyInstaller cmd 模式 ==========
if use_venv:
cmd = [target_python, '-S', '-m', 'PyInstaller']
else:
cmd = [target_python, '-m', 'PyInstaller']
cmd.append('--onefile' if cfg.get('onefile', True) else '--onedir')
# ===== 使用已生成的版本文件 =====
if version_file and os.path.exists(version_file):
cmd.append(f'--version-file={version_file}')
for mod in cfg.get('hidden_imports', []):
cmd.extend(['--hidden-import', mod])
for mod in cfg.get('excludes', []):
cmd.extend(['--exclude-module', mod])
# ===== 添加日志级别 =====
log_level = cfg.get('log_level', 'INFO')
if log_level and log_level != 'INFO':
cmd.extend(['--log-level', log_level])
# 平台
if platform and platform != 'current':
cmd.extend(['--target-arch', platform])
# 收集
if collect:
cmd.extend(['--collect-all', collect])
# 元数据
if copy_metadata:
cmd.extend(['--copy-metadata', copy_metadata])
if not cfg.get('debug', False):
cmd.append('--noconsole')
if cfg.get('clean', False):
cmd.append('--clean')
if cfg.get('strip', True):
cmd.append('--strip')
if cfg.get('name'):
cmd.extend(['--name', cfg['name']])
if cfg.get('output'):
cmd.extend(['--distpath', cfg['output']])
if cfg.get('icon'):
cmd.extend(['--icon', cfg['icon']])
compress_level = cfg.get('compress_level', '默认')
upx_path = cfg.get('upx_path', '')
if upx_path and os.path.exists(upx_path) and compress_level != '不压':
upx_dir = os.path.dirname(upx_path)
cmd.append('--upx-dir')
cmd.append(upx_dir)
current_path = os.environ.get('PATH', '')
if upx_dir not in current_path:
os.environ['PATH'] = upx_dir + os.pathsep + current_path
UPX_FLAGS = ''
if compress_level == '最快':
os.environ['UPX_FLAGS'] = '-1'
elif compress_level == '默认':
os.environ['UPX_FLAGS'] = '-7'
elif compress_level == '最好':
os.environ['UPX_FLAGS'] = '--best'
elif compress_level == '极致':
os.environ['UPX_FLAGS'] = '--ultra-brute'
self.log_signal.emit(f"🗜️ UPX压缩: {compress_level}模式 {os.environ['UPX_FLAGS']}")
else:
cmd.append('--noupx')
if compress_level != '不压':
self.log_signal.emit("⚠️ UPX未找到,使用 --noupx")
for src, dst in cfg.get('data_files', []):
sep = ';' if sys.platform == 'win32' else ':'
cmd.extend(['--add-data', f'{src}{sep}{dst}'])
if cfg.get('extra_args'):
extra = cfg['extra_args']
if isinstance(extra, str):
cmd.extend(extra.split())
else:
cmd.extend(extra)
cmd.append(script)
pack_scripts = cfg.get('pack_scripts', [])
for script_path in pack_scripts:
if os.path.exists(script_path):
script_name = os.path.basename(script_path)
sep = ';' if sys.platform == 'win32' else ':'
already = False
for src, dst in cfg.get('data_files', []):
if src == script_path:
already = True
break
if not already:
cmd.extend(['--add-data', f'{script_path}{sep}.'])
self.log_signal.emit(f"📦 打包外部脚本: {script_name}")
return cmd
def _fix_dir_permissions(dir_path):
"""递归修复目录下所有exe/dll文件的执行权限(Windows用icacls)"""
if not os.path.isdir(dir_path):
return
try:
subprocess.run(['icacls', dir_path, '/grant', 'Everyone:(OI)(CI)RX', '/T'],
capture_output=True, timeout=30)
except:
pass
for root, dirs, files in os.walk(dir_path):
for f in files:
if f.endswith(('.exe', '.dll')):
p = os.path.join(root, f)
if os.path.isfile(p):
try:
subprocess.run(['powershell', '-Command', f'Unblock-File -Path "{p}"'],
capture_output=True, timeout=5)
except:
pass
if not os.access(p, os.X_OK):
try:
os.chmod(p, 0o755)
except:
pass
def _find_best_ccache(self, project_dir=None):
"""找版本最新的ccache,返回路径或None。不排除任何路径,自动修复Nuitka缓存权限。"""
exe = 'ccache.exe' if sys.platform == 'win32' else 'ccache'
candidates = []
def _add_candidate(p):
if not os.path.isfile(p):
return
if not os.access(p, os.X_OK):
try:
os.chmod(p, 0o755)
except:
pass
if os.access(p, os.X_OK):
candidates.append(p)
for d in os.environ.get('PATH', '').split(os.pathsep):
d = d.strip('"').strip("'")
if d:
_add_candidate(os.path.join(d, exe))
if sys.platform == 'win32':
try:
r = subprocess.run(['where', 'ccache'], capture_output=True, text=True, timeout=5)
if r.returncode == 0:
for line in r.stdout.strip().splitlines():
p = line.strip()
if 'Nuitka' in p and 'Cache' in p:
try:
# 向上找到Nuitka根目录(如 C:\...\Local\Nuitka)
nuitka_dir = p
while nuitka_dir and os.path.basename(nuitka_dir) != 'Nuitka':
parent = os.path.dirname(nuitka_dir)
if parent == nuitka_dir:
break
nuitka_dir = parent
# 如果上层也是Nuitka(如 Nuitka\Nuitka),取上层
if os.path.basename(nuitka_dir) == 'Nuitka':
parent = os.path.dirname(nuitka_dir)
if os.path.basename(parent) == 'Nuitka':
nuitka_dir = parent
self._fix_dir_permissions(nuitka_dir)
except:
pass
_add_candidate(p)
except:
pass
# which
w = shutil.which('ccache')
if w:
_add_candidate(w)
if not candidates:
return None
best_path, best_ver = None, (0,)
for p in dict.fromkeys(candidates):
try:
if project_dir:
try:
if os.path.commonpath([os.path.normcase(os.path.abspath(p)),
os.path.normcase(os.path.abspath(project_dir))]) == os.path.normcase(
os.path.abspath(project_dir)):
continue
except ValueError:
pass
r = subprocess.run([p, '--version'], capture_output=True, text=True, timeout=5)
if r.returncode != 0:
cdir = os.path.dirname(p)
env = os.environ.copy()
if cdir not in env.get('PATH', ''):
env['PATH'] = cdir + os.pathsep + env.get('PATH', '')
r = subprocess.run([p, '--version'], capture_output=True, text=True, timeout=5, cwd=cdir, env=env)
if r.returncode == 0:
m = re.search(r'[Vv]ersion\s+(\d+)(?:\.(\d+))?(?:\.(\d+))?', r.stdout)
if not m:
m = re.search(r'ccache\s+(\d+)(?:\.(\d+))?(?:\.(\d+))?', r.stdout)
if not m:
m = re.search(r'(\d+)\.(\d+)(?:\.(\d+))?', r.stdout)
if m:
ver = tuple(int(x) if x else 0 for x in m.groups())
if ver > best_ver:
best_ver, best_path = ver, p
except:
continue
return best_path if best_path else (candidates[0] if candidates else None)
def _parse_progress(self, line):
"""解析真实进度 - 增加完成检测和通用百分比"""
if not line or not isinstance(line, str):
return None
try:
import re
packer = self.config.get('packer')
line_lower = line.lower()
# ===== 优先检测完成信号(所有打包器通用) =====
complete_keywords = [
'completed successfully',
'successfully completed',
'building complete',
'finished successfully',
'done!',
'exe built',
'completed.',
'success',
'complete!',
'finished!',
'build complete',
'successfully built',
]
for kw in complete_keywords:
if kw in line_lower:
return 100
# ===== PyInstaller =====
if packer.startswith('PyInstaller'):
if 'INFO:' in line:
if "开始打包" in line:
return 5
if 'Analysis' in line:
return 10
if 'PYZ' in line:
return 30
if 'PKG' in line:
return 50
if 'EXE' in line:
return 80
if 'Complete' in line or 'completed' in line_lower:
return 95
if "打包完成" in line:
return 100
# ===== Nuitka =====
elif packer == 'Nuitka':
if 'Used command line options' in line:
return 1
if 'Starting Python compilation' in line:
return 5
if 'Completed Python level compilation' in line or 'optimization' in line_lower:
return 10
if 'Generating source code for C backend' in line:
return 20
if 'Running data composer tool' in line:
return 30
if 'Running C compilation via Scons' in line:
return 40
if 'Backend C compiler' in line:
self.safe_log("⏳ 正在编译C代码,这可能需要较长时间...")
return 50
if 'Slow C compilation detected' in line:
return 55
if 'Backend C linking' in line:
return 60
if 'Compiled' in line and 'C files' in line:
# 解析编译进度
match = re.search(r'Compiled\s+(\d+)\s+C files', line)
if match:
compiled = int(match.group(1))
# 假设总共约150个文件,计算进度
progress = min(50 + int(compiled / 150 * 30), 80)
return progress
if 'Onefile: Creating single file' in line or 'Creating single file' in line:
return 85
if 'Onefile payload compression' in line:
return 90
if 'Onefile C linking' in line:
return 92
if 'Removing onefile build directory' in line or 'removing onefile build' in line_lower:
return 95
if 'Removing build directory' in line or 'removing build directory' in line_lower:
return 98
if 'Successfully created' in line:
return 100
# ===== Py2exe =====
elif packer == 'Py2exe':
if 'running' in line_lower or 'py2exe' in line_lower:
return 10
if 'copying' in line_lower:
return 30
if 'building' in line_lower:
return 50
if 'dll' in line_lower:
return 70
if 'complete' in line_lower:
return 100
# ===== cx_Freeze =====
elif packer == 'Cx_Freeze':
if 'running build_exe' in line_lower:
return 10
if 'running egg_info' in line_lower:
return 15
if 'creating directory' in line_lower:
return 20
if 'copying data from package' in line_lower:
return 30
if 'copying' in line_lower and ('.pyd' in line_lower or '.dll' in line_lower):
return 50
if 'writing zip file' in line_lower:
return 70
if 'Missing dependencies' in line:
return 85
if '打包完成' in line or 'cx_Freeze 打包完成' in line:
return 100
# ===== PyApp =====
elif packer == 'PyApp':
if 'generating wheel' in line_lower or '正在生成' in line_lower:
return 10
if 'wheel 包生成成功' in line_lower or 'wheel package' in line_lower:
return 20
if '找到 cargo' in line_lower or 'found cargo' in line_lower:
return 25
if 'fresh' in line_lower and ('unicode' in line_lower or 'proc-macro' in line_lower):
return 30
if 'compiling' in line_lower and 'pyapp' in line_lower:
return 50
if 'running' in line_lower and 'rustc' in line_lower:
return 60
if 'finished release' in line_lower:
return 80
if '已复制' in line_lower or 'copied' in line_lower:
return 90
if '打包成功' in line_lower or 'success' in line_lower:
return 100
# ===== PyOxidizer =====
elif packer == 'PyOxidizer':
if 'compiling' in line_lower:
return 20
if 'linking' in line_lower:
return 60
if 'finished' in line_lower:
return 80
if 'success' in line_lower:
return 100
# ===== Pynsist =====
elif packer == 'Pynsist':
if 'generating' in line_lower:
return 30
if 'compiling' in line_lower:
return 60
if 'success' in line_lower:
return 100
# ===== Py2app =====
elif packer == 'Py2app':
if 'copying' in line_lower:
return 30
if 'building' in line_lower:
return 60
if 'creating' in line_lower:
return 80
if 'complete' in line_lower:
return 100
# ===== 通用百分比解析(兜底) =====
match = re.search(r'(\d+)%', line)
if match:
pct = int(match.group(1))
if 0 <= pct <= 100:
return pct
# ===== 额外完成检测(兜底) =====
if 'Building' in line and ('success' in line_lower or 'complete' in line_lower):
return 100
if 'Success' in line and ('exe' in line_lower or 'built' in line_lower):
return 100
except Exception as e:
pass
return None
def stop(self):
"""停止打包"""
self._is_running = False
if self.process:
packer = self.config.get('packer', '') if hasattr(self, 'config') else ''
if packer == 'Nuitka':
# Nuitka用terminate()优雅终止,
try:
self.process.terminate()
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=1)
except Exception:
pass
else:
try:
self.process.terminate()
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
self.process.kill()
except Exception:
pass
self.process = None
def _install_temp_packer(self, venv_python, packer_name):
"""临时安装打包器"""
import subprocess
clean_env = {'PATH': os.environ.get('PATH', '')}
if sys.platform == 'win32':
clean_env['SYSTEMROOT'] = os.environ.get('SYSTEMROOT', '')
try:
check = subprocess.run(
[venv_python, '-m', 'pip', 'show', packer_name],
capture_output=True, text=True, env=clean_env, timeout=5
)
if check.returncode == 0:
return True
self.log_signal.emit(f"📥 临时安装打包器: {packer_name}")
result = subprocess.run(
[venv_python, '-m', 'pip', 'install', packer_name, '-i', MIRROR, '-q', '--no-warn-script-location'],
capture_output=True, text=True, env=clean_env, timeout=300
)
if result.returncode == 0:
self.log_signal.emit(f"✅ {packer_name} 临时安装成功")
return True
else:
self.log_signal.emit(f"❌ {packer_name} 安装失败: {result.stderr[:100]}")
return False
except Exception as e:
self.log_signal.emit(f"❌ 临时安装异常: {e}")
return False
def _uninstall_temp_packer(self, venv_python, packer_name):
"""卸载临时打包器"""
import subprocess
if packer_name in ['pyinstaller', 'nuitka']:
return
clean_env = {'PATH': os.environ.get('PATH', '')}
if sys.platform == 'win32':
clean_env['SYSTEMROOT'] = os.environ.get('SYSTEMROOT', '')
try:
self.log_signal.emit(f"📤 卸载临时打包器: {packer_name}")
subprocess.run(
[venv_python, '-m', 'pip', 'uninstall', '-y', packer_name],
capture_output=True, text=True, env=clean_env, timeout=60
)
self.log_signal.emit(f"✅ {packer_name} 已卸载")
except Exception as e:
self.log_signal.emit(f"⚠️ 卸载 {packer_name} 失败: {e}")
# ==================== ContentLoader ====================
class ContentLoader(QThread):
finished = pyqtSignal(str, str)
def __init__(self):
super().__init__()
def run(self):
changelog = self._load_or_default("CHANGELOG.txt", self.get_default_changelog())
tutorial = self._load_or_default("TUTORIAL.txt", self.get_default_tutorial())
self.finished.emit(changelog, tutorial)
def _load_or_default(self, filename, default_func):
try:
if os.path.exists(filename):
with open(filename, 'r', encoding='utf-8') as f:
content = f.read().strip()
if content:
return content
except:
pass
return default_func()
@staticmethod
def get_default_changelog():
return "\n".join([
"=" * 50,
f" Python 跨平台打包工具 v{VERSION}",
"=" * 50,
"",
f" 更新日期: {BUILD_DATE}",
f" 作者: {AUTHOR}",
"",
"-" * 40,
"v7.0.0 - 重大更新",
"-" * 40,
" * 支持在线获取GitHub源码",
" * 打包进度彩色显示 (8种风格)",
" * 语法检查与修复功能",
" * 大小预估功能",
" * 异步秒启动优化",
" * 完善约九种打包器支持",
"",
" 打包器支持:",
" - PyInstaller (cmd/spec)",
" - Nuitka (MinGW64/MSVC)",
" - Py2exe / Cx_Freeze",
" - PyApp / Pynsist",
" - PyOxidizer / Py2app",
"",
" 功能特性:",
" - 虚拟环境隔离打包",
" - UPX 压缩集成",
" - ccache 加速编译",
" - 系统资源实时监控",
" - 代码修复预览对比",
" - 多镜像自动切换",
" - 自动安装Python环境",
"=" * 50,
])
@staticmethod
def get_default_tutorial():
return "\n".join([
"=" * 50,
" 使用教程 - Python代码打包工具",
"=" * 50,
"",
"第一步: 选择Python脚本",
" 点击浏览按钮或拖拽.py文件到输入框。",
"",
"第二步: 选择打包器",
" - PyInstaller: 最流行,兼容性好,推荐新手",
" - Nuitka: 编译为C代码,性能好,体积小",
" - Py2exe / Cx_Freeze: 经典老牌工具",
"",
"第三步: 配置选项",
" - 单文件/目录模式: 推荐单文件",
" - 控制台: 调试时勾选,发布时取消",
" - 图标: 选择.ico文件作为程序图标",
" - 数据文件: 添加程序需要的额外文件",
"",
"第四步: 点击开始打包",
" 等待进度条完成,在输出目录找到生成的exe。",
"",
"-" * 40,
" 高级选项说明",
"-" * 40,
"",
" 隐藏导入 (--hidden-import):",
" 如果打包后提示缺少模块,在此添加模块名。",
"",
" 排除模块 (--exclude-module):",
" 排除不需要的模块以减小体积。",
"",
" UPX压缩:",
" UPX可对exe压缩30%-50%。",
" 下载upx.exe放到工具目录即可自动检测。",
"",
" 虚拟环境:",
" 在虚拟环境中安装打包器,避免系统污染。",
"",
" Nuitka编译器:",
" - MinGW64: 自动下载,无需配置",
" - MSVC: 需要安装Visual Studio",
"",
"-" * 40,
" 常见问题",
"-" * 40,
"",
"Q: 打包后exe运行闪退?",
"A: 勾选控制台模式重新打包,在cmd中运行查看错误。",
"",
"Q: 提示模块未找到?",
"A: 在隐藏导入中添加该模块名。",
"",
"Q: 打包后体积太大?",
"A: 使用虚拟环境+UPX压缩+排除无用模块。",
"",
"Q: Nuitka打包很慢?",
"A: Nuitka需要编译C代码,首次较慢,",
" 后续会使用ccache缓存加速。",
"=" * 50,
])
class SyntaxCheckWorker(QThread):
log_signal = pyqtSignal(str)
finished_signal = pyqtSignal(bool, str, object)
def __init__(self, script_path):
super().__init__()
self.script_path = script_path
def run(self):
try:
with open(self.script_path, 'r', encoding='utf-8') as f:
content = f.read()
tree = ast.parse(content)
self.finished_signal.emit(True, "语法检查通过", None)
except SyntaxError as e:
self.log_signal.emit(f"语法错误: {e}")
fix_info = self._analyze_syntax_error(content, e)
self.finished_signal.emit(False, str(e), fix_info)
except Exception as e:
self.finished_signal.emit(False, str(e), None)
def _analyze_syntax_error(self, content, error):
lines = content.split('\n')
lineno = error.lineno - 1 if error.lineno else 0
context_start = max(0, lineno - 3)
context_end = min(len(lines), lineno + 3)
context = []
changes = []
for i in range(context_start, context_end):
prefix = ">>>" if i == lineno else " "
context.append(f"{prefix} {i+1:4d} | {lines[i].rstrip()}")
if lineno < len(lines):
line = lines[lineno]
if line.rstrip().endswith(':') and lineno + 1 < len(lines):
next_line = lines[lineno + 1]
if next_line.strip() and not next_line.startswith((' ', '\t')):
changes.append(f"{lineno+1}: ")
return {'context': context, 'changes': changes, 'lineno': lineno + 1, 'error_msg': str(error)}
class GitHubFetchWorker(QThread):
log_signal = pyqtSignal(str)
finished_signal = pyqtSignal(bool, str, str)
def __init__(self, url):
super().__init__()
self.url = url
def run(self):
try:
import ssl
match = re.search(r'github\.com/([^/]+)/([^/]+?)(?:/tree/([^/]+))?', self.url)
if not match:
raw_url = self.url.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
self.log_signal.emit(f"Downloading: {raw_url}")
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(raw_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, context=context, timeout=30) as resp:
content = resp.read().decode('utf-8', errors='replace')
filename = os.path.basename(raw_url)
save_path = os.path.join(os.getcwd(), filename)
with open(save_path, 'w', encoding='utf-8') as f:
f.write(content)
self.finished_signal.emit(True, f"Saved: {save_path}", save_path)
return
owner = match.group(1)
repo = match.group(2)
branch = match.group(3) or 'main'
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
self.log_signal.emit(f"Fetching repo tree: {owner}/{repo}")
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(api_url, headers={
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/vnd.github.v3+json'
})
with urllib.request.urlopen(req, context=context, timeout=15) as resp:
data = json.loads(resp.read().decode('utf-8'))
py_files = [item for item in data.get('tree', []) if item['path'].endswith('.py')]
if not py_files:
self.finished_signal.emit(False, "No .py files found in repo", "")
return
if len(py_files) == 1:
selected = py_files[0]
else:
main_candidates = [f for f in py_files if f['path'].lower() in ('main.py', 'app.py', 'run.py', 'index.py')]
selected = main_candidates[0] if main_candidates else py_files[0]
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{selected['path']}"
self.log_signal.emit(f"Downloading: {selected['path']}")
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(raw_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, context=context, timeout=30) as resp:
content = resp.read().decode('utf-8', errors='replace')
save_path = os.path.join(os.getcwd(), selected['path'].replace('/', '_'))
with open(save_path, 'w', encoding='utf-8') as f:
f.write(content)
self.finished_signal.emit(True, f"Saved: {save_path}", save_path)
except Exception as e:
self.finished_signal.emit(False, str(e), "")
# ==================== MainWindow ====================
class MainWindow(QMainWindow):
"""Python脚本打包工具 - 主窗口"""
def __init__(self):
super().__init__()
self.pack_thread = None
self.monitor_thread = None
self.progress_bar = None
self._python_path = None
self._data_files = []
self._pack_scripts = []
self.setWindowTitle(f"Python 代码跨平台打包工具 v{VERSION}")
self.setMinimumSize(1100, 780)
self._setup_ui()
self._center_on_screen()
self._start_monitor()
self._auto_detect_python()
self._auto_detect_upx()
QTimer.singleShot(300, self._enable_drag_drop)
def _center_on_screen(self):
screen = QApplication.primaryScreen().availableGeometry()
w = min(1200, int(screen.width() * 0.85))
h = min(850, int(screen.height() * 0.85))
self.resize(w, h)
x = screen.x() + (screen.width() - w) // 2
y = screen.y() + (screen.height() - h) // 2
self.move(x, y)
def _enable_drag_drop(self):
if hasattr(self, 'script_edit'):
self.script_edit.enable_drag_drop()
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setContentsMargins(12, 8, 12, 8)
main_layout.setSpacing(6)
# Toolbar
toolbar = QWidget()
tb = QHBoxLayout(toolbar)
tb.setContentsMargins(0, 0, 0, 0)
tb.setSpacing(6)
self.packer_combo = QComboBox()
self.packer_combo.addItems([
'PyInstaller-cmd', 'PyInstaller-spec', 'Nuitka',
'Py2exe', 'Cx_Freeze', 'PyApp', 'Pynsist', 'PyOxidizer', 'Py2app'
])
self.packer_combo.setFixedWidth(140)
self.packer_combo.currentTextChanged.connect(self._on_packer_changed)
tb.addWidget(QLabel("📦 打包器:"))
tb.addWidget(self.packer_combo)
tb.addSpacing(8)
self.script_edit = DragDropLineEdit()
self.script_edit.setPlaceholderText("选择或拖拽 .py 脚本文件到此处...")
self.script_edit.setMinimumHeight(28)
self.script_edit.textChanged.connect(self._on_script_changed)
tb.addWidget(self.script_edit, stretch=1)
browse_btn = QPushButton("📂 浏览")
browse_btn.setFixedWidth(65)
browse_btn.clicked.connect(self._browse_script)
tb.addWidget(browse_btn)
github_btn = QPushButton("🌐 GitHub")
github_btn.setFixedWidth(75)
github_btn.setToolTip("从GitHub获取源码")
github_btn.clicked.connect(self._fetch_github)
tb.addWidget(github_btn)
main_layout.addWidget(toolbar)
# Content splitter
content_splitter = QSplitter(Qt.Orientation.Horizontal)
content_splitter.setHandleWidth(4)
# Left: config
left_scroll = QScrollArea()
left_scroll.setWidgetResizable(True)
left_scroll.setFrameShape(QFrame.Shape.NoFrame)
left_widget = QWidget()
left_layout = QVBoxLayout(left_widget)
left_layout.setContentsMargins(0, 0, 6, 0)
left_layout.setSpacing(4)
basic_box = CollapsibleBox("🔧 基础设置")
self._setup_basic_options(basic_box)
left_layout.addWidget(basic_box)
adv_box = CollapsibleBox("⚙ 高级选项")
self._setup_advanced_options(adv_box)
left_layout.addWidget(adv_box)
self.nuitka_box = CollapsibleBox("🔬 Nuitka 专用选项")
self._setup_nuitka_options(self.nuitka_box)
left_layout.addWidget(self.nuitka_box)
self.nuitka_box.setVisible(False)
ver_box = CollapsibleBox("📋 版本信息")
self._setup_version_options(ver_box)
left_layout.addWidget(ver_box)
left_layout.addStretch()
left_scroll.setWidget(left_widget)
content_splitter.addWidget(left_scroll)
# Right: log + progress
right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_layout.setContentsMargins(6, 0, 0, 0)
right_layout.setSpacing(4)
# Progress
progress_frame = QWidget()
progress_layout = QVBoxLayout(progress_frame)
progress_layout.setContentsMargins(0, 0, 0, 0)
progress_layout.setSpacing(2)
progress_header = QHBoxLayout()
progress_header.addWidget(QLabel("📊 打包进度:"))
self.progress_style_combo = QComboBox()
self.progress_style_combo.addItems([
'🎨 彩色条纹', '⚡ 简洁', '🌊 波浪', '🔵 点阵',
'☘️ 薄荷绿', '🌸 樱花粉', '🪐 星际紫', '🌊 深海蓝'
])
self.progress_style_combo.setFixedWidth(100)
self.progress_style_combo.currentTextChanged.connect(self._switch_progress_style)
progress_header.addWidget(self.progress_style_combo)
progress_header.addStretch()
progress_layout.addLayout(progress_header)
self.progress_container = QStackedWidget()
self.progress_container.setFixedHeight(55)
self._init_progress_bars()
progress_layout.addWidget(self.progress_container)
progress_frame.setLayout(progress_layout)
right_layout.addWidget(progress_frame)
# Buttons
btn_row = QHBoxLayout()
btn_row.setSpacing(8)
self.check_btn = QPushButton("🔍 语法检查")
self.check_btn.setStyleSheet("background:#17a2b8; color:white; font-weight:bold; padding:6px 14px; border-radius:4px;")
self.check_btn.clicked.connect(self._run_syntax_check)
btn_row.addWidget(self.check_btn)
self.estimate_btn = QPushButton("📏 大小预估")
self.estimate_btn.setStyleSheet("background:#6c757d; color:white; padding:6px 12px; border-radius:4px;")
self.estimate_btn.clicked.connect(self._estimate_size)
btn_row.addWidget(self.estimate_btn)
btn_row.addStretch()
self.pack_btn = QPushButton("🚀 开始打包")
self.pack_btn.setStyleSheet("background:#4CAF50; color:white; font-weight:bold; padding:8px 24px; border-radius:6px; font-size:13px;")
self.pack_btn.clicked.connect(self._start_pack)
btn_row.addWidget(self.pack_btn)
self.stop_btn = QPushButton("⏹ 停止")
self.stop_btn.setEnabled(False)
self.stop_btn.setStyleSheet("background:#f44336; color:white; font-weight:bold; padding:8px 20px; border-radius:6px;")
self.stop_btn.clicked.connect(self._stop_pack)
btn_row.addWidget(self.stop_btn)
self.about_btn = QPushButton("ℹ️ 关于")
self.about_btn.setStyleSheet("background:#2196F3; color:white; padding:8px 16px; border-radius:6px;")
self.about_btn.clicked.connect(self._show_about)
btn_row.addWidget(self.about_btn)
right_layout.addLayout(btn_row)
# Log area
right_layout.addWidget(QLabel("📝 打包日志:"))
self.log_edit = LogTextEdit()
self.log_edit.setMinimumHeight(200)
self.log_edit.files_dropped.connect(self._on_data_files_dropped)
right_layout.addWidget(self.log_edit, stretch=1)
# 系统资源监控栏
monitor_frame = QWidget()
monitor_frame.setStyleSheet("background:#f5f5f5; border-radius:6px; padding:4px;")
monitor_layout = QHBoxLayout(monitor_frame)
monitor_layout.setContentsMargins(8, 2, 8, 2)
monitor_layout.setSpacing(16)
self.cpu_label = QLabel("💻 CPU: --")
self.cpu_label.setStyleSheet("font-weight:bold; color:#333;")
monitor_layout.addWidget(self.cpu_label)
self.mem_label = QLabel("🧠 内存: --")
self.mem_label.setStyleSheet("font-weight:bold; color:#333;")
monitor_layout.addWidget(self.mem_label)
self.temp_label = QLabel("🌡 温度: --")
self.temp_label.setStyleSheet("font-weight:bold; color:#333;")
monitor_layout.addWidget(self.temp_label)
monitor_layout.addStretch()
self.time_label = QLabel("")
self.time_label.setStyleSheet("color:#888; font-size:10px;")
monitor_layout.addWidget(self.time_label)
right_layout.addWidget(monitor_frame)
content_splitter.addWidget(right_widget)
content_splitter.setSizes([420, 680])
main_layout.addWidget(content_splitter, stretch=1)
self.status_label = QLabel("就绪")
self.status_label.setStyleSheet("padding:2px 8px; color:#666;")
main_layout.addWidget(self.status_label)
def _setup_basic_options(self, box):
grid = QGridLayout()
grid.setSpacing(6)
# 输出目录
grid.addWidget(QLabel("输出目录:"), 0, 0)
self.output_edit = QLineEdit()
self.output_edit.setPlaceholderText("默认: 脚本所在目录/dist")
grid.addWidget(self.output_edit, 0, 1)
output_btn = QPushButton("📂")
output_btn.setFixedWidth(35)
output_btn.clicked.connect(lambda: self._browse_dir(self.output_edit))
grid.addWidget(output_btn, 0, 2)
# 程序名称
grid.addWidget(QLabel("程序名称:"), 1, 0)
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("默认: 脚本文件名")
grid.addWidget(self.name_edit, 1, 1, 1, 2)
# 图标
grid.addWidget(QLabel("图标(.ico):"), 2, 0)
self.icon_edit = QLineEdit()
self.icon_edit.setPlaceholderText("选择 .ico 图标文件")
grid.addWidget(self.icon_edit, 2, 1)
icon_btn = QPushButton("📂")
icon_btn.setFixedWidth(35)
icon_btn.clicked.connect(lambda: self._browse_file(self.icon_edit, "图标文件 (*.ico *.png)"))
grid.addWidget(icon_btn, 2, 2)
# 打包模式
grid.addWidget(QLabel("打包模式:"), 3, 0)
mode_widget = QWidget()
mode_layout = QHBoxLayout(mode_widget)
mode_layout.setContentsMargins(0, 0, 0, 0)
self.onefile_cb = QCheckBox("单文件")
self.onefile_cb.setChecked(True)
self.onefile_cb.setToolTip("打包为单个exe文件(推荐)")
mode_layout.addWidget(self.onefile_cb)
self.console_cb = QCheckBox("控制台")
self.console_cb.setToolTip("显示控制台窗口(调试用)")
mode_layout.addWidget(self.console_cb)
self.clean_cb = QCheckBox("清理")
self.clean_cb.setToolTip("打包前清理临时文件")
mode_layout.addWidget(self.clean_cb)
self.strip_cb = QCheckBox("去符号")
self.strip_cb.setChecked(True)
self.strip_cb.setToolTip("去除调试符号,减小体积")
mode_layout.addWidget(self.strip_cb)
mode_layout.addStretch()
grid.addWidget(mode_widget, 3, 1, 1, 2)
# UPX压缩
grid.addWidget(QLabel("UPX压缩:"), 4, 0)
compress_widget = QWidget()
compress_layout = QHBoxLayout(compress_widget)
compress_layout.setContentsMargins(0, 0, 0, 0)
self.compress_combo = QComboBox()
self.compress_combo.addItems(['不压', '最快', '默认', '最好', '极致'])
self.compress_combo.setCurrentText('默认')
self.compress_combo.setToolTip("UPX压缩级别,级别越高压缩率越大但越慢")
compress_layout.addWidget(self.compress_combo)
self.upx_edit = QLineEdit()
self.upx_edit.setPlaceholderText("UPX路径(自动检测)")
self.upx_edit.setReadOnly(True)
compress_layout.addWidget(self.upx_edit, stretch=1)
grid.addWidget(compress_widget, 4, 1, 1, 2)
# Python路径
grid.addWidget(QLabel("Python:"), 5, 0)
self.python_edit = QLineEdit()
self.python_edit.setReadOnly(True)
self.python_edit.setPlaceholderText("自动检测中...")
grid.addWidget(self.python_edit, 5, 1, 1, 2)
# 虚拟环境
self.venv_cb = QCheckBox("使用虚拟环境")
self.venv_cb.setToolTip("在虚拟环境中打包,隔离系统环境")
grid.addWidget(self.venv_cb, 6, 0)
self.venv_edit = QLineEdit()
self.venv_edit.setPlaceholderText("虚拟环境路径(自动检测)")
self.venv_edit.setReadOnly(True)
grid.addWidget(self.venv_edit, 6, 1)
detect_venv_btn = QPushButton("🔍 检测")
detect_venv_btn.setFixedWidth(55)
detect_venv_btn.setToolTip("自动检测虚拟环境")
detect_venv_btn.clicked.connect(self._detect_venv)
grid.addWidget(detect_venv_btn, 6, 2)
box.add_layout(grid)
def _setup_advanced_options(self, box):
layout = QVBoxLayout()
layout.setSpacing(4)
# 隐藏导入
hi_layout = QHBoxLayout()
hi_layout.addWidget(QLabel("隐藏导入:"))
self.hidden_import_edit = QLineEdit()
self.hidden_import_edit.setPlaceholderText("逗号分隔,如: torch,numpy,pandas")
hi_layout.addWidget(self.hidden_import_edit, stretch=1)
layout.addLayout(hi_layout)
# 排除模块
ex_layout = QHBoxLayout()
ex_layout.addWidget(QLabel("排除模块:"))
self.exclude_edit = QLineEdit()
self.exclude_edit.setPlaceholderText("逗号分隔,如: tkinter,test,unittest")
ex_layout.addWidget(self.exclude_edit, stretch=1)
layout.addLayout(ex_layout)
# 数据文件
data_header = QHBoxLayout()
data_header.addWidget(QLabel("📎 数据文件:"))
add_data_btn = QPushButton("+ 添加")
add_data_btn.setFixedWidth(55)
add_data_btn.setToolTip("添加程序需要的额外文件")
add_data_btn.clicked.connect(self._add_data_file)
data_header.addWidget(add_data_btn)
data_header.addStretch()
layout.addLayout(data_header)
self.data_list = QListWidget()
self.data_list.setMaximumHeight(60)
self.data_list.setStyleSheet("QListWidget { font-size:10px; }")
layout.addWidget(self.data_list)
# 外部脚本
script_header = QHBoxLayout()
script_header.addWidget(QLabel("📜 外部脚本:"))
add_script_btn = QPushButton("+ 添加")
add_script_btn.setFixedWidth(55)
add_script_btn.setToolTip("添加需要一起打包的外部脚本")
add_script_btn.clicked.connect(self._add_pack_script)
script_header.addWidget(add_script_btn)
script_header.addStretch()
layout.addLayout(script_header)
self.script_list = QListWidget()
self.script_list.setMaximumHeight(60)
self.script_list.setStyleSheet("QListWidget { font-size:10px; }")
layout.addWidget(self.script_list)
# 额外参数
extra_layout = QHBoxLayout()
extra_layout.addWidget(QLabel("额外参数:"))
self.extra_args_edit = QLineEdit()
self.extra_args_edit.setPlaceholderText("直接传递给打包器的额外参数")
extra_layout.addWidget(self.extra_args_edit, stretch=1)
layout.addLayout(extra_layout)
# 日志级别
log_layout = QHBoxLayout()
log_layout.addWidget(QLabel("日志级别:"))
self.log_level_combo = QComboBox()
self.log_level_combo.addItems(['INFO', 'DEBUG', 'WARN', 'ERROR', 'CRITICAL'])
log_layout.addWidget(self.log_level_combo)
log_layout.addStretch()
layout.addLayout(log_layout)
box.add_layout(layout)
def _setup_nuitka_options(self, box):
grid = QGridLayout()
grid.setSpacing(6)
grid.addWidget(QLabel("编译器:"), 0, 0)
self.backend_combo = QComboBox()
self.backend_combo.addItems(['auto', 'MinGW64', 'MSVC'])
self.backend_combo.setToolTip("选择C编译器后端")
grid.addWidget(self.backend_combo, 0, 1)
grid.addWidget(QLabel("并行数:"), 1, 0)
self.jobs_combo = QComboBox()
self.jobs_combo.addItems(['auto'] + [str(i) for i in range(1, 17)])
self.jobs_combo.setToolTip("并行编译线程数")
grid.addWidget(self.jobs_combo, 1, 1)
grid.addWidget(QLabel("LTO:"), 2, 0)
self.lto_combo = QComboBox()
self.lto_combo.addItems(['no', 'yes', 'thin'])
self.lto_combo.setToolTip("链接时优化,可能增大编译时间和内存占用")
grid.addWidget(self.lto_combo, 2, 1)
grid.addWidget(QLabel("优化级别:"), 3, 0)
self.optimize_combo = QComboBox()
self.optimize_combo.addItems(['平衡', '速度优先', '体积优先'])
grid.addWidget(self.optimize_combo, 3, 1)
self.nuitka_compat_cb = QCheckBox("兼容模式")
self.nuitka_compat_cb.setToolTip("使用Nuitka旧版兼容参数")
grid.addWidget(self.nuitka_compat_cb, 4, 0)
self.low_mem_cb = QCheckBox("低内存模式")
self.low_mem_cb.setToolTip("减少并行编译,降低内存占用")
grid.addWidget(self.low_mem_cb, 4, 1)
self.no_ccache_cb = QCheckBox("禁用ccache")
self.no_ccache_cb.setToolTip("禁用编译缓存加速")
grid.addWidget(self.no_ccache_cb, 5, 0)
self.experimental_cb = QCheckBox("实验性功能")
self.experimental_cb.setToolTip("启用Nuitka实验性功能")
grid.addWidget(self.experimental_cb, 5, 1)
box.add_layout(grid)
def _setup_version_options(self, box):
grid = QGridLayout()
grid.setSpacing(6)
grid.addWidget(QLabel("产品名称:"), 0, 0)
self.product_name_edit = QLineEdit()
self.product_name_edit.setPlaceholderText("如: MyApp")
grid.addWidget(self.product_name_edit, 0, 1)
grid.addWidget(QLabel("公司名称:"), 1, 0)
self.company_edit = QLineEdit()
self.company_edit.setPlaceholderText("如: MyCompany")
grid.addWidget(self.company_edit, 1, 1)
grid.addWidget(QLabel("文件版本:"), 2, 0)
self.file_ver_edit = QLineEdit()
self.file_ver_edit.setPlaceholderText("如: 1.0.0.0")
grid.addWidget(self.file_ver_edit, 2, 1)
grid.addWidget(QLabel("产品版本:"), 3, 0)
self.product_ver_edit = QLineEdit()
self.product_ver_edit.setPlaceholderText("如: 1.0.0")
grid.addWidget(self.product_ver_edit, 3, 1)
box.add_layout(grid)
def _init_progress_bars(self):
self.all_progress_bars = {
'🎨 彩色条纹': StripedProgressBar(),
'⚡ 简洁': EmojiProgressBar(),
'🌊 波浪': WaveProgressBar(),
'🔵 点阵': DotProgressBar(),
'☘️ 薄荷绿': GreenProgressBar(),
'🌸 樱花粉': PinkProgressBar(),
'🪐 星际紫': PurpleProgressBar(),
'🌊 深海蓝': BlueProgressBar(),
}
for name, bar in self.all_progress_bars.items():
self.progress_container.addWidget(bar)
self.progress_bar = self.all_progress_bars['🎨 彩色条纹']
self.progress_container.setCurrentWidget(self.progress_bar)
def _switch_progress_style(self, style_name):
if style_name in self.all_progress_bars:
new_bar = self.all_progress_bars[style_name]
try:
cur_val = self.progress_bar.value()
except:
cur_val = getattr(self.progress_bar, '_value', 0)
new_bar.setValue(cur_val)
self.progress_bar = new_bar
self.progress_container.setCurrentWidget(new_bar)
# ===== Slots =====
def _on_packer_changed(self, packer):
self.nuitka_box.setVisible(packer == 'Nuitka')
def _on_script_changed(self, path):
if path and os.path.exists(path):
self.status_label.setText(f"📄 脚本: {os.path.basename(path)}")
basename = os.path.splitext(os.path.basename(path))[0]
if not self.name_edit.text():
self.name_edit.setPlaceholderText(basename)
parent_dir = os.path.dirname(os.path.abspath(path))
if not self.output_edit.text():
self.output_edit.setPlaceholderText(os.path.join(parent_dir, 'dist'))
def _browse_script(self):
path, _ = QFileDialog.getOpenFileName(self, "选择Python脚本", "", "Python文件 (*.py *.pyw *.spec);;所有文件 (*)")
if path:
self.script_edit.setText(os.path.normpath(path))
def _browse_dir(self, edit):
path = QFileDialog.getExistingDirectory(self, "选择目录")
if path:
edit.setText(os.path.normpath(path))
def _browse_file(self, edit, filter_str):
path, _ = QFileDialog.getOpenFileName(self, "选择文件", "", filter_str)
if path:
edit.setText(os.path.normpath(path))
def _add_data_file(self):
files, _ = QFileDialog.getOpenFileNames(self, "选择数据文件", "", "所有文件 (*)")
for f in files:
norm = os.path.normpath(f)
if norm not in self._data_files:
self._data_files.append(norm)
self.data_list.addItem(f"{os.path.basename(norm)} → .")
def _add_pack_script(self):
files, _ = QFileDialog.getOpenFileNames(self, "选择外部脚本", "", "Python文件 (*.py)")
for f in files:
norm = os.path.normpath(f)
if norm not in self._pack_scripts:
self._pack_scripts.append(norm)
self.script_list.addItem(os.path.basename(norm))
def _on_data_files_dropped(self, files):
for f in files:
norm = os.path.normpath(f)
if norm not in self._data_files:
self._data_files.append(norm)
self.data_list.addItem(f"{os.path.basename(norm)} -> .")
def _fetch_github(self):
url, ok = QInputDialog.getText(self, "GitHub 源码", "输入 GitHub 仓库 URL 或 raw 文件 URL:")
if ok and url.strip():
self.github_worker = GitHubFetchWorker(url.strip())
self.github_worker.log_signal.connect(self.safe_log)
self.github_worker.finished_signal.connect(self._on_github_fetched)
self.github_worker.start()
self.safe_log(f"🌐 正在从 GitHub 获取: {url.strip()}")
def _on_github_fetched(self, success, msg, save_path):
if success and save_path:
self.script_edit.setText(save_path)
self.safe_log(msg)
def _run_syntax_check(self):
script = self._get_script()
if not script:
show_msg(self, "提示", "请先选择 Python 脚本", 2)
return
self.safe_log("🔍 开始语法检查...")
self.check_worker = SyntaxCheckWorker(script)
self.check_worker.log_signal.connect(self.safe_log)
self.check_worker.finished_signal.connect(self._on_syntax_checked)
self.check_worker.start()
def _on_syntax_checked(self, success, msg, fix_info):
self.safe_log(msg)
if not success and fix_info:
ctx = fix_info.get('context', [])
changes = fix_info.get('changes', [])
if ctx:
self.safe_log("--- 错误上下文 ---")
for line in ctx:
self.safe_log(line)
if changes:
self.safe_log("--- 修复建议 ---")
for c in changes:
self.safe_log(f"💡 {c}")
self._offer_auto_fix(fix_info)
def _offer_auto_fix(self, fix_info):
script = self._get_script()
if not script:
return
reply = QMessageBox.question(self, "语法修复",
"检测到语法错误,是否尝试自动修复?\n\n将创建备份文件 (.bak.py)。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
self._auto_fix_syntax(script, fix_info)
def _auto_fix_syntax(self, script_path, fix_info):
try:
with open(script_path, 'r', encoding='utf-8') as f:
content = f.read()
backup = script_path + '.bak.py'
shutil.copy2(script_path, backup)
self.safe_log(f"📁 备份已保存: {os.path.basename(backup)}")
lines = content.split('\n')
lineno = fix_info.get('lineno', 1) - 1
changes_made = []
if lineno < len(lines):
line = lines[lineno]
new_line = line
if ':' in new_line:
new_line = new_line.replace(':', ':')
if new_line != line:
changes_made.append("中文冒号 → 英文冒号")
lines[lineno] = new_line
if new_line.rstrip().endswith(':') and lineno + 1 < len(lines):
next_line = lines[lineno + 1]
if next_line.strip() and not next_line.startswith((' ', '\t')):
lines[lineno + 1] = ' ' + next_line.lstrip()
changes_made.append(f"第{lineno+2}行:添加缩进")
new_content = '\n'.join(lines)
changes_desc = [f"第{lineno+1}行: {c}" for c in changes_made]
dlg = CodePreviewDialog(self, content, new_content, changes_desc, script_path, backup)
if dlg.exec() == QDialog.DialogCode.Accepted:
with open(script_path, 'w', encoding='utf-8') as f:
f.write(new_content)
self.safe_log("✅ 语法修复已应用")
else:
self.safe_log("↩️ 已取消修复,从备份还原")
if os.path.exists(backup):
shutil.copy2(backup, script_path)
except Exception as e:
self.safe_log(f"❌ 自动修复失败: {e}")
def _estimate_size(self):
script = self._get_script()
if not script:
show_msg(self, "提示", "请先选择 Python 脚本", 2)
return
self.safe_log("📏 正在分析脚本大小预估...")
try:
script_size = os.path.getsize(script)
self.safe_log(f" 源文件: {script_size / 1024:.1f} KB")
with open(script, 'r', encoding='utf-8') as f:
content = f.read()
imports = set()
for match in re.finditer(r'^(?:import\s+([\w.]+)|from\s+([\w.]+)\s+import)', content, re.MULTILINE):
mod = match.group(1) or match.group(2)
if mod:
imports.add(mod.split('.')[0])
user_imports = [m for m in imports if m not in STANDARD_LIBS]
self.safe_log(f" 标准库导入: {len(imports) - len(user_imports)} 个")
self.safe_log(f" 第三方导入: {len(user_imports)} 个")
if user_imports:
self.safe_log(f" 第三方包: {', '.join(sorted(user_imports))}")
est_base = 8
est_deps = len(user_imports) * 15
est_total = est_base + est_deps
self.safe_log(f" 📏 预估体积: {est_base}-{est_total} MB")
self.safe_log(f" 💡 提示: UPX 压缩可减小 30%-50%,排除无用模块可进一步优化")
except Exception as e:
self.safe_log(f"❌ 预估失败: {e}")
def _start_pack(self):
script = self._get_script()
if not script:
show_msg(self, "提示", "请先选择 Python 脚本", 2)
return
if self.pack_thread and self.pack_thread.isRunning():
show_msg(self, "提示", "正在打包中,请稍候...", 2)
return
# 预检:找到真正的 Python 解释器(exe 自身不是 Python)
real_python = _get_real_python()
if not real_python:
show_msg(self, "未找到 Python",
"当前环境未检测到系统 Python 解释器。\n\n"
"打包功能需要系统 Python 来调用打包器。\n"
"请安装 Python 3.9+ 后重试。", 5)
return
# 预检:确保打包器已安装
packer = self.packer_combo.currentText()
packer_info = {
'PyInstaller-cmd': ('pyinstaller', 'PyInstaller'),
'PyInstaller-spec': ('pyinstaller', 'PyInstaller'),
'Nuitka': ('nuitka', 'nuitka'),
'Py2exe': ('py2exe', 'py2exe'),
'Cx_Freeze': ('cx-Freeze', 'cx_Freeze'),
'PyApp': ('pyapp', 'pyapp'),
'Pynsist': ('pynsist', 'pynsist'),
'PyOxidizer': ('pyoxidizer', 'pyoxidizer'),
'Py2app': ('py2app', 'py2app'),
}
pip_name, import_name = packer_info.get(packer, ('pyinstaller', 'PyInstaller'))
try:
check = subprocess.run(
[real_python, '-c', f'import {import_name}'],
capture_output=True, text=True, timeout=5,
startupinfo=get_startupinfo()
)
if check.returncode != 0:
reply = QMessageBox.question(self, "缺少打包器",
f"未检测到 {packer}!\n\n"
f"请先安装: pip install {pip_name}\n\n"
f"是否现在安装?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
self.safe_log(f"📥 正在安装 {pip_name}...")
install_result = subprocess.run(
[real_python, '-m', 'pip', 'install', pip_name, '-i', MIRROR],
capture_output=True, text=True, timeout=120,
startupinfo=get_startupinfo()
)
if install_result.returncode == 0:
self.safe_log(f"✅ {pip_name} 安装成功")
else:
self.safe_log(f"❌ 安装失败: {install_result.stderr[:200]}")
show_msg(self, "安装失败", f"请手动安装: pip install {pip_name}", 3)
return
else:
return
except Exception as e:
self.safe_log(f"⚠️ 无法检测打包器: {e}")
config = self._build_config()
self.pack_thread = PackageWorker(config)
self.pack_thread.log_signal.connect(self.safe_log)
self.pack_thread.progress_signal.connect(self._on_progress)
self.pack_thread.finished_signal.connect(self._on_pack_finished)
self.pack_thread.start()
self.pack_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.status_label.setText("🚀 正在打包...")
def _stop_pack(self):
if self.pack_thread and self.pack_thread.isRunning():
self.pack_thread.stop()
self.safe_log("⏹ 用户停止打包")
self.stop_btn.setEnabled(False)
def _on_progress(self, value):
self.progress_bar.setValue(value)
def _on_pack_finished(self, success, msg):
self.pack_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
if success:
self.progress_bar.setValue(100)
self.status_label.setText("✅ 打包完成!")
self.safe_log(f"✅ {msg}")
script = self._get_script()
if script:
output_dir = self.output_edit.text() or os.path.join(os.path.dirname(script), 'dist')
if os.path.exists(output_dir):
reply = QMessageBox.question(self, "打包完成",
f"打包成功!\n\n是否打开输出目录?\n{output_dir}",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
if sys.platform == 'win32':
os.startfile(output_dir)
else:
webbrowser.open(output_dir)
else:
self.status_label.setText(f"❌ 打包失败: {msg}")
self.safe_log(f"❌ {msg}")
def _show_about(self):
dlg = AboutDialog(self)
dlg.exec()
# ===== 自动检测 =====
def _auto_detect_python(self):
real_python = _get_real_python()
if real_python:
self._python_path = real_python
self.python_edit.setText(real_python)
try:
result = subprocess.run([real_python, '--version'], capture_output=True, text=True, timeout=3,
startupinfo=get_startupinfo())
if result.returncode == 0:
ver = (result.stdout or result.stderr).strip()
self.python_edit.setToolTip(ver)
except:
pass
else:
self.python_edit.setText("⚠️ 未检测到 Python")
def _auto_detect_upx(self):
upx_exe = 'upx.exe' if sys.platform == 'win32' else 'upx'
upx_path = shutil.which(upx_exe)
if not upx_path:
exe_dir = get_exe_directory()
local_upx = os.path.join(exe_dir, upx_exe)
if os.path.exists(local_upx):
upx_path = local_upx
if upx_path:
self.upx_edit.setText(upx_path)
self.upx_edit.setStyleSheet("color: green;")
else:
self.upx_edit.setText("未检测到 UPX")
def _detect_venv(self):
script = self._get_script()
search_start = os.path.dirname(script) if script else os.getcwd()
venv_names = ['venv', '.venv', 'env', '.env', 'virtualenv']
current = os.path.abspath(search_start)
for _ in range(5):
for name in venv_names:
venv_dir = os.path.join(current, name)
if os.path.isdir(venv_dir):
python_exe = 'Scripts/python.exe' if sys.platform == 'win32' else 'bin/python3'
python_path = os.path.join(venv_dir, python_exe)
if os.path.exists(python_path):
self.venv_edit.setText(venv_dir)
self.venv_cb.setChecked(True)
self.safe_log(f"🔍 检测到虚拟环境: {venv_dir}")
return
parent = os.path.dirname(current)
if parent == current:
break
current = parent
self.safe_log("⚠️ 未检测到虚拟环境")
def _start_monitor(self):
self.monitor_thread = SystemMonitorThread()
self.monitor_thread.status_updated.connect(self._on_monitor_update)
self.monitor_thread.start()
def _on_monitor_update(self, cpu, mem_percent, mem_used, mem_total, temp_str):
cpu_color = "#F44336" if cpu > 80 else ("#FF9800" if cpu > 60 else "#4CAF50")
mem_color = "#F44336" if mem_percent > 80 else ("#FF9800" if mem_percent > 60 else "#4CAF50")
self.cpu_label.setText(f"💻 CPU: {cpu:.0f}%")
self.cpu_label.setStyleSheet(f"font-weight:bold; color:{cpu_color};")
self.mem_label.setText(f"🧠 内存: {mem_percent:.0f}% ({mem_used:.1f}/{mem_total:.0f}GB)")
self.mem_label.setStyleSheet(f"font-weight:bold; color:{mem_color};")
if temp_str:
try:
temp_val = float(temp_str.replace('C', '').replace('°', '').strip())
temp_color = "#F44336" if temp_val > 80 else ("#FF9800" if temp_val > 65 else "#4CAF50")
except:
temp_color = "#4CAF50"
self.temp_label.setText(f"🌡 温度: {temp_str}")
self.temp_label.setStyleSheet(f"font-weight:bold; color:{temp_color};")
else:
self.temp_label.setText("🌡 温度: --")
self.time_label.setText(datetime.datetime.now().strftime("%H:%M:%S"))
# ===== Utilities =====
def safe_log(self, msg):
if msg is None:
return
try:
timestamp = datetime.datetime.now().strftime("[%H:%M:%S]")
self.log_edit.append_log(f"{timestamp} {msg}")
except Exception:
pass
def _get_script(self):
path = self.script_edit.text().strip()
if path and os.path.isfile(path):
return path
return None
def _build_config(self):
script = self._get_script()
venv_python = None
venv_site_packages = None
if self.venv_cb.isChecked() and self.venv_edit.text():
venv_dir = self.venv_edit.text()
if sys.platform == 'win32':
venv_python = os.path.join(venv_dir, 'Scripts', 'python.exe')
else:
venv_python = os.path.join(venv_dir, 'bin', 'python3')
lib_dir = os.path.join(venv_dir, 'Lib' if sys.platform == 'win32' else 'lib')
for root, dirs, _ in os.walk(lib_dir):
if 'site-packages' in dirs:
venv_site_packages = os.path.join(root, 'site-packages')
break
hidden_imports = [m.strip() for m in self.hidden_import_edit.text().split(',') if m.strip()]
excludes = [m.strip() for m in self.exclude_edit.text().split(',') if m.strip()]
packer = self.packer_combo.currentText()
config = {
'packer': packer,
'script': script,
'target_python': self._python_path or sys.executable,
'use_venv': self.venv_cb.isChecked() and bool(venv_python),
'venv_python': venv_python,
'venv_site_packages': venv_site_packages,
'onefile': self.onefile_cb.isChecked(),
'debug': self.console_cb.isChecked(),
'clean': self.clean_cb.isChecked(),
'strip': self.strip_cb.isChecked(),
'name': self.name_edit.text().strip() or None,
'output': self.output_edit.text().strip() or None,
'icon': self.icon_edit.text().strip() or None,
'hidden_imports': hidden_imports,
'excludes': excludes,
'data_files': [(f, '.') for f in self._data_files],
'pack_scripts': self._pack_scripts[:],
'extra_args': self.extra_args_edit.text().strip() or None,
'compress_level': self.compress_combo.currentText(),
'upx_path': self.upx_edit.text().strip() if os.path.exists(self.upx_edit.text().strip()) else '',
'log_level': self.log_level_combo.currentText(),
'platform': 'current',
'collect': '',
'copy_metadata': '',
'backend': self.backend_combo.currentText(),
'jobs': self.jobs_combo.currentText(),
'lto': self.lto_combo.currentText(),
'optimize': self.optimize_combo.currentText(),
'nuitka_compat': self.nuitka_compat_cb.isChecked(),
'low_memory': self.low_mem_cb.isChecked(),
'disable_ccache': self.no_ccache_cb.isChecked(),
'experimental': self.experimental_cb.isChecked(),
'gui_plugin': 'auto',
'has_mingw': shutil.which('gcc') is not None,
'has_msvc': self._check_msvc(),
'version_info': {
'product_name': self.product_name_edit.text().strip() or None,
'company': self.company_edit.text().strip() or None,
'file_version': self.file_ver_edit.text().strip() or None,
'product_version': self.product_ver_edit.text().strip() or None,
},
'version_file': None,
'response_file': None,
'cache_dir': get_exe_directory(),
}
return config
def _check_msvc(self):
try:
result = subprocess.run(
['where', 'cl.exe'] if sys.platform == 'win32' else ['which', 'cl'],
capture_output=True, text=True, timeout=5,
startupinfo=get_startupinfo())
return result.returncode == 0
except:
return False
def closeEvent(self, event):
if self.pack_thread and self.pack_thread.isRunning():
reply = QMessageBox.question(self, "确认退出",
"打包正在进行中,确定要退出吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
self.pack_thread.stop()
self.pack_thread.wait(3000)
else:
event.ignore()
return
if self.monitor_thread:
self.monitor_thread.stop()
event.accept()
# ==================== 程序入口 ====================
def main():
"""主函数入口"""
if hasattr(Qt, 'AA_EnableHighDpiScaling'):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
app = QApplication(sys.argv)
app.setStyle('Fusion')
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(245, 245, 245))
palette.setColor(QPalette.ColorRole.WindowText, QColor(33, 33, 33))
palette.setColor(QPalette.ColorRole.Base, QColor(255, 255, 255))
palette.setColor(QPalette.ColorRole.Text, QColor(33, 33, 33))
palette.setColor(QPalette.ColorRole.Button, QColor(240, 240, 240))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(33, 33, 33))
palette.setColor(QPalette.ColorRole.Highlight, QColor(33, 150, 243))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
app.setPalette(palette)
app.setStyleSheet("""
QMainWindow { background-color: #f5f5f5; }
QLineEdit { padding: 4px 8px; border: 1px solid #d0d0d0; border-radius: 4px; background-color: white; min-height: 22px; }
QLineEdit:focus { border-color: #2196F3; }
QComboBox { padding: 4px 8px; border: 1px solid #d0d0d0; border-radius: 4px; background-color: white; min-height: 22px; }
QComboBox:focus { border-color: #2196F3; }
QCheckBox { spacing: 4px; }
QScrollArea { border: none; background: transparent; }
QToolTip { background-color: #333; color: white; border: 1px solid #555; padding: 4px 8px; border-radius: 4px; font-size: 11px; }
""")
patch_subprocess_hide_window()
ensure_python_on_startup()
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()