更新0.02版本

This commit is contained in:
李志强 2026-04-07 10:48:27 +08:00
parent 77f718cca6
commit 3051f6ca49
19 changed files with 345 additions and 37 deletions

View File

@ -37,7 +37,7 @@ exe = EXE(
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,

View File

@ -9,7 +9,7 @@ from ui.ball import FloatBall, BALL_SIZE
import ui.theme as theme
from db import database
__VERSION__ = "0.0.1"
__VERSION__ = "0.0.2"
# ===================== 打包兼容核心函数 =====================
def get_resource_path(relative_path):

View File

@ -1 +1,2 @@
PyQt6>=6.4.0
pillow>=9.0.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -43,12 +43,25 @@ def _load_logo_pixmap(path):
def _win32_set_ellipse_window_rgn(hwnd, w, h):
"""用系统区域裁切 HWND"""
"""用系统区域裁切 HWNDw/h 为逻辑像素,内部换算为物理像素"""
if not hwnd or w < 2 or h < 2:
return
gdi32 = ctypes.windll.gdi32
user32 = ctypes.windll.user32
hrgn = gdi32.CreateEllipticRgn(0, 0, w, h)
# 获取窗口所在显示器的 DPI换算为物理像素
try:
MONITOR_DEFAULTTONEAREST = 2
hmon = ctypes.windll.user32.MonitorFromWindow(wintypes.HWND(hwnd), MONITOR_DEFAULTTONEAREST)
dpi_x = ctypes.c_uint(0)
dpi_y = ctypes.c_uint(0)
MDT_EFFECTIVE_DPI = 0
ctypes.windll.shcore.GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, ctypes.byref(dpi_x), ctypes.byref(dpi_y))
scale = dpi_x.value / 96.0
except Exception:
scale = 1.0
pw = max(2, int(w * scale))
ph = max(2, int(h * scale))
hrgn = gdi32.CreateEllipticRgn(0, 0, pw, ph)
if not hrgn:
return
user32.SetWindowRgn(hwnd, hrgn, True)
@ -139,14 +152,21 @@ class FloatBall(QWidget):
_win32_set_ellipse_window_rgn(wid, self.width(), self.height())
_win32_disable_dwm_rounded_shell(wid)
def moveEvent(self, event):
super().moveEvent(event)
# 跨屏移动后 DPI 可能变化,重新设置物理像素 region
if sys.platform == "win32":
wid = int(self.winId())
if wid:
_win32_set_ellipse_window_rgn(wid, self.width(), self.height())
def _apply_window_flags(self):
flags = (
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.Tool
| Qt.WindowType.NoDropShadowWindowHint
| Qt.WindowType.WindowStaysOnTopHint
)
if self._stays_on_top:
flags |= Qt.WindowType.WindowStaysOnTopHint
self.setWindowFlags(flags)
def _get_glow(self): return self._glow
@ -154,6 +174,14 @@ class FloatBall(QWidget):
from PyQt6.QtCore import pyqtProperty
_glow_prop = pyqtProperty(float, _get_glow, _set_glow)
def _current_screen_geometry(self):
"""返回当前窗口所在屏幕的可用区域,找不到时回退到主屏幕。"""
center = self.geometry().center()
screen = QApplication.screenAt(center)
if screen is None:
screen = QApplication.primaryScreen()
return screen.availableGeometry()
def _place_default(self):
screen = QApplication.primaryScreen().availableGeometry()
self.move(
@ -161,6 +189,19 @@ class FloatBall(QWidget):
screen.top() + screen.height() // 2 - self.height() // 2,
)
def place_on_screen_of(self, widget):
"""将悬浮球放到指定 widget 所在屏幕的右侧中央。"""
# frameGeometry().center() 直接是全局坐标,对顶层窗口最可靠
global_center = widget.frameGeometry().center()
screen_obj = QApplication.screenAt(global_center)
if screen_obj is None:
screen_obj = QApplication.primaryScreen()
screen = screen_obj.availableGeometry()
self.move(
screen.right() - self.width() - 20,
screen.top() + screen.height() // 2 - self.height() // 2,
)
def _paint_radius_outer(self):
w, h = self.width(), self.height()
return min(w, h) * 0.5 - 0.5
@ -224,15 +265,17 @@ class FloatBall(QWidget):
# 3. Logocover 铺满圆)
if not self._logo.isNull():
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
cover = max(1, int(2 * r))
side = max(1, int(2 * r))
scaled = self._logo.scaled(
cover, cover,
side, side,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation
)
lx = int(cx - scaled.width() / 2)
ly = int(cy - scaled.height() / 2)
p.drawPixmap(lx, ly, scaled)
# 居中裁切到 side×side避免 Expanding 后图片偏大导致偏移
ox = (scaled.width() - side) // 2
oy = (scaled.height() - side) // 2
cropped = scaled.copy(ox, oy, side, side)
p.drawPixmap(int(cx - side / 2), int(cy - side / 2), cropped)
# 4. 覆在图上的雾面(毛玻璃「蒙在内容上」)
veil = QLinearGradient(cx, cy - r, cx, cy + r)
@ -280,7 +323,11 @@ class FloatBall(QWidget):
if self._dragging:
new_pos = self.pos() + delta
self._drag_start = event.globalPosition().toPoint()
screen = QApplication.primaryScreen().availableGeometry()
# 用鼠标当前位置所在屏幕来限制边界,支持多显示器拖动
cursor_screen = QApplication.screenAt(event.globalPosition().toPoint())
if cursor_screen is None:
cursor_screen = QApplication.primaryScreen()
screen = cursor_screen.availableGeometry()
new_pos.setX(max(screen.left(), min(new_pos.x(), screen.right() - self.width())))
new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height())))
self.move(new_pos)
@ -307,7 +354,7 @@ class FloatBall(QWidget):
self._glow_anim.start()
def _snap_to_edge(self):
screen = QApplication.primaryScreen().availableGeometry()
screen = self._current_screen_geometry()
cx = self.x() + self.width() // 2
cy = self.y() + self.height() // 2
dists = {

View File

@ -38,10 +38,11 @@ from db import database
from ui.group import GroupWidget
import ui.theme as theme
import ui.dialog_style as dialog_style
from ui.updater import Updater
PANEL_W = 260
PANEL_H = 40
MIN_W, MIN_H = 180, 40
MIN_W, MIN_H = 400, 5
ANIM_MS = 180
RESIZE_M = 8
@ -354,6 +355,8 @@ class PanelWindow(QWidget):
self._time_timer.setInterval(1000)
self._time_timer.timeout.connect(self._refresh_time_label)
self._updater = Updater(self)
def showEvent(self, event):
super().showEvent(event)
if not self._screen_hooked:
@ -499,18 +502,63 @@ class PanelWindow(QWidget):
self._time_timer.start()
# ── UI ──────────────────────────────────────────────
def _get_quick_actions(self):
"""返回快捷动作列表,悬浮球右键菜单和左侧快捷栏共用同一份。
每项: (tooltip, qtawesome_icon, callback)
排除重启/退出"""
return [
("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd),
("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell),
("打开默认浏览器", "fa5s.globe", self._open_default_browser),
]
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
self.container = QWidget()
self.container.setObjectName("container")
# container 也开启鼠标追踪,子 widget 的 MouseMove 会冒泡上来
self.container.setMouseTracking(True)
inner = QVBoxLayout(self.container)
# 横向:左侧快捷栏 + 右侧主内容
h_layout = QHBoxLayout(self.container)
h_layout.setContentsMargins(0, 0, 0, 0)
h_layout.setSpacing(0)
# ── 左侧快捷栏
self._quick_bar = QWidget()
self._quick_bar.setObjectName("quick_bar")
self._quick_bar.setFixedWidth(50)
quick_layout = QVBoxLayout(self._quick_bar)
quick_layout.setContentsMargins(0, 10, 0, 10)
quick_layout.setSpacing(6)
quick_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
self._quick_btns = []
for tooltip, icon_name, callback in self._get_quick_actions():
btn = QPushButton()
btn.setFixedSize(34, 34)
btn.setToolTip(tooltip)
btn.setStyleSheet("border:none; background:transparent; border-radius:4px;")
try:
btn.setIcon(qta.icon(icon_name, color="#888"))
btn.setIconSize(QSize(16, 16))
except Exception:
btn.setText(tooltip[0])
btn.clicked.connect(callback)
quick_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter)
self._quick_btns.append((btn, icon_name))
quick_layout.addStretch()
h_layout.addWidget(self._quick_bar)
# ── 右侧主内容
right_widget = QWidget()
right_widget.setMouseTracking(True)
inner = QVBoxLayout(right_widget)
inner.setContentsMargins(8, 10, 8, 8)
inner.setSpacing(6)
h_layout.addWidget(right_widget)
# ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板)
self._title_bar = QWidget()
@ -683,6 +731,12 @@ class PanelWindow(QWidget):
bottom_layout.addWidget(self.menu_btn)
bottom_layout.addStretch()
self.update_btn = QPushButton()
self.update_btn.setFixedSize(28, 28)
self.update_btn.setToolTip("检查更新")
self.update_btn.setStyleSheet("border:none; background:transparent; border-radius:4px;")
self.update_btn.clicked.connect(lambda: self._updater.check(silent_if_latest=False))
bottom_layout.addWidget(self.update_btn)
bottom_layout.addWidget(self.theme_btn)
bottom_layout.addWidget(self.settings_btn)
bottom_layout.addWidget(self.quit_btn)
@ -781,6 +835,26 @@ class PanelWindow(QWidget):
if hasattr(self, "time_title_label"):
self.time_title_label.setStyleSheet("font-size:11px; color: #888;")
# 左侧快捷栏
bar_side_bg = "rgba(0,0,0,20)" if is_dark else "rgba(0,0,0,6)"
self._quick_bar.setStyleSheet(
f"""
QWidget#quick_bar {{
background: {bar_side_bg};
border-right: 1px solid {t['panel_border']};
border-radius: 10px 0 0 10px;
}}
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
QPushButton:hover {{ background:{t['header_hover']}; }}
"""
)
for btn, icon_name in self._quick_btns:
try:
btn.setIcon(qta.icon(icon_name, color=ic))
btn.setIconSize(QSize(14, 14))
except Exception:
pass
# 底部工具栏
bar_bg2 = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,8)"
self.bottom_bar.setStyleSheet(
@ -798,6 +872,8 @@ class PanelWindow(QWidget):
qta.icon("fa5s.sun" if is_dark else "fa5s.moon", color=ic)
)
self.theme_btn.setIconSize(QSize(14, 14))
self.update_btn.setIcon(qta.icon("fa5s.sync-alt", color=ic))
self.update_btn.setIconSize(QSize(14, 14))
self.menu_btn.setIcon(qta.icon("fa5s.bars", color=ic))
self.menu_btn.setIconSize(QSize(14, 14))
self.settings_btn.setIcon(qta.icon("fa5s.cog", color=ic))
@ -820,6 +896,23 @@ class PanelWindow(QWidget):
if ret == QMessageBox.StandardButton.Yes:
QApplication.quit()
def _restart_application(self):
import subprocess
subprocess.Popen([sys.executable] + sys.argv)
QApplication.quit()
def _open_admin_cmd(self):
import ctypes
ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", None, None, 1)
def _open_admin_powershell(self):
import ctypes
ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", None, None, 1)
def _open_default_browser(self):
import webbrowser
webbrowser.open("https://www.baidu.com")
def _toggle_theme(self):
theme.set_theme("light" if theme.name() == "dark" else "dark")
self._apply_theme()
@ -1050,13 +1143,9 @@ class PanelWindow(QWidget):
"""收缩:隐藏内容区,窗口缩到只剩标题栏高度"""
self._body.hide()
self.weather_bar.hide()
# 记录当前高度,展开时恢复
self._quick_bar.hide()
self._expanded_height = self.height()
title_h = (
self.container.layout().contentsMargins().top()
+ self.container.layout().contentsMargins().bottom()
+ self._title_bar.sizeHint().height()
)
title_h = self._title_bar.sizeHint().height() + 20
self.setFixedHeight(title_h)
ic = "#cccccc" if theme.name() == "dark" else "#555555"
self._min_btn.setIcon(qta.icon("fa5s.chevron-down", color=ic))
@ -1066,6 +1155,7 @@ class PanelWindow(QWidget):
"""展开:恢复内容区和窗口高度"""
self.weather_bar.show()
self._body.show()
self._quick_bar.show()
h = getattr(self, "_expanded_height", 520)
self.setMinimumHeight(MIN_H)
self.setMaximumHeight(16777215)
@ -1096,7 +1186,11 @@ class PanelWindow(QWidget):
y = int(database.get_setting("panel_y", ""))
w = int(database.get_setting("panel_w", str(PANEL_W)))
h = int(database.get_setting("panel_h", str(PANEL_H)))
screen = QApplication.primaryScreen().availableGeometry()
# 用保存坐标所在屏幕来限制范围,支持多显示器
screen_obj = QApplication.screenAt(QPoint(x, y))
if screen_obj is None:
screen_obj = QApplication.primaryScreen()
screen = screen_obj.availableGeometry()
x = max(screen.left(), min(x, screen.right() - w))
y = max(screen.top(), min(y, screen.bottom() - h))
self._persist_w = w
@ -1119,7 +1213,11 @@ class PanelWindow(QWidget):
# ── 显示/隐藏 ────────────────────────────────────────
def show_near(self, ball_pos: QPoint, ball_size: int):
screen = QApplication.primaryScreen().availableGeometry()
# 根据球所在屏幕来定位 panel避免跑到主屏幕
screen_obj = QApplication.screenAt(ball_pos)
if screen_obj is None:
screen_obj = QApplication.primaryScreen()
screen = screen_obj.availableGeometry()
pw, ph = self.width(), self.height()
bx, by = ball_pos.x(), ball_pos.y()
@ -1165,6 +1263,7 @@ class PanelWindow(QWidget):
def minimize_to_ball(self):
self.hide_panel()
if hasattr(self, "_ball_ref"):
self._ball_ref.place_on_screen_of(self)
self._ball_ref.show()
self._ball_ref.setWindowOpacity(1.0)
@ -1360,20 +1459,11 @@ class PanelWindow(QWidget):
QMenu::separator { height:1px; background:#444; margin:4px 8px; }
"""
)
menu.addAction("🖥 显示面板").triggered.connect(
lambda: (
self.show_near(self._ball_ref.pos(), self._ball_ref.width())
if hasattr(self, "_ball_ref")
else None
)
)
menu.addAction("🔽 最小化到悬浮球").triggered.connect(self.minimize_to_ball)
for tooltip, _icon, callback in self._get_quick_actions():
menu.addAction(tooltip).triggered.connect(callback)
menu.addSeparator()
self._autostart_act = menu.addAction("🚀 开机自启: 开")
self._autostart_act.triggered.connect(self._toggle_autostart)
self._autostart_enabled = True
menu.addSeparator()
menu.addAction("❌ 退出").triggered.connect(QApplication.quit)
menu.addAction("🔄 重启本程序").triggered.connect(self._restart_application)
menu.addAction("❌ 退出本程序").triggered.connect(QApplication.quit)
self.tray.setContextMenu(menu)
self.tray.activated.connect(self._on_tray_activated)

View File

@ -270,6 +270,7 @@ class SettingsWindow(QDialog):
grid = QHBoxLayout(card)
grid.setContentsMargins(14, 14, 14, 14)
grid.setSpacing(18)
grid.setAlignment(Qt.AlignmentFlag.AlignHCenter)
donate_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate"

169
ui/updater.py Normal file
View File

@ -0,0 +1,169 @@
"""
软件自动更新模块
- 检查版本GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware
- 下载新版 exe替换当前程序后重启
"""
import os
import sys
import subprocess
import tempfile
from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication
import urllib.request
UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware"
def _current_version() -> str:
"""从 main 模块取当前版本号,避免循环导入。"""
try:
import importlib
m = importlib.import_module("__main__")
return getattr(m, "__VERSION__", "0.0.0")
except Exception:
return "0.0.0"
def _is_newer(latest: str, current: str) -> bool:
try:
def _parse(v):
return tuple(int(x) for x in v.strip().lstrip("v").split("."))
return _parse(latest) > _parse(current)
except Exception:
return latest != current
class _CheckWorker(QThread):
result = pyqtSignal(dict) # 成功:返回 data 字段
error = pyqtSignal(str)
def run(self):
try:
import json
import urllib.request
with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=10) as resp:
body = json.loads(resp.read().decode())
if body.get("code") == 200:
self.result.emit(body["data"])
else:
self.error.emit(body.get("msg", "接口返回异常"))
except Exception as e:
self.error.emit(str(e))
class _DownloadWorker(QThread):
progress = pyqtSignal(int) # 0-100
finished = pyqtSignal(str) # 下载完成,返回临时文件路径
error = pyqtSignal(str)
def __init__(self, url: str):
super().__init__()
self._url = url
def run(self):
try:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".exe")
tmp.close()
dst = tmp.name
def _reporthook(count, block_size, total_size):
if total_size > 0:
pct = min(100, int(count * block_size * 100 / total_size))
self.progress.emit(pct)
urllib.request.urlretrieve(self._url, dst, _reporthook)
self.progress.emit(100)
self.finished.emit(dst)
except Exception as e:
self.error.emit(str(e))
def _replace_and_restart(new_exe: str):
"""
写一个批处理脚本等待当前进程退出 覆盖 exe 启动新版
仅在打包为 exe 时执行真正替换开发环境直接启动下载文件
"""
current_exe = sys.executable if getattr(sys, "frozen", False) else None
if current_exe:
bat = tempfile.NamedTemporaryFile(delete=False, suffix=".bat", mode="w", encoding="gbk")
bat.write(f"""@echo off
ping 127.0.0.1 -n 3 >nul
move /y "{new_exe}" "{current_exe}"
start "" "{current_exe}"
del "%~f0"
""")
bat.close()
subprocess.Popen(["cmd", "/c", bat.name], creationflags=subprocess.CREATE_NO_WINDOW)
else:
# 开发环境:直接运行下载的 exe
subprocess.Popen([new_exe])
QApplication.quit()
class Updater(QObject):
"""对外接口:调用 check() 即可。"""
def __init__(self, parent=None):
super().__init__(parent)
self._parent_widget = parent
def check(self, silent_if_latest: bool = False):
self._silent = silent_if_latest
self._worker = _CheckWorker()
self._worker.result.connect(self._on_check_result)
self._worker.error.connect(self._on_check_error)
self._worker.start()
def _on_check_result(self, data: dict):
current = _current_version()
latest = data.get("latestVersion", "")
url = data.get("downloadUrl", "")
if not _is_newer(latest, current):
if not self._silent:
QMessageBox.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}")
return
notes = data.get("releaseNotes", "") or ""
msg = f"发现新版本 v{latest}(当前 v{current}"
if notes:
msg += f"\n\n更新内容:\n{notes}"
msg += "\n\n是否立即下载更新?"
ret = QMessageBox.question(
self._parent_widget, "发现新版本", msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if ret != QMessageBox.StandardButton.Yes:
return
self._download(url)
def _on_check_error(self, err: str):
if not self._silent:
QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}")
def _download(self, url: str):
self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget)
self._progress.setWindowTitle("下载更新")
self._progress.setMinimumDuration(0)
self._progress.setValue(0)
self._dl_worker = _DownloadWorker(url)
self._dl_worker.progress.connect(self._progress.setValue)
self._dl_worker.finished.connect(self._on_download_done)
self._dl_worker.error.connect(self._on_download_error)
self._progress.canceled.connect(self._dl_worker.terminate)
self._dl_worker.start()
def _on_download_done(self, path: str):
self._progress.close()
_replace_and_restart(path)
def _on_download_error(self, err: str):
self._progress.close()
QMessageBox.critical(self._parent_widget, "下载失败", f"下载更新失败:\n{err}")