优化更新
This commit is contained in:
Binary file not shown.
+21
-4
@@ -2,13 +2,20 @@
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM 一键打包:PyInstaller -> dist\niumasoftware -> 生成 Inno 脚本 -> (可选) 编译安装包
|
||||
REM 需要:
|
||||
REM - Python + PyInstaller(pip install pyinstaller)
|
||||
REM - Inno Setup(可选,用于自动编译 .iss)
|
||||
REM Build flow: PyInstaller -> generate ISS -> optional ISCC compile
|
||||
REM Requirements:
|
||||
REM - Python + PyInstaller
|
||||
REM - Inno Setup (optional, for auto compile)
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo === Check Python architecture ===
|
||||
for /f %%i in ('python -c "import platform; print(platform.architecture()[0][:2])"') do set "PY_BITS=%%i"
|
||||
if not defined PY_BITS goto :pycheck_fail
|
||||
if not "%PY_BITS%"=="64" goto :pycheck_fail
|
||||
echo Python architecture: %PY_BITS%-bit
|
||||
|
||||
echo.
|
||||
echo === Clean old build artifacts ===
|
||||
if exist "build" rmdir /s /q "build"
|
||||
@@ -60,6 +67,16 @@ echo installer\niumasoftware.generated.iss
|
||||
echo.
|
||||
exit /b 0
|
||||
|
||||
:pycheck_fail
|
||||
echo.
|
||||
echo ERROR: Current python is not 64-bit, cannot build 64-bit installer payload.
|
||||
echo Please use 64-bit Python, for example:
|
||||
echo py -3.13-64 -m pip install -r requirements.txt
|
||||
echo py -3.13-64 -m pip install pyinstaller
|
||||
echo py -3.13-64 -m PyInstaller --version
|
||||
echo Then rerun this script.
|
||||
exit /b 1
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo Build failed.
|
||||
|
||||
@@ -39,6 +39,8 @@ python main.py
|
||||
.\build_installer.bat
|
||||
```
|
||||
|
||||
> 注意:安装包要求 64 位应用。`build_installer.bat` 已内置校验,若当前 `python` 不是 64 位会直接失败并提示切换到 64 位解释器。
|
||||
|
||||
它会自动完成:
|
||||
|
||||
- 清理 `build/`、`dist/`
|
||||
|
||||
@@ -87,21 +87,19 @@ class GlobalHotkey(QAbstractNativeEventFilter):
|
||||
super().__init__()
|
||||
self._panel = panel
|
||||
self._ball = ball
|
||||
self._hwnd = None
|
||||
|
||||
def install(self):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
# 用一个隐藏窗口的 HWND 来接收 WM_HOTKEY
|
||||
self._hwnd = int(self._panel.winId())
|
||||
# 注册线程级别的全局热键,避免因主窗口 hide/show 导致 HWND 重建/失效
|
||||
ctypes.windll.user32.RegisterHotKey(
|
||||
self._hwnd, self._HOTKEY_ID, self._MOD_ALT, self._VK_BACKTICK
|
||||
None, self._HOTKEY_ID, self._MOD_ALT, self._VK_BACKTICK
|
||||
)
|
||||
QApplication.instance().installNativeEventFilter(self)
|
||||
|
||||
def uninstall(self):
|
||||
if self._hwnd:
|
||||
ctypes.windll.user32.UnregisterHotKey(self._hwnd, self._HOTKEY_ID)
|
||||
if sys.platform == "win32":
|
||||
ctypes.windll.user32.UnregisterHotKey(None, self._HOTKEY_ID)
|
||||
QApplication.instance().removeNativeEventFilter(self)
|
||||
|
||||
def nativeEventFilter(self, event_type, message):
|
||||
@@ -114,7 +112,7 @@ class GlobalHotkey(QAbstractNativeEventFilter):
|
||||
return False, 0
|
||||
|
||||
def _toggle(self):
|
||||
if self._panel.isVisible():
|
||||
if self._panel.is_really_visible():
|
||||
self._panel.minimize_to_ball()
|
||||
else:
|
||||
self._panel.show_near(self._ball.pos(), BALL_SIZE)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
PyQt6>=6.4.0
|
||||
pillow>=9.0.0
|
||||
psutil>=5.9.0
|
||||
qtawesome>=1.2.0
|
||||
send2trash>=1.8.0
|
||||
# 可选:读取硬件温度传感器(LibreHardwareMonitor 等);缺失时自动降级到 nvidia-smi / ACPI
|
||||
wmi>=1.5.1; sys_platform == "win32"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+334
-109
@@ -21,7 +21,6 @@ from PyQt6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QGridLayout,
|
||||
QFrame,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
@@ -178,8 +177,6 @@ class WeatherBox(QWidget):
|
||||
lay.addWidget(self._left_widget)
|
||||
lay.addWidget(self._right_widget)
|
||||
|
||||
self._last_tooltip: str | None = None
|
||||
|
||||
def set_theme_colors(self, color_fg: str):
|
||||
self._temp_label.setStyleSheet(
|
||||
f"font-weight:900; font-size:28px; color:{color_fg}; background:transparent; padding:0px; margin:0px;"
|
||||
@@ -264,7 +261,6 @@ class WeatherBox(QWidget):
|
||||
self._weather_name_label.setText("获取中…")
|
||||
self._location_label.setText("")
|
||||
self._wind_label.setText("")
|
||||
self._last_tooltip = None
|
||||
|
||||
|
||||
def _make_tray_icon():
|
||||
@@ -410,16 +406,32 @@ class PanelWindow(QWidget):
|
||||
self._weather_fetching = False
|
||||
self._time_base_utc_ts: float = 0.0
|
||||
self._time_mono_base: float = 0.0
|
||||
# 默认按中国时区(大概率与你的 IP 定位一致);后续会在天气接口返回后修正
|
||||
self._tz_offset_sec: int = 8 * 3600
|
||||
# 默认取本机系统时区;天气接口仅用于定位参考,不再反向改写时区
|
||||
try:
|
||||
self._tz_offset_sec: int = int(
|
||||
datetime.datetime.now().astimezone().utcoffset().total_seconds()
|
||||
)
|
||||
except Exception:
|
||||
self._tz_offset_sec = 8 * 3600
|
||||
self._time_timer = QTimer(self)
|
||||
self._time_timer.setInterval(1000)
|
||||
self._time_timer.timeout.connect(self._refresh_time_label)
|
||||
|
||||
self._updater = Updater(self)
|
||||
|
||||
self._dock_state = "none"
|
||||
self._is_collapsed = False
|
||||
self._is_hiding = False
|
||||
self._temp_disable_collapse = False
|
||||
self._dock_timer = QTimer(self)
|
||||
self._dock_timer.setInterval(200)
|
||||
self._dock_timer.timeout.connect(self._on_dock_timer_timeout)
|
||||
self._dock_timer.start()
|
||||
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._apply_win32_blur(theme.name() == "dark")
|
||||
if not self._screen_hooked:
|
||||
wh = self.windowHandle()
|
||||
if wh is not None:
|
||||
@@ -478,17 +490,8 @@ class PanelWindow(QWidget):
|
||||
return
|
||||
self.weather_box.update_weather(data)
|
||||
self._weather_fetching = False
|
||||
# 用 weather 接口返回的 adcode(由 IP 自动定位)推断时区偏移
|
||||
# 目前你的接口数据基本为中国区(你给的示例 adcode=320706),中国全时区统一为 UTC+8
|
||||
try:
|
||||
adcode = data.get("adcode", "")
|
||||
ad_str = str(adcode).strip()
|
||||
if ad_str.isdigit() and len(ad_str) == 6:
|
||||
self._tz_offset_sec = 8 * 3600
|
||||
else:
|
||||
self._tz_offset_sec = 0
|
||||
except Exception:
|
||||
self._tz_offset_sec = 8 * 3600
|
||||
# 时区不再依赖天气接口(接口失败时旧逻辑会把时区清零,导致时间差 8 小时),
|
||||
# 统一使用初始化时的系统时区。
|
||||
if self._time_base_utc_ts > 0:
|
||||
self._refresh_time_label()
|
||||
|
||||
@@ -511,7 +514,7 @@ class PanelWindow(QWidget):
|
||||
delta_sec = _time.monotonic() - self._time_mono_base
|
||||
utc_now = self._time_base_utc_ts + delta_sec
|
||||
local_now = utc_now + self._tz_offset_sec
|
||||
dt = datetime.datetime.utcfromtimestamp(local_now)
|
||||
dt = datetime.datetime.fromtimestamp(local_now, tz=datetime.timezone.utc)
|
||||
self.time_label.setText(dt.strftime("%H:%M:%S"))
|
||||
self.time_date_label.setText(dt.strftime("%Y-%m-%d"))
|
||||
|
||||
@@ -573,6 +576,7 @@ class PanelWindow(QWidget):
|
||||
("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell),
|
||||
("打开默认浏览器", "fa5s.globe", self._open_default_browser),
|
||||
("解除占用", "fa5s.unlock", self._open_unlocker),
|
||||
("硬件温度", "fa5s.thermometer-half", self._open_temperature),
|
||||
]
|
||||
|
||||
def _build_ui(self):
|
||||
@@ -674,6 +678,7 @@ class PanelWindow(QWidget):
|
||||
self.weather_refresh_btn = QPushButton("刷新")
|
||||
self.weather_refresh_btn.setFixedSize(24, 24)
|
||||
self.weather_refresh_btn.setText("")
|
||||
_install_themed_tooltip(self.weather_refresh_btn, "刷新天气")
|
||||
self.weather_refresh_btn.setStyleSheet(
|
||||
"border:none; background:transparent; border-radius:6px;"
|
||||
)
|
||||
@@ -718,8 +723,14 @@ class PanelWindow(QWidget):
|
||||
search_row.setSpacing(6)
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setPlaceholderText("🔍 搜索程序...")
|
||||
self.search_box.setPlaceholderText("搜索程序...")
|
||||
self.search_box.setClearButtonEnabled(True)
|
||||
self.search_box.textChanged.connect(self._on_search)
|
||||
# 前置搜索图标(随主题在 _apply_theme 中重新着色)
|
||||
self._search_action = self.search_box.addAction(
|
||||
qta.icon("fa5s.search", color="#888"),
|
||||
QLineEdit.ActionPosition.LeadingPosition,
|
||||
)
|
||||
|
||||
self.add_group_btn = QPushButton("添加分组")
|
||||
self.add_group_btn.setFixedHeight(32)
|
||||
@@ -823,12 +834,13 @@ class PanelWindow(QWidget):
|
||||
ic = "#cccccc" if is_dark else "#555555"
|
||||
|
||||
self._apply_tooltip_theme(t, is_dark)
|
||||
self._apply_win32_blur(is_dark)
|
||||
|
||||
self.container.setStyleSheet(
|
||||
f"""
|
||||
QWidget#container {{
|
||||
background: {t['panel_bg']};
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid {t['panel_border']};
|
||||
}}
|
||||
"""
|
||||
@@ -859,6 +871,19 @@ class PanelWindow(QWidget):
|
||||
"""
|
||||
self.add_group_btn.setStyleSheet(txt_btn_style)
|
||||
self.add_folder_btn.setStyleSheet(txt_btn_style)
|
||||
# 文字按钮前置图标(随主题着色)
|
||||
try:
|
||||
self.add_group_btn.setIcon(qta.icon("fa5s.plus", color=ic))
|
||||
self.add_group_btn.setIconSize(QSize(11, 11))
|
||||
self.add_folder_btn.setIcon(qta.icon("fa5s.folder-plus", color=ic))
|
||||
self.add_folder_btn.setIconSize(QSize(11, 11))
|
||||
except Exception:
|
||||
pass
|
||||
# 搜索图标随主题着色
|
||||
try:
|
||||
self._search_action.setIcon(qta.icon("fa5s.search", color=ic))
|
||||
except Exception:
|
||||
pass
|
||||
self.scroll.setStyleSheet(
|
||||
f"""
|
||||
QScrollArea {{ border:none; background:transparent; }}
|
||||
@@ -874,6 +899,13 @@ class PanelWindow(QWidget):
|
||||
self.pin_btn.setIconSize(QSize(13, 13))
|
||||
self._min_btn.setIcon(qta.icon("fa5s.minus", color=ic))
|
||||
self._min_btn.setIconSize(QSize(13, 13))
|
||||
# 标题栏按钮悬停反馈
|
||||
title_btn_style = (
|
||||
f"QPushButton {{ border:none; background:transparent; border-radius:5px; }}"
|
||||
f"QPushButton:hover {{ background:{t['header_hover']}; }}"
|
||||
)
|
||||
self.pin_btn.setStyleSheet(title_btn_style)
|
||||
self._min_btn.setStyleSheet(title_btn_style)
|
||||
self.app_title.setStyleSheet(
|
||||
f"font-size:13px; font-weight:bold; background:transparent; color:{t['search_color']};"
|
||||
)
|
||||
@@ -883,7 +915,8 @@ class PanelWindow(QWidget):
|
||||
self.weather_box.set_theme_colors(t["search_color"])
|
||||
if hasattr(self, "weather_refresh_btn"):
|
||||
self.weather_refresh_btn.setStyleSheet(
|
||||
"border:none; background:transparent; border-radius:6px;"
|
||||
f"QPushButton {{ border:none; background:transparent; border-radius:6px; }}"
|
||||
f"QPushButton:hover {{ background:{t['header_hover']}; }}"
|
||||
)
|
||||
# 刷新按钮图标跟随主题颜色
|
||||
try:
|
||||
@@ -912,7 +945,7 @@ class PanelWindow(QWidget):
|
||||
QWidget#quick_bar {{
|
||||
background: {bar_side_bg};
|
||||
border: 1px solid {t['panel_border']};
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
|
||||
QPushButton:hover {{ background:{t['header_hover']}; }}
|
||||
@@ -931,7 +964,7 @@ class PanelWindow(QWidget):
|
||||
f"""
|
||||
QWidget#bottom_bar {{
|
||||
background: {bar_bg2};
|
||||
border-radius: 0 0 10px 10px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
border-top: 1px solid {t['panel_border']};
|
||||
}}
|
||||
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
|
||||
@@ -951,6 +984,10 @@ class PanelWindow(QWidget):
|
||||
self.quit_btn.setIcon(qta.icon("fa5s.sign-out-alt", color=ic))
|
||||
self.quit_btn.setIconSize(QSize(14, 14))
|
||||
|
||||
# 托盘菜单跟随主题
|
||||
if getattr(self, "_tray_menu", None) is not None:
|
||||
self._tray_menu.setStyleSheet(self._bottom_menu_stylesheet())
|
||||
|
||||
for i in range(self.groups_layout.count() - 1):
|
||||
item = self.groups_layout.itemAt(i)
|
||||
if item and item.widget():
|
||||
@@ -1036,6 +1073,21 @@ class PanelWindow(QWidget):
|
||||
dlg = UnlockDialog(self)
|
||||
dlg.exec()
|
||||
|
||||
def _open_temperature(self):
|
||||
try:
|
||||
from ui.temperature import TemperatureWindow
|
||||
except Exception as e:
|
||||
dialog_style.warning(self, "无法打开硬件温度", f"加载模块失败:{e}")
|
||||
return
|
||||
# 非模态独立窗口:可与其它功能窗口同时打开、自由切换
|
||||
win = getattr(self, "_temperature_win", None)
|
||||
if win is None:
|
||||
win = TemperatureWindow(self)
|
||||
self._temperature_win = win
|
||||
win.show()
|
||||
win.raise_()
|
||||
win.activateWindow()
|
||||
|
||||
def _open_wechat_multi(self):
|
||||
from ui.wechat_multi import WechatMultiDialog
|
||||
dlg = WechatMultiDialog(self)
|
||||
@@ -1050,6 +1102,12 @@ class PanelWindow(QWidget):
|
||||
self._settings_win._apply_theme()
|
||||
except Exception:
|
||||
pass
|
||||
# 温度窗口同样跟随主题
|
||||
if hasattr(self, "_temperature_win") and self._temperature_win.isVisible():
|
||||
try:
|
||||
self._temperature_win._apply_theme()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _show_settings(self):
|
||||
from ui.settings_window import SettingsWindow
|
||||
@@ -1249,6 +1307,7 @@ class PanelWindow(QWidget):
|
||||
if was_visible:
|
||||
self.setGeometry(geo)
|
||||
self.show()
|
||||
self._apply_win32_blur(theme.name() == "dark")
|
||||
ball = getattr(self, "_ball_ref", None)
|
||||
if ball is not None:
|
||||
ball.set_stays_on_top(on_top)
|
||||
@@ -1256,6 +1315,10 @@ class PanelWindow(QWidget):
|
||||
def _toggle_pin(self):
|
||||
self._pinned = not self._pinned
|
||||
self._sync_pin_dependent_ui()
|
||||
if self._pinned:
|
||||
if getattr(self, "_is_collapsed", False):
|
||||
self._expand_panel()
|
||||
self._dock_state = "none"
|
||||
|
||||
def _on_min_click(self):
|
||||
"""图钉开启时收缩内容区,否则最小化到悬浮球"""
|
||||
@@ -1296,6 +1359,8 @@ class PanelWindow(QWidget):
|
||||
|
||||
def _save_geometry(self):
|
||||
"""把当前位置和尺寸写入数据库"""
|
||||
if getattr(self, "_is_collapsed", False):
|
||||
return
|
||||
g = self.geometry()
|
||||
database.set_setting("panel_x", str(g.x()))
|
||||
database.set_setting("panel_y", str(g.y()))
|
||||
@@ -1306,6 +1371,7 @@ class PanelWindow(QWidget):
|
||||
self._persist_h = g.height()
|
||||
database.set_setting("panel_w", str(g.width()))
|
||||
database.set_setting("panel_h", str(g.height()))
|
||||
self._check_dock_state()
|
||||
|
||||
def _restore_geometry(self):
|
||||
"""从数据库恢复上次的位置、尺寸和透明度"""
|
||||
@@ -1332,6 +1398,7 @@ class PanelWindow(QWidget):
|
||||
self.setWindowOpacity(max(30, min(100, opacity)) / 100)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
self._check_dock_state()
|
||||
|
||||
def _on_search(self, keyword):
|
||||
for i in range(self.groups_layout.count() - 1):
|
||||
@@ -1341,6 +1408,61 @@ class PanelWindow(QWidget):
|
||||
|
||||
# ── 显示/隐藏 ────────────────────────────────────────
|
||||
def show_near(self, ball_pos: QPoint, ball_size: int):
|
||||
self._is_hiding = False
|
||||
|
||||
if getattr(self, "_dock_state", "none") != "none":
|
||||
self._is_collapsed = False
|
||||
self._temp_disable_collapse = True
|
||||
|
||||
# 从窗口当前位置的屏幕中获取大小,恢复到边缘贴靠展开位置
|
||||
screen_obj = QApplication.screenAt(self.pos())
|
||||
if screen_obj is None:
|
||||
screen_obj = QApplication.primaryScreen()
|
||||
screen = screen_obj.availableGeometry()
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
|
||||
# 计算展开时的最终坐标
|
||||
if self._dock_state == "top":
|
||||
y = screen.top()
|
||||
elif self._dock_state == "left":
|
||||
x = screen.left()
|
||||
elif self._dock_state == "right":
|
||||
x = screen.right() - w
|
||||
|
||||
# 进场滑出偏移方向
|
||||
offset_x, offset_y = x, y
|
||||
if self._dock_state == "top":
|
||||
offset_y = y - 30
|
||||
elif self._dock_state == "left":
|
||||
offset_x = x - 30
|
||||
elif self._dock_state == "right":
|
||||
offset_x = x + 30
|
||||
|
||||
self.move(offset_x, offset_y)
|
||||
self.setWindowOpacity(0)
|
||||
self.show()
|
||||
self.raise_()
|
||||
|
||||
if hasattr(self, "_ball_ref"):
|
||||
self._ball_ref.hide()
|
||||
|
||||
if self._anim:
|
||||
self._anim.stop()
|
||||
self._anim = QPropertyAnimation(self, b"windowOpacity")
|
||||
self._anim.setDuration(ANIM_MS)
|
||||
self._anim.setStartValue(0.0)
|
||||
self._anim.setEndValue(1.0)
|
||||
self._anim.start()
|
||||
|
||||
self._anim2 = QPropertyAnimation(self, b"pos")
|
||||
self._anim2.setDuration(ANIM_MS)
|
||||
self._anim2.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
self._anim2.setStartValue(QPoint(offset_x, offset_y))
|
||||
self._anim2.setEndValue(QPoint(x, y))
|
||||
self._anim2.finished.connect(self._save_geometry)
|
||||
self._anim2.start()
|
||||
return
|
||||
|
||||
# 根据球所在屏幕来定位 panel,避免跑到主屏幕
|
||||
screen_obj = QApplication.screenAt(ball_pos)
|
||||
if screen_obj is None:
|
||||
@@ -1354,6 +1476,8 @@ class PanelWindow(QWidget):
|
||||
x = bx + ball_size + 8
|
||||
y = max(screen.top(), min(by + ball_size // 2 - ph // 2, screen.bottom() - ph))
|
||||
|
||||
self._is_collapsed = False
|
||||
self._temp_disable_collapse = True
|
||||
self.move(x, y + 30)
|
||||
self.setWindowOpacity(0)
|
||||
self.show()
|
||||
@@ -1379,15 +1503,24 @@ class PanelWindow(QWidget):
|
||||
self._anim2.start()
|
||||
|
||||
def hide_panel(self):
|
||||
self._is_hiding = True
|
||||
if self._anim:
|
||||
self._anim.stop()
|
||||
self._anim = QPropertyAnimation(self, b"windowOpacity")
|
||||
self._anim.setDuration(ANIM_MS)
|
||||
self._anim.setStartValue(1.0)
|
||||
self._anim.setEndValue(0.0)
|
||||
self._anim.finished.connect(self.hide)
|
||||
|
||||
def _on_hide_finished():
|
||||
self.hide()
|
||||
self._is_hiding = False
|
||||
|
||||
self._anim.finished.connect(_on_hide_finished)
|
||||
self._anim.start()
|
||||
|
||||
def is_really_visible(self) -> bool:
|
||||
return self.isVisible() and not getattr(self, "_is_hiding", False)
|
||||
|
||||
def minimize_to_ball(self):
|
||||
self.hide_panel()
|
||||
if hasattr(self, "_ball_ref"):
|
||||
@@ -1518,6 +1651,8 @@ class PanelWindow(QWidget):
|
||||
self._do_resize(gp)
|
||||
return True
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
|
||||
self._dock_state = "none"
|
||||
self._is_collapsed = False
|
||||
self.move(gp - self._win_drag_pos)
|
||||
return True
|
||||
# 悬停时更新光标
|
||||
@@ -1528,6 +1663,7 @@ class PanelWindow(QWidget):
|
||||
self._resize_edge = None
|
||||
self._win_drag_pos = None
|
||||
self._update_cursor(event.globalPosition().toPoint())
|
||||
self._check_dock_state()
|
||||
self._save_geometry()
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
@@ -1538,6 +1674,8 @@ class PanelWindow(QWidget):
|
||||
self._do_resize(event.globalPosition().toPoint())
|
||||
return
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
|
||||
self._dock_state = "none"
|
||||
self._is_collapsed = False
|
||||
self.move(event.globalPosition().toPoint() - self._win_drag_pos)
|
||||
return
|
||||
self._update_cursor(event.globalPosition().toPoint())
|
||||
@@ -1563,6 +1701,7 @@ class PanelWindow(QWidget):
|
||||
self._resize_edge = None
|
||||
self._win_drag_pos = None
|
||||
self._update_cursor(event.globalPosition().toPoint())
|
||||
self._check_dock_state()
|
||||
self._save_geometry() # 拖动/resize 结束后保存位置和尺寸
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
@@ -1574,7 +1713,25 @@ class PanelWindow(QWidget):
|
||||
QTimer.singleShot(200, self._check_focus_lost)
|
||||
|
||||
def _check_focus_lost(self):
|
||||
if not self.isActiveWindow() and not self._pinned:
|
||||
if self._pinned or not self.isVisible():
|
||||
return
|
||||
if self.isActiveWindow():
|
||||
return
|
||||
# 焦点转移到了本面板弹出的对话框/子窗口(添加分组、设置、右键菜单等)时不隐藏
|
||||
active = QApplication.activeWindow()
|
||||
if active is not None and active is not self:
|
||||
p = active.parentWidget()
|
||||
while p is not None:
|
||||
if p is self:
|
||||
return
|
||||
p = p.parentWidget()
|
||||
if QApplication.activePopupWidget() is not None:
|
||||
return
|
||||
if getattr(self, "_dock_state", "none") != "none":
|
||||
self._temp_disable_collapse = False
|
||||
if not self._is_collapsed:
|
||||
self._collapse_panel()
|
||||
return
|
||||
self.hide_panel()
|
||||
|
||||
# ── 托盘 ─────────────────────────────────────────────
|
||||
@@ -1584,15 +1741,7 @@ class PanelWindow(QWidget):
|
||||
self.tray.setToolTip("桌面文件整理")
|
||||
|
||||
menu = QMenu()
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu { background:#2b2b2b; color:#eee; border:1px solid #555;
|
||||
padding:4px; border-radius:6px; }
|
||||
QMenu::item { padding:6px 20px; border-radius:4px; }
|
||||
QMenu::item:selected { background:#3a3a3a; }
|
||||
QMenu::separator { height:1px; background:#444; margin:4px 8px; }
|
||||
"""
|
||||
)
|
||||
menu.setStyleSheet(self._bottom_menu_stylesheet())
|
||||
for tooltip, _icon, callback in self._get_quick_actions():
|
||||
menu.addAction(tooltip).triggered.connect(callback)
|
||||
menu.addSeparator()
|
||||
@@ -1600,6 +1749,7 @@ class PanelWindow(QWidget):
|
||||
menu.addAction("❌ 退出本程序").triggered.connect(QApplication.quit)
|
||||
|
||||
self.tray.setContextMenu(menu)
|
||||
self._tray_menu = menu # 主题切换时同步样式
|
||||
self.tray.activated.connect(self._on_tray_activated)
|
||||
self.tray.show()
|
||||
|
||||
@@ -1613,93 +1763,167 @@ class PanelWindow(QWidget):
|
||||
elif hasattr(self, "_ball_ref"):
|
||||
self.show_near(self._ball_ref.pos(), self._ball_ref.width())
|
||||
|
||||
def _toggle_autostart(self):
|
||||
self._autostart_enabled = not self._autostart_enabled
|
||||
_set_autostart(self._autostart_enabled)
|
||||
self._autostart_act.setText(
|
||||
f"🚀 开机自启: {'开' if self._autostart_enabled else '关'}"
|
||||
)
|
||||
# ── 边缘吸附与收起 ─────────────────────────────────────
|
||||
def _check_dock_state(self):
|
||||
if self._pinned:
|
||||
self._dock_state = "none"
|
||||
return
|
||||
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geom = screen.geometry()
|
||||
|
||||
class SettingsPopup(QWidget):
|
||||
"""设置弹出面板:透明度调节等"""
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
threshold = 20
|
||||
|
||||
def __init__(self, panel: "PanelWindow"):
|
||||
super().__init__(panel, Qt.WindowType.Popup)
|
||||
self._panel = panel
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
|
||||
self._build()
|
||||
# 检测顶部吸附
|
||||
if abs(y - screen_geom.top()) <= threshold:
|
||||
self._dock_state = "top"
|
||||
self.move(x, screen_geom.top())
|
||||
# 检测左侧吸附
|
||||
elif abs(x - screen_geom.left()) <= threshold:
|
||||
self._dock_state = "left"
|
||||
self.move(screen_geom.left(), y)
|
||||
# 检测右侧吸附
|
||||
elif abs((x + w) - screen_geom.right()) <= threshold:
|
||||
self._dock_state = "right"
|
||||
self.move(screen_geom.right() - w, y)
|
||||
else:
|
||||
self._dock_state = "none"
|
||||
|
||||
def _build(self):
|
||||
from PyQt6.QtWidgets import QSlider, QLabel, QVBoxLayout, QHBoxLayout
|
||||
def _on_dock_timer_timeout(self):
|
||||
if not self.isVisible() or self._dock_state == "none" or self._pinned or self._win_drag_pos or self._resizing:
|
||||
return
|
||||
|
||||
t = theme.current()
|
||||
is_dark = theme.name() == "dark"
|
||||
cursor_pos = QCursor.pos()
|
||||
geom = self.geometry()
|
||||
is_hovered = geom.contains(cursor_pos)
|
||||
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QWidget {{
|
||||
background: {t['panel_bg']};
|
||||
border: 1px solid {t['panel_border']};
|
||||
border-radius: 8px;
|
||||
color: {t['search_color']};
|
||||
}}
|
||||
QSlider::groove:horizontal {{
|
||||
height: 4px;
|
||||
background: {t['scrollbar']};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
width: 14px; height: 14px;
|
||||
margin: -5px 0;
|
||||
background: #4a9eff;
|
||||
border-radius: 7px;
|
||||
}}
|
||||
QSlider::sub-page:horizontal {{
|
||||
background: #4a9eff;
|
||||
border-radius: 2px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
if not is_hovered:
|
||||
screen = QApplication.screenAt(cursor_pos)
|
||||
if screen:
|
||||
screen_geom = screen.geometry()
|
||||
tolerance = 3
|
||||
if self._dock_state == "top" and cursor_pos.y() <= screen_geom.top() + tolerance:
|
||||
if geom.left() <= cursor_pos.x() <= geom.right():
|
||||
is_hovered = True
|
||||
elif self._dock_state == "left" and cursor_pos.x() <= screen_geom.left() + tolerance:
|
||||
if geom.top() <= cursor_pos.y() <= geom.bottom():
|
||||
is_hovered = True
|
||||
elif self._dock_state == "right" and cursor_pos.x() >= screen_geom.right() - tolerance:
|
||||
if geom.top() <= cursor_pos.y() <= geom.bottom():
|
||||
is_hovered = True
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(10)
|
||||
if is_hovered:
|
||||
self._temp_disable_collapse = False
|
||||
if self._is_collapsed:
|
||||
self._expand_panel()
|
||||
else:
|
||||
if not self._is_collapsed and not getattr(self, "_temp_disable_collapse", False):
|
||||
self._collapse_panel()
|
||||
|
||||
# 透明度
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
lbl = QLabel("透明度")
|
||||
lbl.setStyleSheet(
|
||||
f"color:{t['search_color']}; font-size:12px; background:transparent; border:none;"
|
||||
)
|
||||
lbl.setFixedWidth(42)
|
||||
def _collapse_panel(self):
|
||||
if self._is_collapsed:
|
||||
return
|
||||
self._is_collapsed = True
|
||||
|
||||
self._opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._opacity_slider.setRange(30, 100)
|
||||
saved_opacity = int(database.get_setting("panel_opacity", "100"))
|
||||
self._opacity_slider.setValue(saved_opacity)
|
||||
self._opacity_slider.setFixedWidth(140)
|
||||
self._opacity_slider.valueChanged.connect(self._on_opacity)
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geom = screen.geometry()
|
||||
|
||||
self._opacity_val = QLabel(f"{self._opacity_slider.value()}%")
|
||||
self._opacity_val.setStyleSheet(
|
||||
f"color:{t['search_color']}; font-size:11px; background:transparent; border:none;"
|
||||
)
|
||||
self._opacity_val.setFixedWidth(34)
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
target_x, target_y = x, y
|
||||
sliver = 3 # 边缘缩进后露出的像素宽度
|
||||
|
||||
row.addWidget(lbl)
|
||||
row.addWidget(self._opacity_slider)
|
||||
row.addWidget(self._opacity_val)
|
||||
layout.addLayout(row)
|
||||
if self._dock_state == "top":
|
||||
target_y = screen_geom.top() - h + sliver
|
||||
elif self._dock_state == "left":
|
||||
target_x = screen_geom.left() - w + sliver
|
||||
elif self._dock_state == "right":
|
||||
target_x = screen_geom.right() - sliver
|
||||
|
||||
self.adjustSize()
|
||||
self._animate_to(target_x, target_y)
|
||||
|
||||
def _on_opacity(self, val: int):
|
||||
self._panel.setWindowOpacity(val / 100)
|
||||
self._opacity_val.setText(f"{val}%")
|
||||
database.set_setting("panel_opacity", str(val))
|
||||
def _expand_panel(self):
|
||||
if not self._is_collapsed:
|
||||
return
|
||||
self._is_collapsed = False
|
||||
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geom = screen.geometry()
|
||||
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
target_x, target_y = x, y
|
||||
|
||||
if self._dock_state == "top":
|
||||
target_y = screen_geom.top()
|
||||
elif self._dock_state == "left":
|
||||
target_x = screen_geom.left()
|
||||
elif self._dock_state == "right":
|
||||
target_x = screen_geom.right() - w
|
||||
|
||||
self._animate_to(target_x, target_y)
|
||||
|
||||
def _animate_to(self, x, y):
|
||||
if hasattr(self, "_dock_anim") and self._dock_anim:
|
||||
self._dock_anim.stop()
|
||||
|
||||
self._dock_anim = QPropertyAnimation(self, b"pos")
|
||||
self._dock_anim.setDuration(200)
|
||||
self._dock_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
self._dock_anim.setStartValue(self.pos())
|
||||
self._dock_anim.setEndValue(QPoint(x, y))
|
||||
if not self._is_collapsed:
|
||||
self._dock_anim.finished.connect(self.raise_)
|
||||
self._dock_anim.start()
|
||||
|
||||
def _apply_win32_blur(self, is_dark: bool):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import windll, sizeof, byref, c_int, Structure
|
||||
|
||||
class ACCENT_POLICY(Structure):
|
||||
_fields_ = [
|
||||
("AccentState", c_int),
|
||||
("AccentFlags", c_int),
|
||||
("GradientColor", c_int),
|
||||
("AnimationId", c_int)
|
||||
]
|
||||
|
||||
class WINDOWCOMPOSITIONATTRIBDATA(Structure):
|
||||
_fields_ = [
|
||||
("Attribute", c_int),
|
||||
("Data", ctypes.c_void_p),
|
||||
("SizeOfData", ctypes.c_int)
|
||||
]
|
||||
|
||||
hwnd = int(self.winId())
|
||||
accent = ACCENT_POLICY()
|
||||
accent.AccentState = 4 # ACCENT_ENABLE_ACRYLICBLURBEHIND
|
||||
|
||||
if is_dark:
|
||||
# 磨砂黑色:深灰色半透明
|
||||
accent.GradientColor = 0x90202020
|
||||
else:
|
||||
# 磨砂白色:白色半透明
|
||||
accent.GradientColor = 0x90F5F5F5
|
||||
|
||||
accent.AccentFlags = 2
|
||||
|
||||
data = WINDOWCOMPOSITIONATTRIBDATA()
|
||||
data.Attribute = 19 # WCA_ACCENT_POLICY
|
||||
data.Data = ctypes.cast(byref(accent), ctypes.c_void_p)
|
||||
data.SizeOfData = sizeof(accent)
|
||||
|
||||
windll.user32.SetWindowCompositionAttribute(hwnd, byref(data))
|
||||
except Exception as e:
|
||||
print("Failed to apply win32 composition blur:", e)
|
||||
|
||||
|
||||
def _set_autostart(enable: bool):
|
||||
@@ -1715,7 +1939,8 @@ def _set_autostart(enable: bool):
|
||||
app_name = "DesktopOrganizer"
|
||||
if enable:
|
||||
exe = (
|
||||
f'"{sys.executable}" "{os.path.abspath("main.py")}"'
|
||||
# 用 __file__ 定位入口,避免从其它工作目录启动时写入错误路径
|
||||
f'"{sys.executable}" "{os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "main.py"))}"'
|
||||
if not getattr(sys, "frozen", False)
|
||||
else f'"{sys.executable}"'
|
||||
)
|
||||
|
||||
+74
-17
@@ -17,6 +17,32 @@ import shortcut_target
|
||||
import ui.dialog_style as dialog_style
|
||||
|
||||
|
||||
def _copy_file_into_folder(src_path: str, folder_path: str) -> str:
|
||||
"""把文件复制到文件夹分组目录,避免同名覆盖(自动追加序号)。返回最终路径。"""
|
||||
import shutil
|
||||
dest_path = os.path.join(folder_path, os.path.basename(src_path))
|
||||
if os.path.exists(dest_path) and os.path.normpath(dest_path) != os.path.normpath(src_path):
|
||||
base, ext = os.path.splitext(os.path.basename(src_path))
|
||||
i = 1
|
||||
while os.path.exists(dest_path):
|
||||
dest_path = os.path.join(folder_path, f"{base} ({i}){ext}")
|
||||
i += 1
|
||||
try:
|
||||
if os.path.normpath(dest_path) != os.path.normpath(src_path):
|
||||
shutil.copy2(src_path, dest_path)
|
||||
return dest_path
|
||||
except Exception as e:
|
||||
print(f"复制失败: {src_path} -> {e}")
|
||||
return src_path # 复制失败就用原路径
|
||||
|
||||
|
||||
def _group_folder_path(group_id: int) -> str:
|
||||
"""分组对应的文件夹路径(非文件夹分组返回空串)。"""
|
||||
groups = database.get_groups()
|
||||
info = next((g for g in groups if g["id"] == group_id), {})
|
||||
return (info.get("folder_path", "") or "").strip()
|
||||
|
||||
|
||||
def item_count_for_group(group_id: int) -> int:
|
||||
"""分组内程序/文件总数(文件夹分组数磁盘文件,普通分组数数据库条目)。"""
|
||||
groups = database.get_groups()
|
||||
@@ -253,7 +279,53 @@ class FlowWidget(QWidget):
|
||||
self.update()
|
||||
mime = event.mimeData()
|
||||
if mime.hasFormat("application/x-item-id"):
|
||||
item_id = int(mime.data("application/x-item-id").data().decode())
|
||||
raw = bytes(mime.data("application/x-item-id")).decode()
|
||||
item_id: int | None
|
||||
if raw in ("", "none"):
|
||||
item_id = None
|
||||
else:
|
||||
try:
|
||||
item_id = int(raw)
|
||||
except ValueError:
|
||||
item_id = None
|
||||
|
||||
folder_path = _group_folder_path(self.group_id)
|
||||
is_folder_group = bool(folder_path) and os.path.isdir(folder_path)
|
||||
|
||||
if item_id is None:
|
||||
# 文件夹分组的条目(无数据库 id):按源文件处理
|
||||
if not mime.hasUrls():
|
||||
event.ignore()
|
||||
return
|
||||
if is_folder_group:
|
||||
for url in mime.urls():
|
||||
path = url.toLocalFile()
|
||||
if path and os.path.isfile(path):
|
||||
_copy_file_into_folder(path, folder_path)
|
||||
else:
|
||||
# 拖入普通分组:写入数据库
|
||||
for url in mime.urls():
|
||||
path = url.toLocalFile()
|
||||
if path:
|
||||
store = shortcut_target.path_for_storage(path)
|
||||
name = shortcut_target.item_name_from_sources(path, store)
|
||||
database.add_item(self.group_id, name, store)
|
||||
self.refresh()
|
||||
self.item_dropped.emit(0)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
if is_folder_group:
|
||||
# 数据库条目拖入文件夹分组:复制源文件到目录,原分组条目保持不变
|
||||
if mime.hasUrls():
|
||||
for url in mime.urls():
|
||||
path = url.toLocalFile()
|
||||
if path and os.path.isfile(path):
|
||||
_copy_file_into_folder(path, folder_path)
|
||||
self.refresh()
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
pos = self._container.mapFrom(self, event.position().toPoint())
|
||||
insert_pos = self._container.index_at(pos)
|
||||
database.move_item(item_id, self.group_id, 999)
|
||||
@@ -344,22 +416,7 @@ class FlowWidget(QWidget):
|
||||
for path in clipboard_paths:
|
||||
if is_folder_group:
|
||||
# 真正复制文件到文件夹目录
|
||||
import shutil
|
||||
dest_path = os.path.join(folder_path, os.path.basename(path))
|
||||
# 避免覆盖同名文件,自动重命名
|
||||
if os.path.exists(dest_path) and dest_path != path:
|
||||
base, ext = os.path.splitext(os.path.basename(path))
|
||||
i = 1
|
||||
while os.path.exists(dest_path):
|
||||
dest_path = os.path.join(folder_path, f"{base} ({i}){ext}")
|
||||
i += 1
|
||||
try:
|
||||
if path != dest_path:
|
||||
shutil.copy2(path, dest_path)
|
||||
final_path = dest_path
|
||||
except Exception as e:
|
||||
print(f"复制失败: {path} -> {e}")
|
||||
final_path = path # 复制失败就用原路径
|
||||
final_path = _copy_file_into_folder(path, folder_path)
|
||||
name = os.path.splitext(os.path.basename(final_path))[0] or os.path.basename(final_path)
|
||||
else:
|
||||
final_path = shortcut_target.path_for_storage(path)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""硬件温度数据源组件的获取与启动。
|
||||
|
||||
Windows 用户态没有任何 API 能直接读 CPU 核心温度(需要内核级 MSR 访问),
|
||||
因此本模块负责一键获取并启动开源组件 LibreHardwareMonitor(MPL-2.0):
|
||||
它由内核驱动读取传感器,再通过 WMI(root\\LibreHardwareMonitor)暴露出来,
|
||||
温度窗口会自动读取并展示每个核心的温度。
|
||||
|
||||
下载策略:
|
||||
1. 先查官方 GitHub API 拿到最新版本的精确地址与 SHA-256 摘要;
|
||||
2. 优先直连官方地址;失败则改用公共加速镜像(仅在有官方摘要时使用);
|
||||
3. 无论走了哪条通道,都按官方 SHA-256 校验文件完整性后才解压。
|
||||
|
||||
组件安装到 %LOCALAPPDATA%\\niumasoftware\\tools\\LibreHardwareMonitor。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from ctypes import wintypes
|
||||
from typing import Any, Callable
|
||||
|
||||
API_URL = (
|
||||
"https://api.github.com/repos/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest"
|
||||
)
|
||||
ASSET_NAME = "LibreHardwareMonitor.zip"
|
||||
LATEST_DOWNLOAD_URL = (
|
||||
"https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest/"
|
||||
f"download/{ASSET_NAME}"
|
||||
)
|
||||
RELEASE_PAGE = (
|
||||
"https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest"
|
||||
)
|
||||
# 公共 GitHub 加速前缀:仅用于「有官方摘要可校验」时的兜底通道
|
||||
MIRROR_PREFIXES = (
|
||||
"https://ghfast.top/",
|
||||
"https://gh-proxy.com/",
|
||||
"https://ghproxy.net/",
|
||||
)
|
||||
|
||||
EXE_NAME = "librehardwaremonitor.exe"
|
||||
PROCESS_NAME = "librehardwaremonitor"
|
||||
|
||||
_release_cache: dict[str, Any] = {}
|
||||
|
||||
|
||||
def tool_dir() -> str:
|
||||
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
|
||||
return os.path.join(base, "niumasoftware", "tools", "LibreHardwareMonitor")
|
||||
|
||||
|
||||
def find_exe() -> str:
|
||||
"""返回已安装的 LibreHardwareMonitor.exe 路径,没有则返回空串。"""
|
||||
root = tool_dir()
|
||||
if not os.path.isdir(root):
|
||||
return ""
|
||||
for current, _dirs, files in os.walk(root):
|
||||
for name in files:
|
||||
if name.lower() == EXE_NAME:
|
||||
return os.path.join(current, name)
|
||||
return ""
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
for proc in psutil.process_iter(["name"]):
|
||||
name = (proc.info.get("name") or "").lower()
|
||||
if name.startswith(PROCESS_NAME):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _release_info() -> dict:
|
||||
"""查询官方 release 信息:{url, digest, tag}。失败时返回只有 latest 地址的兜底值。"""
|
||||
if _release_cache:
|
||||
return _release_cache
|
||||
|
||||
info: dict[str, Any] = {"url": LATEST_DOWNLOAD_URL, "digest": "", "tag": ""}
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
API_URL,
|
||||
headers={"User-Agent": "niumasoftware-temperature", "Accept": "application/vnd.github+json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=12) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
for asset in data.get("assets") or []:
|
||||
if str(asset.get("name")) != ASSET_NAME:
|
||||
continue
|
||||
info["url"] = str(asset.get("browser_download_url") or LATEST_DOWNLOAD_URL)
|
||||
digest = str(asset.get("digest") or "")
|
||||
if digest.startswith("sha256:"):
|
||||
info["digest"] = digest.split(":", 1)[1].strip().lower()
|
||||
info["tag"] = str(data.get("tag_name") or "")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_release_cache.update(info)
|
||||
return _release_cache
|
||||
|
||||
|
||||
def _sha256(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _download_one(
|
||||
url: str,
|
||||
dest_zip: str,
|
||||
progress: Callable[[int, int], None] | None,
|
||||
is_cancelled: Callable[[], bool] | None,
|
||||
) -> None:
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "niumasoftware-temperature"}
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as resp, open(dest_zip, "wb") as fh:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
if progress is not None:
|
||||
progress(0, total)
|
||||
got = 0
|
||||
while True:
|
||||
if is_cancelled is not None and is_cancelled():
|
||||
raise RuntimeError("已取消")
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
fh.write(chunk)
|
||||
got += len(chunk)
|
||||
if progress is not None:
|
||||
progress(got, total)
|
||||
|
||||
|
||||
def download(
|
||||
dest_zip: str,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
"""下载组件压缩包,自动在官方源与镜像之间切换,并按官方摘要校验。"""
|
||||
info = _release_info()
|
||||
official = str(info.get("url") or LATEST_DOWNLOAD_URL)
|
||||
digest = str(info.get("digest") or "")
|
||||
|
||||
candidates: list[tuple[str, str]] = [("官方源", official)]
|
||||
if digest:
|
||||
# 只有在能按官方 SHA-256 校验时才允许走第三方镜像
|
||||
for prefix in MIRROR_PREFIXES:
|
||||
candidates.append(("镜像", f"{prefix}{official}"))
|
||||
|
||||
errors: list[str] = []
|
||||
for kind, url in candidates:
|
||||
try:
|
||||
_download_one(url, dest_zip, progress, is_cancelled)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"{kind}:{exc}")
|
||||
continue
|
||||
|
||||
if digest:
|
||||
actual = _sha256(dest_zip)
|
||||
if actual != digest:
|
||||
errors.append(f"{kind}:文件校验不一致")
|
||||
continue
|
||||
return
|
||||
|
||||
raise RuntimeError(";".join(errors) if errors else "下载失败")
|
||||
|
||||
|
||||
def install(
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> str:
|
||||
"""下载并解压组件,返回可执行文件路径。失败抛异常。"""
|
||||
target = tool_dir()
|
||||
if os.path.isdir(target):
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
|
||||
fd, temp_zip = tempfile.mkstemp(suffix=".zip", prefix="lhm_")
|
||||
os.close(fd)
|
||||
try:
|
||||
download(temp_zip, progress, is_cancelled)
|
||||
with zipfile.ZipFile(temp_zip) as zf:
|
||||
for member in zf.infolist():
|
||||
name = member.filename.replace("\\", "/")
|
||||
if name.startswith("/") or ".." in name.split("/"):
|
||||
raise RuntimeError("下载包内容异常,已中止解压")
|
||||
zf.extractall(target)
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_zip)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
exe = find_exe()
|
||||
if not exe:
|
||||
raise RuntimeError("下载包中未找到 LibreHardwareMonitor.exe")
|
||||
return exe
|
||||
|
||||
|
||||
def launch(exe_path: str, *, elevated: bool = True) -> tuple[bool, str]:
|
||||
"""启动组件;elevated=True 会请求管理员权限(读取硬件传感器必需)。"""
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
return False, "未找到可执行文件"
|
||||
try:
|
||||
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
||||
shell32.ShellExecuteW.restype = ctypes.c_ssize_t
|
||||
shell32.ShellExecuteW.argtypes = [
|
||||
wintypes.HWND,
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.c_int,
|
||||
]
|
||||
op = "runas" if elevated else "open"
|
||||
result = int(
|
||||
shell32.ShellExecuteW(
|
||||
None, op, exe_path, None, os.path.dirname(exe_path), 1
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
||||
if result > 32:
|
||||
return True, ""
|
||||
if result == 5: # ERROR_ACCESS_DENIED:用户在 UAC 中点了“否”
|
||||
return False, "已取消管理员授权(读取硬件传感器需要管理员权限)"
|
||||
if result == 2:
|
||||
return False, "文件不存在"
|
||||
return False, f"启动失败(错误码 {result})"
|
||||
+54
-8
@@ -7,13 +7,36 @@ from PyQt6.QtGui import (
|
||||
QDrag, QPixmap, QPainter, QColor, QIcon, QPen, QKeySequence,
|
||||
QFont, QFontMetrics, QTextLayout, QTextOption,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QMimeData, QByteArray, QSize, QPoint, QFileInfo, QPropertyAnimation, QEasingCurve
|
||||
from PyQt6.QtCore import Qt, QMimeData, QByteArray, QSize, QPoint, QFileInfo
|
||||
from db import database
|
||||
from ui.flow_layout import ITEM_W
|
||||
import ui.theme as theme
|
||||
import ui.dialog_style as dialog_style
|
||||
import shortcut_target
|
||||
|
||||
_icon_provider = QFileIconProvider()
|
||||
# 图标缓存:同一文件路径在刷新/重建条目时避免重复读磁盘提取图标
|
||||
_icon_cache: dict[str, QIcon] = {}
|
||||
_ICON_CACHE_MAX = 512
|
||||
|
||||
|
||||
def _cached_icon(key: str, loader) -> QIcon:
|
||||
icon = _icon_cache.get(key)
|
||||
if icon is None:
|
||||
icon = loader()
|
||||
if len(_icon_cache) >= _ICON_CACHE_MAX:
|
||||
_icon_cache.clear()
|
||||
_icon_cache[key] = icon
|
||||
return icon
|
||||
|
||||
|
||||
def get_icon_width() -> int:
|
||||
"""设置中的图标宽度(限制在 48~120),ItemWidget 尺寸以此为准。"""
|
||||
try:
|
||||
w = int(database.get_setting("icon_width", str(ITEM_W)))
|
||||
except Exception:
|
||||
w = ITEM_W
|
||||
return max(48, min(120, w))
|
||||
|
||||
|
||||
def _wrapped_line_count(text: str, font: QFont, width_px: int) -> int:
|
||||
@@ -56,10 +79,13 @@ def two_line_display_text(text: str, font: QFont, width_px: int) -> str:
|
||||
|
||||
|
||||
def extract_icon(path: str) -> QIcon:
|
||||
key = os.path.normcase(path)
|
||||
|
||||
def _load() -> QIcon:
|
||||
try:
|
||||
icon = _icon_provider.icon(QFileInfo(path))
|
||||
if not icon.isNull():
|
||||
px = icon.pixmap(QSize(36, 36))
|
||||
px = icon.pixmap(QSize(64, 64))
|
||||
if not px.isNull() and px.width() > 4:
|
||||
return icon
|
||||
except Exception:
|
||||
@@ -72,6 +98,8 @@ def extract_icon(path: str) -> QIcon:
|
||||
if ext == ".url": return qta.icon("fa5s.globe", color=color)
|
||||
return qta.icon("fa5s.file", color=color)
|
||||
|
||||
return _cached_icon(key, _load)
|
||||
|
||||
|
||||
class ItemWidget(QWidget):
|
||||
DRAG_THRESHOLD = 8
|
||||
@@ -79,10 +107,14 @@ class ItemWidget(QWidget):
|
||||
def __init__(self, item_data: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.item_data = item_data
|
||||
self.setFixedSize(68, 76)
|
||||
# 尺寸跟随设置中的「图标宽度」,高度 = 宽度 + 文本区
|
||||
w = get_icon_width()
|
||||
self._icon_px = max(24, int(w * 0.53))
|
||||
self.setFixedSize(w, w + 8)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._drag_start_pos = QPoint()
|
||||
self._selected = False
|
||||
self._hovered = False
|
||||
self._build_ui()
|
||||
|
||||
def showEvent(self, event):
|
||||
@@ -95,12 +127,12 @@ class ItemWidget(QWidget):
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(4, 6, 4, 4)
|
||||
layout.setContentsMargins(4, 6, 4, 2)
|
||||
layout.setSpacing(3)
|
||||
|
||||
icon = extract_icon(self.item_data["path"])
|
||||
self.icon_label = QLabel()
|
||||
self.icon_label.setPixmap(icon.pixmap(QSize(36, 36)))
|
||||
self.icon_label.setPixmap(icon.pixmap(QSize(self._icon_px, self._icon_px)))
|
||||
self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
|
||||
@@ -133,17 +165,31 @@ class ItemWidget(QWidget):
|
||||
def matches(self, keyword: str) -> bool:
|
||||
return keyword.lower() in self.item_data["name"].lower()
|
||||
|
||||
# ── 选中高亮 ─────────────────────────────────────────
|
||||
# ── 选中/悬停高亮 ─────────────────────────────────────
|
||||
def _set_selected(self, val: bool):
|
||||
self._selected = val
|
||||
self.update()
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.update()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hovered = False
|
||||
self.update()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
if self._selected:
|
||||
if self._selected or self._hovered:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if self._selected:
|
||||
p.setBrush(QColor(74, 158, 255, 50))
|
||||
p.setPen(QPen(QColor("#4a9eff"), 1.5))
|
||||
else:
|
||||
p.setBrush(QColor(128, 128, 128, 36))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawRoundedRect(1, 1, self.width() - 2, self.height() - 2, 6, 6)
|
||||
p.end()
|
||||
super().paintEvent(event)
|
||||
@@ -305,7 +351,7 @@ class ItemWidget(QWidget):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.item_data["path"] = store
|
||||
self.icon_label.setPixmap(extract_icon(store).pixmap(QSize(36, 36)))
|
||||
self.icon_label.setPixmap(extract_icon(store).pixmap(QSize(self._icon_px, self._icon_px)))
|
||||
self._update_name_display()
|
||||
|
||||
def _delete_item(self, skip_confirm: bool = False, skip_refresh: bool = False):
|
||||
|
||||
+1072
File diff suppressed because it is too large
Load Diff
+30
-30
@@ -2,40 +2,40 @@ from db.database import get_setting, set_setting
|
||||
|
||||
THEMES = {
|
||||
"dark": {
|
||||
"panel_bg": "rgba(28,28,28,242)",
|
||||
"panel_border": "rgba(255,255,255,18)",
|
||||
"header_bg": "#3a3a3a",
|
||||
"header_hover": "#484848",
|
||||
"item_name_color":"#ddd",
|
||||
"search_bg": "rgba(255,255,255,12)",
|
||||
"search_border": "#555",
|
||||
"search_color": "#eee",
|
||||
"panel_bg": "rgba(30,30,30,120)", # Lower opacity for frosted glass effect
|
||||
"panel_border": "rgba(255,255,255,30)", # Lighter border highlight
|
||||
"header_bg": "rgba(255,255,255,25)", # Semitransparent headers
|
||||
"header_hover": "rgba(255,255,255,45)",
|
||||
"item_name_color":"#ffffff",
|
||||
"search_bg": "rgba(255,255,255,20)",
|
||||
"search_border": "rgba(255,255,255,30)",
|
||||
"search_color": "#ffffff",
|
||||
"search_focus": "#4a9eff",
|
||||
"btn_color": "#999",
|
||||
"btn_hover": "#fff",
|
||||
"scrollbar": "#555",
|
||||
"menu_bg": "#2b2b2b",
|
||||
"menu_color": "#eee",
|
||||
"menu_border": "#555",
|
||||
"menu_selected": "#3a3a3a",
|
||||
"btn_color": "#cccccc",
|
||||
"btn_hover": "#ffffff",
|
||||
"scrollbar": "rgba(255,255,255,50)",
|
||||
"menu_bg": "rgba(30,30,30,230)",
|
||||
"menu_color": "#ffffff",
|
||||
"menu_border": "rgba(255,255,255,30)",
|
||||
"menu_selected": "rgba(255,255,255,40)",
|
||||
},
|
||||
"light": {
|
||||
"panel_bg": "rgba(245,245,245,250)",
|
||||
"panel_border": "rgba(0,0,0,15)",
|
||||
"header_bg": "#e0e0e0",
|
||||
"header_hover": "#d0d0d0",
|
||||
"item_name_color":"#222",
|
||||
"search_bg": "rgba(0,0,0,8)",
|
||||
"search_border": "#bbb",
|
||||
"search_color": "#222",
|
||||
"panel_bg": "rgba(255,255,255,140)", # Lower opacity for frosted glass effect
|
||||
"panel_border": "rgba(0,0,0,30)", # Subtler border
|
||||
"header_bg": "rgba(0,0,0,15)", # Semitransparent headers
|
||||
"header_hover": "rgba(0,0,0,25)",
|
||||
"item_name_color":"#111111",
|
||||
"search_bg": "rgba(0,0,0,10)",
|
||||
"search_border": "rgba(0,0,0,20)",
|
||||
"search_color": "#111111",
|
||||
"search_focus": "#4a9eff",
|
||||
"btn_color": "#555",
|
||||
"btn_hover": "#000",
|
||||
"scrollbar": "#bbb",
|
||||
"menu_bg": "#f5f5f5",
|
||||
"menu_color": "#222",
|
||||
"menu_border": "#ccc",
|
||||
"menu_selected": "#e0e0e0",
|
||||
"btn_color": "#555555",
|
||||
"btn_hover": "#000000",
|
||||
"scrollbar": "rgba(0,0,0,40)",
|
||||
"menu_bg": "rgba(255,255,255,240)",
|
||||
"menu_color": "#111111",
|
||||
"menu_border": "rgba(0,0,0,20)",
|
||||
"menu_selected": "rgba(0,0,0,15)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user