diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index 4a20648..afdd11e 100644 Binary files a/__pycache__/main.cpython-313.pyc and b/__pycache__/main.cpython-313.pyc differ diff --git a/build_installer.bat b/build_installer.bat index b7e0afa..240f90d 100644 --- a/build_installer.bat +++ b/build_installer.bat @@ -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. diff --git a/docs/开发.md b/docs/开发.md index a16d7d4..3ba9fef 100644 --- a/docs/开发.md +++ b/docs/开发.md @@ -39,6 +39,8 @@ python main.py .\build_installer.bat ``` +> 注意:安装包要求 64 位应用。`build_installer.bat` 已内置校验,若当前 `python` 不是 64 位会直接失败并提示切换到 64 位解释器。 + 它会自动完成: - 清理 `build/`、`dist/` diff --git a/main.py b/main.py index 1812072..74a4899 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/requirements.txt b/requirements.txt index c2386db..ea307cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ PyQt6>=6.4.0 pillow>=9.0.0 -psutil>=5.9.0 \ No newline at end of file +psutil>=5.9.0 +qtawesome>=1.2.0 +send2trash>=1.8.0 +# 可选:读取硬件温度传感器(LibreHardwareMonitor 等);缺失时自动降级到 nvidia-smi / ACPI +wmi>=1.5.1; sys_platform == "win32" diff --git a/ui/__pycache__/dock.cpython-313.pyc b/ui/__pycache__/dock.cpython-313.pyc index 2a4ba7f..7f00c5a 100644 Binary files a/ui/__pycache__/dock.cpython-313.pyc and b/ui/__pycache__/dock.cpython-313.pyc differ diff --git a/ui/__pycache__/group.cpython-313.pyc b/ui/__pycache__/group.cpython-313.pyc index f25aa4f..3a55695 100644 Binary files a/ui/__pycache__/group.cpython-313.pyc and b/ui/__pycache__/group.cpython-313.pyc differ diff --git a/ui/__pycache__/item.cpython-313.pyc b/ui/__pycache__/item.cpython-313.pyc index 1a6b05b..f18826b 100644 Binary files a/ui/__pycache__/item.cpython-313.pyc and b/ui/__pycache__/item.cpython-313.pyc differ diff --git a/ui/__pycache__/theme.cpython-313.pyc b/ui/__pycache__/theme.cpython-313.pyc index f0854c6..b994f70 100644 Binary files a/ui/__pycache__/theme.cpython-313.pyc and b/ui/__pycache__/theme.cpython-313.pyc differ diff --git a/ui/dock.py b/ui/dock.py index 52ae88e..c96212b 100644 --- a/ui/dock.py +++ b/ui/dock.py @@ -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,8 +1713,26 @@ class PanelWindow(QWidget): QTimer.singleShot(200, self._check_focus_lost) def _check_focus_lost(self): - if not self.isActiveWindow() and not self._pinned: - self.hide_panel() + 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() # ── 托盘 ───────────────────────────────────────────── def _setup_tray(self): @@ -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}"' ) diff --git a/ui/group.py b/ui/group.py index a932017..4681f8b 100644 --- a/ui/group.py +++ b/ui/group.py @@ -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) diff --git a/ui/hwmon_tool.py b/ui/hwmon_tool.py new file mode 100644 index 0000000..4a147e4 --- /dev/null +++ b/ui/hwmon_tool.py @@ -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})" diff --git a/ui/item.py b/ui/item.py index 2889565..3c47f51 100644 --- a/ui/item.py +++ b/ui/item.py @@ -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,21 +79,26 @@ def two_line_display_text(text: str, font: QFont, width_px: int) -> str: def extract_icon(path: str) -> QIcon: - try: - icon = _icon_provider.icon(QFileInfo(path)) - if not icon.isNull(): - px = icon.pixmap(QSize(36, 36)) - if not px.isNull() and px.width() > 4: - return icon - except Exception: - pass - ext = os.path.splitext(path)[1].lower() - color = "#4a9eff" - if ext == ".exe": return qta.icon("fa5s.desktop", color=color) - if ext == ".lnk": return qta.icon("fa5s.link", color=color) - if ext in (".bat", ".cmd"): return qta.icon("fa5s.terminal", color=color) - if ext == ".url": return qta.icon("fa5s.globe", color=color) - return qta.icon("fa5s.file", color=color) + key = os.path.normcase(path) + + def _load() -> QIcon: + try: + icon = _icon_provider.icon(QFileInfo(path)) + if not icon.isNull(): + px = icon.pixmap(QSize(64, 64)) + if not px.isNull() and px.width() > 4: + return icon + except Exception: + pass + ext = os.path.splitext(path)[1].lower() + color = "#4a9eff" + if ext == ".exe": return qta.icon("fa5s.desktop", color=color) + if ext == ".lnk": return qta.icon("fa5s.link", color=color) + if ext in (".bat", ".cmd"): return qta.icon("fa5s.terminal", color=color) + 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): @@ -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) - p.setBrush(QColor(74, 158, 255, 50)) - p.setPen(QPen(QColor("#4a9eff"), 1.5)) + 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): diff --git a/ui/temperature.py b/ui/temperature.py new file mode 100644 index 0000000..4b99786 --- /dev/null +++ b/ui/temperature.py @@ -0,0 +1,1072 @@ +"""硬件温度监控窗口。 + +数据来源(按优先级自动降级): +1. LibreHardwareMonitor / OpenHardwareMonitor 的 WMI 接口 + —— 唯一能给出「每个 CPU 核心 / GPU / 主板 / 硬盘」真实温度的常规途径 +2. nvidia-smi(NVIDIA 显卡) +3. 存储设备温度(原生 IOCTL,部分驱动支持) +4. psutil(仅 Linux/macOS 可用) +5. ACPI 热区(root\\WMI)—— 兜底,仅作参考 + +注意:Windows 用户态没有任何 API 能直接读到 CPU 核心温度(ThrottleStop / HWiNFO 之类 +都靠内核驱动直接读 MSR)。因此 ACPI 热区的读数常常与真实核心温度相差极大, +本窗口把它单独归入「参考值」并明确标注,绝不冒充 CPU 核心温度。 + +窗口为独立非模态窗口,可与「解除占用」「微信多开」等窗口同时打开、自由切换。 +""" +from __future__ import annotations + +import ctypes +import os +import subprocess +import time +from ctypes import wintypes +from typing import Any + +import psutil +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressBar, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from db import database +import ui.dialog_style as dialog_style +import ui.theme as theme + + +GROUP_ORDER = ("CPU", "GPU", "主板", "存储", "参考值", "其他") + +GROUP_NOTES = { + "参考值": "ACPI 热区读数:由主板固件提供,通常不反映 CPU 核心温度(可能明显偏低),仅供对比参考。", +} + +LHM_DOWNLOAD_URL = "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases" + +# 自动刷新周期(毫秒):窗口打开期间每 3 秒重新采集一次 +REFRESH_INTERVAL_MS = 3000 + +_CREATE_NO_WINDOW = 0x08000000 +# ACPI / 存储的查询结果缓存时间(秒):与刷新周期一致,保证每次刷新的值都是最新的; +# 查询失败时用较长的 TTL,避免反复触发耗时的失败调用。 +_ACPI_TTL_OK = 3.0 +_ACPI_TTL_FAIL = 90.0 +_STORAGE_TTL = 3.0 + + +# ── 采集 ──────────────────────────────────────────────── +def _field(obj: Any, name: str) -> Any: + """大小写不敏感地读取 WMI 对象字段。""" + for key in (name, name.lower(), name.upper()): + try: + return getattr(obj, key) + except Exception: + continue + return None + + +def _classify(identifier: str, name: str) -> str: + s = f"{identifier} {name}".lower() + if any(k in s for k in ("gpu", "nvidia", "radeon", "graphics")): + return "GPU" + if any(k in s for k in ("cpu", "core", "package", "tctl", "tdie")): + return "CPU" + if any(k in s for k in ("nvme", "hdd", "ssd", "storage", "drive", "disk")): + return "存储" + if any(k in s for k in ("lpc", "mainboard", "motherboard", "superio", "nct")): + return "主板" + return "其他" + + +def _read_lhm() -> tuple[list[dict], str]: + """LibreHardwareMonitor / OpenHardwareMonitor 暴露的 WMI 传感器。""" + try: + import wmi # type: ignore + except Exception: + return [], "" + + namespaces = ( + ("root\\LibreHardwareMonitor", "LibreHardwareMonitor"), + ("root\\OpenHardwareMonitor", "OpenHardwareMonitor"), + ) + for ns, label in namespaces: + rows = None + try: + conn = wmi.WMI(namespace=ns) + try: + rows = conn.query( + "SELECT Name, Value, Identifier, SensorType FROM Sensor " + "WHERE SensorType='Temperature'" + ) + except Exception: + # 个别版本/权限下不支持 WQL 过滤,退化为全量遍历 + rows = conn.Sensor() + except Exception: + continue + if rows is None: + continue + + out: list[dict] = [] + for row in rows: + sensor_type = _field(row, "SensorType") + if sensor_type is not None and str(sensor_type).lower() != "temperature": + continue + try: + value = float(_field(row, "Value")) + except (TypeError, ValueError): + continue + ident = str(_field(row, "Identifier") or "") + name = str(_field(row, "Name") or "").strip() or "传感器" + out.append( + { + "group": _classify(ident, name), + "name": name, + "value": value, + "trusted": True, + } + ) + if out: + return out, label + return [], "" + + +def _find_nvidia_smi() -> str: + from shutil import which + + exe = which("nvidia-smi") + if exe: + return exe + candidates = ( + os.path.join( + os.environ.get("SystemRoot", r"C:\Windows"), "System32", "nvidia-smi.exe" + ), + r"C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe", + ) + for path in candidates: + if os.path.exists(path): + return path + return "" + + +def _read_nvidia() -> list[dict]: + exe = _find_nvidia_smi() + if not exe: + return [] + try: + proc = subprocess.run( + [ + exe, + "--query-gpu=index,name,temperature.gpu", + "--format=csv,noheader,nounits", + ], + capture_output=True, + text=True, + timeout=4, + creationflags=_CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except Exception: + return [] + if proc.returncode != 0: + return [] + + out: list[dict] = [] + for line in (proc.stdout or "").splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 3: + continue + try: + value = float(parts[2]) + except ValueError: + continue + name = parts[1] or f"GPU {parts[0]}" + out.append({"group": "GPU", "name": name, "value": value, "trusted": True}) + return out + + +_acpi_cache: dict[str, Any] = {"ts": 0.0, "data": [], "ok": False} + + +def _read_acpi() -> list[dict]: + """ACPI 热区,精度一般且通常需要管理员权限。""" + try: + import wmi # type: ignore + except Exception: + return [] + try: + conn = wmi.WMI(namespace="root\\WMI") + zones = conn.MSAcpi_ThermalZoneTemperature() + except Exception: + return [] + + out: list[dict] = [] + for i, zone in enumerate(zones): + try: + raw = float(_field(zone, "CurrentTemperature")) + except (TypeError, ValueError): + continue + celsius = raw / 10.0 - 273.15 + if not -40.0 <= celsius <= 150.0: + continue + inst = str(_field(zone, "InstanceName") or "") + tag = inst.rsplit("\\", 1)[-1] if "\\" in inst else f"ZONE{i + 1}" + out.append( + { + "group": "参考值", + "name": f"ACPI 热区 {tag}", + "value": celsius, + "trusted": False, + } + ) + return out + + +def _read_acpi_cached() -> list[dict]: + now = time.monotonic() + ttl = _ACPI_TTL_OK if _acpi_cache["ok"] else _ACPI_TTL_FAIL + if _acpi_cache["ts"] and now - _acpi_cache["ts"] < ttl: + return list(_acpi_cache["data"]) + data = _read_acpi() + _acpi_cache.update({"ts": now, "data": data, "ok": bool(data)}) + return list(data) + + +# ── 存储设备温度(原生 IOCTL,不需要第三方软件) ──────────── +_IOCTL_STORAGE_QUERY_PROPERTY = 0x2D1400 +_STORAGE_DEVICE_TEMPERATURE_PROPERTY = 0x2D +_PROPERTY_STANDARD_QUERY = 0 +_INVALID_HANDLE = ctypes.c_void_p(-1).value + +_kernel32: Any = None + + +class _STORAGE_PROPERTY_QUERY(ctypes.Structure): + _fields_ = [ + ("PropertyId", ctypes.c_uint32), + ("QueryType", ctypes.c_uint32), + ("AdditionalParameters", ctypes.c_ubyte * 8), + ] + + +class _STORAGE_TEMPERATURE_INFO(ctypes.Structure): + _fields_ = [ + ("Index", ctypes.c_ushort), + ("Temperature", ctypes.c_short), + ("OverThreshold", ctypes.c_short), + ("UnderThreshold", ctypes.c_short), + ("OverThresholdChangable", ctypes.c_ubyte), + ("UnderThresholdChangable", ctypes.c_ubyte), + ("EventGenerated", ctypes.c_ubyte), + ("Reserved0", ctypes.c_ubyte), + ("Reserved1", ctypes.c_uint32), + ] + + +class _STORAGE_TEMPERATURE_DESCRIPTOR(ctypes.Structure): + _fields_ = [ + ("Version", ctypes.c_uint32), + ("Size", ctypes.c_uint32), + ("CriticalTemperature", ctypes.c_ushort), + ("WarningTemperature", ctypes.c_ushort), + ("InfoCount", ctypes.c_ushort), + ("Reserved0", ctypes.c_ubyte * 2), + ("Info", _STORAGE_TEMPERATURE_INFO * 8), + ] + + +def _get_kernel32() -> Any: + global _kernel32 + if _kernel32 is None: + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + k32.CreateFileW.restype = wintypes.HANDLE + k32.DeviceIoControl.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + wintypes.LPVOID, + ] + k32.DeviceIoControl.restype = wintypes.BOOL + k32.CloseHandle.argtypes = [wintypes.HANDLE] + k32.CloseHandle.restype = wintypes.BOOL + _kernel32 = k32 + return _kernel32 + + +def _disk_temperatures(index: int) -> list[float]: + """读取某个物理磁盘的温度(首个为复合温度)。驱动不支持时返回空列表。""" + if os.name != "nt": + return [] + try: + k32 = _get_kernel32() + handle = k32.CreateFileW(f"\\\\.\\PhysicalDrive{index}", 0, 3, None, 3, 0, None) + if not handle or handle == _INVALID_HANDLE: + return [] + try: + query = _STORAGE_PROPERTY_QUERY() + query.PropertyId = _STORAGE_DEVICE_TEMPERATURE_PROPERTY + query.QueryType = _PROPERTY_STANDARD_QUERY + desc = _STORAGE_TEMPERATURE_DESCRIPTOR() + returned = wintypes.DWORD(0) + ok = k32.DeviceIoControl( + handle, + _IOCTL_STORAGE_QUERY_PROPERTY, + ctypes.byref(query), + ctypes.sizeof(query), + ctypes.byref(desc), + ctypes.sizeof(desc), + ctypes.byref(returned), + None, + ) + if not ok: + return [] + temps: list[float] = [] + for i in range(min(int(desc.InfoCount), 8)): + value = float(desc.Info[i].Temperature) + if -40.0 <= value <= 150.0: + temps.append(value) + return temps + finally: + k32.CloseHandle(handle) + except Exception: + return [] + + +_storage_cache: dict[str, Any] = {"ts": 0.0, "data": []} + + +def _read_storage_cached() -> list[dict]: + now = time.monotonic() + if _storage_cache["ts"] and now - _storage_cache["ts"] < _STORAGE_TTL: + return list(_storage_cache["data"]) + + out: list[dict] = [] + for index in range(8): + temps = _disk_temperatures(index) + if not temps: + continue + out.append( + { + "group": "存储", + "name": f"磁盘 {index}", + "value": temps[0], + "trusted": True, + } + ) + for i, value in enumerate(temps[1:], start=1): + out.append( + { + "group": "存储", + "name": f"磁盘 {index} 传感器 {i}", + "value": value, + "trusted": True, + } + ) + _storage_cache.update({"ts": now, "data": out}) + return list(out) + + +def _read_psutil() -> list[dict]: + getter = getattr(psutil, "sensors_temperatures", None) + if getter is None: + return [] + try: + data = getter() or {} + except Exception: + return [] + + out: list[dict] = [] + for chip, entries in data.items(): + for entry in entries: + label = str(getattr(entry, "label", "") or chip) + try: + value = float(entry.current) + except Exception: + continue + out.append( + { + "group": _classify(f"{chip} {label}", label), + "name": label, + "value": value, + } + ) + return out + + +def collect_temperatures() -> tuple[list[dict], str]: + """返回 (传感器列表, 数据源描述)。任何来源失败都静默降级。 + + ACPI 热区仅在完全没有可信来源时才作为「参考值」出现,绝不冒充 CPU 核心温度。 + """ + sensors: list[dict] = [] + sources: list[str] = [] + + def _add(items: list[dict], label: str): + if items: + sensors.extend(items) + sources.append(label) + + try: + lhm, lhm_label = _read_lhm() + except Exception: + lhm, lhm_label = [], "" + _add(lhm, lhm_label) + + if not sensors: + try: + _add(_read_psutil(), "psutil") + except Exception: + pass + + if not any(s["group"] == "GPU" for s in sensors): + try: + _add(_read_nvidia(), "nvidia-smi") + except Exception: + pass + + if not any(s["group"] == "存储" for s in sensors): + try: + _add(_read_storage_cached(), "存储设备") + except Exception: + pass + + if not any(s.get("trusted") for s in sensors): + try: + _add(_read_acpi_cached(), "ACPI 热区(仅供参考)") + except Exception: + pass + + seen: set[tuple[str, str]] = set() + unique: list[dict] = [] + for item in sensors: + key = (item["group"], item["name"]) + if key in seen: + continue + seen.add(key) + item.setdefault("trusted", True) + unique.append(item) + + unique.sort( + key=lambda s: ( + GROUP_ORDER.index(s["group"]) if s["group"] in GROUP_ORDER else len(GROUP_ORDER), + s["name"], + ) + ) + return unique, "、".join(sources) + + +# ── 采集线程 ───────────────────────────────────────────── +class _TempWorker(QThread): + data = pyqtSignal(list, str) + + def __init__( + self, interval_ms: int = REFRESH_INTERVAL_MS, parent: QWidget | None = None + ): + super().__init__(parent) + self._interval_ms = max(500, int(interval_ms)) + + def run(self): + while not self.isInterruptionRequested(): + started = time.monotonic() + try: + sensors, source = collect_temperatures() + except Exception: + sensors, source = [], "" + if self.isInterruptionRequested(): + break + self.data.emit(sensors, source) + + # 扣除采集耗时,使刷新节奏尽量贴近设定周期 + elapsed_ms = int((time.monotonic() - started) * 1000) + remain = max(200, self._interval_ms - elapsed_ms) + while remain > 0 and not self.isInterruptionRequested(): + step = min(200, remain) + self.msleep(step) + remain -= step + + +class _InstallWorker(QThread): + """后台下载并解压温度采集组件(LibreHardwareMonitor)。""" + + progress = pyqtSignal(int, int) + done = pyqtSignal(str) + failed = pyqtSignal(str) + + def run(self): + try: + from ui.hwmon_tool import install + + exe = install( + progress=lambda got, total: self.progress.emit(got, total), + is_cancelled=self.isInterruptionRequested, + ) + except Exception as exc: # noqa: BLE001 + self.failed.emit(str(exc)) + return + if self.isInterruptionRequested(): + return + self.done.emit(exe) + + +def _temp_color(value: float) -> str: + if value < 50: + return "#3fb950" + if value < 65: + return "#d29922" + if value < 80: + return "#f0883e" + return "#f85149" + + +def _solid(color: str) -> str: + """把 rgba(...) 转成不透明 rgb(...),避免顶层窗口半透明背景异常。""" + c = (color or "").strip() + if c.startswith("rgba"): + return c.replace("rgba", "rgb", 1).rsplit(",", 1)[0] + ")" + return c + + +# ── 组件 ──────────────────────────────────────────────── +class _SensorRow(QWidget): + """单个传感器:名称 + 温度条 + 数值。""" + + def __init__( + self, name: str, trusted: bool = True, parent: QWidget | None = None + ): + super().__init__(parent) + self._trusted = trusted + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(10) + + self._name_lbl = QLabel(name) + self._name_lbl.setMinimumWidth(80) + self._name_lbl.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred + ) + self._name_lbl.setToolTip(name) + self._name_lbl.setStyleSheet("background:transparent;") + + self._bar = QProgressBar() + self._bar.setRange(0, 100) + self._bar.setTextVisible(False) + self._bar.setFixedSize(170, 8) + + self._value_lbl = QLabel("--") + self._value_lbl.setFixedWidth(62) + self._value_lbl.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + + lay.addWidget(self._name_lbl, 1) + lay.addWidget(self._bar) + lay.addWidget(self._value_lbl) + + def update_value(self, value: float): + # 参考值(如 ACPI 热区)用灰色 + "≈" 标注,避免被误读为 CPU 核心温度 + if self._trusted: + color = _temp_color(value) + text = f"{value:.0f} °C" + else: + color = "#8b949e" + text = f"≈{value:.0f} °C" + self._bar.setValue(max(0, min(100, int(round(value))))) + self._bar.setStyleSheet( + "QProgressBar { background: rgba(128,128,128,45); border:none;" + " border-radius:4px; }" + f"QProgressBar::chunk {{ background: {color}; border-radius:4px; }}" + ) + self._value_lbl.setText(text) + self._value_lbl.setStyleSheet( + f"color:{color}; font-weight:bold; background:transparent;" + ) + + +class _GroupCard(QFrame): + """按类别(CPU/GPU/...)聚合的卡片。""" + + def __init__(self, title: str, note: str = "", parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("temp_card") + lay = QVBoxLayout(self) + lay.setContentsMargins(12, 10, 12, 12) + lay.setSpacing(8) + + self._base_title = title + self._title_lbl = QLabel(title) + self._title_lbl.setObjectName("temp_card_title") + lay.addWidget(self._title_lbl) + + if note: + note_lbl = QLabel(note) + note_lbl.setObjectName("temp_card_note") + note_lbl.setWordWrap(True) + lay.addWidget(note_lbl) + + self._rows_box = QVBoxLayout() + self._rows_box.setContentsMargins(0, 0, 0, 0) + self._rows_box.setSpacing(6) + lay.addLayout(self._rows_box) + + self._rows: dict[str, _SensorRow] = {} + + def sync(self, items: list[dict]): + names = [str(it["name"]) for it in items] + if names != list(self._rows.keys()): + self._clear_rows() + for item in items: + row = _SensorRow(str(item["name"]), bool(item.get("trusted", True))) + self._rows_box.addWidget(row) + self._rows[str(item["name"])] = row + + hottest = 0.0 + for item in items: + value = float(item["value"]) + hottest = max(hottest, value) + row = self._rows.get(str(item["name"])) + if row is not None: + row.update_value(value) + + self._title_lbl.setText(f"{self._base_title} 最高 {hottest:.0f} °C") + + def _clear_rows(self): + while self._rows_box.count(): + item = self._rows_box.takeAt(0) + w = item.widget() + if w is not None: + w.setParent(None) + w.deleteLater() + self._rows.clear() + + +class TemperatureWindow(QWidget): + """硬件温度窗口:非模态、可与其他功能窗口同时打开并自由切换。""" + + def __init__(self, panel: QWidget | None = None): + super().__init__(panel) + self._panel = panel + self.setWindowTitle("硬件温度") + self.setMinimumSize(460, 400) + self.resize(540, 540) + # 独立顶层窗口 + 非模态:不阻塞其它窗口、可自由切换 + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowSystemMenuHint + | Qt.WindowType.WindowMinimizeButtonHint + | Qt.WindowType.WindowCloseButtonHint + ) + self._worker: _TempWorker | None = None + self._install_worker: _InstallWorker | None = None + self._cards: dict[str, _GroupCard] = {} + self._auto_launch_done = False + self._build_ui() + self._apply_theme() + + # 退出程序时确保采集/下载线程已停止,避免残留线程 + app = QApplication.instance() + if app is not None: + app.aboutToQuit.connect(self._stop_worker) + app.aboutToQuit.connect(self._stop_install_worker) + + # ── UI ────────────────────────────────────────────── + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(16, 14, 16, 14) + root.setSpacing(10) + + head = QHBoxLayout() + head.setContentsMargins(0, 0, 0, 0) + head.setSpacing(8) + + title = QLabel("硬件温度") + title.setStyleSheet("font-size:14px; font-weight:bold; background:transparent;") + self._status_lbl = QLabel("正在读取传感器…") + self._status_lbl.setObjectName("temp_sub") + + self._refresh_btn = QPushButton("刷新") + self._refresh_btn.setFixedHeight(28) + self._refresh_btn.clicked.connect(self._restart_worker) + + self._close_btn = QPushButton("关闭") + self._close_btn.setFixedHeight(28) + self._close_btn.clicked.connect(self.close) + + head.addWidget(title) + head.addWidget(self._status_lbl) + head.addStretch() + head.addWidget(self._refresh_btn) + head.addWidget(self._close_btn) + root.addLayout(head) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + self._scroll.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + + host = QWidget() + host.setObjectName("temp_host") + host_layout = QVBoxLayout(host) + host_layout.setContentsMargins(0, 0, 0, 0) + host_layout.setSpacing(8) + + self._cards_box = QVBoxLayout() + self._cards_box.setContentsMargins(0, 0, 0, 0) + self._cards_box.setSpacing(8) + host_layout.addLayout(self._cards_box) + + self._empty_lbl = QLabel( + "暂未读取到任何温度数据。\n\n" + "Windows 自身不提供 CPU 核心温度接口,需要由能直读 MSR 的程序提供数据:" + "安装并以管理员身份运行 LibreHardwareMonitor 后,本窗口会自动读到" + "CPU 各核心、GPU、主板、硬盘的温度。" + ) + self._empty_lbl.setWordWrap(True) + self._empty_lbl.setAlignment(Qt.AlignmentFlag.AlignTop) + host_layout.addWidget(self._empty_lbl) + host_layout.addStretch() + self._scroll.setWidget(host) + root.addWidget(self._scroll, 1) + + bottom = QHBoxLayout() + bottom.setContentsMargins(0, 0, 0, 0) + bottom.setSpacing(8) + + self._hint_lbl = QLabel("数据源:检测中…") + self._hint_lbl.setObjectName("temp_sub") + self._hint_lbl.setWordWrap(True) + + self._guide_btn = QPushButton("说明") + self._guide_btn.setFixedHeight(28) + self._guide_btn.setVisible(False) + self._guide_btn.clicked.connect(self._show_guide) + + self._integrate_btn = QPushButton("一键集成温度读取") + self._integrate_btn.setFixedHeight(28) + self._integrate_btn.setVisible(False) + self._integrate_btn.clicked.connect(self._on_integrate) + + bottom.addWidget(self._hint_lbl, 1) + bottom.addWidget(self._guide_btn) + bottom.addWidget(self._integrate_btn) + root.addLayout(bottom) + + def _apply_theme(self): + t = theme.current() + is_dark = theme.name() == "dark" + txt = t.get("search_color", "#eee") + sub = "#888" if is_dark else "#666" + border = t.get("menu_border", t.get("panel_border", "rgba(128,128,128,60)")) + inp_bg = t.get("search_bg", "rgba(0,0,0,20)") + hover = t.get("header_hover", "rgba(128,128,128,40)") + bg = _solid(t.get("menu_bg", "rgba(30,30,30,235)")) + line = t.get("search_border", border) + + self.setStyleSheet( + f""" + QWidget {{ + background: {bg}; + color: {txt}; + font-size: 12px; + }} + QWidget#temp_host, QScrollArea, QScrollArea > QWidget > QWidget {{ + background: transparent; + }} + QFrame#temp_card {{ + background: {inp_bg}; + border: 1px solid {line}; + border-radius: 10px; + }} + QFrame#temp_card QLabel {{ background: transparent; }} + QLabel#temp_card_title {{ font-size: 12px; font-weight: bold; }} + QLabel#temp_card_note {{ color: {sub}; font-size: 11px; }} + QLabel#temp_sub {{ color: {sub}; background: transparent; }} + QPushButton {{ + background: {inp_bg}; + color: {txt}; + border: 1px solid {line}; + border-radius: 6px; + padding: 4px 14px; + }} + QPushButton:hover {{ border-color: #4a9eff; color: #4a9eff; }} + QScrollBar:vertical {{ + width: 8px; background: transparent; margin: 0; + }} + QScrollBar::handle:vertical {{ + background: {t.get('scrollbar', 'rgba(128,128,128,80)')}; + border-radius: 4px; min-height: 26px; + }} + QScrollBar::add-line, QScrollBar::sub-line {{ height: 0px; }} + QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} + """ + ) + + # ── 生命周期 ───────────────────────────────────────── + def showEvent(self, event): + super().showEvent(event) + self._apply_theme() + self._start_worker() + self._maybe_auto_launch_sensors() + + def hideEvent(self, event): + super().hideEvent(event) + self._stop_worker() + self._stop_install_worker() + + def closeEvent(self, event): + self._stop_worker() + self._stop_install_worker() + super().closeEvent(event) + + # ── 数据 ──────────────────────────────────────────── + def _start_worker(self): + if self._worker is not None and self._worker.isRunning(): + return + self._worker = _TempWorker(REFRESH_INTERVAL_MS, self) + self._worker.data.connect(self._on_data) + self._worker.start() + + def _stop_worker(self): + worker = self._worker + self._worker = None + if worker is None: + return + try: + worker.requestInterruption() + if not worker.wait(3000): + worker.terminate() + worker.wait(800) + except Exception: + pass + try: + worker.deleteLater() + except Exception: + pass + + def _restart_worker(self): + self._stop_worker() + self._status_lbl.setText("正在读取传感器…") + self._start_worker() + + def _on_data(self, sensors: list, source: str): + self._status_lbl.setText( + f"每 {REFRESH_INTERVAL_MS // 1000} 秒自动刷新 · 更新于 " + f"{time.strftime('%H:%M:%S')}" + ) + + grouped: dict[str, list[dict]] = {} + for item in sensors: + grouped.setdefault(str(item["group"]), []).append(item) + + for group in GROUP_ORDER: + items = grouped.get(group, []) + card = self._cards.get(group) + if not items: + if card is not None: + card.setVisible(False) + continue + if card is None: + card = _GroupCard(group, GROUP_NOTES.get(group, "")) + self._cards_box.addWidget(card) + self._cards[group] = card + card.setVisible(True) + card.sync(items) + + has_data = bool(sensors) + has_trusted = any(item.get("trusted", True) for item in sensors) + self._empty_lbl.setVisible(not has_data) + self._guide_btn.setVisible(not has_trusted) + self._integrate_btn.setVisible(not has_trusted) + if not has_trusted: + self._integrate_btn.setEnabled(self._install_worker is None) + + if not has_data: + self._hint_lbl.setStyleSheet("") + self._hint_lbl.setText( + "未检测到任何可用的温度数据源。点击「一键集成温度读取」自动获取并启动" + "开源组件 LibreHardwareMonitor,即可读到每个 CPU 核心、GPU、硬盘温度。" + ) + elif not has_trusted: + # 只有 ACPI 之类参考值:明确提示不可当作 CPU 核心温度 + self._hint_lbl.setStyleSheet("color:#d29922; background:transparent;") + self._hint_lbl.setText( + f"数据源:{source or '未知'}。未检测到 CPU/GPU 核心温度来源," + "以上数值仅供参考。点击「一键集成温度读取」可自动获取真实传感器数据。" + ) + else: + self._hint_lbl.setStyleSheet("") + self._hint_lbl.setText(f"数据源:{source or '未知'}") + + # ── 一键集成温度数据源 ──────────────────────────────── + def _on_integrate(self): + from ui.hwmon_tool import find_exe, is_running + + if is_running(): + self._hint_lbl.setStyleSheet("color:#4a9eff; background:transparent;") + self._hint_lbl.setText( + "温度采集组件正在运行,传感器数据会在几秒内自动出现;" + "若长时间没有核心温度,请检查其是否以管理员身份运行。" + ) + return + if self._install_worker is not None: + return + exe = find_exe() + if exe: + self._launch_sensors(exe) + else: + self._start_install() + + def _start_install(self): + self._integrate_btn.setEnabled(False) + self._integrate_btn.setText("正在下载…") + self._hint_lbl.setStyleSheet("color:#4a9eff; background:transparent;") + self._hint_lbl.setText("正在从 GitHub 下载开源组件 LibreHardwareMonitor…") + + worker = _InstallWorker(self) + worker.progress.connect(self._on_install_progress) + worker.done.connect(self._on_install_done) + worker.failed.connect(self._on_install_failed) + self._install_worker = worker + worker.start() + + def _on_install_progress(self, got: int, total: int): + if total > 0: + percent = int(got * 100 / total) + self._integrate_btn.setText(f"下载中 {percent}%") + self._hint_lbl.setText( + f"正在下载 LibreHardwareMonitor… {percent}%({got // 1024} KB / {total // 1024} KB)" + ) + else: + self._integrate_btn.setText("正在下载…") + self._hint_lbl.setText(f"正在下载 LibreHardwareMonitor… {got // 1024} KB") + + def _on_install_done(self, exe: str): + self._stop_install_worker() + self._integrate_btn.setText("一键集成温度读取") + if self.isVisible(): + self._launch_sensors(exe) + + def _on_install_failed(self, message: str): + self._stop_install_worker() + self._integrate_btn.setText("一键集成温度读取") + self._integrate_btn.setEnabled(True) + self._hint_lbl.setStyleSheet("color:#d29922; background:transparent;") + self._hint_lbl.setText(f"自动获取组件失败:{message}") + ret = dialog_style.question( + self, + "自动获取失败", + f"下载或安装温度采集组件失败:\n{message}\n\n" + "是否打开官方下载页手动获取?(手动解压后放进程序同级目录也能被识别)", + ) + if ret == QMessageBox.StandardButton.Yes: + import webbrowser + + webbrowser.open(LHM_DOWNLOAD_URL) + + def _launch_sensors(self, exe: str): + from ui.hwmon_tool import launch + + ok, err = launch(exe) + if ok: + # 记住用户的选择,之后打开窗口时自动拉起(仅一次,避免反复弹 UAC) + try: + database.set_setting("temp_auto_start_sensors", "1") + except Exception: + pass + self._hint_lbl.setStyleSheet("color:#4a9eff; background:transparent;") + self._hint_lbl.setText( + "已启动 LibreHardwareMonitor(请在 UAC 弹窗中允许)。" + "首次运行它可能还会提示安装硬件访问驱动,按提示确认即可;" + "传感器数据将在几秒内自动出现。" + ) + else: + dialog_style.warning( + self, + "启动失败", + f"无法启动温度采集组件:{err}\n\n可手动以管理员身份运行:\n{exe}", + ) + + def _stop_install_worker(self): + worker = self._install_worker + self._install_worker = None + if worker is None: + return + try: + worker.requestInterruption() + if not worker.wait(3000): + worker.terminate() + worker.wait(800) + except Exception: + pass + try: + worker.deleteLater() + except Exception: + pass + + def _maybe_auto_launch_sensors(self): + """曾集成过的用户:打开窗口时自动拉起组件,省去每次手动启动。""" + if self._auto_launch_done or self._install_worker is not None: + return + try: + if str(database.get_setting("temp_auto_start_sensors", "0")) != "1": + return + except Exception: + return + self._auto_launch_done = True + from ui.hwmon_tool import find_exe, is_running, launch + + if is_running(): + return + exe = find_exe() + if exe: + launch(exe) + + def _show_guide(self): + box = QMessageBox(self) + box.setWindowTitle("如何读取真实的核心温度") + box.setIcon(QMessageBox.Icon.Information) + box.setTextFormat(Qt.TextFormat.PlainText) + box.setText( + "Windows 自身不提供 CPU 核心温度接口。\n" + "ThrottleStop、HWiNFO、Core Temp 之类的工具之所以能显示每个核心的温度," + "是因为它们加载了内核驱动,直接读取 CPU 的 MSR 温度寄存器。\n\n" + "点击「一键集成温度读取」后,本程序会自动完成:\n" + "1. 从官方 GitHub 下载开源组件 LibreHardwareMonitor(约 6 MB,装到用户目录,不写系统目录)\n" + "2. 以管理员身份启动它(读取硬件需要权限,会弹一次 UAC;首次运行它可能还会提示安装" + "硬件访问驱动,按提示确认即可)\n" + "3. 之后本窗口每 3 秒自动读取它暴露的传感器;以后再打开窗口会自动拉起组件,无需重复操作\n\n" + "本窗口自动读取的来源:\n" + "· LibreHardwareMonitor / OpenHardwareMonitor:每个 CPU 核心、GPU、主板、硬盘温度\n" + "· nvidia-smi:NVIDIA 显卡温度\n" + "· 存储设备:部分驱动支持直接读取磁盘温度" + ) + open_btn = box.addButton("打开下载页", QMessageBox.ButtonRole.AcceptRole) + box.addButton("知道了", QMessageBox.ButtonRole.RejectRole) + box.setStyleSheet(dialog_style.stylesheet()) + box.exec() + if box.clickedButton() is open_btn: + import webbrowser + + webbrowser.open(LHM_DOWNLOAD_URL) diff --git a/ui/theme.py b/ui/theme.py index 2a1529d..2b7d974 100644 --- a/ui/theme.py +++ b/ui/theme.py @@ -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)", }, }