feat(source): 重构下载模块并优化用户界面

- 重写 DownloadThread 类,使用 aria2c 实现更高效的下载
- 更新 ProgressWindow 类,增加更多下载信息显示
- 修改 MainWindow 类,改进下载失败的错误处理和用户提示
- 优化 animations.py 中的动画效果
This commit is contained in:
hyb-oyqq
2025-07-16 13:01:56 +08:00
parent a9c6a5263c
commit 5f66db7566
5 changed files with 235 additions and 62 deletions

2
.gitignore vendored
View File

@@ -169,3 +169,5 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
nuitka-crash-report.xml

View File

@@ -1,4 +1,4 @@
# 🍓FRAISEMOE2-Installer🍓 # 🍓FRAISEMOE-Addons-Installer-NEXT🍓
``` ```
🔊 注意:本库仍然努力更新中,大部分文档不可用,敬请谅解。 🔊 注意:本库仍然努力更新中,大部分文档不可用,敬请谅解。
@@ -63,7 +63,6 @@
- [使用步骤](#使用步骤) - [使用步骤](#使用步骤)
- [版本控制](#版本控制) - [版本控制](#版本控制)
- [作者](#作者) - [作者](#作者)
- [捐助/打赏本项目](#-捐助遵循自愿原则非强制一经赞赏无法退还)
- [特别鸣谢](#特别鸣谢) - [特别鸣谢](#特别鸣谢)
- [协议](#协议) - [协议](#协议)
@@ -96,6 +95,17 @@
--- ---
## 👨‍💻 作者
- [ouyangqiqi](https://github.com/hyb-oyqq): 本仓库现维护者
## 💡 注意事项
1. 请勿使用经过二次修改的应用:若使用未知来源或修改后的应用导致个人利益受损,作者和开发人员不承担任何责任。
2. 请遵循所有规则:请严格遵守 [使用须知文档](https://github.com/hyb-oyqq/FRAISEMOE2-Installer/blob/master/FAQ.md) 和本文档中的规则,如有违反,作者和开发人员不承担责任。
3. 免费开源:本应用免费、开源,如有通过非免费途径获取,请立即向来源申请退款并积极维权。
## 🎉 特别鸣谢 ## 🎉 特别鸣谢
- [Yanam1Anna](https://github.com/Yanam1Anna): 本项目的原作者,提供了大量代码和资源。 - [Yanam1Anna](https://github.com/Yanam1Anna): 本项目的原作者,提供了大量代码和资源。
- [HTony03](https://github.com/HTony03):对于本项目部分源码的重构、逻辑优化和功能实现提供了大力支持。 - [HTony03](https://github.com/HTony03):对于本项目部分源码的重构、逻辑优化和功能实现提供了大力支持。

View File

@@ -24,6 +24,15 @@ from animations import MultiStageAnimations
import sys import sys
import os import os
def resource_path(relative_path):
"""获取资源的绝对路径适用于开发环境和PyInstaller打包环境"""
try:
# PyInstaller创建的临时文件夹
base_path = sys._MEIPASS
except Exception:
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def load_base64_image(base64_str): def load_base64_image(base64_str):
pixmap = QPixmap() pixmap = QPixmap()
pixmap.loadFromData(base64.b64decode(base64_str)) pixmap.loadFromData(base64.b64decode(base64_str))
@@ -31,13 +40,13 @@ def load_base64_image(base64_str):
# 配置信息 # 配置信息
app_data = { app_data = {
"APP_VERSION": "4.10.0.17496", "APP_VERSION": "1.0.0",
"APP_NAME": "@FRAISEMOE2 Addons Installer", "APP_NAME": "FRAISEMOE Addons Installer NEXT",
"TEMP": "TEMP", "TEMP": "TEMP",
"CACHE": "FRAISEMOE", "CACHE": "FRAISEMOE",
"PLUGIN": "PLUGIN", "PLUGIN": "PLUGIN",
"CONFIG_URL": "aHR0cHM6Ly9hcmNoaXZlLm92b2Zpc2guY29tL2FwaS93aWRnZXQvbmVrb3BhcmEvZG93bmxvYWRfdXJsLmpzb24=", "CONFIG_URL": "aHR0cHM6Ly9hcmNoaXZlLm92b2Zpc2guY29tL2FwaS93aWRnZXQvbmVrb3BhcmEvZG93bmxvYWRfdXJsX2RlYnVnLmpzb24=",
"UA": "TW96aWxsYS81LjAgKExpbnV4IGRlYmlhbjEyIEZyYWlzZU1vZS1BY2NlcHQpIEdlY2tvLzIwMTAwMTAxIEZpcmVmb3gvMTE0LjA=", "UA": "TW96aWxsYS81LjAgKExpbnV4IGRlYmlhbjEyIEZyYWlzZU1vZTItQWNjZXB0KSBHZWNrby8yMDEwMDEwMSBGaXJlZm94LzExNC4wIEZyYWlzZU1vZTIvMS4wLjA=",
"game_info": { "game_info": {
"NEKOPARA Vol.1": { "NEKOPARA Vol.1": {
"exe": "nekopara_vol1.exe", "exe": "nekopara_vol1.exe",
@@ -84,7 +93,7 @@ TEMP = os.getenv(app_data["TEMP"]) or app_data["TEMP"]
CACHE = os.path.join(TEMP, app_data["CACHE"]) CACHE = os.path.join(TEMP, app_data["CACHE"])
PLUGIN = os.path.join(CACHE, app_data["PLUGIN"]) PLUGIN = os.path.join(CACHE, app_data["PLUGIN"])
CONFIG_URL = decode_base64(app_data["CONFIG_URL"]) CONFIG_URL = decode_base64(app_data["CONFIG_URL"])
UA = decode_base64(app_data["UA"]) + f" FraiseMoe/{APP_VERSION}" UA = decode_base64(app_data["UA"])
GAME_INFO = app_data["game_info"] GAME_INFO = app_data["game_info"]
BLOCK_SIZE = 67108864 BLOCK_SIZE = 67108864
HASH_SIZE = 134217728 HASH_SIZE = 134217728
@@ -266,56 +275,170 @@ class AdminPrivileges:
# 下载线程类 # 下载线程类
class DownloadThread(QThread): class DownloadThread(QThread):
progress = Signal(int) progress = Signal(dict)
finished = Signal(bool, str) finished = Signal(bool, str)
def __init__(self, url, _7z_path, parent=None): def __init__(self, url, _7z_path, game_version, parent=None):
super().__init__(parent) super().__init__(parent)
self.url = url self.url = url
self._7z_path = _7z_path self._7z_path = _7z_path
self.game_version = game_version
self.process = None
self.is_running = True
def stop(self):
if self.process and self.process.poll() is None:
self.is_running = False
# 使用 taskkill 强制终止进程及其子进程
subprocess.run(['taskkill', '/F', '/T', '/PID', str(self.process.pid)], check=True)
self.finished.emit(False, "下载已手动停止。")
def run(self): def run(self):
import subprocess
import re
from urllib.parse import urlparse
try: try:
headers = {"User-Agent": UA} aria2c_path = resource_path("aria2c.exe")
r = requests.get(self.url, headers=headers, stream=True, timeout=10) download_dir = os.path.dirname(self._7z_path)
r.raise_for_status() file_name = os.path.basename(self._7z_path)
total_size = int(r.headers.get("content-length", 0))
with open(self._7z_path, "wb") as f: parsed_url = urlparse(self.url)
for chunk in r.iter_content(chunk_size=BLOCK_SIZE): referer = f"{parsed_url.scheme}://{parsed_url.netloc}/"
f.write(chunk)
self.progress.emit(f.tell() * 100 // total_size) command = [
self.finished.emit(True, "") aria2c_path,
except requests.exceptions.RequestException as e: '--dir', download_dir,
self.finished.emit(False, f"\n网络请求错误\n\n【错误信息】: {e}\n") '--out', file_name,
'--user-agent', UA,
'--referer', referer,
'--header', f'Origin: {referer.rstrip("/")}',
'--header', 'Accept: */*',
'--header', 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
'--header', 'Accept-Encoding: gzip, deflate, br',
'--header', 'Cache-Control: no-cache',
'--header', 'Pragma: no-cache',
'--header', 'DNT: 1',
'--header', 'Sec-Fetch-Dest: empty',
'--header', 'Sec-Fetch-Mode: cors',
'--header', 'Sec-Fetch-Site: same-origin',
'--http-accept-gzip=true',
'--min-tls-version=TLSv1.2',
'--console-log-level=info',
'--summary-interval=1',
'--log-level=info',
'--allow-overwrite=true',
'--max-tries=3',
'--retry-wait=2',
'--connect-timeout=60',
'--timeout=60',
'--auto-file-renaming=false',
self.url
]
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', creationflags=creation_flags)
# 正则表达式用于解析aria2c的输出
# 例如: #1 GID[...]( 5%) CN:1 DL:10.5MiB/s ETA:1m30s
progress_pattern = re.compile(r'\((\d+)%\).*?CN:(\d+).*?DL:([\d\.]+[KMG]?i?B/s).*?ETA:([\w\d]+)')
full_output = []
while self.is_running and self.process.poll() is None:
line = self.process.stdout.readline()
if not line:
break
full_output.append(line)
print(line.strip()) # 在控制台输出实时日志
match = progress_pattern.search(line)
if match:
percent = int(match.group(1))
threads = match.group(2)
speed = match.group(3)
eta = match.group(4)
self.progress.emit({
"game": self.game_version,
"percent": percent,
"threads": threads,
"speed": speed,
"eta": eta
})
return_code = self.process.wait()
if not self.is_running: # 如果是手动停止的
return
if return_code == 0:
self.progress.emit({
"game": self.game_version,
"percent": 100,
"threads": "N/A",
"speed": "N/A",
"eta": "完成"
})
self.finished.emit(True, "")
else:
error_message = f"\nAria2c下载失败退出码: {return_code}\n\n--- Aria2c 输出 ---\n{''.join(full_output)}\n---------------------\n"
self.finished.emit(False, error_message)
except Exception as e: except Exception as e:
self.finished.emit(False, f"\n未知错误\n\n【错误信息】: {e}\n") if self.is_running:
self.finished.emit(False, f"\n下载时发生未知错误\n\n【错误信息】: {e}\n")
# 下载进度窗口类 # 下载进度窗口类
class ProgressWindow(QDialog): class ProgressWindow(QDialog):
# 添加一个信号,用于通知主窗口下载已停止
download_stopped = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
super(ProgressWindow, self).__init__(parent) super(ProgressWindow, self).__init__(parent)
self.setWindowTitle(f"下载进度 {APP_NAME}") self.setWindowTitle(f"下载进度 {APP_NAME}")
self.resize(400, 100) self.resize(450, 180)
self.progress_bar_max = 100
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowSystemMenuHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowSystemMenuHint)
layout = QVBoxLayout() layout = QVBoxLayout()
self.game_label = QLabel("正在准备下载...")
self.progress_bar = QProgressBar() self.progress_bar = QProgressBar()
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
self.label = QLabel("\n正在下载...\n") self.stats_label = QLabel("速度: - | 线程: - | 剩余时间: -")
layout.addWidget(self.label) self.stop_button = QtWidgets.QPushButton("停止下载")
layout.addWidget(self.game_label)
layout.addWidget(self.progress_bar) layout.addWidget(self.progress_bar)
layout.addWidget(self.stats_label)
layout.addWidget(self.stop_button)
self.setLayout(layout) self.setLayout(layout)
def setmaxvalue(self, value): def update_progress(self, data):
self.progress_bar_max = value game_version = data.get("game", "未知游戏")
self.progress_bar.setMaximum(value) percent = data.get("percent", 0)
speed = data.get("speed", "-")
threads = data.get("threads", "-")
eta = data.get("eta", "-")
def setprogressbarval(self, value): self.game_label.setText(f"正在下载: {game_version}")
self.progress_bar.setValue(value) self.progress_bar.setValue(int(percent))
if value == self.progress_bar_max: self.stats_label.setText(f"速度: {speed} | 线程: {threads} | 剩余时间: {eta}")
QTimer.singleShot(2000, self.close)
if percent == 100:
self.stop_button.setEnabled(False)
self.stop_button.setText("下载完成")
QTimer.singleShot(1500, self.accept)
def closeEvent(self, event):
# 覆盖默认的关闭事件,防止用户通过其他方式关闭窗口
# 如果需要,可以在这里添加逻辑,例如询问用户是否要停止下载
event.ignore()
def on_stop_clicked(self):
self.stop_button.setEnabled(False)
self.stop_button.setText("正在停止...")
self.download_stopped.emit()
self.reject() # 关闭窗口并返回一个QDialog.Rejected值
# 主窗口类 # 主窗口类
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -332,7 +455,7 @@ class MainWindow(QMainWindow):
self.setWindowIcon(QIcon(pixmap)) self.setWindowIcon(QIcon(pixmap))
# 设置窗口标题为APP_NAME加版本号 # 设置窗口标题为APP_NAME加版本号
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") self.setWindowTitle(f"{APP_NAME} vFraiseMoe2/1.0.0")
# 初始化动画系统 (从animations.py导入) # 初始化动画系统 (从animations.py导入)
self.animator = MultiStageAnimations(self.ui) self.animator = MultiStageAnimations(self.ui)
@@ -403,8 +526,13 @@ class MainWindow(QMainWindow):
response = requests.get(CONFIG_URL, headers=headers, timeout=10) response = requests.get(CONFIG_URL, headers=headers, timeout=10)
response.raise_for_status() response.raise_for_status()
config_data = response.json() config_data = response.json()
if not all(f"vol.{i+1}.data" in config_data for i in range(4)) or "after.data" not in config_data: # 修正键名检查,确保所有必需的键都存在
raise ValueError("配置文件数据异常") required_keys = [f"vol.{i+1}.data" for i in range(4)] + ["after.data"]
if not all(key in config_data for key in required_keys):
missing_keys = [key for key in required_keys if key not in config_data]
raise ValueError(f"配置文件缺少必要的键: {', '.join(missing_keys)}")
# 修正提取URL的逻辑确保使用正确的键
return { return {
f"vol{i+1}": config_data[f"vol.{i+1}.data"]["url"] for i in range(4) f"vol{i+1}": config_data[f"vol.{i+1}.data"]["url"] for i in range(4)
} | { } | {
@@ -451,35 +579,49 @@ class MainWindow(QMainWindow):
self.show_result() self.show_result()
return return
progress_window = ProgressWindow(self) self.progress_window = ProgressWindow(self)
progress_window.show()
self.current_download_thread = DownloadThread(url, _7z_path, self) self.current_download_thread = DownloadThread(url, _7z_path, game_version, self)
self.current_download_thread.progress.connect(progress_window.setprogressbarval) self.current_download_thread.progress.connect(self.progress_window.update_progress)
self.current_download_thread.finished.connect( self.current_download_thread.finished.connect(
lambda success, error: self.install_setting( lambda success, error: self.install_setting(
success, success,
error, error,
progress_window, self.progress_window,
url,
game_folder, game_folder,
game_version, game_version,
_7z_path, _7z_path,
plugin_path, plugin_path,
) )
) )
# 连接停止按钮的信号
self.progress_window.stop_button.clicked.connect(self.current_download_thread.stop)
# 连接窗口关闭信号,以处理用户手动停止的情况
self.progress_window.download_stopped.connect(self.on_download_stopped)
self.current_download_thread.start() self.current_download_thread.start()
self.progress_window.exec() # 使用exec()以模态方式显示对话框
def install_setting( def install_setting(
self, self,
success, success,
error, error,
progress_window, progress_window,
url,
game_folder, game_folder,
game_version, game_version,
_7z_path, _7z_path,
plugin_path, plugin_path,
): ):
progress_window.close() if not success and error == "下载已手动停止。":
# 用户手动停止了下载,不需要进行后续操作
return
if self.progress_window.isVisible():
self.progress_window.close()
if success: if success:
try: try:
msg_box = self.hash_manager.hash_pop_window() msg_box = self.hash_manager.hash_pop_window()
@@ -499,9 +641,6 @@ class MainWindow(QMainWindow):
shutil.copy(sig_path, game_folder) shutil.copy(sig_path, game_folder)
self.installed_status[game_version] = True self.installed_status[game_version] = True
QtWidgets.QMessageBox.information(
self, f"通知 {APP_NAME}", f"\n{game_version} 补丁已安装\n"
)
except (py7zr.Bad7zFile, FileNotFoundError, Exception) as e: except (py7zr.Bad7zFile, FileNotFoundError, Exception) as e:
QtWidgets.QMessageBox.critical( QtWidgets.QMessageBox.critical(
self, self,
@@ -510,13 +649,35 @@ class MainWindow(QMainWindow):
) )
finally: finally:
msg_box.close() msg_box.close()
self.next_download_task()
else: else:
QtWidgets.QMessageBox.critical( print(f"--- Download Failed: {game_version} ---")
self, print(error)
f"错误 {APP_NAME}", print("------------------------------------")
f"\n文件获取失败\n网络状态异常或服务器故障\n\n【错误信息】:{error}\n", msg_box = QtWidgets.QMessageBox(self)
) msg_box.setWindowTitle(f"下载失败 {APP_NAME}")
self.next_download_task() msg_box.setText(f"\n文件获取失败: {game_version}\n\n是否重试?")
retry_button = msg_box.addButton("重试", QtWidgets.QMessageBox.ButtonRole.YesRole)
next_button = msg_box.addButton("下一个", QtWidgets.QMessageBox.ButtonRole.NoRole)
end_button = msg_box.addButton("结束", QtWidgets.QMessageBox.ButtonRole.RejectRole)
icon_data = img_data.get("icon")
if icon_data:
pixmap = load_base64_image(icon_data)
if not pixmap.isNull():
msg_box.setWindowIcon(QIcon(pixmap))
msg_box.exec()
clicked_button = msg_box.clickedButton()
if clicked_button == retry_button:
self.download_setting(url, game_folder, game_version, _7z_path, plugin_path)
elif clicked_button == next_button:
self.next_download_task()
else: # End button or closed dialog
self.download_queue.clear()
self.after_hash_compare(PLUGIN_HASH)
def pre_hash_compare(self, install_path, game_version, plugin_hash): def pre_hash_compare(self, install_path, game_version, plugin_hash):
msg_box = self.hash_manager.hash_pop_window() msg_box = self.hash_manager.hash_pop_window()
@@ -570,9 +731,19 @@ class MainWindow(QMainWindow):
if not self.download_queue: if not self.download_queue:
self.after_hash_compare(PLUGIN_HASH) self.after_hash_compare(PLUGIN_HASH)
return return
# 检查下载线程是否仍在运行,以避免在手动停止后立即开始下一个任务
if self.current_download_thread and self.current_download_thread.isRunning():
return
url, game_folder, game_version, _7z_path, plugin_path = self.download_queue.popleft() url, game_folder, game_version, _7z_path, plugin_path = self.download_queue.popleft()
self.download_setting(url, game_folder, game_version, _7z_path, plugin_path) self.download_setting(url, game_folder, game_version, _7z_path, plugin_path)
def on_download_stopped(self):
"""当用户点击停止按钮时调用的槽函数"""
# 清空下载队列,因为用户决定停止
self.download_queue.clear()
# 可以在这里决定是否立即进行哈希比较或显示结果
self.after_hash_compare(PLUGIN_HASH)
def after_hash_compare(self, plugin_hash): def after_hash_compare(self, plugin_hash):
msg_box = self.hash_manager.hash_pop_window() msg_box = self.hash_manager.hash_pop_window()
result = self.hash_manager.cfg_after_hash_compare( result = self.hash_manager.cfg_after_hash_compare(

View File

@@ -84,7 +84,7 @@ class MultiStageAnimations:
pos_anim.setDuration(duration) pos_anim.setDuration(duration)
pos_anim.setStartValue(QPoint(-widget.width(), end_pos.y())) pos_anim.setStartValue(QPoint(-widget.width(), end_pos.y()))
pos_anim.setEndValue(end_pos) pos_anim.setEndValue(end_pos)
pos_anim.setEasingCurve(QEasingCurve.OutBack) pos_anim.setEasingCurve(QEasingCurve.Type.OutBack)
# 透明度动画 # 透明度动画
opacity_anim = QPropertyAnimation(widget.graphicsEffect(), b"opacity") opacity_anim = QPropertyAnimation(widget.graphicsEffect(), b"opacity")
@@ -107,16 +107,6 @@ class MultiStageAnimations:
anim_group.start() anim_group.start()
self.animations.append(anim_group) self.animations.append(anim_group)
def start_mainbg_animation(self):
"""启动主背景淡入动画"""
main_anim = QPropertyAnimation(self.ui.Mainbg.graphicsEffect(), b"opacity")
main_anim.setDuration(800)
main_anim.setStartValue(0)
main_anim.setEndValue(1)
main_anim.finished.connect(self.start_menu_animations)
main_anim.start()
self.animations.append(main_anim)
def start_mainbg_animation(self): def start_mainbg_animation(self):
"""启动主背景淡入动画(带延迟)""" """启动主背景淡入动画(带延迟)"""
main_anim = QPropertyAnimation(self.ui.Mainbg.graphicsEffect(), b"opacity") main_anim = QPropertyAnimation(self.ui.Mainbg.graphicsEffect(), b"opacity")
@@ -141,7 +131,7 @@ class MultiStageAnimations:
pos_anim.setDuration(item["duration"]) pos_anim.setDuration(item["duration"])
pos_anim.setStartValue(QPoint(item["end_pos"].x(), self.canvas_height + 100)) pos_anim.setStartValue(QPoint(item["end_pos"].x(), self.canvas_height + 100))
pos_anim.setEndValue(item["end_pos"]) pos_anim.setEndValue(item["end_pos"])
pos_anim.setEasingCurve(QEasingCurve.OutBack) pos_anim.setEasingCurve(QEasingCurve.Type.OutBack)
# 透明度动画 # 透明度动画
opacity_anim = QPropertyAnimation(item["widget"].graphicsEffect(), b"opacity") opacity_anim = QPropertyAnimation(item["widget"].graphicsEffect(), b"opacity")

BIN
source/aria2c.exe Normal file

Binary file not shown.