更新0.02版本
This commit is contained in:
parent
77f718cca6
commit
3051f6ca49
@ -37,7 +37,7 @@ exe = EXE(
|
|||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=True,
|
console=False,
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
main.py
2
main.py
@ -9,7 +9,7 @@ from ui.ball import FloatBall, BALL_SIZE
|
|||||||
import ui.theme as theme
|
import ui.theme as theme
|
||||||
from db import database
|
from db import database
|
||||||
|
|
||||||
__VERSION__ = "0.0.1"
|
__VERSION__ = "0.0.2"
|
||||||
|
|
||||||
# ===================== 打包兼容核心函数 =====================
|
# ===================== 打包兼容核心函数 =====================
|
||||||
def get_resource_path(relative_path):
|
def get_resource_path(relative_path):
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
PyQt6>=6.4.0
|
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
69
ui/ball.py
69
ui/ball.py
@ -43,12 +43,25 @@ def _load_logo_pixmap(path):
|
|||||||
|
|
||||||
|
|
||||||
def _win32_set_ellipse_window_rgn(hwnd, w, h):
|
def _win32_set_ellipse_window_rgn(hwnd, w, h):
|
||||||
"""用系统区域裁切 HWND。"""
|
"""用系统区域裁切 HWND,w/h 为逻辑像素,内部换算为物理像素。"""
|
||||||
if not hwnd or w < 2 or h < 2:
|
if not hwnd or w < 2 or h < 2:
|
||||||
return
|
return
|
||||||
gdi32 = ctypes.windll.gdi32
|
gdi32 = ctypes.windll.gdi32
|
||||||
user32 = ctypes.windll.user32
|
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:
|
if not hrgn:
|
||||||
return
|
return
|
||||||
user32.SetWindowRgn(hwnd, hrgn, True)
|
user32.SetWindowRgn(hwnd, hrgn, True)
|
||||||
@ -139,14 +152,21 @@ class FloatBall(QWidget):
|
|||||||
_win32_set_ellipse_window_rgn(wid, self.width(), self.height())
|
_win32_set_ellipse_window_rgn(wid, self.width(), self.height())
|
||||||
_win32_disable_dwm_rounded_shell(wid)
|
_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):
|
def _apply_window_flags(self):
|
||||||
flags = (
|
flags = (
|
||||||
Qt.WindowType.FramelessWindowHint
|
Qt.WindowType.FramelessWindowHint
|
||||||
| Qt.WindowType.Tool
|
| Qt.WindowType.Tool
|
||||||
| Qt.WindowType.NoDropShadowWindowHint
|
| Qt.WindowType.NoDropShadowWindowHint
|
||||||
|
| Qt.WindowType.WindowStaysOnTopHint
|
||||||
)
|
)
|
||||||
if self._stays_on_top:
|
|
||||||
flags |= Qt.WindowType.WindowStaysOnTopHint
|
|
||||||
self.setWindowFlags(flags)
|
self.setWindowFlags(flags)
|
||||||
|
|
||||||
def _get_glow(self): return self._glow
|
def _get_glow(self): return self._glow
|
||||||
@ -154,6 +174,14 @@ class FloatBall(QWidget):
|
|||||||
from PyQt6.QtCore import pyqtProperty
|
from PyQt6.QtCore import pyqtProperty
|
||||||
_glow_prop = pyqtProperty(float, _get_glow, _set_glow)
|
_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):
|
def _place_default(self):
|
||||||
screen = QApplication.primaryScreen().availableGeometry()
|
screen = QApplication.primaryScreen().availableGeometry()
|
||||||
self.move(
|
self.move(
|
||||||
@ -161,6 +189,19 @@ class FloatBall(QWidget):
|
|||||||
screen.top() + screen.height() // 2 - self.height() // 2,
|
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):
|
def _paint_radius_outer(self):
|
||||||
w, h = self.width(), self.height()
|
w, h = self.width(), self.height()
|
||||||
return min(w, h) * 0.5 - 0.5
|
return min(w, h) * 0.5 - 0.5
|
||||||
@ -224,15 +265,17 @@ class FloatBall(QWidget):
|
|||||||
# 3. Logo(cover 铺满圆)
|
# 3. Logo(cover 铺满圆)
|
||||||
if not self._logo.isNull():
|
if not self._logo.isNull():
|
||||||
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
||||||
cover = max(1, int(2 * r))
|
side = max(1, int(2 * r))
|
||||||
scaled = self._logo.scaled(
|
scaled = self._logo.scaled(
|
||||||
cover, cover,
|
side, side,
|
||||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||||
Qt.TransformationMode.SmoothTransformation
|
Qt.TransformationMode.SmoothTransformation
|
||||||
)
|
)
|
||||||
lx = int(cx - scaled.width() / 2)
|
# 居中裁切到 side×side,避免 Expanding 后图片偏大导致偏移
|
||||||
ly = int(cy - scaled.height() / 2)
|
ox = (scaled.width() - side) // 2
|
||||||
p.drawPixmap(lx, ly, scaled)
|
oy = (scaled.height() - side) // 2
|
||||||
|
cropped = scaled.copy(ox, oy, side, side)
|
||||||
|
p.drawPixmap(int(cx - side / 2), int(cy - side / 2), cropped)
|
||||||
|
|
||||||
# 4. 覆在图上的雾面(毛玻璃「蒙在内容上」)
|
# 4. 覆在图上的雾面(毛玻璃「蒙在内容上」)
|
||||||
veil = QLinearGradient(cx, cy - r, cx, cy + r)
|
veil = QLinearGradient(cx, cy - r, cx, cy + r)
|
||||||
@ -280,7 +323,11 @@ class FloatBall(QWidget):
|
|||||||
if self._dragging:
|
if self._dragging:
|
||||||
new_pos = self.pos() + delta
|
new_pos = self.pos() + delta
|
||||||
self._drag_start = event.globalPosition().toPoint()
|
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.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())))
|
new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height())))
|
||||||
self.move(new_pos)
|
self.move(new_pos)
|
||||||
@ -307,7 +354,7 @@ class FloatBall(QWidget):
|
|||||||
self._glow_anim.start()
|
self._glow_anim.start()
|
||||||
|
|
||||||
def _snap_to_edge(self):
|
def _snap_to_edge(self):
|
||||||
screen = QApplication.primaryScreen().availableGeometry()
|
screen = self._current_screen_geometry()
|
||||||
cx = self.x() + self.width() // 2
|
cx = self.x() + self.width() // 2
|
||||||
cy = self.y() + self.height() // 2
|
cy = self.y() + self.height() // 2
|
||||||
dists = {
|
dists = {
|
||||||
|
|||||||
138
ui/dock.py
138
ui/dock.py
@ -38,10 +38,11 @@ from db import database
|
|||||||
from ui.group import GroupWidget
|
from ui.group import GroupWidget
|
||||||
import ui.theme as theme
|
import ui.theme as theme
|
||||||
import ui.dialog_style as dialog_style
|
import ui.dialog_style as dialog_style
|
||||||
|
from ui.updater import Updater
|
||||||
|
|
||||||
PANEL_W = 260
|
PANEL_W = 260
|
||||||
PANEL_H = 40
|
PANEL_H = 40
|
||||||
MIN_W, MIN_H = 180, 40
|
MIN_W, MIN_H = 400, 5
|
||||||
ANIM_MS = 180
|
ANIM_MS = 180
|
||||||
RESIZE_M = 8
|
RESIZE_M = 8
|
||||||
|
|
||||||
@ -354,6 +355,8 @@ class PanelWindow(QWidget):
|
|||||||
self._time_timer.setInterval(1000)
|
self._time_timer.setInterval(1000)
|
||||||
self._time_timer.timeout.connect(self._refresh_time_label)
|
self._time_timer.timeout.connect(self._refresh_time_label)
|
||||||
|
|
||||||
|
self._updater = Updater(self)
|
||||||
|
|
||||||
def showEvent(self, event):
|
def showEvent(self, event):
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
if not self._screen_hooked:
|
if not self._screen_hooked:
|
||||||
@ -499,18 +502,63 @@ class PanelWindow(QWidget):
|
|||||||
self._time_timer.start()
|
self._time_timer.start()
|
||||||
|
|
||||||
# ── UI ──────────────────────────────────────────────
|
# ── 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):
|
def _build_ui(self):
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
self.container = QWidget()
|
self.container = QWidget()
|
||||||
self.container.setObjectName("container")
|
self.container.setObjectName("container")
|
||||||
# container 也开启鼠标追踪,子 widget 的 MouseMove 会冒泡上来
|
|
||||||
self.container.setMouseTracking(True)
|
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.setContentsMargins(8, 10, 8, 8)
|
||||||
inner.setSpacing(6)
|
inner.setSpacing(6)
|
||||||
|
h_layout.addWidget(right_widget)
|
||||||
|
|
||||||
# ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板)
|
# ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板)
|
||||||
self._title_bar = QWidget()
|
self._title_bar = QWidget()
|
||||||
@ -683,6 +731,12 @@ class PanelWindow(QWidget):
|
|||||||
|
|
||||||
bottom_layout.addWidget(self.menu_btn)
|
bottom_layout.addWidget(self.menu_btn)
|
||||||
bottom_layout.addStretch()
|
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.theme_btn)
|
||||||
bottom_layout.addWidget(self.settings_btn)
|
bottom_layout.addWidget(self.settings_btn)
|
||||||
bottom_layout.addWidget(self.quit_btn)
|
bottom_layout.addWidget(self.quit_btn)
|
||||||
@ -781,6 +835,26 @@ class PanelWindow(QWidget):
|
|||||||
if hasattr(self, "time_title_label"):
|
if hasattr(self, "time_title_label"):
|
||||||
self.time_title_label.setStyleSheet("font-size:11px; color: #888;")
|
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)"
|
bar_bg2 = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,8)"
|
||||||
self.bottom_bar.setStyleSheet(
|
self.bottom_bar.setStyleSheet(
|
||||||
@ -798,6 +872,8 @@ class PanelWindow(QWidget):
|
|||||||
qta.icon("fa5s.sun" if is_dark else "fa5s.moon", color=ic)
|
qta.icon("fa5s.sun" if is_dark else "fa5s.moon", color=ic)
|
||||||
)
|
)
|
||||||
self.theme_btn.setIconSize(QSize(14, 14))
|
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.setIcon(qta.icon("fa5s.bars", color=ic))
|
||||||
self.menu_btn.setIconSize(QSize(14, 14))
|
self.menu_btn.setIconSize(QSize(14, 14))
|
||||||
self.settings_btn.setIcon(qta.icon("fa5s.cog", color=ic))
|
self.settings_btn.setIcon(qta.icon("fa5s.cog", color=ic))
|
||||||
@ -820,6 +896,23 @@ class PanelWindow(QWidget):
|
|||||||
if ret == QMessageBox.StandardButton.Yes:
|
if ret == QMessageBox.StandardButton.Yes:
|
||||||
QApplication.quit()
|
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):
|
def _toggle_theme(self):
|
||||||
theme.set_theme("light" if theme.name() == "dark" else "dark")
|
theme.set_theme("light" if theme.name() == "dark" else "dark")
|
||||||
self._apply_theme()
|
self._apply_theme()
|
||||||
@ -1050,13 +1143,9 @@ class PanelWindow(QWidget):
|
|||||||
"""收缩:隐藏内容区,窗口缩到只剩标题栏高度"""
|
"""收缩:隐藏内容区,窗口缩到只剩标题栏高度"""
|
||||||
self._body.hide()
|
self._body.hide()
|
||||||
self.weather_bar.hide()
|
self.weather_bar.hide()
|
||||||
# 记录当前高度,展开时恢复
|
self._quick_bar.hide()
|
||||||
self._expanded_height = self.height()
|
self._expanded_height = self.height()
|
||||||
title_h = (
|
title_h = self._title_bar.sizeHint().height() + 20
|
||||||
self.container.layout().contentsMargins().top()
|
|
||||||
+ self.container.layout().contentsMargins().bottom()
|
|
||||||
+ self._title_bar.sizeHint().height()
|
|
||||||
)
|
|
||||||
self.setFixedHeight(title_h)
|
self.setFixedHeight(title_h)
|
||||||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||||||
self._min_btn.setIcon(qta.icon("fa5s.chevron-down", color=ic))
|
self._min_btn.setIcon(qta.icon("fa5s.chevron-down", color=ic))
|
||||||
@ -1066,6 +1155,7 @@ class PanelWindow(QWidget):
|
|||||||
"""展开:恢复内容区和窗口高度"""
|
"""展开:恢复内容区和窗口高度"""
|
||||||
self.weather_bar.show()
|
self.weather_bar.show()
|
||||||
self._body.show()
|
self._body.show()
|
||||||
|
self._quick_bar.show()
|
||||||
h = getattr(self, "_expanded_height", 520)
|
h = getattr(self, "_expanded_height", 520)
|
||||||
self.setMinimumHeight(MIN_H)
|
self.setMinimumHeight(MIN_H)
|
||||||
self.setMaximumHeight(16777215)
|
self.setMaximumHeight(16777215)
|
||||||
@ -1096,7 +1186,11 @@ class PanelWindow(QWidget):
|
|||||||
y = int(database.get_setting("panel_y", ""))
|
y = int(database.get_setting("panel_y", ""))
|
||||||
w = int(database.get_setting("panel_w", str(PANEL_W)))
|
w = int(database.get_setting("panel_w", str(PANEL_W)))
|
||||||
h = int(database.get_setting("panel_h", str(PANEL_H)))
|
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))
|
x = max(screen.left(), min(x, screen.right() - w))
|
||||||
y = max(screen.top(), min(y, screen.bottom() - h))
|
y = max(screen.top(), min(y, screen.bottom() - h))
|
||||||
self._persist_w = w
|
self._persist_w = w
|
||||||
@ -1119,7 +1213,11 @@ class PanelWindow(QWidget):
|
|||||||
|
|
||||||
# ── 显示/隐藏 ────────────────────────────────────────
|
# ── 显示/隐藏 ────────────────────────────────────────
|
||||||
def show_near(self, ball_pos: QPoint, ball_size: int):
|
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()
|
pw, ph = self.width(), self.height()
|
||||||
bx, by = ball_pos.x(), ball_pos.y()
|
bx, by = ball_pos.x(), ball_pos.y()
|
||||||
|
|
||||||
@ -1165,6 +1263,7 @@ class PanelWindow(QWidget):
|
|||||||
def minimize_to_ball(self):
|
def minimize_to_ball(self):
|
||||||
self.hide_panel()
|
self.hide_panel()
|
||||||
if hasattr(self, "_ball_ref"):
|
if hasattr(self, "_ball_ref"):
|
||||||
|
self._ball_ref.place_on_screen_of(self)
|
||||||
self._ball_ref.show()
|
self._ball_ref.show()
|
||||||
self._ball_ref.setWindowOpacity(1.0)
|
self._ball_ref.setWindowOpacity(1.0)
|
||||||
|
|
||||||
@ -1360,20 +1459,11 @@ class PanelWindow(QWidget):
|
|||||||
QMenu::separator { height:1px; background:#444; margin:4px 8px; }
|
QMenu::separator { height:1px; background:#444; margin:4px 8px; }
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
menu.addAction("🖥 显示面板").triggered.connect(
|
for tooltip, _icon, callback in self._get_quick_actions():
|
||||||
lambda: (
|
menu.addAction(tooltip).triggered.connect(callback)
|
||||||
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)
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
self._autostart_act = menu.addAction("🚀 开机自启: 开")
|
menu.addAction("🔄 重启本程序").triggered.connect(self._restart_application)
|
||||||
self._autostart_act.triggered.connect(self._toggle_autostart)
|
menu.addAction("❌ 退出本程序").triggered.connect(QApplication.quit)
|
||||||
self._autostart_enabled = True
|
|
||||||
menu.addSeparator()
|
|
||||||
menu.addAction("❌ 退出").triggered.connect(QApplication.quit)
|
|
||||||
|
|
||||||
self.tray.setContextMenu(menu)
|
self.tray.setContextMenu(menu)
|
||||||
self.tray.activated.connect(self._on_tray_activated)
|
self.tray.activated.connect(self._on_tray_activated)
|
||||||
|
|||||||
@ -270,6 +270,7 @@ class SettingsWindow(QDialog):
|
|||||||
grid = QHBoxLayout(card)
|
grid = QHBoxLayout(card)
|
||||||
grid.setContentsMargins(14, 14, 14, 14)
|
grid.setContentsMargins(14, 14, 14, 14)
|
||||||
grid.setSpacing(18)
|
grid.setSpacing(18)
|
||||||
|
grid.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||||
|
|
||||||
donate_dir = os.path.join(
|
donate_dir = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate"
|
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate"
|
||||||
|
|||||||
169
ui/updater.py
Normal file
169
ui/updater.py
Normal 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}")
|
||||||
Loading…
Reference in New Issue
Block a user