This commit is contained in:
2025-10-13 11:20:19 +08:00
parent 697104d1ce
commit e5d5afb1a8
16 changed files with 2008 additions and 49 deletions

View File

@@ -1,65 +1,350 @@
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMessageBox
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout
from PyQt5.QtCore import Qt, QUrl, QTimer
from PyQt5.QtGui import QDesktopServices
from qfluentwidgets import PrimaryPushSettingCard, FluentIcon
from qfluentwidgets import InfoBar, InfoBarPosition, SubtitleLabel
from qfluentwidgets import (
PrimaryPushSettingCard, FluentIcon, InfoBar, InfoBarPosition,
SubtitleLabel, BodyLabel, CaptionLabel, StrongBodyLabel,
ElevatedCardWidget, PrimaryPushButton, PushButton,
ProgressBar, TextEdit
)
from .function_fit_interface import FunctionFitInterface
from app.tools.check_update import check_update
from app.tools.auto_updater import AutoUpdater, check_update_availability
__version__ = "1.0.5"
__version__ = "1.0.2"
class AboutInterface(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("aboutInterface")
# 初始化更新相关变量
self.updater = None
self.update_info = None
self._setup_ui()
def _setup_ui(self):
"""设置用户界面"""
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignTop)
layout.setContentsMargins(20, 30, 20, 20) # 添加边距
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.setContentsMargins(20, 30, 20, 20)
# 页面标题
title = SubtitleLabel("MRobot 帮助页面", self)
title.setAlignment(Qt.AlignCenter)
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
# 添加空间隔
layout.addSpacing(10)
card = PrimaryPushSettingCard(
text="检查更新",
icon=FluentIcon.DOWNLOAD,
title="更新",
content=f"MRobot_Toolbox 当前版本:{__version__}",
)
card.clicked.connect(self.on_check_update_clicked)
layout.addWidget(card)
# 版本信息卡片 - 学习AI界面风格
version_card = ElevatedCardWidget()
version_layout = QVBoxLayout(version_card)
version_layout.setContentsMargins(24, 20, 24, 20)
version_title = StrongBodyLabel("版本信息")
version_layout.addWidget(version_title)
current_version_label = BodyLabel(f"当前版本v{__version__}")
version_layout.addWidget(current_version_label)
def on_check_update_clicked(self):
layout.addWidget(version_card)
# 检查更新按钮
self.check_update_card = PrimaryPushSettingCard(
text="检查更新",
icon=FluentIcon.SYNC,
title="检查更新",
content="检查是否有新版本可用",
)
self.check_update_card.clicked.connect(self.check_for_updates)
layout.addWidget(self.check_update_card)
# 更新信息卡片(初始隐藏)
self.update_info_card = ElevatedCardWidget()
self.update_info_card.hide()
self._setup_update_info_card()
layout.addWidget(self.update_info_card)
layout.addStretch()
def _setup_update_info_card(self):
"""设置更新信息卡片"""
layout = QVBoxLayout(self.update_info_card)
layout.setContentsMargins(24, 20, 24, 20)
layout.setSpacing(16)
# 标题
self.update_title = StrongBodyLabel("发现新版本")
layout.addWidget(self.update_title)
# 版本对比
version_layout = QHBoxLayout()
current_layout = QVBoxLayout()
current_layout.addWidget(CaptionLabel("当前版本"))
self.current_version_label = SubtitleLabel(f"v{__version__}")
current_layout.addWidget(self.current_version_label)
arrow_label = SubtitleLabel("")
arrow_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
arrow_label.setFixedWidth(30)
latest_layout = QVBoxLayout()
latest_layout.addWidget(CaptionLabel("最新版本"))
self.latest_version_label = SubtitleLabel("v--")
latest_layout.addWidget(self.latest_version_label)
version_layout.addLayout(current_layout)
version_layout.addWidget(arrow_label)
version_layout.addLayout(latest_layout)
layout.addLayout(version_layout)
# 更新信息
info_layout = QHBoxLayout()
self.file_size_label = BodyLabel("文件大小: --")
self.release_date_label = BodyLabel("发布时间: --")
info_layout.addWidget(self.file_size_label)
info_layout.addStretch()
info_layout.addWidget(self.release_date_label)
layout.addLayout(info_layout)
# 更新说明
layout.addWidget(CaptionLabel("更新说明:"))
self.notes_display = TextEdit()
self.notes_display.setReadOnly(True)
self.notes_display.setFixedHeight(200)
self.notes_display.setText("暂无更新说明")
layout.addWidget(self.notes_display)
# 进度条(初始隐藏)
self.progress_widget = QWidget()
progress_layout = QVBoxLayout(self.progress_widget)
self.progress_label = BodyLabel("准备更新...")
progress_layout.addWidget(self.progress_label)
self.progress_bar = ProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
progress_layout.addWidget(self.progress_bar)
self.progress_widget.hide()
layout.addWidget(self.progress_widget)
# 按钮区域
button_layout = QHBoxLayout()
self.manual_btn = PushButton("手动下载")
self.manual_btn.setIcon(FluentIcon.LINK)
self.manual_btn.clicked.connect(self.open_manual_download)
self.update_btn = PrimaryPushButton("开始更新")
self.update_btn.setIcon(FluentIcon.DOWNLOAD)
self.update_btn.clicked.connect(self.start_update)
self.cancel_btn = PushButton("取消")
self.cancel_btn.clicked.connect(self.cancel_update)
button_layout.addWidget(self.manual_btn)
button_layout.addStretch()
button_layout.addWidget(self.update_btn)
button_layout.addWidget(self.cancel_btn)
layout.addLayout(button_layout)
def check_for_updates(self):
"""检查更新"""
self.check_update_card.setEnabled(False)
self.check_update_card.setContent("正在检查更新...")
# 延迟执行检查避免阻塞UI
QTimer.singleShot(100, self._perform_check)
def _perform_check(self):
"""执行更新检查"""
try:
latest = check_update(__version__)
if latest:
# 直接用浏览器打开下载链接
QDesktopServices.openUrl(QUrl("https://github.com/goldenfishs/MRobot/releases/latest"))
InfoBar.success(
title="发现新版本",
content=f"检测到新版本:{latest},已为你打开下载页面。",
parent=self,
position=InfoBarPosition.TOP,
duration=5000
)
elif latest is None:
InfoBar.info(
title="已是最新版本",
content="当前已是最新版本,无需更新。",
parent=self,
position=InfoBarPosition.TOP,
duration=3000
)
except Exception:
InfoBar.error(
title="检查更新失败",
content="无法获取最新版本,请检查网络连接。",
parent=self,
position=InfoBarPosition.TOP,
duration=4000
)
self.update_info = check_update_availability(__version__)
if self.update_info:
self._show_update_available()
else:
self._show_no_update()
except Exception as e:
self._show_error(f"检查更新失败: {str(e)}")
def _show_update_available(self):
"""显示发现更新"""
# 更新按钮状态
self.check_update_card.setEnabled(True)
self.check_update_card.setContent("发现新版本!")
# 显示更新信息卡片
self.update_info_card.show()
# 设置版本信息
if self.update_info:
version = self.update_info.get('version', 'Unknown')
self.latest_version_label.setText(f"v{version}")
# 设置文件信息
asset_size = self.update_info.get('asset_size', 0)
file_size = self._format_file_size(asset_size)
self.file_size_label.setText(f"文件大小: {file_size}")
# 设置发布时间
release_date = self.update_info.get('release_date', '')
formatted_date = self._format_date(release_date)
self.release_date_label.setText(f"发布时间: {formatted_date}")
# 设置更新说明
notes = self.update_info.get('release_notes', '暂无更新说明')
self.notes_display.setText(notes[:500] + ('...' if len(notes) > 500 else ''))
InfoBar.success(
title="发现新版本",
content=f"检测到新版本 v{version}",
parent=self,
position=InfoBarPosition.TOP,
duration=3000
)
def _show_no_update(self):
"""显示无更新"""
self.check_update_card.setEnabled(True)
self.check_update_card.setContent("已是最新版本")
InfoBar.info(
title="已是最新版本",
content="当前已是最新版本,无需更新。",
parent=self,
position=InfoBarPosition.TOP,
duration=3000
)
def _show_error(self, error_msg: str):
"""显示错误"""
self.check_update_card.setEnabled(True)
self.check_update_card.setContent("检查更新失败")
InfoBar.error(
title="检查更新失败",
content=error_msg,
parent=self,
position=InfoBarPosition.TOP,
duration=4000
)
def start_update(self):
"""开始更新"""
if not self.update_info:
return
# 显示进度UI
self.progress_widget.show()
self.update_btn.setEnabled(False)
self.manual_btn.setEnabled(False)
# 启动更新器
self.updater = AutoUpdater(__version__)
self.updater.signals.progress_changed.connect(self.update_progress)
self.updater.signals.status_changed.connect(self.update_status)
self.updater.signals.error_occurred.connect(self.update_error)
self.updater.signals.update_completed.connect(self.update_completed)
# 开始更新流程
self.updater.start()
def update_progress(self, value: int):
"""更新进度"""
self.progress_bar.setValue(value)
def update_status(self, status: str):
"""更新状态"""
self.progress_label.setText(status)
def update_error(self, error_msg: str):
"""更新错误"""
self.progress_widget.hide()
self.update_btn.setEnabled(True)
self.manual_btn.setEnabled(True)
InfoBar.error(
title="更新失败",
content=error_msg,
parent=self,
position=InfoBarPosition.TOP,
duration=4000
)
def update_completed(self):
"""更新完成"""
self.progress_label.setText("更新完成,准备重启...")
self.progress_bar.setValue(100)
InfoBar.success(
title="更新完成",
content="更新安装完成程序将在3秒后重启",
parent=self,
position=InfoBarPosition.TOP,
duration=3000
)
# 延迟重启
QTimer.singleShot(3000, self._restart_app)
def cancel_update(self):
"""取消更新"""
if self.updater and self.updater.isRunning():
self.updater.cancel_update()
self.update_info_card.hide()
self.check_update_card.setContent("检查是否有新版本可用")
def open_manual_download(self):
"""打开手动下载页面"""
QDesktopServices.openUrl(QUrl("https://github.com/goldenfishs/MRobot/releases/latest"))
InfoBar.info(
title="手动下载",
content="已为您打开下载页面",
parent=self,
position=InfoBarPosition.TOP,
duration=2000
)
def _restart_app(self):
"""重启应用程序"""
if self.updater:
self.updater.restart_application()
def _format_file_size(self, size_bytes: int) -> str:
"""格式化文件大小"""
if size_bytes == 0:
return "--"
size = float(size_bytes)
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
def _format_date(self, date_str: str) -> str:
"""格式化日期"""
if not date_str:
return "--"
try:
from datetime import datetime
date_obj = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return date_obj.strftime('%Y-%m-%d')
except:
return date_str[:10] if len(date_str) >= 10 else date_str

View File

@@ -25,9 +25,8 @@ class MainWindow(FluentWindow):
self.initInterface()
self.initNavigation()
# 检查更新
# checkUpdate(self, flag=True)
# checkAnnouncement(self) # 检查公告
# 后台检查更新(不弹窗,只显示通知)
# self.check_updates_in_background()
def initWindow(self):
self.setMicaEffectEnabled(False)
@@ -74,6 +73,14 @@ class MainWindow(FluentWindow):
None,
NavigationItemPosition.BOTTOM
)
def check_updates_in_background(self):
"""后台检查更新"""
try:
# 后台更新检查已移至关于页面手动触发
pass
except Exception as e:
print(f"初始化完成: {e}")
# main_window.py 只需修改关闭事件
def closeEvent(self, e):

340
app/tools/auto_updater.py Normal file
View File

@@ -0,0 +1,340 @@
"""
自动更新模块
实现软件的自动更新功能,包括下载、解压、安装等完整流程
"""
import os
import sys
import shutil
import tempfile
import zipfile
import subprocess
import platform
from pathlib import Path
from typing import Optional, Callable
from urllib.parse import urlparse
import requests
from packaging.version import parse as vparse
from PyQt5.QtCore import QThread, pyqtSignal, QObject
class UpdaterSignals(QObject):
"""更新器信号类"""
progress_changed = pyqtSignal(int) # 进度变化信号 (0-100)
status_changed = pyqtSignal(str) # 状态变化信号
error_occurred = pyqtSignal(str) # 错误信号
update_completed = pyqtSignal() # 更新完成信号
update_cancelled = pyqtSignal() # 更新取消信号
class AutoUpdater(QThread):
"""自动更新器类"""
def __init__(self, current_version: str, repo: str = "goldenfishs/MRobot"):
super().__init__()
self.current_version = current_version
self.repo = repo
self.signals = UpdaterSignals()
self.cancelled = False
# 获取当前程序信息
self.is_frozen = getattr(sys, 'frozen', False)
self.app_dir = self._get_app_directory()
self.temp_dir = None
def _get_app_directory(self) -> str:
"""获取应用程序目录"""
if self.is_frozen:
# 如果是打包的exe返回exe所在目录
return os.path.dirname(sys.executable)
else:
# 如果是Python脚本返回项目根目录
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def cancel_update(self):
"""取消更新"""
self.cancelled = True
self.signals.update_cancelled.emit()
def check_for_updates(self) -> Optional[dict]:
"""检查是否有新版本可用"""
try:
self.signals.status_changed.emit("正在检查更新...")
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
response = requests.get(url, timeout=10)
if response.status_code == 200:
release_data = response.json()
latest_version = release_data["tag_name"].lstrip("v")
if vparse(latest_version) > vparse(self.current_version):
return {
'version': latest_version,
'download_url': self._get_download_url(release_data),
'release_notes': release_data.get('body', ''),
'release_date': release_data.get('published_at', ''),
'asset_name': self._get_asset_name(release_data)
}
return None
else:
raise Exception(f"GitHub API请求失败: {response.status_code}")
except Exception as e:
self.signals.error_occurred.emit(f"检查更新失败: {str(e)}")
return None
def _get_download_url(self, release_data: dict) -> Optional[str]:
"""从release数据中获取适合当前平台的下载链接"""
assets = release_data.get('assets', [])
system = platform.system().lower()
# 根据操作系统选择合适的安装包
for asset in assets:
name = asset['name'].lower()
if system == 'windows':
if 'installer' in name and name.endswith('.exe'):
return asset['browser_download_url']
if name.endswith('.exe') or name.endswith('.zip'):
return asset['browser_download_url']
elif system == 'darwin': # macOS
if name.endswith('.dmg') or name.endswith('.zip'):
return asset['browser_download_url']
elif system == 'linux':
if name.endswith('.tar.gz') or name.endswith('.zip'):
return asset['browser_download_url']
# 如果没找到特定平台的,返回第一个可用文件
for asset in assets:
name = asset['name'].lower()
if any(name.endswith(ext) for ext in ['.zip', '.exe', '.dmg', '.tar.gz']):
return asset['browser_download_url']
return None
def _get_asset_name(self, release_data: dict) -> Optional[str]:
"""获取资源文件名"""
download_url = self._get_download_url(release_data)
if download_url:
return os.path.basename(urlparse(download_url).path)
return None
def download_update(self, download_url: str, filename: str) -> Optional[str]:
"""下载更新文件"""
try:
self.signals.status_changed.emit("正在下载更新...")
# 创建临时目录
self.temp_dir = tempfile.mkdtemp(prefix="MRobot_update_")
file_path = os.path.join(self.temp_dir, filename)
response = requests.get(download_url, stream=True, timeout=30)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if self.cancelled:
return None
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if total_size > 0:
progress = int((downloaded_size / total_size) * 50) # 下载占50%进度
self.signals.progress_changed.emit(progress)
self.signals.status_changed.emit("下载完成")
return file_path
except Exception as e:
self.signals.error_occurred.emit(f"下载失败: {str(e)}")
return None
def extract_update(self, file_path: str) -> Optional[str]:
"""解压更新文件"""
try:
self.signals.status_changed.emit("正在解压文件...")
self.signals.progress_changed.emit(50)
if not self.temp_dir:
raise Exception("临时目录未初始化")
extract_dir = os.path.join(self.temp_dir, "extracted")
os.makedirs(extract_dir, exist_ok=True)
# 根据文件扩展名选择解压方法
if file_path.endswith('.zip'):
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
elif file_path.endswith('.tar.gz'):
import tarfile
with tarfile.open(file_path, 'r:gz') as tar_ref:
tar_ref.extractall(extract_dir)
else:
raise Exception(f"不支持的文件格式: {file_path}")
self.signals.progress_changed.emit(70)
self.signals.status_changed.emit("解压完成")
return extract_dir
except Exception as e:
self.signals.error_occurred.emit(f"解压失败: {str(e)}")
return None
def install_update(self, extract_dir: str) -> bool:
"""安装更新"""
try:
self.signals.status_changed.emit("正在安装更新...")
self.signals.progress_changed.emit(80)
if not self.temp_dir:
raise Exception("临时目录未初始化")
# 创建备份目录
backup_dir = os.path.join(self.temp_dir, "backup")
os.makedirs(backup_dir, exist_ok=True)
# 备份当前程序文件
self._backup_current_files(backup_dir)
# 复制新文件
self._copy_update_files(extract_dir)
self.signals.progress_changed.emit(95)
self.signals.status_changed.emit("安装完成")
return True
except Exception as e:
self.signals.error_occurred.emit(f"安装失败: {str(e)}")
# 尝试恢复备份
self._restore_backup(backup_dir)
return False
def _backup_current_files(self, backup_dir: str):
"""备份当前程序文件"""
important_files = ['MRobot.py', 'MRobot.exe', 'app/', 'assets/']
for item in important_files:
src_path = os.path.join(self.app_dir, item)
if os.path.exists(src_path):
dst_path = os.path.join(backup_dir, item)
if os.path.isdir(src_path):
shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
shutil.copy2(src_path, dst_path)
def _copy_update_files(self, extract_dir: str):
"""复制更新文件到应用程序目录"""
# 查找解压目录中的主要文件/文件夹
extract_contents = os.listdir(extract_dir)
# 如果解压后只有一个文件夹,进入该文件夹
if len(extract_contents) == 1 and os.path.isdir(os.path.join(extract_dir, extract_contents[0])):
extract_dir = os.path.join(extract_dir, extract_contents[0])
# 复制文件到应用程序目录
for item in os.listdir(extract_dir):
src_path = os.path.join(extract_dir, item)
dst_path = os.path.join(self.app_dir, item)
if os.path.isdir(src_path):
if os.path.exists(dst_path):
shutil.rmtree(dst_path)
shutil.copytree(src_path, dst_path)
else:
shutil.copy2(src_path, dst_path)
def _restore_backup(self, backup_dir: str):
"""恢复备份文件"""
try:
for item in os.listdir(backup_dir):
src_path = os.path.join(backup_dir, item)
dst_path = os.path.join(self.app_dir, item)
if os.path.isdir(src_path):
if os.path.exists(dst_path):
shutil.rmtree(dst_path)
shutil.copytree(src_path, dst_path)
else:
shutil.copy2(src_path, dst_path)
except Exception as e:
print(f"恢复备份失败: {e}")
def restart_application(self):
"""重启应用程序"""
try:
self.signals.status_changed.emit("正在重启应用程序...")
if self.is_frozen:
# 如果是打包的exe
executable = sys.executable
else:
# 如果是Python脚本
executable = sys.executable
script_path = os.path.join(self.app_dir, "MRobot.py")
# 启动新进程
if platform.system() == 'Windows':
subprocess.Popen([executable] + ([script_path] if not self.is_frozen else []))
else:
subprocess.Popen([executable] + ([script_path] if not self.is_frozen else []))
# 退出当前进程
sys.exit(0)
except Exception as e:
self.signals.error_occurred.emit(f"重启失败: {str(e)}")
def cleanup(self):
"""清理临时文件"""
if self.temp_dir and os.path.exists(self.temp_dir):
try:
shutil.rmtree(self.temp_dir)
except Exception as e:
print(f"清理临时文件失败: {e}")
def run(self):
"""执行更新流程"""
try:
# 检查更新
update_info = self.check_for_updates()
if not update_info or self.cancelled:
return
# 下载更新
downloaded_file = self.download_update(
update_info['download_url'],
update_info['asset_name']
)
if not downloaded_file or self.cancelled:
return
# 解压更新
extract_dir = self.extract_update(downloaded_file)
if not extract_dir or self.cancelled:
return
# 安装更新
if self.install_update(extract_dir) and not self.cancelled:
self.signals.progress_changed.emit(100)
self.signals.update_completed.emit()
except Exception as e:
self.signals.error_occurred.emit(f"更新过程中发生错误: {str(e)}")
finally:
# 清理临时文件
self.cleanup()
def check_update_availability(current_version: str, repo: str = "goldenfishs/MRobot") -> Optional[dict]:
"""快速检查是否有新版本可用(不下载)"""
updater = AutoUpdater(current_version, repo)
return updater.check_for_updates()