优化更新

This commit is contained in:
2026-09-23 16:51:40 +08:00
parent cd4ffbfa90
commit 68e8eb91a7
15 changed files with 1858 additions and 193 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+335 -110
View File
@@ -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}"'
)
+74 -17
View File
@@ -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)
+244
View File
@@ -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})"
+70 -24
View File
@@ -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):
+1072
View File
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -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)",
},
}