1603 lines
59 KiB
Python
1603 lines
59 KiB
Python
import sys
|
||
import os
|
||
import json
|
||
import threading
|
||
import socket
|
||
import struct
|
||
import time as _time
|
||
import datetime
|
||
import urllib.request
|
||
import webbrowser
|
||
import qtawesome as qta
|
||
from PyQt6.QtWidgets import (
|
||
QWidget,
|
||
QVBoxLayout,
|
||
QHBoxLayout,
|
||
QScrollArea,
|
||
QPushButton,
|
||
QApplication,
|
||
QMenu,
|
||
QSystemTrayIcon,
|
||
QLineEdit,
|
||
QLabel,
|
||
QMessageBox,
|
||
QGridLayout,
|
||
)
|
||
from PyQt6.QtCore import (
|
||
Qt,
|
||
QPoint,
|
||
QPropertyAnimation,
|
||
QEasingCurve,
|
||
QTimer,
|
||
QEvent,
|
||
QSize,
|
||
pyqtSignal,
|
||
)
|
||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QBrush, QPen, QIcon, QCursor
|
||
from db import database
|
||
from ui.group import GroupWidget
|
||
import ui.theme as theme
|
||
import ui.dialog_style as dialog_style
|
||
from ui.updater import Updater
|
||
|
||
PANEL_W = 260
|
||
PANEL_H = 40
|
||
MIN_W, MIN_H = 400, 5
|
||
ANIM_MS = 180
|
||
RESIZE_M = 8
|
||
|
||
|
||
class WeatherBox(QWidget):
|
||
"""顶部天气信息模块(重写:大图标 + 左侧文案 + 右侧温度布局)"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._weather_dir = os.path.join(
|
||
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "weather"
|
||
)
|
||
|
||
# 顶部栏高度由 PanelWindow 强制对齐(weather_bar = 80)
|
||
self.setFixedHeight(80)
|
||
# 控制宽度,避免把右侧“网络时间”挤出
|
||
self.setFixedWidth(170)
|
||
|
||
# 左侧:图标(大)+ 天气名称(小一点)
|
||
self._left_widget = QWidget()
|
||
self._left_widget.setFixedWidth(68)
|
||
left_lay = QVBoxLayout(self._left_widget)
|
||
left_lay.setContentsMargins(0, 0, 0, 0)
|
||
left_lay.setSpacing(6)
|
||
|
||
self._icon_label = QLabel()
|
||
self._icon_label.setFixedSize(70, 70)
|
||
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._icon_label.setStyleSheet("background:transparent;")
|
||
|
||
self._weather_name_label = QLabel("")
|
||
self._weather_name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._weather_name_label.setStyleSheet(
|
||
"font-size:12px; font-weight:700; background:transparent;"
|
||
)
|
||
self._weather_name_label.setFixedWidth(self._left_widget.width())
|
||
|
||
left_lay.addWidget(self._icon_label)
|
||
left_lay.addWidget(self._weather_name_label)
|
||
|
||
# 右侧:温度(大)+ 地区 + 风力
|
||
self._right_widget = QWidget()
|
||
self._right_widget.setFixedWidth(102)
|
||
right_lay = QVBoxLayout(self._right_widget)
|
||
right_lay.setContentsMargins(0, 0, 0, 0)
|
||
right_lay.setSpacing(4)
|
||
|
||
self._temp_label = QLabel("--°C")
|
||
self._temp_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
self._temp_label.setFixedWidth(self._right_widget.width())
|
||
self._temp_label.setStyleSheet(
|
||
"font-weight:900; font-size:28px; background:transparent; padding:0px; margin:0px;"
|
||
)
|
||
|
||
self._location_label = QLabel("")
|
||
self._location_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
self._location_label.setFixedWidth(self._right_widget.width())
|
||
self._location_label.setStyleSheet(
|
||
"font-size:12px; font-weight:700; background:transparent;"
|
||
)
|
||
|
||
self._wind_label = QLabel("")
|
||
self._wind_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
self._wind_label.setFixedWidth(self._right_widget.width())
|
||
self._wind_label.setStyleSheet(
|
||
"font-size:12px; background:transparent;"
|
||
)
|
||
|
||
right_lay.addWidget(self._temp_label)
|
||
right_lay.addWidget(self._location_label)
|
||
right_lay.addWidget(self._wind_label)
|
||
|
||
lay = QHBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(6)
|
||
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;"
|
||
)
|
||
self._weather_name_label.setStyleSheet(
|
||
f"font-size:12px; font-weight:700; color:{color_fg}; background:transparent;"
|
||
)
|
||
self._location_label.setStyleSheet(
|
||
f"font-size:12px; font-weight:700; color:{color_fg}; background:transparent;"
|
||
)
|
||
self._wind_label.setStyleSheet(
|
||
f"font-size:12px; color:{color_fg}; background:transparent;"
|
||
)
|
||
|
||
def _set_elided(self, label: QLabel, text: str):
|
||
text = text or ""
|
||
# 统一右侧省略,避免撑爆布局
|
||
fm = label.fontMetrics()
|
||
label.setText(
|
||
fm.elidedText(text, Qt.TextElideMode.ElideRight, max(10, label.width()))
|
||
)
|
||
|
||
def _make_weather_icon(self, weather: str) -> QPixmap:
|
||
wname = (weather or "").strip()
|
||
img_path = os.path.join(self._weather_dir, f"{wname}.png")
|
||
if os.path.exists(img_path):
|
||
pm = QPixmap(img_path)
|
||
if not pm.isNull():
|
||
target = self._icon_label.size()
|
||
return pm.scaled(
|
||
target.width(),
|
||
target.height(),
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
# fallback:透明背景的占位圆
|
||
pm = QPixmap(self._icon_label.size())
|
||
pm.fill(Qt.GlobalColor.transparent)
|
||
p = QPainter(pm)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
p.setPen(QPen(QColor("#888888"), 2))
|
||
p.drawEllipse(6, 6, pm.width() - 12, pm.height() - 12)
|
||
p.end()
|
||
return pm
|
||
|
||
def update_weather(self, data: dict):
|
||
if not data:
|
||
return
|
||
|
||
temp = data.get("temperature", "--")
|
||
weather = str(data.get("weather", "") or "")
|
||
province = str(data.get("province", "") or "")
|
||
city = str(data.get("city", "") or "")
|
||
district = str(data.get("district", "") or "")
|
||
wind_direction = str(data.get("wind_direction", "") or "")
|
||
wind_power = str(data.get("wind_power", "") or "")
|
||
|
||
# 地点:优先 city,其次 province;如有 district 追加
|
||
location = city or province or "--"
|
||
if district and district not in location:
|
||
location = f"{location}{district}"
|
||
|
||
# 温度:尽量显示为整数(接口一般是数字)
|
||
if temp in ("", None):
|
||
temp_text = "--°C"
|
||
else:
|
||
try:
|
||
temp_text = f"{int(float(temp))}°C"
|
||
except Exception:
|
||
temp_text = f"{temp}°C"
|
||
|
||
self._temp_label.setText(temp_text)
|
||
self._icon_label.setPixmap(self._make_weather_icon(weather))
|
||
|
||
self._set_elided(self._weather_name_label, weather)
|
||
self._set_elided(self._location_label, location)
|
||
self._set_elided(self._wind_label, f"{wind_direction}{wind_power}".replace(" ", ""))
|
||
|
||
def show_loading(self):
|
||
self._temp_label.setText("--°C")
|
||
self._icon_label.setPixmap(QPixmap(self._icon_label.size()))
|
||
self._weather_name_label.setText("获取中…")
|
||
self._location_label.setText("")
|
||
self._wind_label.setText("")
|
||
self._last_tooltip = None
|
||
|
||
|
||
def _make_tray_icon():
|
||
logo = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
|
||
if os.path.exists(logo):
|
||
return QIcon(logo)
|
||
px = QPixmap(32, 32)
|
||
px.fill(Qt.GlobalColor.transparent)
|
||
p = QPainter(px)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
p.setBrush(QBrush(QColor("#4a9eff")))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawRoundedRect(2, 2, 28, 28, 6, 6)
|
||
p.setBrush(QBrush(QColor("white")))
|
||
for y in [9, 15, 21]:
|
||
p.drawRoundedRect(7, y, 18, 3, 1, 1)
|
||
p.end()
|
||
return QIcon(px)
|
||
|
||
|
||
class GroupsContainer(QWidget):
|
||
"""支持分组拖拽排序的容器"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setAcceptDrops(True)
|
||
self._indicator_pos = -1 # 指示线位置(像素 y)
|
||
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasFormat("application/x-group-id"):
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event):
|
||
if event.mimeData().hasFormat("application/x-group-id"):
|
||
self._indicator_pos = event.position().toPoint().y()
|
||
self.update()
|
||
event.acceptProposedAction()
|
||
|
||
def dragLeaveEvent(self, event):
|
||
self._indicator_pos = -1
|
||
self.update()
|
||
|
||
def dropEvent(self, event):
|
||
self._indicator_pos = -1
|
||
self.update()
|
||
if not event.mimeData().hasFormat("application/x-group-id"):
|
||
event.ignore()
|
||
return
|
||
|
||
gid = int(event.mimeData().data("application/x-group-id").data().decode())
|
||
drop_y = event.position().toPoint().y()
|
||
|
||
# 找到插入位置
|
||
layout = self.layout()
|
||
insert_idx = layout.count() - 1 # 默认插到最后(stretch 前)
|
||
for i in range(layout.count() - 1):
|
||
item = layout.itemAt(i)
|
||
if item and item.widget():
|
||
geo = item.widget().geometry()
|
||
if drop_y < geo.center().y():
|
||
insert_idx = i
|
||
break
|
||
|
||
# 收集当前顺序,把拖动的移到 insert_idx
|
||
ids = []
|
||
for i in range(layout.count() - 1):
|
||
item = layout.itemAt(i)
|
||
if item and item.widget():
|
||
ids.append(item.widget().group_data["id"])
|
||
|
||
if gid in ids:
|
||
ids.remove(gid)
|
||
ids.insert(min(insert_idx, len(ids)), gid)
|
||
from db import database
|
||
|
||
database.reorder_groups(ids)
|
||
# 通知父级刷新
|
||
p = self.parent()
|
||
while p:
|
||
if hasattr(p, "refresh_groups"):
|
||
p.refresh_groups()
|
||
break
|
||
p = p.parent()
|
||
|
||
event.acceptProposedAction()
|
||
|
||
def paintEvent(self, event):
|
||
super().paintEvent(event)
|
||
if self._indicator_pos < 0:
|
||
return
|
||
p = QPainter(self)
|
||
p.setPen(QPen(QColor("#4a9eff"), 2))
|
||
p.drawLine(0, self._indicator_pos, self.width(), self._indicator_pos)
|
||
p.end()
|
||
|
||
|
||
class PanelWindow(QWidget):
|
||
weather_updated = pyqtSignal(dict)
|
||
# base_utc_ts: 接口返回的 UTC 时间戳(秒)
|
||
# tz_offset_sec: 目标时区相对 UTC 的偏移(秒)
|
||
time_synced = pyqtSignal(float, int)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("牛马软件柜")
|
||
# 默认不置顶;仅图钉开启时与悬浮球一并置顶(见 _apply_pin_window_layer)
|
||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
|
||
# 任务栏/开始菜单图标:使用项目 logo.png
|
||
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
|
||
if os.path.exists(logo_path):
|
||
self.setWindowIcon(QIcon(logo_path))
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setAcceptDrops(True)
|
||
self.setMinimumSize(MIN_W, MIN_H)
|
||
self.resize(PANEL_W, PANEL_H)
|
||
self._persist_w = PANEL_W
|
||
self._persist_h = PANEL_H
|
||
self._screen_hooked = False
|
||
# 让窗口本身也追踪鼠标(不按键也收到 mouseMoveEvent)
|
||
self.setMouseTracking(True)
|
||
|
||
self._anim = None
|
||
self._win_drag_pos = None
|
||
self._resizing = False
|
||
self._resize_edge = None
|
||
self._resize_start_global = None
|
||
self._resize_start_geo = None
|
||
self._pinned = False
|
||
|
||
self._build_ui()
|
||
self._setup_tray()
|
||
self._apply_theme()
|
||
self._restore_geometry()
|
||
self.weather_updated.connect(self._on_weather_updated)
|
||
self.time_synced.connect(self._on_time_synced)
|
||
|
||
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
|
||
self._time_timer = QTimer(self)
|
||
self._time_timer.setInterval(1000)
|
||
self._time_timer.timeout.connect(self._refresh_time_label)
|
||
|
||
self._updater = Updater(self)
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
if not self._screen_hooked:
|
||
wh = self.windowHandle()
|
||
if wh is not None:
|
||
wh.screenChanged.connect(self._on_screen_changed)
|
||
self._screen_hooked = True
|
||
# 首次显示时异步获取一次天气
|
||
if getattr(self, "_weather_once", False) is False:
|
||
self._weather_once = True
|
||
self._update_weather_async()
|
||
|
||
if getattr(self, "_time_once", False) is False:
|
||
self._time_once = True
|
||
self._update_time_from_ip_async()
|
||
|
||
def _on_screen_changed(self, screen=None):
|
||
"""跨不同 DPI 的显示器拖动时,Qt 可能改变窗口逻辑尺寸,按记录值拉回。"""
|
||
if getattr(self, "_body", None) is not None and not self._body.isVisible():
|
||
return
|
||
w = getattr(self, "_persist_w", PANEL_W)
|
||
h = getattr(self, "_persist_h", PANEL_H)
|
||
if w >= MIN_W and h >= MIN_H:
|
||
self.resize(w, h)
|
||
|
||
# ── 天气 ─────────────────────────────────────────────
|
||
def _update_weather_async(self):
|
||
def _worker():
|
||
data: dict | None = None
|
||
try:
|
||
url = "https://uapis.cn/api/v1/misc/weather"
|
||
req = urllib.request.Request(
|
||
url,
|
||
headers={
|
||
"User-Agent": "Mozilla/5.0",
|
||
"Accept": "application/json",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
raw = resp.read().decode("utf-8", "replace").strip()
|
||
if raw:
|
||
data = json.loads(raw)
|
||
if not isinstance(data, dict):
|
||
data = None
|
||
except Exception as e:
|
||
# 方便你在控制台看到原因
|
||
print("Weather fetch failed:", repr(e))
|
||
print("Weather raw (if any):", repr(locals().get("raw", ""))[:200])
|
||
data = None
|
||
|
||
# 线程内不要直接操作 QWidget,改为发信号
|
||
self.weather_updated.emit(data or {})
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
def _on_weather_updated(self, data: dict):
|
||
if not hasattr(self, "weather_box"):
|
||
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
|
||
if self._time_base_utc_ts > 0:
|
||
self._refresh_time_label()
|
||
|
||
def _refresh_weather(self):
|
||
if getattr(self, "_weather_fetching", False):
|
||
return
|
||
self._weather_fetching = True
|
||
try:
|
||
self.weather_box.show_loading()
|
||
except Exception:
|
||
pass
|
||
self._update_weather_async()
|
||
|
||
def _refresh_time_label(self):
|
||
if not hasattr(self, "time_label"):
|
||
return
|
||
if self._time_base_utc_ts <= 0:
|
||
return
|
||
# 用接口返回的 UTC 基准时间 + monotonic 增量(不受本机时间影响)
|
||
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)
|
||
self.time_label.setText(dt.strftime("%H:%M:%S"))
|
||
self.time_date_label.setText(dt.strftime("%Y-%m-%d"))
|
||
|
||
def _update_time_from_ip_async(self):
|
||
def _ntp_query(server: str, timeout: float) -> float:
|
||
# ... (保持原有的 ntp_query 逻辑不变) ...
|
||
msg = b"\x1b" + 47 * b"\0"
|
||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||
s.settimeout(timeout)
|
||
s.sendto(msg, (server, 123))
|
||
data, _ = s.recvfrom(512)
|
||
t = struct.unpack("!II", data[40:48])
|
||
return t[0] + t[1] / 2**32 - 2208988800.0
|
||
|
||
def _worker():
|
||
# 这里的服务器列表是解决问题的核心:优先国内阿里、腾讯节点
|
||
servers = [
|
||
"ntp.aliyun.com",
|
||
"ntp.tencent.com",
|
||
"cn.pool.ntp.org",
|
||
"pool.ntp.org",
|
||
]
|
||
base_utc_ts = 0.0
|
||
|
||
for srv in servers:
|
||
try:
|
||
# 尝试连接,超时时间设为 2.5 秒
|
||
base_utc_ts = float(_ntp_query(srv, timeout=2.5))
|
||
if base_utc_ts > 0:
|
||
break # 只要有一个成功,就跳出循环
|
||
except Exception:
|
||
continue # 失败了换下一个服务器
|
||
|
||
# 兜底:如果所有服务器都连不上,回退到本机时间
|
||
if base_utc_ts <= 0:
|
||
base_utc_ts = _time.time()
|
||
|
||
tz_offset_sec = int(getattr(self, "_tz_offset_sec", 8 * 3600))
|
||
self.time_synced.emit(base_utc_ts, tz_offset_sec)
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
def _on_time_synced(self, base_utc_ts: float, tz_offset_sec: int):
|
||
self._time_base_utc_ts = base_utc_ts
|
||
self._tz_offset_sec = tz_offset_sec
|
||
self._time_mono_base = _time.monotonic()
|
||
self._refresh_time_label()
|
||
if not self._time_timer.isActive():
|
||
self._time_timer.start()
|
||
|
||
# ── UI ──────────────────────────────────────────────
|
||
def _get_quick_actions(self):
|
||
"""返回快捷动作列表,悬浮球右键菜单和左侧快捷栏共用同一份。
|
||
每项: (tooltip, qtawesome_icon, callback)
|
||
排除重启/退出。"""
|
||
return [
|
||
("微信多开", "fa5b.weixin", self._open_wechat_multi),
|
||
("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd),
|
||
("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell),
|
||
("打开默认浏览器", "fa5s.globe", self._open_default_browser),
|
||
]
|
||
|
||
def _build_ui(self):
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.container = QWidget()
|
||
self.container.setObjectName("container")
|
||
self.container.setMouseTracking(True)
|
||
|
||
# 横向:左侧快捷栏 + 右侧主内容
|
||
h_layout = QHBoxLayout(self.container)
|
||
h_layout.setContentsMargins(0, 0, 0, 0)
|
||
h_layout.setSpacing(0)
|
||
|
||
# ── 左侧快捷栏
|
||
self._quick_bar = QWidget()
|
||
self._quick_bar.setObjectName("quick_bar")
|
||
self._quick_bar.setFixedWidth(50)
|
||
quick_layout = QVBoxLayout(self._quick_bar)
|
||
quick_layout.setContentsMargins(0, 10, 0, 10)
|
||
quick_layout.setSpacing(6)
|
||
quick_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
self._quick_btns = []
|
||
for tooltip, icon_name, callback in self._get_quick_actions():
|
||
btn = QPushButton()
|
||
btn.setFixedSize(34, 34)
|
||
btn.setToolTip(tooltip)
|
||
btn.setStyleSheet("border:none; background:transparent; border-radius:4px;")
|
||
try:
|
||
btn.setIcon(qta.icon(icon_name, color="#888"))
|
||
btn.setIconSize(QSize(16, 16))
|
||
except Exception:
|
||
btn.setText(tooltip[0])
|
||
btn.clicked.connect(callback)
|
||
quick_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||
self._quick_btns.append((btn, icon_name))
|
||
|
||
quick_layout.addStretch()
|
||
h_layout.addWidget(self._quick_bar)
|
||
|
||
# ── 右侧主内容
|
||
right_widget = QWidget()
|
||
right_widget.setMouseTracking(True)
|
||
inner = QVBoxLayout(right_widget)
|
||
inner.setContentsMargins(8, 10, 8, 8)
|
||
inner.setSpacing(6)
|
||
h_layout.addWidget(right_widget)
|
||
|
||
# ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板)
|
||
self._title_bar = QWidget()
|
||
self._title_bar.setMouseTracking(True)
|
||
title_bar = QHBoxLayout(self._title_bar)
|
||
title_bar.setContentsMargins(0, 0, 0, 0)
|
||
title_bar.setSpacing(6)
|
||
|
||
self.pin_btn = QPushButton()
|
||
self.pin_btn.setFixedSize(26, 26)
|
||
self.pin_btn.setToolTip(
|
||
"固定:面板与悬浮球置顶;开启后最小化只收缩内容,不变悬浮球"
|
||
)
|
||
self.pin_btn.setStyleSheet("border:none; background:transparent;")
|
||
self.pin_btn.clicked.connect(self._toggle_pin)
|
||
|
||
# 程序名称
|
||
self.app_title = QLabel("牛马软件柜")
|
||
self.app_title.setStyleSheet(
|
||
"font-size:13px; font-weight:bold; background:transparent;"
|
||
)
|
||
|
||
self._min_btn = QPushButton()
|
||
self._min_btn.setFixedSize(26, 26)
|
||
self._min_btn.setToolTip("最小化")
|
||
self._min_btn.setStyleSheet("border:none; background:transparent;")
|
||
self._min_btn.clicked.connect(self._on_min_click)
|
||
|
||
title_bar.addWidget(self.pin_btn)
|
||
title_bar.addWidget(self.app_title)
|
||
title_bar.addStretch()
|
||
title_bar.addWidget(self._min_btn)
|
||
inner.addWidget(self._title_bar)
|
||
|
||
# ── 顶部信息栏:左天气 + 右网络时间
|
||
self.weather_bar = QWidget()
|
||
self.weather_bar.setFixedHeight(80) # 强制整个天气栏高度对齐 WeatherBox
|
||
wb_layout = QHBoxLayout(self.weather_bar)
|
||
wb_layout.setContentsMargins(5, 0, 5, 0) # 上下边距设为 0,消除空隙
|
||
wb_layout.setSpacing(10)
|
||
|
||
self.weather_box = WeatherBox()
|
||
wb_layout.addWidget(self.weather_box)
|
||
|
||
self.weather_refresh_btn = QPushButton("刷新")
|
||
self.weather_refresh_btn.setFixedSize(24, 24)
|
||
self.weather_refresh_btn.setText("")
|
||
self.weather_refresh_btn.setStyleSheet(
|
||
"border:none; background:transparent; border-radius:6px;"
|
||
)
|
||
self.weather_refresh_btn.clicked.connect(self._refresh_weather)
|
||
wb_layout.addWidget(self.weather_refresh_btn)
|
||
|
||
wb_layout.addStretch()
|
||
|
||
self.time_box = QWidget()
|
||
tb_layout = QVBoxLayout(self.time_box)
|
||
tb_layout.setContentsMargins(0, 0, 0, 0)
|
||
tb_layout.setSpacing(0)
|
||
|
||
self.time_title_label = QLabel("网络时间")
|
||
self.time_title_label.setStyleSheet("font-size:11px; color:#888;")
|
||
self.time_title_label.setAlignment(
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
|
||
)
|
||
|
||
self.time_label = QLabel("--:--:--")
|
||
self.time_label.setStyleSheet("font-weight:bold; font-size:18px; color:#ddd;")
|
||
self.time_label.setAlignment(
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
|
||
self.time_date_label = QLabel("--")
|
||
self.time_date_label.setStyleSheet("font-size:11px; color:#888;")
|
||
self.time_date_label.setAlignment(
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
|
||
tb_layout.addWidget(self.time_title_label)
|
||
tb_layout.addWidget(self.time_label)
|
||
tb_layout.addWidget(self.time_date_label)
|
||
|
||
wb_layout.addWidget(self.time_box)
|
||
inner.addWidget(self.weather_bar)
|
||
|
||
# ── 搜索框 + 文字按钮(同一行)
|
||
search_row = QHBoxLayout()
|
||
search_row.setContentsMargins(0, 0, 0, 0)
|
||
search_row.setSpacing(6)
|
||
|
||
self.search_box = QLineEdit()
|
||
self.search_box.setPlaceholderText("🔍 搜索程序...")
|
||
self.search_box.textChanged.connect(self._on_search)
|
||
|
||
self.add_group_btn = QPushButton("添加分组")
|
||
self.add_group_btn.setFixedHeight(32)
|
||
self.add_group_btn.setToolTip("新建分组")
|
||
self.add_group_btn.clicked.connect(self._add_group)
|
||
|
||
self.add_folder_btn = QPushButton("添加文件夹")
|
||
self.add_folder_btn.setFixedHeight(32)
|
||
self.add_folder_btn.setToolTip("读取文件夹,自动创建分组")
|
||
self.add_folder_btn.clicked.connect(self._add_from_folder)
|
||
|
||
search_row.addWidget(self.search_box)
|
||
search_row.addWidget(self.add_group_btn)
|
||
search_row.addWidget(self.add_folder_btn)
|
||
|
||
# ── 可收缩的内容区(搜索+分组+底部栏)
|
||
self._body = QWidget()
|
||
body_layout = QVBoxLayout(self._body)
|
||
body_layout.setContentsMargins(0, 0, 0, 0)
|
||
body_layout.setSpacing(6)
|
||
body_layout.addLayout(search_row)
|
||
|
||
# ── 分组滚动区
|
||
self.scroll = QScrollArea()
|
||
self.scroll.setWidgetResizable(True)
|
||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
|
||
self.groups_container = GroupsContainer()
|
||
self.groups_container.setStyleSheet("background:transparent;")
|
||
self.groups_layout = QVBoxLayout(self.groups_container)
|
||
self.groups_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.groups_layout.setSpacing(6)
|
||
self.groups_layout.addStretch()
|
||
|
||
self.scroll.setWidget(self.groups_container)
|
||
body_layout.addWidget(self.scroll)
|
||
|
||
# ── 底部工具栏
|
||
self.bottom_bar = QWidget()
|
||
self.bottom_bar.setObjectName("bottom_bar")
|
||
self.bottom_bar.setFixedHeight(36)
|
||
bottom_layout = QHBoxLayout(self.bottom_bar)
|
||
bottom_layout.setContentsMargins(6, 0, 6, 0)
|
||
bottom_layout.setSpacing(4)
|
||
|
||
self.menu_btn = QPushButton()
|
||
self.menu_btn.setFixedSize(28, 28)
|
||
self.menu_btn.setToolTip("菜单")
|
||
self.menu_btn.setStyleSheet(
|
||
"border:none; background:transparent; border-radius:4px;"
|
||
)
|
||
self.menu_btn.clicked.connect(self._show_bottom_menu)
|
||
|
||
self.theme_btn = QPushButton()
|
||
self.theme_btn.setFixedSize(28, 28)
|
||
self.theme_btn.setToolTip("切换亮/暗主题")
|
||
self.theme_btn.setStyleSheet(
|
||
"border:none; background:transparent; border-radius:4px;"
|
||
)
|
||
self.theme_btn.clicked.connect(self._toggle_theme)
|
||
|
||
self.settings_btn = QPushButton()
|
||
self.settings_btn.setFixedSize(28, 28)
|
||
self.settings_btn.setToolTip("设置")
|
||
self.settings_btn.setStyleSheet(
|
||
"border:none; background:transparent; border-radius:4px;"
|
||
)
|
||
self.settings_btn.clicked.connect(self._show_settings)
|
||
|
||
self.quit_btn = QPushButton()
|
||
self.quit_btn.setFixedSize(28, 28)
|
||
self.quit_btn.setToolTip("退出程序")
|
||
self.quit_btn.setStyleSheet(
|
||
"border:none; background:transparent; border-radius:4px;"
|
||
)
|
||
self.quit_btn.clicked.connect(self._quit_application)
|
||
|
||
bottom_layout.addWidget(self.menu_btn)
|
||
bottom_layout.addStretch()
|
||
self.update_btn = QPushButton()
|
||
self.update_btn.setFixedSize(28, 28)
|
||
self.update_btn.setToolTip("检查更新")
|
||
self.update_btn.setStyleSheet("border:none; background:transparent; border-radius:4px;")
|
||
self.update_btn.clicked.connect(lambda: self._updater.check(silent_if_latest=False))
|
||
bottom_layout.addWidget(self.update_btn)
|
||
bottom_layout.addWidget(self.theme_btn)
|
||
bottom_layout.addWidget(self.settings_btn)
|
||
bottom_layout.addWidget(self.quit_btn)
|
||
body_layout.addWidget(self.bottom_bar)
|
||
|
||
inner.addWidget(self._body)
|
||
|
||
root.addWidget(self.container)
|
||
# eventFilter 只用于 resize 拖拽,不拦截子 widget 的 drop
|
||
self.container.installEventFilter(self)
|
||
self.refresh_groups()
|
||
|
||
def _apply_theme(self):
|
||
t = theme.current()
|
||
is_dark = theme.name() == "dark"
|
||
ic = "#cccccc" if is_dark else "#555555"
|
||
|
||
self.container.setStyleSheet(
|
||
f"""
|
||
QWidget#container {{
|
||
background: {t['panel_bg']};
|
||
border-radius: 10px;
|
||
border: 1px solid {t['panel_border']};
|
||
}}
|
||
"""
|
||
)
|
||
self.search_box.setStyleSheet(
|
||
f"""
|
||
QLineEdit {{
|
||
background: {t['search_bg']};
|
||
border: 1px solid {t['search_border']};
|
||
border-radius: 5px;
|
||
color: {t['search_color']};
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
}}
|
||
QLineEdit:focus {{ border-color: {t['search_focus']}; }}
|
||
"""
|
||
)
|
||
txt_btn_style = f"""
|
||
QPushButton {{
|
||
border: 1px solid {t['search_border']};
|
||
border-radius: 5px;
|
||
color: {t['btn_color']};
|
||
background: {t['search_bg']};
|
||
font-size: 11px;
|
||
padding: 0 8px;
|
||
}}
|
||
QPushButton:hover {{ background: {t['header_hover']}; color: {t['btn_hover']}; }}
|
||
"""
|
||
self.add_group_btn.setStyleSheet(txt_btn_style)
|
||
self.add_folder_btn.setStyleSheet(txt_btn_style)
|
||
self.scroll.setStyleSheet(
|
||
f"""
|
||
QScrollArea {{ border:none; background:transparent; }}
|
||
QScrollBar:vertical {{ background:transparent; width:4px; }}
|
||
QScrollBar::handle:vertical {{ background:{t['scrollbar']}; border-radius:2px; }}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height:0; }}
|
||
"""
|
||
)
|
||
|
||
self.pin_btn.setIcon(
|
||
qta.icon("fa5s.thumbtack", color="#f90" if self._pinned else ic)
|
||
)
|
||
self.pin_btn.setIconSize(QSize(13, 13))
|
||
self._min_btn.setIcon(qta.icon("fa5s.minus", color=ic))
|
||
self._min_btn.setIconSize(QSize(13, 13))
|
||
self.app_title.setStyleSheet(
|
||
f"font-size:13px; font-weight:bold; background:transparent; color:{t['search_color']};"
|
||
)
|
||
|
||
# 顶部天气栏
|
||
self.weather_bar.setStyleSheet("background:transparent; border-radius:6px;")
|
||
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;"
|
||
)
|
||
# 刷新按钮图标跟随主题颜色
|
||
try:
|
||
self.weather_refresh_btn.setIcon(
|
||
qta.icon("fa5s.sync-alt", color=t["search_color"])
|
||
)
|
||
self.weather_refresh_btn.setIconSize(QSize(14, 14))
|
||
except Exception:
|
||
pass
|
||
# 网络时间文字
|
||
if hasattr(self, "time_label"):
|
||
self.time_label.setStyleSheet(
|
||
f"font-weight:bold; font-size:18px; color:{t['search_color']};"
|
||
)
|
||
if hasattr(self, "time_date_label"):
|
||
self.time_date_label.setStyleSheet(
|
||
f"font-size:11px; color:{t['search_color']};"
|
||
)
|
||
if hasattr(self, "time_title_label"):
|
||
self.time_title_label.setStyleSheet("font-size:11px; color: #888;")
|
||
|
||
# 左侧快捷栏
|
||
bar_side_bg = "rgba(0,0,0,20)" if is_dark else "rgba(0,0,0,6)"
|
||
self._quick_bar.setStyleSheet(
|
||
f"""
|
||
QWidget#quick_bar {{
|
||
background: {bar_side_bg};
|
||
border-right: 1px solid {t['panel_border']};
|
||
border-radius: 10px 0 0 10px;
|
||
}}
|
||
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
|
||
QPushButton:hover {{ background:{t['header_hover']}; }}
|
||
"""
|
||
)
|
||
for btn, icon_name in self._quick_btns:
|
||
try:
|
||
btn.setIcon(qta.icon(icon_name, color=ic))
|
||
btn.setIconSize(QSize(14, 14))
|
||
except Exception:
|
||
pass
|
||
|
||
# 底部工具栏
|
||
bar_bg2 = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,8)"
|
||
self.bottom_bar.setStyleSheet(
|
||
f"""
|
||
QWidget#bottom_bar {{
|
||
background: {bar_bg2};
|
||
border-radius: 0 0 10px 10px;
|
||
border-top: 1px solid {t['panel_border']};
|
||
}}
|
||
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
|
||
QPushButton:hover {{ background:{t['header_hover']}; }}
|
||
"""
|
||
)
|
||
self.theme_btn.setIcon(
|
||
qta.icon("fa5s.sun" if is_dark else "fa5s.moon", color=ic)
|
||
)
|
||
self.theme_btn.setIconSize(QSize(14, 14))
|
||
self.update_btn.setIcon(qta.icon("fa5s.sync-alt", color=ic))
|
||
self.update_btn.setIconSize(QSize(14, 14))
|
||
self.menu_btn.setIcon(qta.icon("fa5s.bars", color=ic))
|
||
self.menu_btn.setIconSize(QSize(14, 14))
|
||
self.settings_btn.setIcon(qta.icon("fa5s.cog", color=ic))
|
||
self.settings_btn.setIconSize(QSize(14, 14))
|
||
self.quit_btn.setIcon(qta.icon("fa5s.sign-out-alt", color=ic))
|
||
self.quit_btn.setIconSize(QSize(14, 14))
|
||
|
||
for i in range(self.groups_layout.count() - 1):
|
||
item = self.groups_layout.itemAt(i)
|
||
if item and item.widget():
|
||
item.widget()._apply_theme()
|
||
|
||
def _quit_application(self):
|
||
ret = dialog_style.question(
|
||
self,
|
||
"退出",
|
||
"确认退出吗?",
|
||
default_button=QMessageBox.StandardButton.No,
|
||
)
|
||
if ret == QMessageBox.StandardButton.Yes:
|
||
QApplication.quit()
|
||
|
||
def _restart_application(self):
|
||
import subprocess
|
||
subprocess.Popen([sys.executable] + sys.argv)
|
||
QApplication.quit()
|
||
|
||
def _open_admin_cmd(self):
|
||
import ctypes
|
||
ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", None, None, 1)
|
||
|
||
def _open_admin_powershell(self):
|
||
import ctypes
|
||
ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", None, None, 1)
|
||
|
||
def _open_default_browser(self):
|
||
import webbrowser
|
||
webbrowser.open("https://www.baidu.com")
|
||
|
||
def _open_wechat_multi(self):
|
||
from ui.wechat_multi import WechatMultiDialog
|
||
dlg = WechatMultiDialog(self)
|
||
dlg.exec()
|
||
|
||
def _toggle_theme(self):
|
||
theme.set_theme("light" if theme.name() == "dark" else "dark")
|
||
self._apply_theme()
|
||
# 若设置窗口已打开,同步更新其主题
|
||
if hasattr(self, "_settings_win") and self._settings_win.isVisible():
|
||
try:
|
||
self._settings_win._apply_theme()
|
||
except Exception:
|
||
pass
|
||
|
||
def _show_settings(self):
|
||
from ui.settings_window import SettingsWindow
|
||
|
||
if hasattr(self, "_settings_win") and self._settings_win.isVisible():
|
||
self._settings_win.raise_()
|
||
self._settings_win.activateWindow()
|
||
return
|
||
self._settings_win = SettingsWindow(self)
|
||
self._settings_win.show()
|
||
|
||
def _show_bottom_menu(self):
|
||
menu = QMenu(self)
|
||
menu.setStyleSheet(self._bottom_menu_stylesheet())
|
||
menu.addAction(self._make_doubao_icon(), "豆包会话").triggered.connect(
|
||
self._open_doubao_session
|
||
)
|
||
menu.addAction(
|
||
self._make_round_menu_icon("D", "#4d6bfe"), "DeepSeek"
|
||
).triggered.connect(
|
||
lambda: self._open_url_in_browser(
|
||
"https://chat.deepseek.com/", "DeepSeek"
|
||
)
|
||
)
|
||
menu.addAction(
|
||
self._make_round_menu_icon("G", "#4285f4"), "Gemini"
|
||
).triggered.connect(
|
||
lambda: self._open_url_in_browser(
|
||
"https://gemini.google.com/", "Gemini"
|
||
)
|
||
)
|
||
# 菜单贴近按钮下方显示,避免漂移到不自然位置
|
||
pos = self.menu_btn.mapToGlobal(self.menu_btn.rect().bottomLeft())
|
||
menu.exec(pos)
|
||
|
||
def _bottom_menu_stylesheet(self) -> str:
|
||
t = theme.current()
|
||
return f"""
|
||
QMenu {{
|
||
background: {t['menu_bg']};
|
||
color: {t['menu_color']};
|
||
border: 1px solid {t['menu_border']};
|
||
border-radius: 8px;
|
||
padding: 4px;
|
||
}}
|
||
QMenu::item {{
|
||
padding: 6px 14px;
|
||
border-radius: 5px;
|
||
background: transparent;
|
||
}}
|
||
QMenu::item:selected {{
|
||
background: {t['menu_selected']};
|
||
}}
|
||
QMenu::separator {{
|
||
height: 1px;
|
||
background: {t['menu_border']};
|
||
margin: 4px 8px;
|
||
}}
|
||
"""
|
||
|
||
def _make_doubao_icon(self) -> QIcon:
|
||
return self._make_round_menu_icon("豆", "#3b82f6", font_pt=9)
|
||
|
||
def _make_round_menu_icon(
|
||
self, letter: str, bg_hex: str, *, font_pt: int = 9
|
||
) -> QIcon:
|
||
pm = QPixmap(18, 18)
|
||
pm.fill(Qt.GlobalColor.transparent)
|
||
p = QPainter(pm)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
p.setBrush(QBrush(QColor(bg_hex)))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(1, 1, 16, 16)
|
||
p.setPen(QPen(QColor("white")))
|
||
f = p.font()
|
||
f.setPointSize(font_pt)
|
||
f.setBold(True)
|
||
p.setFont(f)
|
||
p.drawText(pm.rect(), int(Qt.AlignmentFlag.AlignCenter), letter)
|
||
p.end()
|
||
return QIcon(pm)
|
||
|
||
def _open_url_in_browser(self, url: str, title: str) -> None:
|
||
try:
|
||
ok = webbrowser.open(url)
|
||
except Exception as e:
|
||
dialog_style.warning(
|
||
self, "打开失败", f"无法在默认浏览器中打开{title}:{e}"
|
||
)
|
||
return
|
||
if not ok:
|
||
dialog_style.warning(
|
||
self,
|
||
"打开失败",
|
||
f"系统未注册可用的默认浏览器,请手动在浏览器中打开{title}:\n{url}",
|
||
)
|
||
|
||
def _open_doubao_session(self):
|
||
"""用系统默认浏览器打开豆包会话页。"""
|
||
self._open_url_in_browser("https://www.doubao.com/chat/", "豆包")
|
||
|
||
def refresh_groups(self):
|
||
# 记录当前折叠状态
|
||
collapsed_ids = set()
|
||
for i in range(self.groups_layout.count() - 1):
|
||
item = self.groups_layout.itemAt(i)
|
||
if item and item.widget():
|
||
gw = item.widget()
|
||
if gw.collapsed:
|
||
collapsed_ids.add(gw.group_data["id"])
|
||
|
||
while self.groups_layout.count() > 1:
|
||
item = self.groups_layout.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
for gdata in database.get_groups():
|
||
gw = GroupWidget(gdata)
|
||
gw.request_refresh_all.connect(self.refresh_groups)
|
||
# 恢复折叠状态
|
||
if gdata["id"] in collapsed_ids:
|
||
gw.collapsed = True
|
||
gw.flow.setVisible(False)
|
||
t = theme.current()
|
||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||
import qtawesome as _qta
|
||
|
||
gw.toggle_icon.setPixmap(
|
||
_qta.icon("fa5s.chevron-right", color=ic).pixmap(14, 14)
|
||
)
|
||
self.groups_layout.insertWidget(self.groups_layout.count() - 1, gw)
|
||
|
||
def reset_groups_and_items(self):
|
||
"""清空所有分组与程序,并刷新界面。"""
|
||
database.reset_groups_and_items(recreate_default_group=True)
|
||
# 清空搜索关键字,确保刷新后可见全部内容
|
||
try:
|
||
self.search_box.setText("")
|
||
except Exception:
|
||
pass
|
||
self.refresh_groups()
|
||
|
||
def _add_group(self):
|
||
name, ok = dialog_style.get_text(self, "新建分组", "分组名称:")
|
||
if ok and name.strip():
|
||
database.add_group(name.strip())
|
||
self.refresh_groups()
|
||
|
||
def _add_from_folder(self):
|
||
folder = dialog_style.get_existing_directory(self, "选择文件夹")
|
||
if not folder:
|
||
return
|
||
# 统一路径分隔符
|
||
folder = os.path.normpath(folder)
|
||
group_name = os.path.basename(folder)
|
||
gid = database.add_group(group_name, folder_path=folder)
|
||
# 文件夹分组直接读目录,不写入 items 表
|
||
count = sum(
|
||
1 for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))
|
||
)
|
||
self.refresh_groups()
|
||
dialog_style.information(
|
||
self,
|
||
"完成",
|
||
f"已创建分组「{group_name}」,共 {count} 个文件(实时读取)。",
|
||
)
|
||
|
||
def _sync_pin_dependent_ui(self):
|
||
"""图钉图标、最小化提示、置顶标志(面板 + 悬浮球)。"""
|
||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||
self.pin_btn.setIcon(
|
||
qta.icon("fa5s.thumbtack", color="#f90" if self._pinned else ic)
|
||
)
|
||
self.pin_btn.setIconSize(QSize(13, 13))
|
||
if self._pinned:
|
||
self._min_btn.setToolTip("收缩内容区(固定模式)")
|
||
else:
|
||
self._min_btn.setToolTip("最小化到悬浮球")
|
||
self._apply_pin_window_layer()
|
||
|
||
def _apply_pin_window_layer(self):
|
||
"""图钉开 → 面板与悬浮球 WindowStaysOnTop;关 → 普通叠放,不挡其他程序。"""
|
||
on_top = self._pinned
|
||
if getattr(self, "_pin_top_applied", None) is not on_top:
|
||
self._pin_top_applied = on_top
|
||
was_visible = self.isVisible()
|
||
geo = self.geometry()
|
||
flags = Qt.WindowType.FramelessWindowHint
|
||
if on_top:
|
||
flags |= Qt.WindowType.WindowStaysOnTopHint
|
||
self.setWindowFlags(flags)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setAcceptDrops(True)
|
||
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
|
||
if os.path.exists(logo_path):
|
||
self.setWindowIcon(QIcon(logo_path))
|
||
if was_visible:
|
||
self.setGeometry(geo)
|
||
self.show()
|
||
ball = getattr(self, "_ball_ref", None)
|
||
if ball is not None:
|
||
ball.set_stays_on_top(on_top)
|
||
|
||
def _toggle_pin(self):
|
||
self._pinned = not self._pinned
|
||
self._sync_pin_dependent_ui()
|
||
|
||
def _on_min_click(self):
|
||
"""图钉开启时收缩内容区,否则最小化到悬浮球"""
|
||
if self._pinned:
|
||
if self._body.isVisible():
|
||
self._collapse_body()
|
||
else:
|
||
self._expand_body()
|
||
else:
|
||
self.minimize_to_ball()
|
||
|
||
def _collapse_body(self):
|
||
"""收缩:隐藏内容区,窗口缩到只剩标题栏高度"""
|
||
self._body.hide()
|
||
self.weather_bar.hide()
|
||
self._quick_bar.hide()
|
||
self._expanded_height = self.height()
|
||
title_h = self._title_bar.sizeHint().height() + 20
|
||
self.setFixedHeight(title_h)
|
||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||
self._min_btn.setIcon(qta.icon("fa5s.chevron-down", color=ic))
|
||
self._min_btn.setIconSize(QSize(13, 13))
|
||
|
||
def _expand_body(self):
|
||
"""展开:恢复内容区和窗口高度"""
|
||
self.weather_bar.show()
|
||
self._body.show()
|
||
self._quick_bar.show()
|
||
h = getattr(self, "_expanded_height", 520)
|
||
self.setMinimumHeight(MIN_H)
|
||
self.setMaximumHeight(16777215)
|
||
self.resize(self.width(), h)
|
||
self._persist_w = self.width()
|
||
self._persist_h = h
|
||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||
self._min_btn.setIcon(qta.icon("fa5s.minus", color=ic))
|
||
self._min_btn.setIconSize(QSize(13, 13))
|
||
|
||
def _save_geometry(self):
|
||
"""把当前位置和尺寸写入数据库"""
|
||
g = self.geometry()
|
||
database.set_setting("panel_x", str(g.x()))
|
||
database.set_setting("panel_y", str(g.y()))
|
||
# 收缩为仅标题栏时高度变小,勿把临时高度写入库/覆盖跨屏用的 persist
|
||
if getattr(self, "_body", None) is not None and not self._body.isVisible():
|
||
return
|
||
self._persist_w = g.width()
|
||
self._persist_h = g.height()
|
||
database.set_setting("panel_w", str(g.width()))
|
||
database.set_setting("panel_h", str(g.height()))
|
||
|
||
def _restore_geometry(self):
|
||
"""从数据库恢复上次的位置、尺寸和透明度"""
|
||
try:
|
||
x = int(database.get_setting("panel_x", ""))
|
||
y = int(database.get_setting("panel_y", ""))
|
||
w = int(database.get_setting("panel_w", str(PANEL_W)))
|
||
h = int(database.get_setting("panel_h", str(PANEL_H)))
|
||
# 用保存坐标所在屏幕来限制范围,支持多显示器
|
||
screen_obj = QApplication.screenAt(QPoint(x, y))
|
||
if screen_obj is None:
|
||
screen_obj = QApplication.primaryScreen()
|
||
screen = screen_obj.availableGeometry()
|
||
x = max(screen.left(), min(x, screen.right() - w))
|
||
y = max(screen.top(), min(y, screen.bottom() - h))
|
||
self._persist_w = w
|
||
self._persist_h = h
|
||
self.setGeometry(x, y, w, h)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
# 恢复透明度
|
||
try:
|
||
opacity = int(database.get_setting("panel_opacity", "100"))
|
||
self.setWindowOpacity(max(30, min(100, opacity)) / 100)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
def _on_search(self, keyword):
|
||
for i in range(self.groups_layout.count() - 1):
|
||
item = self.groups_layout.itemAt(i)
|
||
if item and item.widget():
|
||
item.widget().filter(keyword)
|
||
|
||
# ── 显示/隐藏 ────────────────────────────────────────
|
||
def show_near(self, ball_pos: QPoint, ball_size: int):
|
||
# 根据球所在屏幕来定位 panel,避免跑到主屏幕
|
||
screen_obj = QApplication.screenAt(ball_pos)
|
||
if screen_obj is None:
|
||
screen_obj = QApplication.primaryScreen()
|
||
screen = screen_obj.availableGeometry()
|
||
pw, ph = self.width(), self.height()
|
||
bx, by = ball_pos.x(), ball_pos.y()
|
||
|
||
x = bx - pw - 8
|
||
if x < screen.left():
|
||
x = bx + ball_size + 8
|
||
y = max(screen.top(), min(by + ball_size // 2 - ph // 2, screen.bottom() - ph))
|
||
|
||
self.move(x, y + 30)
|
||
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(x, y + 30))
|
||
self._anim2.setEndValue(QPoint(x, y))
|
||
self._anim2.finished.connect(self._save_geometry)
|
||
self._anim2.start()
|
||
|
||
def hide_panel(self):
|
||
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)
|
||
self._anim.start()
|
||
|
||
def minimize_to_ball(self):
|
||
self.hide_panel()
|
||
if hasattr(self, "_ball_ref"):
|
||
self._ball_ref.place_on_screen_of(self)
|
||
self._ball_ref.show()
|
||
self._ball_ref.setWindowOpacity(1.0)
|
||
|
||
def toggle_near(self, ball_pos: QPoint, ball_size: int):
|
||
if self.isVisible():
|
||
if not self._pinned:
|
||
self.minimize_to_ball()
|
||
else:
|
||
self.show_near(ball_pos, ball_size)
|
||
|
||
# ── 边缘检测 ─────────────────────────────────────────
|
||
def _edge_at(self, p: QPoint):
|
||
x, y, w, h = p.x(), p.y(), self.width(), self.height()
|
||
m = RESIZE_M
|
||
l = x <= m
|
||
r = x >= w - m
|
||
t = y <= m
|
||
b = y >= h - m
|
||
if b and r:
|
||
return "br"
|
||
if b and l:
|
||
return "bl"
|
||
if t and r:
|
||
return "tr"
|
||
if t and l:
|
||
return "tl"
|
||
if r:
|
||
return "r"
|
||
if l:
|
||
return "l"
|
||
if b:
|
||
return "b"
|
||
if t:
|
||
return "t"
|
||
return None
|
||
|
||
_EDGE_CUR = {
|
||
"r": Qt.CursorShape.SizeHorCursor,
|
||
"l": Qt.CursorShape.SizeHorCursor,
|
||
"b": Qt.CursorShape.SizeVerCursor,
|
||
"t": Qt.CursorShape.SizeVerCursor,
|
||
"br": Qt.CursorShape.SizeFDiagCursor,
|
||
"tl": Qt.CursorShape.SizeFDiagCursor,
|
||
"bl": Qt.CursorShape.SizeBDiagCursor,
|
||
"tr": Qt.CursorShape.SizeBDiagCursor,
|
||
}
|
||
|
||
def _update_cursor(self, global_pos: QPoint):
|
||
"""根据全局坐标更新光标形状(不依赖事件来源 widget)"""
|
||
local = self.mapFromGlobal(global_pos)
|
||
edge = self._edge_at(local)
|
||
self.setCursor(self._EDGE_CUR.get(edge, Qt.CursorShape.ArrowCursor))
|
||
|
||
def _do_resize(self, gp: QPoint):
|
||
d = gp - self._resize_start_global
|
||
g = self._resize_start_geo
|
||
x, y, w, h = g.x(), g.y(), g.width(), g.height()
|
||
e = self._resize_edge
|
||
if "r" in e:
|
||
w = max(MIN_W, w + d.x())
|
||
if "l" in e:
|
||
nw = max(MIN_W, w - d.x())
|
||
x += w - nw
|
||
w = nw
|
||
if "b" in e:
|
||
h = max(MIN_H, h + d.y())
|
||
if "t" in e:
|
||
nh = max(MIN_H, h - d.y())
|
||
y += h - nh
|
||
h = nh
|
||
self.setGeometry(x, y, w, h)
|
||
|
||
def _container_pos_is_title_bar(self, pos_in_container: QPoint) -> bool:
|
||
"""仅标题栏区域允许拖动整个面板。"""
|
||
w = self.container.childAt(pos_in_container)
|
||
p = w
|
||
while p:
|
||
if p is self._title_bar:
|
||
return True
|
||
p = p.parentWidget()
|
||
return False
|
||
|
||
# ── eventFilter:处理 container 上的 resize/drag ─────
|
||
def eventFilter(self, obj, event):
|
||
if obj is self.container:
|
||
et = event.type()
|
||
# drop 事件放行给子 widget
|
||
if et in (
|
||
QEvent.Type.DragEnter,
|
||
QEvent.Type.DragMove,
|
||
QEvent.Type.Drop,
|
||
QEvent.Type.DragLeave,
|
||
):
|
||
return False
|
||
|
||
if (
|
||
et == QEvent.Type.MouseButtonPress
|
||
and event.button() == Qt.MouseButton.LeftButton
|
||
):
|
||
local = self.mapFromGlobal(event.globalPosition().toPoint())
|
||
edge = self._edge_at(local)
|
||
if edge:
|
||
self._resizing = True
|
||
self._resize_edge = edge
|
||
self._resize_start_global = event.globalPosition().toPoint()
|
||
self._resize_start_geo = self.geometry()
|
||
return True
|
||
pos_c = self.container.mapFrom(self, local)
|
||
if not self._container_pos_is_title_bar(pos_c):
|
||
return False
|
||
self._win_drag_pos = (
|
||
event.globalPosition().toPoint() - self.frameGeometry().topLeft()
|
||
)
|
||
|
||
elif et == QEvent.Type.MouseMove:
|
||
gp = event.globalPosition().toPoint()
|
||
if self._resizing:
|
||
self._do_resize(gp)
|
||
return True
|
||
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
|
||
self.move(gp - self._win_drag_pos)
|
||
return True
|
||
# 悬停时更新光标
|
||
self._update_cursor(gp)
|
||
|
||
elif et == QEvent.Type.MouseButtonRelease:
|
||
self._resizing = False
|
||
self._resize_edge = None
|
||
self._win_drag_pos = None
|
||
self._update_cursor(event.globalPosition().toPoint())
|
||
self._save_geometry()
|
||
|
||
return super().eventFilter(obj, event)
|
||
|
||
# ── PanelWindow 自身的鼠标事件(鼠标在 container 外时)
|
||
def mouseMoveEvent(self, event):
|
||
if self._resizing:
|
||
self._do_resize(event.globalPosition().toPoint())
|
||
return
|
||
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
|
||
self.move(event.globalPosition().toPoint() - self._win_drag_pos)
|
||
return
|
||
self._update_cursor(event.globalPosition().toPoint())
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
edge = self._edge_at(event.pos())
|
||
if edge:
|
||
self._resizing = True
|
||
self._resize_edge = edge
|
||
self._resize_start_global = event.globalPosition().toPoint()
|
||
self._resize_start_geo = self.geometry()
|
||
elif self.container.geometry().contains(event.pos()):
|
||
pos_c = self.container.mapFrom(self, event.pos())
|
||
if self._container_pos_is_title_bar(pos_c):
|
||
self._win_drag_pos = (
|
||
event.globalPosition().toPoint()
|
||
- self.frameGeometry().topLeft()
|
||
)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
self._resizing = False
|
||
self._resize_edge = None
|
||
self._win_drag_pos = None
|
||
self._update_cursor(event.globalPosition().toPoint())
|
||
self._save_geometry() # 拖动/resize 结束后保存位置和尺寸
|
||
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
|
||
def focusOutEvent(self, event):
|
||
if not self._pinned:
|
||
QTimer.singleShot(200, self._check_focus_lost)
|
||
|
||
def _check_focus_lost(self):
|
||
if not self.isActiveWindow() and not self._pinned:
|
||
self.hide_panel()
|
||
|
||
# ── 托盘 ─────────────────────────────────────────────
|
||
def _setup_tray(self):
|
||
self.tray = QSystemTrayIcon(self)
|
||
self.tray.setIcon(_make_tray_icon())
|
||
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; }
|
||
"""
|
||
)
|
||
for tooltip, _icon, callback in self._get_quick_actions():
|
||
menu.addAction(tooltip).triggered.connect(callback)
|
||
menu.addSeparator()
|
||
menu.addAction("🔄 重启本程序").triggered.connect(self._restart_application)
|
||
menu.addAction("❌ 退出本程序").triggered.connect(QApplication.quit)
|
||
|
||
self.tray.setContextMenu(menu)
|
||
self.tray.activated.connect(self._on_tray_activated)
|
||
self.tray.show()
|
||
|
||
def _on_tray_activated(self, reason):
|
||
if reason in (
|
||
QSystemTrayIcon.ActivationReason.Trigger,
|
||
QSystemTrayIcon.ActivationReason.DoubleClick,
|
||
):
|
||
if self.isVisible():
|
||
self.minimize_to_ball()
|
||
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 '关'}"
|
||
)
|
||
|
||
|
||
class SettingsPopup(QWidget):
|
||
"""设置弹出面板:透明度调节等"""
|
||
|
||
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()
|
||
|
||
def _build(self):
|
||
from PyQt6.QtWidgets import QSlider, QLabel, QVBoxLayout, QHBoxLayout
|
||
|
||
t = theme.current()
|
||
is_dark = theme.name() == "dark"
|
||
|
||
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;
|
||
}}
|
||
"""
|
||
)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(14, 12, 14, 12)
|
||
layout.setSpacing(10)
|
||
|
||
# 透明度
|
||
row = QHBoxLayout()
|
||
row.setSpacing(10)
|
||
lbl = QLabel("透明度")
|
||
lbl.setStyleSheet(
|
||
f"color:{t['search_color']}; font-size:12px; background:transparent; border:none;"
|
||
)
|
||
lbl.setFixedWidth(42)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
row.addWidget(lbl)
|
||
row.addWidget(self._opacity_slider)
|
||
row.addWidget(self._opacity_val)
|
||
layout.addLayout(row)
|
||
|
||
self.adjustSize()
|
||
|
||
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 _set_autostart(enable: bool):
|
||
try:
|
||
import winreg
|
||
|
||
key = winreg.OpenKey(
|
||
winreg.HKEY_CURRENT_USER,
|
||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||
0,
|
||
winreg.KEY_SET_VALUE,
|
||
)
|
||
app_name = "DesktopOrganizer"
|
||
if enable:
|
||
exe = (
|
||
f'"{sys.executable}" "{os.path.abspath("main.py")}"'
|
||
if not getattr(sys, "frozen", False)
|
||
else f'"{sys.executable}"'
|
||
)
|
||
winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, exe)
|
||
else:
|
||
try:
|
||
winreg.DeleteValue(key, app_name)
|
||
except FileNotFoundError:
|
||
pass
|
||
winreg.CloseKey(key)
|
||
except Exception as e:
|
||
print(f"自启设置失败: {e}")
|