From 68e8eb91a7ae017a7b914b180e0863fe24228282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Wed, 23 Sep 2026 16:51:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/main.cpython-313.pyc | Bin 4709 -> 12502 bytes build_installer.bat | 25 +- docs/开发.md | 2 + main.py | 12 +- requirements.txt | 6 +- ui/__pycache__/dock.cpython-313.pyc | Bin 102674 -> 114439 bytes ui/__pycache__/group.cpython-313.pyc | Bin 37641 -> 40672 bytes ui/__pycache__/item.cpython-313.pyc | Bin 24403 -> 26895 bytes ui/__pycache__/theme.cpython-313.pyc | Bin 2121 -> 2269 bytes ui/dock.py | 445 ++++++++--- ui/group.py | 91 ++- ui/hwmon_tool.py | 244 ++++++ ui/item.py | 94 ++- ui/temperature.py | 1072 ++++++++++++++++++++++++++ ui/theme.py | 60 +- 15 files changed, 1858 insertions(+), 193 deletions(-) create mode 100644 ui/hwmon_tool.py create mode 100644 ui/temperature.py diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index 4a20648333fcce5e6574012b52fb204582405f07..afdd11e4705144da219c5e44a3280451a555d3b7 100644 GIT binary patch literal 12502 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 T^^2}B?9=tP^b}L14Do*f_Kj7{ delta 2144 zcmZWqO>7%Q6rSd*#8ZT+wlKw!$Ur}gFZJIV-M+I!v+Qgg0)LC1z z8=7!QkU-)B0u6{uMavC@DnLkpihAIJRD^`uRHSSPzXeeVq(ZpBi8t#m0Ws3PdGpPi zH*eniX7&s1$J>|IJ30agT8MsLKNCXe2R?9{=a#a57L-?zid0-cwB2KhVeBb*@&XlP zjtK>l7pdszq~Oi_sL#>Gf|U1De?CA1&e&TB=0h};@1Py>QrlT)K1{>Tq_5DGkI)F{ zQf1R_kLvG5PY@ba1HFjGDseS9@+d-@gis}xMtg3>C!-lWgO$(?J|0uB?HRW~hQ=Rk zwHoy)&8jyI82jo*-Ky3m6^|V}rWtzUc%7ZWceCGcJRrhK0lxL`nVc9ERj=WhU^v{( z?vF+VLul(SBw~J|`V4VGD2CaCVpJfi#C}aA+3Vd~*g#(-5#>6ellewk&Fu1>d{-vMZyYTacx37Qn{!eF4-}nHqgEt^MXl9qwRvx&)Po{)z z!CW+LsnyV|=_Z}Cy;Ig=OE>LsyU&5+HsA%V)@az$Nu5H9b`9I!c~hs*heoNcS^ANA zOJ6w79+OoZW#{A`Z#Qf}d)TM)Lq&T?AE`W~&oxa)9zA84m1B$6Y}2SrHFV9W>E=nR z*{aNGbtBhWw7aXPw3B*OU#Oc_-I%F1Dc3EU1(!fZWL{x=Qz_wUD8VYJ68@IGn;HT6 zODf}8M=X-wEPX~0Izdi|Y&e|>x_m`N!rmE(dCFe)S7(BkkeyG5v4_2u-U^8a_}#=- z_DUqi9?3|z$*9aL@3eBv)r#Hhbs@@*WI}i|lsd+YOfb2{o!wwwd8>;R`}n4h|7$Ac zPN~>s18kOYQP*-#EcgEpBPCK28~^aBM2cDVv?q!OIW>C7gb0^IfXg9mcrFo^5@VjC zW0DkiW0m~ZpuEG~ro5A#6Z^6%5Zxu7>&L(onCG82?R|twVsQ`Wdf4Y$$Q-*1($nj8 z)O}p-f|K3TUd7kIz1Y7kE{Mf#JZkpC?NPSe9Z0J_)n7>FWy1qJ_oTUDzzqOynNS>c zwhC@+1=s-x3;hQ>#4&bZTSyR!hxz{9;81{!{gcU~yCG*s_|#S>%A;;@8$B3|a|5|t zbU`;5IL!tXN>JDye4Lx^+K6HXrBHd3yU>R;prVYD=nx+JZ5rJtB+)chI>$-}*|F^O z@SAu{5p2IX+dNe@bdB0Vt7%d{EQjZaOlolD#1?;)OwQuvWjesV%l6|j=2MbbVFQYV zx3kkqO3uMF-3h`ml);b#cXBZd;wW2Fw!s2&#|de>bCD=v%{!j&u>A)Xrt}tn3u%N2 zgZ=&dWw1qSR-e;t(r9Wm>f>I#T<|x9?gPQtU`m1jo6QCduulfF1GblHbyKg}zFE!G zEQ{J?wqDci(6PmFYj1Ac8f{XYj7Tm2MgV6Uv_oI?jc_T07!PKp12PYm>Q}W@(ceJhM(o zeDn2OtvPkFT?t+jn?sxO&KRL~F!7s3rB_4#K&FR(Zn}{kL z<#e&7d*_<96Y~xIemV_f{;M(%fq(;Hd<_Mzq2LH*KZlH~;_u 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 2a4ba7fc90a22f3299de78050f58f93b6320bbe4..7f00c5a6b6ab1f766d58c0a290d6e5d2f25a6c22 100644 GIT binary patch delta 29690 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 Mr10+&_~Etve{3~j0{{R3 delta 21943 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 U!uLRvkU;Wxm3^;*7pLU^0`V%zGXMYp diff --git a/ui/__pycache__/group.cpython-313.pyc b/ui/__pycache__/group.cpython-313.pyc index f25aa4ff7e05ea4c11af78d00a1cd75615d794e5..3a55695f1c39527e3f1a5d3e70cadc4da6530dbe 100644 GIT binary patch delta 8664 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 delta 6679 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 OUoe>YHHS~O%Krlq)9Zl% delta 4942 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 Ujg)f3C)_5u5fW}Ql=IL30!c^&*Z=?k delta 843 zcmYk3OOMh}7={ajlp-yc;eHu#>Y$AzmJT(rabe=t%~`l?dgBtgBpq}?O!y5W8xr@f zjs6IutJ%0|;(uu3UVT5x)ah5=Jm>q)>3I@<<-g~o-=di2@cj7nbM}nCmR@hOMvUcH z9$8?g$RaBt%dCQ|GMd&{9oabSX&yQ)#v3t}V{vwdNrJVJN!CH8*aKvmog*_$MoR1< zGI!|Q-aub1vkPRkei+wS7mLPWQk8rI6g z_b}6S-On7GxBFe!-93-XmkD_^8u@~?UK-&g33)so`@)s&t>$yf^n8BdF5iY$nnkty z5?8h*Z(^B`wSnG?e!8k`q(fWnQT25N%C1TUa8xSjXqvVW@9Y~YXj+zqR8`tJ)~T?x zZCi=^3CHx_`1j_s$#m_N@Hg8W_x1D1tCQHb-QVPs?OqA^yqFA9oKOJ^#A!~b@4LZ$ z;tVGgZ|Z6w(n8{dDh65N93AKedE!EN_>{Pa1L&8C%OO{Yt0C8j>zq))WsSVpNd`?$ zDB(bh24|d52DgbjbeXcd;V(i->5e2gk_40F{wW;6ks~;A1V@hG$PpYlf+I(8 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)", }, }