!/usr/bin/env python3
-- coding: utf-8 --
"""Python脚本打包工具 - PyQt6完整版 """
import sys, json, time, ast, shutil, subprocess, threading, multiprocessing, io
import tempfile, webbrowser, fnmatch, glob, ctypes, platform as sys_platform
import urllib.request, zipfile, traceback
import textwrap
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 *
from PIL import Image
try:
from PyQt6.QtMultimedia import QMediaPlayer
from PyQt6.QtMultimediaWidgets import QVideoWidget
MEDIA_AVAILABLE = True
except (ImportError, AttributeError):
MEDIA_AVAILABLE = False
VERSION = "8.1.0"
BUILD_DATE = datetime.datetime.now().strftime("%Y-%m-%d")
AUTHOR = "wcj6376"
==================== 镜像源 ====================
MIRRORS = [
"https://pypi.tuna.tsinghua.edu.cn/simple",
"https://mirrors.aliyun.com/pypi/simple/",
"https://pypi.mirrors.ustc.edu.cn/simple/"
]
MIRROR = MIRRORS[0]
==================== 常量定义 ====================
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','docx':'python-docx',
'pyautogui':'PyAutoGUI','wx':'wxPython','qtpy':'QtPy','PySide2':'PySide2','clr': 'pythonnet',
'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','LibreHardwareMonitor': 'PyLibreHardwareMonitor',
'win32api':'pywin32','win32con':'pywin32','win32gui':'pywin32','win32ui':'pywin32',
'win32file':'pywin32','win32process':'pywin32','win32security':'pywin32',
'win32service':'pywin32','win32net':'pywin32','win32event':'pywin32',
'win32pipe':'pywin32','win32clipboard':'pywin32','win32console':'pywin32',
'win32profile':'pywin32','win32cred':'pywin32','win32crypt':'pywin32',
'win32job':'pywin32','win32ras':'pywin32','win32timezone':'pywin32',
'win32wnet':'pywin32','win32com':'pywin32','win32com.client':'pywin32',
'win32com.server':'pywin32','pywin32':'pywin32','dde':'pywin32','odbc':'pywin32',
'win32help':'pywin32','win32inet':'pywin32','win32mail':'pywin32',
'win32mapi':'pywin32','win32pdh':'pywin32','win32print':'pywin32',
'win32trace':'pywin32','win32transaction':'pywin32','win32evtlog':'pywin32',
'win32perf':'pywin32','win32ts':'pywin32','win32usb':'pywin32','win32verstamp':'pywin32'
}
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','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','stack_data','test','tests','tox','traitlets',
'typed_ast','typeshed_client','unittest2','venv','virtualenv','wcwidth','wheel',
'wmctrl','win32','win32con','yaml','pyyaml'
})
BASELINE_EXCLUDE = frozenset({
--- 测试框架 ---
'pytest', '_pytest', 'py', 'nose', 'nose2', 'mock', 'unittest2', 'tox',
'coverage', 'hypothesis', 'pytest_cov', 'pytest_xdist',
# --- 代码质量 / 静态检查 ---
'pylint', 'astroid', 'flake8', 'pycodestyle', 'pyflakes', 'mccabe',
'black', 'isort', 'autopep8', 'yapf', 'bandit', 'pydocstyle',
'mypy', 'mypy_extensions', 'mypyc', 'typed_ast', 'pathspec',
# --- 交互式解释器 / Notebook(PyInstaller 分析重灾区)---
'IPython', 'ipython', 'ipykernel', 'ipywidgets', 'jupyter',
'jupyter_client', 'jupyter_core', 'jupyterlab', 'jupyterlab_server',
'notebook', 'nbconvert', 'nbformat', 'nbclient', 'qtconsole',
'traitlets', 'jedi', 'parso', 'prompt_toolkit', 'pygments', 'wcwidth',
'backcall', 'pickleshare', 'matplotlib_inline', 'stack_data',
'executing', 'asttokens', 'pure_eval', 'debugpy', 'pydevd',
# --- 打包 / 构建工具链 ---
'PyInstaller', 'pyinstaller', 'cx_Freeze', 'nuitka', 'py2exe', 'py2app',
'pkg_resources', 'pip', 'wheel', 'build', 'pep517',
'pyproject_hooks', 'virtualenv', 'pipenv', 'poetry', 'twine',
'setuptools_scm', 'distutils_hack', '_distutils_hack',
# --- 文档工具 ---
'sphinx', 'docutils', 'alabaster', 'snowballstemmer', 'imagesize',
'sphinxcontrib',
# --- 重型科学计算(未被实际 import 时排除)---
'scipy', 'matplotlib', 'sympy', 'numba', 'llvmlite', 'torch',
'tensorflow', 'keras', 'sklearn', 'scikit_learn', 'seaborn', 'plotly',
'bokeh', 'statsmodels', 'h5py', 'tables', 'numexpr', 'bottleneck',
'pyarrow', 'fastparquet', 'dask', 'xarray', 'sqlalchemy',
# --- GUI 框架(互斥,未被 import 时排除,否则体积暴涨)---
'PyQt5', 'PyQt6', 'PySide2', 'PySide6', 'qtpy', 'wx', 'kivy', 'pygame',
'PyQt5_sip', 'PyQt6_sip', 'shiboken2', 'shiboken6',
# --- 网络 / Web 框架 ---
'selenium', 'scrapy', 'django', 'flask', 'tornado', 'aiohttp', 'fastapi',
# --- 标准库中的冗余部分 ---
'lib2to3', 'pydoc_data', 'idlelib', 'turtledemo', 'tkinter.test',
'test', 'tests', 'distutils.command.bdist_msi',
})
RUNTIME_SAFE_KEEP = frozenset({
'six', 'typing_extensions', 'typing-extensions', 'packaging',
'zipp', 'importlib_metadata', 'importlib-metadata',
'importlib_resources', 'importlib-resources',
'pytz', 'tzdata', 'tzlocal', 'python_dateutil', 'python-dateutil',
'dateutil', 'certifi', 'idna', 'urllib3',
'charset_normalizer', 'charset-normalizer',
'et_xmlfile', 'et-xmlfile', 'attrs', 'attr',
'pyparsing', 'setuptools', 'setuptools_rust', 'cffi', 'pycparser',
})
DEPENDENCY_MAP = {
'requests': ['urllib3', 'certifi', 'idna', 'charset_normalizer'],
'openpyxl': ['et_xmlfile'],
'pandas': ['numpy', 'pytz', 'python-dateutil', 'six', 'tzdata'],
'python-dateutil': ['six'],
'dateutil': ['six'],
'docx': ['lxml', 'typing_extensions', 'python-docx'],
'python-docx': ['lxml', 'typing_extensions', 'python-docx'],
'matplotlib': ['numpy', 'pillow', 'kiwisolver', 'pyparsing'],
'scikit-learn': ['numpy', 'scipy', 'joblib', 'threadpoolctl'],
'sklearn': ['numpy', 'scipy', 'joblib', 'threadpoolctl'],
'Image': ['pillow'],
'opencv': ['opencv-python','numpy'],
'cv2': ['opencv-python','numpy'],
'GPUtil': ['setuptools', 'nvidia-ml-py'],
'win32api': ['pywin32'],
'win32con': ['pywin32'],
'win32gui': ['pywin32'],
'win32com': ['pywin32'],
}
FILTER_MODULES = frozenset({
'PyInstaller', 'module', 'yaml', 'pyyaml',
'unittest', 'test', 'tests', 'pythonwin',
'pytest', 'nose', 'mock', 'tox', 'coverage',
'pylint', 'flake8', 'black', 'mypy', 'isort', 'autopep8',
'jupyter', 'ipython', 'notebook', 'ipykernel',
'build', 'packaging', 'pep517', 'pyproject_hooks',
'pyinstaller', 'pyi_hooks_contrib', 'pyi_hooks','pyinstaller-hooks-contrib',
'pywin32_ctypes', 'pip', 'wheel', 'pkg_resources', 'distribute',
})
NEVER_PACK = frozenset({
'pyinstaller', 'pyi_hooks_contrib', 'pyi_hooks','pyinstaller-hooks-contrib',
'pywin32_ctypes', 'pip', 'wheel', 'pkg_resources', 'distribute',
})
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解释器路径"""
import os
import subprocess
import sys
源码模式
if not getattr(sys, 'frozen', False):
return sys.executable
def is_same_file(path1, path2):
"""判断两个路径是否指向同一文件(跨平台)"""
try:
return os.path.samefile(path1, path2)
except:
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=2,
startupinfo=get_startupinfo()
)
return result.returncode == 0 and ('Python' in result.stdout or 'Python' in result.stderr)
except:
return False
if sys.platform == 'win32':
try:
result = self._run_hidden(['py', '-c', 'import sys; print(sys.executable)'],
capture_output=True, text=True, timeout=5,
startupinfo=get_startupinfo())
if result.returncode == 0 and result.stdout.strip():
py_path = result.stdout.strip()
if is_valid_python(py_path):
return py_path
except Exception:
pass
for py_name in ['python3', 'python']:
try:
result = self._run_hidden([py_name, '-c', 'import sys; print(sys.executable)'],
capture_output=True, text=True, timeout=5,
startupinfo=get_startupinfo())
if result.returncode == 0 and result.stdout.strip():
py_path = result.stdout.strip()
if is_valid_python(py_path):
return py_path
except Exception:
pass
if sys.platform == 'win32':
username = os.environ.get('USERNAME', '')
default_paths = [
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\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
if hasattr(sys, '_MEIPASS'):
meipass = sys._MEIPASS
search_dir = os.path.dirname(meipass)
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
import shutil
for cmd in ['python3', 'python']:
p = shutil.which(cmd)
if p and is_valid_python(p):
return p
return None
def get_short_path(path):
"""获取 Windows 短路径(8.3格式),仅当路径包含空格时使用"""
if sys.platform != 'win32' or not path:
return path
if ' ' not in path:
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 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
def load_dep_map():
"""加载依赖映射表(独立配置文件,不影响原有缓存)"""
global DEPENDENCY_MAP
if os.path.exists(DEP_MAP_FILE):
try:
import re
import ast
with open(DEP_MAP_FILE, 'r', encoding='utf-8-sig') as f:
content = f.read()
new_map = {}
for line in content.split('\n'):
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, val = line.split('=', 1)
key = key.strip()
val = val.strip()
try:
deps = ast.literal_eval(val)
if isinstance(deps, list):
new_map[key] = deps
except:
降级解析:提取所有引号内的内容
deps = re.findall(r'"([^"]+)"', val)
if deps:
new_map[key] = deps
if new_map:
DEPENDENCY_MAP = new_map
return
except Exception as e:
pass
# ===== 不存在则用源码中的 DEPENDENCY_MAP 创建 =====
try:
with open(DEP_MAP_FILE, 'w', encoding='utf-8-sig') as f:
for mod, deps in DEPENDENCY_MAP.items():
deps_str = ', '.join([f'"{d}"' for d in deps])
f.write(f'{mod} = [{deps_str}]\n')
except Exception as e:
pass
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-sig') 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-sig') 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}"
def pip_install(python_exe, package, env=None, timeout=300, quiet=False):
"""
安装包,失败时自动切换镜像源
返回: (success, result)
"""
global MIRROR
if env is None:
env = {'PATH': os.environ.get('PATH', '')}
if sys.platform == 'win32':
env['SYSTEMROOT'] = os.environ.get('SYSTEMROOT', '')
cmd = [python_exe, '-m', 'pip', 'install', package]
if quiet:
cmd.append('-q')
cmd.append('--no-warn-script-location')
for mirror in MIRRORS:
try:
cmd_with_mirror = cmd + ['-i', mirror]
result = subprocess.run(
cmd_with_mirror,
capture_output=True, text=True,
env=env,
timeout=timeout
)
if result.returncode == 0:
MIRROR = mirror
===== 新增:安装 pywin32 后自动激活 =====
if package.lower() == 'pywin32' and sys.platform == 'win32':
try:
subprocess.run(
[python_exe, '-c', 'import pywin32_postinstall; pywin32_postinstall.install()'],
capture_output=True, text=True,
env=env,
timeout=30
)
except Exception as e:
pass # 激活失败不影响安装成功返回
return True, result
except subprocess.TimeoutExpired:
continue
except Exception:
continue
return False, None
DEP_MAP_FILE = os.path.join(get_exe_directory(), ".dep_map.toml")
===== 全局内存缓存 =====
_memory_cache = None
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():
""" 所有 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 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 AnalyzeUsedThread(QThread):
"""后台执行 _analyze_used 的纯计算部分(无UI操作)"""
finished = pyqtSignal(list, list, list, bool) # result, real_imports, extra_deps, uses_tkinter
error = pyqtSignal(str)
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-sig', errors='replace') as f:
source = f.read()
except Exception as e:
self.error.emit(str(e))
self.finished.emit([], [], [], False)
return
imports = set()
uses_tkinter = False
# ===== 1. AST 解析所有 import =====
try:
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
mod = alias.name.split('.')[0]
if mod == 'tkinter':
uses_tkinter = True
imports.add('tk')
elif mod == 'tk':
imports.add('tk')
elif mod not in STANDARD_LIBS:
imports.add(mod)
elif isinstance(node, ast.ImportFrom):
if node.module:
mod = node.module.split('.')[0]
if mod == 'tkinter':
uses_tkinter = True
imports.add('tk')
elif mod == 'tk':
imports.add('tk')
elif mod not in STANDARD_LIBS:
imports.add(mod)
except Exception:
import re
for line in source.split('\n'):
line = line.strip()
if line.startswith('import ') or line.startswith('from '):
if 'tkinter' in line:
uses_tkinter = True
imports.add('tk')
else:
match = re.match(r'(?:from\s+(\S+)\s+import|import\s+(\S+))', line)
if match:
mod = match.group(1) or match.group(2)
if mod:
mod = mod.split('.')[0]
if mod not in STANDARD_LIBS:
imports.add(mod)
# ===== 2. 过滤(使用全局 FILTER_MODULES) =====
result = []
for mod in sorted(imports):
mod_clean = mod.split('==')[0].split(' ')[0].strip()
if mod_clean and mod_clean not in FILTER_MODULES:
if mod_clean not in result:
result.append(mod_clean)
# ===== 3. 确保 tk 在结果中 =====
if uses_tkinter and 'tk' not in result:
result.append('tk')
# ===== 4. 自动补充隐式依赖(使用全局 DEPENDENCY_MAP) =====
real_imports = result.copy()
extra_deps = set()
for mod, deps in DEPENDENCY_MAP.items():
if mod in result:
for dep in deps:
if dep not in result:
result.append(dep)
extra_deps.add(dep)
self.finished.emit(result, real_imports, list(extra_deps), uses_tkinter)
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-sig') 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-sig') as f:
self.left_lines = f.readlines()
with open(self.right_file, 'r', encoding='utf-8-sig') 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[i].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[i]) - len(lines[i].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[i].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[i].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[i].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-sig') 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-sig') 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-sig') 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
dll_paths = [
'LibreHardwareMonitorLib.dll',
'./LibreHardwareMonitorLib.dll',
'../LibreHardwareMonitorLib.dll',
os.path.join(os.path.dirname(__file__), 'LibreHardwareMonitorLib.dll'),
os.path.join(get_exe_directory(), 'LibreHardwareMonitorLib.dll'),
os.path.join(get_exe_directory(), 'tools', '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/powershell 命令获取温度(Windows 备用,返回 float)"""
if sys.platform != 'win32':
return None
try:
import subprocess
# 优先用 powershell(Win11 24H2 已移除 wmic)
ps_cmd = (
'Get-CimInstance -Namespace root/wmi '
'-ClassName MSAcpi_ThermalZoneTemperature | '
'Select-Object -ExpandProperty CurrentTemperature'
)
result = subprocess.run(
['powershell', '-NoProfile', '-Command', ps_cmd],
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)
# fallback 到 wmic(旧系统)
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