commit 10843a5bc5073aab4a30d5730c1af8d7bcabc8d2 Author: 李志强 <357099073@qq.com> Date: Fri Apr 3 21:50:36 2026 +0800 first commit diff --git a/CleanDesktopOrganizer.spec b/CleanDesktopOrganizer.spec new file mode 100644 index 0000000..b0f47c5 --- /dev/null +++ b/CleanDesktopOrganizer.spec @@ -0,0 +1,47 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [('assets', 'assets'), ('logo.png', '.')] +binaries = [] +hiddenimports = ['qtawesome'] +tmp_ret = collect_all('qtawesome') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('PyQt6') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='CleanDesktopOrganizer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['logo.png'], +) diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..4a20648 Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/__pycache__/shortcut_target.cpython-313.pyc b/__pycache__/shortcut_target.cpython-313.pyc new file mode 100644 index 0000000..af634f8 Binary files /dev/null and b/__pycache__/shortcut_target.cpython-313.pyc differ diff --git a/assets/imgs/donate/wx.jpg b/assets/imgs/donate/wx.jpg new file mode 100644 index 0000000..867bbf8 Binary files /dev/null and b/assets/imgs/donate/wx.jpg differ diff --git a/assets/imgs/donate/zfb.jpg b/assets/imgs/donate/zfb.jpg new file mode 100644 index 0000000..41715cc Binary files /dev/null and b/assets/imgs/donate/zfb.jpg differ diff --git a/assets/imgs/weather/中雨.png b/assets/imgs/weather/中雨.png new file mode 100644 index 0000000..d245ee5 Binary files /dev/null and b/assets/imgs/weather/中雨.png differ diff --git a/assets/imgs/weather/多云.png b/assets/imgs/weather/多云.png new file mode 100644 index 0000000..24d1af6 Binary files /dev/null and b/assets/imgs/weather/多云.png differ diff --git a/assets/imgs/weather/大雨.png b/assets/imgs/weather/大雨.png new file mode 100644 index 0000000..bc4f442 Binary files /dev/null and b/assets/imgs/weather/大雨.png differ diff --git a/assets/imgs/weather/小雨.png b/assets/imgs/weather/小雨.png new file mode 100644 index 0000000..d4c4c10 Binary files /dev/null and b/assets/imgs/weather/小雨.png differ diff --git a/assets/imgs/weather/晴.png b/assets/imgs/weather/晴.png new file mode 100644 index 0000000..5066fef Binary files /dev/null and b/assets/imgs/weather/晴.png differ diff --git a/assets/imgs/weather/暴雨.png b/assets/imgs/weather/暴雨.png new file mode 100644 index 0000000..0a75500 Binary files /dev/null and b/assets/imgs/weather/暴雨.png differ diff --git a/assets/imgs/weather/阴.png b/assets/imgs/weather/阴.png new file mode 100644 index 0000000..55be223 Binary files /dev/null and b/assets/imgs/weather/阴.png differ diff --git a/assets/imgs/weather/雷阵雨.png b/assets/imgs/weather/雷阵雨.png new file mode 100644 index 0000000..7ef2613 Binary files /dev/null and b/assets/imgs/weather/雷阵雨.png differ diff --git a/assets/imgs/weather/霾.png b/assets/imgs/weather/霾.png new file mode 100644 index 0000000..a20726e Binary files /dev/null and b/assets/imgs/weather/霾.png differ diff --git a/data.db b/data.db new file mode 100644 index 0000000..9e2ca81 Binary files /dev/null and b/data.db differ diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e5e3bc4 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1 @@ +# db package diff --git a/db/__pycache__/__init__.cpython-312.pyc b/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..4bbbd2f Binary files /dev/null and b/db/__pycache__/__init__.cpython-312.pyc differ diff --git a/db/__pycache__/__init__.cpython-313.pyc b/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b8ed6de Binary files /dev/null and b/db/__pycache__/__init__.cpython-313.pyc differ diff --git a/db/__pycache__/database.cpython-313.pyc b/db/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..7b728aa Binary files /dev/null and b/db/__pycache__/database.cpython-313.pyc differ diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..423ad12 --- /dev/null +++ b/db/database.py @@ -0,0 +1,230 @@ +import sqlite3 +import os +import shutil + +# 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录) +APP_NAME = "CleanDesktopOrganizer" + +_PROJECT_DB_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "data.db" +) + +_BASE_DIR = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or "" +DB_DIR = os.path.join(_BASE_DIR, APP_NAME) if _BASE_DIR else None +DB_PATH = os.path.join(DB_DIR, "data.db") if DB_DIR else _PROJECT_DB_PATH + + +def _ensure_db_dir(): + if DB_DIR: + os.makedirs(DB_DIR, exist_ok=True) + + +def _migrate_old_db_if_needed(): + """ + 首次升级:把旧版项目目录下的 data.db 自动拷贝到 APPDATA, + 避免你之前分组/设置丢失。 + """ + try: + if os.path.isfile(_PROJECT_DB_PATH) and not os.path.isfile(DB_PATH): + _ensure_db_dir() + shutil.copy2(_PROJECT_DB_PATH, DB_PATH) + except Exception: + # 迁移失败不影响程序运行(会在新目录重新创建库) + pass + + +def get_conn(): + _ensure_db_dir() + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + _migrate_old_db_if_needed() + conn = get_conn() + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + position INTEGER DEFAULT 0, + folder_path TEXT DEFAULT '' + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + icon_path TEXT, + position INTEGER DEFAULT 0, + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + # 迁移:旧库没有 folder_path 字段时自动添加 + try: + c.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''") + conn.commit() + except Exception: + pass # 字段已存在则忽略 + + # 默认分组 + c.execute("SELECT COUNT(*) FROM groups") + if c.fetchone()[0] == 0: + c.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)") + conn.commit() + conn.close() + + _migrate_item_shortcut_paths_to_targets() + + +def _migrate_item_shortcut_paths_to_targets(): + """将旧数据中仍保存为 .lnk 的路径改存为快捷方式目标(若可解析)。仅执行一次。""" + conn = get_conn() + row = conn.execute( + "SELECT value FROM settings WHERE key='items_lnk_targets_migrated'" + ).fetchone() + conn.close() + if row and row["value"] == "1": + return + + from shortcut_target import path_for_storage + + conn = get_conn() + rows = conn.execute("SELECT id, path FROM items").fetchall() + for r in rows: + iid, p = r["id"], (r["path"] or "") + pl = p.lower() + if not pl.endswith(".lnk"): + continue + try: + if not os.path.isfile(p): + continue + except OSError: + continue + new_p = path_for_storage(p) + if new_p != p: + conn.execute("UPDATE items SET path=? WHERE id=?", (new_p, iid)) + conn.execute( + "INSERT INTO settings (key, value) VALUES (?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + ("items_lnk_targets_migrated", "1"), + ) + conn.commit() + conn.close() + + +# ── Settings ───────────────────────────────────────────── +def get_setting(key: str, default: str = "") -> str: + conn = get_conn() + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + conn.close() + return row["value"] if row else default + + +def set_setting(key: str, value: str): + conn = get_conn() + conn.execute( + "INSERT INTO settings (key, value) VALUES (?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value) + ) + conn.commit() + conn.close() + + +# ── Groups ────────────────────────────────────────────── +def get_groups(): + conn = get_conn() + rows = conn.execute("SELECT * FROM groups ORDER BY position").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_group(name, folder_path=""): + conn = get_conn() + conn.execute("INSERT INTO groups (name, folder_path) VALUES (?,?)", (name, folder_path)) + conn.commit() + gid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.close() + return gid + + +def rename_group(gid, name): + conn = get_conn() + conn.execute("UPDATE groups SET name=? WHERE id=?", (name, gid)) + conn.commit() + conn.close() + + +def delete_group(gid): + conn = get_conn() + conn.execute("DELETE FROM groups WHERE id=?", (gid,)) + conn.commit() + conn.close() + + +def reorder_groups(id_list): + conn = get_conn() + for pos, gid in enumerate(id_list): + conn.execute("UPDATE groups SET position=? WHERE id=?", (pos, gid)) + conn.commit() + conn.close() + + +# ── Items ──────────────────────────────────────────────── +def get_items(group_id): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM items WHERE group_id=? ORDER BY position", (group_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_item(group_id, name, path, icon_path=None): + from shortcut_target import path_for_storage + + path = path_for_storage(path) + conn = get_conn() + conn.execute( + "INSERT INTO items (group_id, name, path, icon_path) VALUES (?,?,?,?)", + (group_id, name, path, icon_path), + ) + conn.commit() + iid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.close() + return iid + + +def delete_item(item_id): + conn = get_conn() + conn.execute("DELETE FROM items WHERE id=?", (item_id,)) + conn.commit() + conn.close() + + +def move_item(item_id, new_group_id, new_position): + conn = get_conn() + conn.execute( + "UPDATE items SET group_id=?, position=? WHERE id=?", + (new_group_id, new_position, item_id), + ) + conn.commit() + conn.close() + + +def reorder_items(group_id, id_list): + conn = get_conn() + for pos, iid in enumerate(id_list): + conn.execute("UPDATE items SET position=? WHERE id=?", (pos, iid)) + conn.commit() + conn.close() diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..bfe99ec Binary files /dev/null and b/logo.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..d35cc6a --- /dev/null +++ b/main.py @@ -0,0 +1,106 @@ +import sys +import time +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication +from db.database import init_db +from ui.dock import PanelWindow, _set_autostart +from ui.ball import FloatBall, BALL_SIZE +import ui.theme as theme +from db import database + + +def _wake_existing_or_exit() -> bool: + """ + Windows 单实例: + - 若已有进程在运行:唤醒已有窗口并返回 False(当前进程退出) + - 若无已有进程:返回 True(继续正常启动) + """ + if sys.platform != "win32": + return True + + import ctypes + import ctypes.wintypes + + mutex_name = r"Global\CleanDesktopOrganizerSingleton" + title = "牛马软件柜" + + kernel32 = ctypes.windll.kernel32 + user32 = ctypes.windll.user32 + + ERROR_ALREADY_EXISTS = 183 + h_mutex = kernel32.CreateMutexW(None, False, mutex_name) + if not h_mutex: + return True + + last_err = kernel32.GetLastError() + if last_err == ERROR_ALREADY_EXISTS: + # 轮询一小段时间,避免首次窗口尚未创建 + hwnd = None + for _ in range(8): # ~2.4s + hwnd = user32.FindWindowW(None, title) + if hwnd: + break + time.sleep(0.3) + + if hwnd: + # SW_SHOW = 5 + user32.ShowWindow(hwnd, 5) + user32.SetForegroundWindow(hwnd) + return False + + return True + + +def main(): + # 必须在创建 QApplication 之前:不按各显示器缩放,逻辑像素固定(跨分辨率/跨屏拖动宽高保持一致) + if not _wake_existing_or_exit(): + return + QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi, True) + # 这个策略同样要求在创建 QApplication 前设置 + try: + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + except Exception: + pass + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + init_db() + theme.load() # 从数据库读取上次主题 + _set_autostart(True) + + panel = PanelWindow() + ball = FloatBall() + + panel._ball_ref = ball + + ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE)) + ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos)) + + # 判断是否有保存的位置:有则直接在原位显示,没有则用悬浮球旁边 + has_saved = bool(database.get_setting("panel_x", "")) + if has_saved: + # _restore_geometry 已在 PanelWindow.__init__ 里执行,直接 show + panel.setWindowOpacity(0) + panel.show() + panel.raise_() + if hasattr(panel, "_ball_ref"): + panel._ball_ref.hide() + # 淡入 + from PyQt6.QtCore import QPropertyAnimation + anim = QPropertyAnimation(panel, b"windowOpacity") + anim.setDuration(180) + anim.setStartValue(0.0) + anim.setEndValue(1.0) + anim.start() + panel._anim = anim + else: + ball._place_default() + panel.show_near(ball.pos(), BALL_SIZE) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f565544 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyQt6>=6.4.0 diff --git a/shortcut_target.py b/shortcut_target.py new file mode 100644 index 0000000..4e81c18 --- /dev/null +++ b/shortcut_target.py @@ -0,0 +1,123 @@ +"""将用户选中的 .lnk / .url 解析为实际要存储的路径(目标程序或 URL)。""" +import base64 +import json +import os +import re +import subprocess +import sys + + +def _looks_like_shell_lnk(path: str) -> bool: + """Windows Shell Link 文件头为 4C 00 00 00(扩展名异常时仍可识别)。""" + try: + with open(path, "rb") as f: + return f.read(4) == b"L\x00\x00\x00" + except OSError: + return False + + +def _read_internet_shortcut_url(path: str) -> str | None: + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + s = line.strip() + if s[:4].upper() == "URL=": + return s[4:].strip() + except OSError: + pass + return None + + +def _resolve_windows_lnk(lnk_path: str) -> str | None: + if sys.platform != "win32": + return None + env = os.environ.copy() + env["LNK_B64"] = base64.b64encode(os.path.normpath(lnk_path).encode("utf-8")).decode( + "ascii" + ) + ps = ( + "$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:LNK_B64));" + "$sc=(New-Object -ComObject WScript.Shell).CreateShortcut($p);" + "@{ t=[string]$sc.TargetPath; a=[string]$sc.Arguments }|ConvertTo-Json -Compress" + ) + try: + r = subprocess.run( + [ + "powershell", + "-NoProfile", + "-Sta", + "-Command", + ps, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + timeout=15, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + raw = (r.stdout or "").strip().lstrip("\ufeff") + if not raw: + return None + data = json.loads(raw) + target = (data.get("t") or "").strip() + args = (data.get("a") or "").strip() + if not target: + return None + low = target.lower() + if low.endswith("cmd.exe") or low.endswith("\\cmd.exe"): + for m in re.finditer( + r"([a-zA-Z]:[^\"'\s]+\.(?:bat|cmd))|\x22([a-zA-Z]:[^\"]+\.(?:bat|cmd))\x22", + args, + re.IGNORECASE, + ): + cand = (m.group(1) or m.group(2) or "").strip('"') + if cand and os.path.isfile(cand): + return os.path.normpath(cand) + if os.path.isfile(target) or low.startswith("\\\\"): + return os.path.normpath(target) + return os.path.normpath(target) + except Exception: + return None + + +def path_for_storage(local_path: str) -> str: + """ + 拖拽/选择文件时写入数据库的 path:.lnk/.url 解析为目标,其它保持原路径。 + 解析失败时退回本地路径。 + """ + if not local_path: + return local_path + local_path = os.path.normpath(local_path.strip()) + + ext = os.path.splitext(local_path)[1].lower() + if ext == ".url": + try: + if os.path.isfile(local_path): + url = _read_internet_shortcut_url(local_path) + if url: + return url + except (OSError, ValueError): + pass + return local_path + + try: + is_file = os.path.isfile(local_path) + except (OSError, ValueError): + is_file = False + + is_lnk = ext in (".lnk", ".ink") or ( + is_file and _looks_like_shell_lnk(local_path) + ) + if is_lnk and is_file: + resolved = _resolve_windows_lnk(local_path) + if resolved: + return resolved + return local_path + + +def item_name_from_sources(local_path: str, store_path: str) -> str: + """列表显示名:仍用快捷方式/所选文件的文件名(无扩展名),与原来行为一致。""" + base = os.path.splitext(os.path.basename(local_path))[0] + return base or os.path.basename(store_path) diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..67e55b5 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# ui package diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7d8818f Binary files /dev/null and b/ui/__pycache__/__init__.cpython-312.pyc differ diff --git a/ui/__pycache__/__init__.cpython-313.pyc b/ui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..26f1ea1 Binary files /dev/null and b/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/ui/__pycache__/ball.cpython-313.pyc b/ui/__pycache__/ball.cpython-313.pyc new file mode 100644 index 0000000..af91f78 Binary files /dev/null and b/ui/__pycache__/ball.cpython-313.pyc differ diff --git a/ui/__pycache__/dialog_style.cpython-313.pyc b/ui/__pycache__/dialog_style.cpython-313.pyc new file mode 100644 index 0000000..978dbcd Binary files /dev/null and b/ui/__pycache__/dialog_style.cpython-313.pyc differ diff --git a/ui/__pycache__/dock.cpython-313.pyc b/ui/__pycache__/dock.cpython-313.pyc new file mode 100644 index 0000000..ac00814 Binary files /dev/null and b/ui/__pycache__/dock.cpython-313.pyc differ diff --git a/ui/__pycache__/flow_layout.cpython-313.pyc b/ui/__pycache__/flow_layout.cpython-313.pyc new file mode 100644 index 0000000..81b4381 Binary files /dev/null and b/ui/__pycache__/flow_layout.cpython-313.pyc differ diff --git a/ui/__pycache__/group.cpython-313.pyc b/ui/__pycache__/group.cpython-313.pyc new file mode 100644 index 0000000..f355e40 Binary files /dev/null and b/ui/__pycache__/group.cpython-313.pyc differ diff --git a/ui/__pycache__/item.cpython-313.pyc b/ui/__pycache__/item.cpython-313.pyc new file mode 100644 index 0000000..bcd80b3 Binary files /dev/null and b/ui/__pycache__/item.cpython-313.pyc differ diff --git a/ui/__pycache__/settings_window.cpython-313.pyc b/ui/__pycache__/settings_window.cpython-313.pyc new file mode 100644 index 0000000..a062859 Binary files /dev/null and b/ui/__pycache__/settings_window.cpython-313.pyc differ diff --git a/ui/__pycache__/theme.cpython-313.pyc b/ui/__pycache__/theme.cpython-313.pyc new file mode 100644 index 0000000..2f4b84d Binary files /dev/null and b/ui/__pycache__/theme.cpython-313.pyc differ diff --git a/ui/ball.py b/ui/ball.py new file mode 100644 index 0000000..9fbf2a4 --- /dev/null +++ b/ui/ball.py @@ -0,0 +1,206 @@ +import os +from PyQt6.QtWidgets import QWidget, QApplication +from PyQt6.QtCore import Qt, QPoint, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRectF +from PyQt6.QtGui import (QPainter, QPixmap, QBrush, QColor, QPen, + QPainterPath, QRadialGradient, QIcon) + +BALL_SIZE = 60 +LOGO_SIZE = 34 +LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png") +DRAG_THRESHOLD = 6 + + +class FloatBall(QWidget): + clicked = pyqtSignal() + right_clicked = pyqtSignal(QPoint) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowFlags( + Qt.WindowType.FramelessWindowHint | + Qt.WindowType.WindowStaysOnTopHint | + Qt.WindowType.Tool + ) + if os.path.exists(LOGO_PATH): + self.setWindowIcon(QIcon(LOGO_PATH)) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + # 关键:让整个窗口区域都透明,不显示系统边框 + self.setFixedSize(BALL_SIZE + 20, BALL_SIZE + 20) # 留出光晕空间 + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self._logo = QPixmap(LOGO_PATH) if os.path.exists(LOGO_PATH) else QPixmap() + self._drag_start = QPoint() + self._dragging = False + self._hovered = False + self._glow = 0.0 # 0.0~1.0 光晕强度 + + # 光晕动画 + self._glow_anim = QPropertyAnimation(self, b"_glow_prop") + self._glow_anim.setDuration(300) + self._glow_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + + # 闲置淡出 + self._idle_timer = QTimer(self) + self._idle_timer.setSingleShot(True) + self._idle_timer.setInterval(3000) + self._idle_timer.timeout.connect(self._fade_out) + + self._place_default() + self._idle_timer.start() + + # Qt property 用于动画 + def _get_glow(self): return self._glow + def _set_glow(self, v): self._glow = v; self.update() + from PyQt6.QtCore import pyqtProperty + _glow_prop = pyqtProperty(float, _get_glow, _set_glow) + + def _place_default(self): + screen = QApplication.primaryScreen().availableGeometry() + pad = (self.width() - BALL_SIZE) // 2 + self.move(screen.right() - self.width() - 20 + pad, + screen.top() + screen.height() // 2 - self.height() // 2) + + # ── 绘制 ──────────────────────────────────────────── + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + pad = (self.width() - BALL_SIZE) // 2 # 光晕留白 + cx = self.width() / 2 + cy = self.height() / 2 + r = BALL_SIZE / 2 - 1 + + # 1. 外部光晕(hover 时) + if self._glow > 0: + glow_r = r + 10 * self._glow + grad = QRadialGradient(cx, cy, glow_r) + grad.setColorAt(0, QColor(255, 255, 255, int(80 * self._glow))) + grad.setColorAt(0.5, QColor(255, 255, 255, int(30 * self._glow))) + grad.setColorAt(1, QColor(255, 255, 255, 0)) + p.setBrush(QBrush(grad)) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(QRectF(cx - glow_r, cy - glow_r, glow_r * 2, glow_r * 2)) + + # 2. 圆形裁剪 + clip = QPainterPath() + clip.addEllipse(QRectF(cx - r, cy - r, r * 2, r * 2)) + p.setClipPath(clip) + + # 3. 白色半透明毛玻璃底 + p.setBrush(QBrush(QColor(255, 255, 255, 210))) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2)) + + # 4. 顶部高光 + hi = QRadialGradient(cx, cy - r * 0.3, r * 0.9) + hi.setColorAt(0, QColor(255, 255, 255, 140)) + hi.setColorAt(1, QColor(255, 255, 255, 0)) + p.setBrush(QBrush(hi)) + p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2)) + + # 5. logo 居中 + if not self._logo.isNull(): + scaled = self._logo.scaled( + LOGO_SIZE, LOGO_SIZE, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + lx = int(cx - scaled.width() / 2) + ly = int(cy - scaled.height() / 2) + p.drawPixmap(lx, ly, scaled) + + # 6. 边框(取消裁剪后画) + p.setClipping(False) + p.setBrush(Qt.BrushStyle.NoBrush) + p.setPen(QPen(QColor(200, 200, 200, 120), 1.5)) + p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2)) + + # 7. hover 时内圈白色光晕边 + if self._glow > 0: + p.setPen(QPen(QColor(255, 255, 255, int(160 * self._glow)), 2)) + p.drawEllipse(QRectF(cx - r + 1, cy - r + 1, (r - 1) * 2, (r - 1) * 2)) + + p.end() + + # ── 鼠标事件 ──────────────────────────────────────── + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._drag_start = event.globalPosition().toPoint() + self._dragging = False + self.setWindowOpacity(1.0) + self._idle_timer.stop() + elif event.button() == Qt.MouseButton.RightButton: + self.right_clicked.emit(event.globalPosition().toPoint()) + + def mouseMoveEvent(self, event): + if not (event.buttons() & Qt.MouseButton.LeftButton): + return + delta = event.globalPosition().toPoint() - self._drag_start + if not self._dragging and delta.manhattanLength() > DRAG_THRESHOLD: + self._dragging = True + if self._dragging: + new_pos = self.pos() + delta + self._drag_start = event.globalPosition().toPoint() + screen = QApplication.primaryScreen().availableGeometry() + new_pos.setX(max(screen.left(), min(new_pos.x(), screen.right() - self.width()))) + new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height()))) + self.move(new_pos) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + if not self._dragging: + self.clicked.emit() + else: + self._snap_to_edge() + self._idle_timer.start() + + def enterEvent(self, event): + self._hovered = True + self.setWindowOpacity(1.0) + self._idle_timer.stop() + self._glow_anim.stop() + self._glow_anim.setStartValue(self._glow) + self._glow_anim.setEndValue(1.0) + self._glow_anim.start() + + def leaveEvent(self, event): + self._hovered = False + self._glow_anim.stop() + self._glow_anim.setStartValue(self._glow) + self._glow_anim.setEndValue(0.0) + self._glow_anim.start() + self._idle_timer.start() + + # ── 吸附边缘 ───────────────────────────────────────── + def _snap_to_edge(self): + screen = QApplication.primaryScreen().availableGeometry() + cx = self.x() + self.width() // 2 + cy = self.y() + self.height() // 2 + dists = { + "l": cx - screen.left(), + "r": screen.right() - cx, + "t": cy - screen.top(), + "b": screen.bottom() - cy, + } + side = min(dists, key=dists.get) + ends = { + "l": QPoint(screen.left(), self.y()), + "r": QPoint(screen.right() - self.width(), self.y()), + "t": QPoint(self.x(), screen.top()), + "b": QPoint(self.x(), screen.bottom() - self.height()), + } + anim = QPropertyAnimation(self, b"pos") + anim.setDuration(200) + anim.setEasingCurve(QEasingCurve.Type.OutCubic) + anim.setStartValue(self.pos()) + anim.setEndValue(ends[side]) + self._snap_anim = anim + anim.start() + + def _fade_out(self): + self._anim_fade = QPropertyAnimation(self, b"windowOpacity") + self._anim_fade.setDuration(600) + self._anim_fade.setStartValue(1.0) + self._anim_fade.setEndValue(0.3) + self._anim_fade.start() diff --git a/ui/dialog_style.py b/ui/dialog_style.py new file mode 100644 index 0000000..2caf3a6 --- /dev/null +++ b/ui/dialog_style.py @@ -0,0 +1,222 @@ +"""标准对话框(QMessageBox / QInputDialog / QFileDialog)与当前主题一致。""" +from __future__ import annotations + +from PyQt6.QtWidgets import ( + QFileDialog, + QInputDialog, + QMessageBox, + QWidget, + QDialog, +) + +import ui.theme as theme + + +def stylesheet() -> str: + t = theme.current() + bg = t["menu_bg"] + fg = t["menu_color"] + border = t["menu_border"] + hover = t["menu_selected"] + inp = t["search_bg"] + inp_b = t["search_border"] + focus = t["search_focus"] + return f""" + QMessageBox {{ + background-color: {bg}; + color: {fg}; + }} + QMessageBox QLabel {{ + color: {fg}; + background: transparent; + min-width: 160px; + }} + QMessageBox QPushButton {{ + min-width: 64px; + padding: 6px 14px; + border: 1px solid {border}; + border-radius: 5px; + background: {inp}; + color: {fg}; + }} + QMessageBox QPushButton:hover {{ + background: {hover}; + }} + QMessageBox QPushButton:default {{ + border-color: {focus}; + }} + QInputDialog {{ + background-color: {bg}; + color: {fg}; + }} + QInputDialog QLabel {{ + color: {fg}; + background: transparent; + }} + QInputDialog QLineEdit {{ + background: {inp}; + border: 1px solid {inp_b}; + border-radius: 5px; + padding: 6px 8px; + color: {fg}; + min-width: 260px; + }} + QInputDialog QLineEdit:focus {{ + border-color: {focus}; + }} + QInputDialog QPushButton {{ + min-width: 72px; + padding: 6px 14px; + border: 1px solid {border}; + border-radius: 5px; + background: {inp}; + color: {fg}; + }} + QInputDialog QPushButton:hover {{ + background: {hover}; + }} + QFileDialog {{ + background-color: {bg}; + color: {fg}; + }} + QFileDialog QLabel {{ + color: {fg}; + background: transparent; + }} + QFileDialog QLineEdit, QFileDialog QComboBox {{ + background: {inp}; + border: 1px solid {inp_b}; + border-radius: 4px; + padding: 4px 6px; + color: {fg}; + }} + QFileDialog QLineEdit:focus, QFileDialog QComboBox:focus {{ + border-color: {focus}; + }} + QFileDialog QTreeView, QFileDialog QListView, QFileDialog QTableView {{ + background: {inp}; + border: 1px solid {inp_b}; + border-radius: 4px; + color: {fg}; + outline: none; + }} + QFileDialog QTreeView::item:selected, QFileDialog QListView::item:selected {{ + background: {hover}; + }} + QFileDialog QPushButton {{ + min-width: 72px; + padding: 6px 12px; + border: 1px solid {border}; + border-radius: 5px; + background: {inp}; + color: {fg}; + }} + QFileDialog QPushButton:hover {{ + background: {hover}; + }} + QFileDialog QComboBox QAbstractItemView {{ + background: {bg}; + color: {fg}; + border: 1px solid {border}; + }} + """ + + +def _apply(w: QWidget | None) -> None: + if w is not None: + w.setStyleSheet(stylesheet()) + + +def question( + parent: QWidget | None, + title: str, + text: str, + *, + buttons: QMessageBox.StandardButton = ( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ), + default_button: QMessageBox.StandardButton | None = None, + icon: QMessageBox.Icon = QMessageBox.Icon.Question, +) -> QMessageBox.StandardButton: + msg = QMessageBox(parent) + msg.setWindowTitle(title) + msg.setText(text) + msg.setIcon(icon) + msg.setStandardButtons(buttons) + if default_button is not None: + msg.setDefaultButton(default_button) + _apply(msg) + # 防止对话框因为文本长度/默认最小宽度变得过宽 + msg.setFixedWidth(320) + return QMessageBox.StandardButton(msg.exec()) + + +def warning(parent: QWidget | None, title: str, text: str) -> None: + msg = QMessageBox(parent) + msg.setWindowTitle(title) + msg.setText(text) + msg.setIcon(QMessageBox.Icon.Warning) + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + _apply(msg) + msg.setFixedWidth(320) + msg.exec() + + +def information(parent: QWidget | None, title: str, text: str) -> None: + msg = QMessageBox(parent) + msg.setWindowTitle(title) + msg.setText(text) + msg.setIcon(QMessageBox.Icon.Information) + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + _apply(msg) + msg.setFixedWidth(320) + msg.exec() + + +def get_text( + parent: QWidget | None, + title: str, + label: str, + text: str = "", +) -> tuple[str, bool]: + d = QInputDialog(parent) + d.setWindowTitle(title) + d.setLabelText(label) + d.setTextValue(text) + _apply(d) + ok = d.exec() == QDialog.DialogCode.Accepted + return d.textValue(), ok + + +def get_open_file_name( + parent: QWidget | None, + caption: str, + directory: str, + filter_str: str, +) -> tuple[str, str]: + fd = QFileDialog(parent, caption, directory, filter_str) + fd.setOption(QFileDialog.Option.DontUseNativeDialog, True) + fd.setFileMode(QFileDialog.FileMode.ExistingFile) + _apply(fd) + if fd.exec() == QDialog.DialogCode.Accepted: + files = fd.selectedFiles() + if files: + return files[0], fd.selectedNameFilter() + return "", "" + + +def get_existing_directory( + parent: QWidget | None, + caption: str, + directory: str = "", +) -> str: + fd = QFileDialog(parent, caption, directory) + fd.setOption(QFileDialog.Option.DontUseNativeDialog, True) + fd.setFileMode(QFileDialog.FileMode.Directory) + fd.setOption(QFileDialog.Option.ShowDirsOnly, True) + _apply(fd) + if fd.exec() == QDialog.DialogCode.Accepted: + files = fd.selectedFiles() + if files: + return files[0] + return "" diff --git a/ui/dock.py b/ui/dock.py new file mode 100644 index 0000000..205af2d --- /dev/null +++ b/ui/dock.py @@ -0,0 +1,1365 @@ +import sys +import os +import json +import threading +import socket +import struct +import time as _time +import datetime +import urllib.request +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 + +PANEL_W = 260 +PANEL_H = 40 +MIN_W, MIN_H = 180, 40 +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("牛马软件柜") + self.setWindowFlags( + Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint + ) + # 任务栏/开始菜单图标:使用项目 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) + + 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 _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + + self.container = QWidget() + self.container.setObjectName("container") + # container 也开启鼠标追踪,子 widget 的 MouseMove 会冒泡上来 + self.container.setMouseTracking(True) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(8, 10, 8, 8) + inner.setSpacing(6) + + # ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板) + 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.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.addStretch() + 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_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.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 _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 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 _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 _toggle_pin(self): + self._pinned = not self._pinned + 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)) + # 更新最小化按钮 tooltip + if self._pinned: + self._min_btn.setToolTip("收缩内容区(固定模式)") + else: + self._min_btn.setToolTip("最小化到悬浮球") + + 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._expanded_height = self.height() + title_h = ( + self.container.layout().contentsMargins().top() + + self.container.layout().contentsMargins().bottom() + + 46 + ) + 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._body.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 = QApplication.primaryScreen().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): + screen = QApplication.primaryScreen().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.show() + self._ball_ref.setWindowOpacity(1.0) + self._ball_ref._idle_timer.start() + + 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; } + """ + ) + menu.addAction("🖥 显示面板").triggered.connect( + lambda: ( + self.show_near(self._ball_ref.pos(), self._ball_ref.width()) + if hasattr(self, "_ball_ref") + else None + ) + ) + menu.addAction("🔽 最小化到悬浮球").triggered.connect(self.minimize_to_ball) + menu.addSeparator() + self._autostart_act = menu.addAction("🚀 开机自启: 开") + self._autostart_act.triggered.connect(self._toggle_autostart) + self._autostart_enabled = True + menu.addSeparator() + 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}") diff --git a/ui/flow_layout.py b/ui/flow_layout.py new file mode 100644 index 0000000..bdbb9d8 --- /dev/null +++ b/ui/flow_layout.py @@ -0,0 +1,191 @@ +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import QPoint, QTimer, Qt, QRect, QSize +from PyQt6.QtGui import QPainter, QColor, QPen + +ITEM_W = 72 +ITEM_H = 80 +GAP = 4 + + +RUBBER_MIN = 4 # 小于此尺寸的框选视为误触 + + +class FlowContainer(QWidget): + """手动绝对定位的流式容器,完全绕开 QLayout。""" + + def __init__(self, parent=None): + super().__init__(parent) + self._widgets: list[QWidget] = [] + self._pending = False + self._drag_highlight = False + self.setMouseTracking(True) + self._rubber_active = False + self._rubber_origin = QPoint() + self._rubber_rect: QRect | None = None + + def add_widget(self, w: QWidget): + w.setParent(self) + self._widgets.append(w) + if self.width() > 0: + self._relayout() + else: + self._schedule_relayout() + + def clear_widgets(self): + for w in self._widgets: + w.hide() + w.setParent(None) + self._widgets.clear() + self.setFixedHeight(60) + + def get_widgets(self) -> list: + return list(self._widgets) + + def set_drag_highlight(self, active: bool): + if self._drag_highlight != active: + self._drag_highlight = active + self.update() + + def showEvent(self, event): + super().showEvent(event) + self._schedule_relayout() + + def resizeEvent(self, event): + self._relayout() + super().resizeEvent(event) + + def _schedule_relayout(self): + if not self._pending: + self._pending = True + QTimer.singleShot(0, self._delayed_relayout) + + def _delayed_relayout(self): + self._pending = False + self._relayout() + + def _relayout(self): + avail_w = self.width() + if avail_w <= 0: + p = self.parent() + while p: + if p.width() > 0: + avail_w = p.width() - 16 + break + p = p.parent() + if avail_w <= 0: + self._schedule_relayout() + return + + x, y = GAP, GAP + row_h = 0 + + for w in self._widgets: + if not w.isVisible(): + w.move(-9999, -9999) + continue + iw = w.width() if w.width() > 0 else ITEM_W + ih = w.height() if w.height() > 0 else ITEM_H + if row_h > 0 and x + iw + GAP > avail_w: + x = GAP + y += row_h + GAP + row_h = 0 + w.move(x, y) + x += iw + GAP + row_h = max(row_h, ih) + + visible = [w for w in self._widgets if w.isVisible()] + total_h = (y + row_h + GAP) if visible else 60 + self.setFixedHeight(max(total_h, 0)) + + def index_at(self, pos: QPoint) -> int: + """拖拽插入位置:与流式布局顺序一致,同行每个图标单独判断,不再只认行内第一个。""" + visible = [w for w in self._widgets if w.isVisible()] + if not visible: + return 0 + + if pos.y() < visible[0].geometry().top(): + return 0 + + for w in visible: + g = w.geometry() + if pos.y() < g.top(): + return self._widgets.index(w) + if g.top() <= pos.y() <= g.bottom(): + if pos.x() < g.left(): + return self._widgets.index(w) + if pos.x() <= g.right(): + mid = (g.left() + g.right()) // 2 + if pos.x() < mid: + return self._widgets.index(w) + return self._widgets.index(w) + 1 + # 落在该行、但在本图标右侧,继续看下一个(同行或下一行) + return len(self._widgets) + + def _clear_selection_except(self, keep): + for w in self._widgets: + if w is not keep and hasattr(w, "_set_selected"): + w._set_selected(False) + + def clear_selection(self): + self._clear_selection_except(None) + + def _begin_rubber(self, pos: QPoint): + self.clear_selection() + self._rubber_active = True + self._rubber_origin = pos + self._rubber_rect = QRect(pos, QSize()) + self.grabMouse() + self.update() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + ch = self.childAt(event.pos()) + if ch is None: + self._begin_rubber(event.pos()) + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._rubber_active: + self._rubber_rect = QRect(self._rubber_origin, event.pos()).normalized() + self.update() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self._rubber_active and event.button() == Qt.MouseButton.LeftButton: + self._rubber_active = False + self.releaseMouse() + r = self._rubber_rect + self._rubber_rect = None + self.update() + if r is not None and r.width() >= RUBBER_MIN and r.height() >= RUBBER_MIN: + self.clear_selection() + for w in self._widgets: + if not w.isVisible(): + continue + if hasattr(w, "_set_selected") and r.intersects(w.geometry()): + w._set_selected(True) + return + super().mouseReleaseEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + need_drag = self._drag_highlight + need_rubber = self._rubber_rect is not None and self._rubber_active + if not need_drag and not need_rubber: + return + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + if need_drag: + pen = QPen(QColor("#4a9eff"), 2, Qt.PenStyle.DashLine) + pen.setDashPattern([6, 4]) + p.setPen(pen) + p.setBrush(QColor(74, 158, 255, 25)) + p.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 6, 6) + if need_rubber: + pen = QPen(QColor("#4a9eff"), 1, Qt.PenStyle.DashLine) + p.setPen(pen) + p.setBrush(QColor(74, 158, 255, 40)) + p.drawRoundedRect(self._rubber_rect, 2, 2) + p.end() diff --git a/ui/group.py b/ui/group.py new file mode 100644 index 0000000..a932017 --- /dev/null +++ b/ui/group.py @@ -0,0 +1,546 @@ +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QMessageBox, QMenu, QSizePolicy +) +from PyQt6.QtCore import Qt, pyqtSignal, QSize, QPoint, QMimeData, QByteArray +from PyQt6.QtGui import ( + QDragEnterEvent, QDragMoveEvent, QDropEvent, QCursor, QDrag, QPainter, QColor, QPen, + QShortcut, QKeySequence, +) +from db import database +from ui.item import ItemWidget +from ui.flow_layout import FlowContainer +import ui.theme as theme +import qtawesome as qta +import shortcut_target +import ui.dialog_style as dialog_style + + +def item_count_for_group(group_id: int) -> int: + """分组内程序/文件总数(文件夹分组数磁盘文件,普通分组数数据库条目)。""" + groups = database.get_groups() + group_info = next((g for g in groups if g["id"] == group_id), {}) + folder_path = (group_info.get("folder_path", "") or "").strip() + if folder_path and os.path.isdir(folder_path): + n = 0 + try: + import stat as _stat + for fname in os.listdir(folder_path): + fpath = os.path.join(folder_path, fname) + try: + if _stat.S_ISREG(os.stat(fpath).st_mode): + n += 1 + except OSError: + continue + except OSError: + pass + return n + return len(database.get_items(group_id)) + + +class DragHandle(QLabel): + """分组拖拽排序手柄""" + + def __init__(self, group_widget, parent=None): + super().__init__(parent) + self.group_widget = group_widget + self.setFixedSize(18, 18) + self.setCursor(Qt.CursorShape.SizeVerCursor) + self.setToolTip("拖拽排序") + self.setPixmap(qta.icon("fa5s.grip-vertical", color="#888").pixmap(14, 14)) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._drag_start = event.pos() + + def mouseMoveEvent(self, event): + if not (event.buttons() & Qt.MouseButton.LeftButton): + return + if (event.pos() - self._drag_start).manhattanLength() < 6: + return + drag = QDrag(self) + mime = QMimeData() + gid = self.group_widget.group_data["id"] + mime.setData("application/x-group-id", QByteArray(str(gid).encode())) + drag.setMimeData(mime) + px = self.group_widget.header.grab() + drag.setPixmap(px) + drag.setHotSpot(QPoint(px.width() // 2, px.height() // 2)) + drag.exec(Qt.DropAction.MoveAction) + + +class FlowWidget(QWidget): + item_dropped = pyqtSignal(int) + refreshed = pyqtSignal() + + def __init__(self, group_id, parent=None): + super().__init__(parent) + self.group_id = group_id + self.setAcceptDrops(True) + self._drop_indicator = -1 + + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(0) + + self._container = FlowContainer(self) + layout.addWidget(self._container) + layout.addStretch() + + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + _del_sc = QShortcut(QKeySequence.StandardKey.Delete, self) + _del_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + _del_sc.activated.connect(self._delete_selected_items) + _esc_sc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) + _esc_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + _esc_sc.activated.connect(self._container.clear_selection) + + self.refresh() + + def refresh(self, keyword=""): + self._container.clear_widgets() + + # 判断是否是文件夹分组 + groups = database.get_groups() + group_info = next((g for g in groups if g["id"] == self.group_id), {}) + folder_path = (group_info.get("folder_path", "") or "").strip() + + if folder_path and os.path.isdir(folder_path): + # 文件夹分组:直接读目录,不走数据库 + items = [] + try: + for fname in sorted(os.listdir(folder_path)): + fpath = os.path.join(folder_path, fname) + try: + import stat as _stat + if _stat.S_ISREG(os.stat(fpath).st_mode): + name = os.path.splitext(fname)[0] or fname + items.append({"id": None, "name": name, + "path": fpath, "group_id": self.group_id}) + except Exception: + continue + except Exception: + pass + else: + # 普通分组:走数据库 + items = database.get_items(self.group_id) + + for data in items: + iw = ItemWidget(data, self._container) + iw.show() + if keyword and not iw.matches(keyword): + iw.hide() + self._container.add_widget(iw) + + self._container._schedule_relayout() + self.updateGeometry() + self.refreshed.emit() + + def apply_theme(self): + for w in self._container.get_widgets(): + w._apply_theme() + + def filter(self, keyword: str): + # 不重建,只切换可见性,然后重新布局 + for w in self._container.get_widgets(): + if keyword: + w.setVisible(w.matches(keyword)) + else: + w.show() + self._container._relayout() + self.updateGeometry() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + cg = self._container.geometry() + if not cg.contains(event.pos()): + lp = self._container.mapFrom(self, event.pos()) + w, h = max(1, self._container.width()), max(1, self._container.height()) + lp.setX(max(0, min(lp.x(), w - 1))) + lp.setY(max(0, min(lp.y(), h - 1))) + self._container._begin_rubber(lp) + return + super().mousePressEvent(event) + + def _delete_selected_items(self): + widgets = [w for w in self._container.get_widgets() if getattr(w, "_selected", False)] + if not widgets: + return + ret = dialog_style.question( + self, + "批量删除", + f"确定删除选中的 {len(widgets)} 项?", + default_button=QMessageBox.StandardButton.No, + ) + if ret != QMessageBox.StandardButton.Yes: + return + for w in widgets: + w._delete_item(skip_confirm=True, skip_refresh=True) + self.refresh() + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasFormat("application/x-item-id") or \ + event.mimeData().hasUrls(): + self._container.set_drag_highlight(True) + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event: QDragMoveEvent): + if event.mimeData().hasFormat("application/x-item-id"): + pos = self._container.mapFrom(self, event.position().toPoint()) + self._drop_indicator = self._container.index_at(pos) + self.update() + event.acceptProposedAction() + + def dragLeaveEvent(self, event): + self._container.set_drag_highlight(False) + self._drop_indicator = -1 + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + if self._drop_indicator < 0: + return + from PyQt6.QtGui import QPainter, QColor, QPen + all_w = self._container.get_widgets() + if not all_w: + return + pos = self._drop_indicator + p = QPainter(self) + p.setPen(QPen(QColor("#4a9eff"), 2)) + offset = self._container.pos() + + def _draw_vline(x_local: int, g): + x = offset.x() + x_local + y1 = offset.y() + g.top() + y2 = offset.y() + g.bottom() + p.drawLine(x, y1, x, y2) + + if pos >= len(all_w): + for j in range(len(all_w) - 1, -1, -1): + if all_w[j].isVisible(): + g = all_w[j].geometry() + _draw_vline(g.right() + 2, g) + break + p.end() + return + + w = all_w[pos] + if w.isVisible(): + g = w.geometry() + _draw_vline(g.left() - 2, g) + else: + placed = False + for j in range(pos, len(all_w)): + if all_w[j].isVisible(): + g = all_w[j].geometry() + _draw_vline(g.left() - 2, g) + placed = True + break + if not placed: + for j in range(pos - 1, -1, -1): + if all_w[j].isVisible(): + g = all_w[j].geometry() + _draw_vline(g.right() + 2, g) + break + p.end() + + def dropEvent(self, event: QDropEvent): + self._container.set_drag_highlight(False) + self._drop_indicator = -1 + self.update() + mime = event.mimeData() + if mime.hasFormat("application/x-item-id"): + item_id = int(mime.data("application/x-item-id").data().decode()) + pos = self._container.mapFrom(self, event.position().toPoint()) + insert_pos = self._container.index_at(pos) + database.move_item(item_id, self.group_id, 999) + items = database.get_items(self.group_id) + ids = [it["id"] for it in items if it["id"] != item_id] + ids.insert(min(insert_pos, len(ids)), item_id) + database.reorder_items(self.group_id, ids) + self.refresh() + self.item_dropped.emit(item_id) + event.acceptProposedAction() + elif mime.hasUrls(): + 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() + event.acceptProposedAction() + else: + event.ignore() + + def contextMenuEvent(self, event): + from PyQt6.QtWidgets import QApplication + t = theme.current() + menu_style = f""" + QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']}; + border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }} + QMenu::item {{ padding:5px 16px; border-radius:3px; }} + QMenu::item:selected {{ background:{t['menu_selected']}; }} + QMenu::item:disabled {{ color:#666; }} + """ + menu = QMenu(self) + menu.setStyleSheet(menu_style) + + refresh_act = menu.addAction("🔄 刷新分组") + menu.addSeparator() + + # 检测剪贴板 + clipboard = QApplication.clipboard() + mime = clipboard.mimeData() + paste_act = None + clipboard_paths = [] + + if mime and mime.hasUrls(): + # 获取本分组信息 + groups = database.get_groups() + group_info = next((g for g in groups if g["id"] == self.group_id), {}) + is_folder_group = bool(group_info.get("folder_path", "")) + + # 快捷方式扩展名 + shortcut_exts = {".exe", ".lnk", ".bat", ".cmd", ".url"} + + for url in mime.urls(): + path = url.toLocalFile() + if not path: + continue + ext = os.path.splitext(path)[1].lower() + if is_folder_group: + # 文件夹分组:接受所有文件 + if os.path.isfile(path): + clipboard_paths.append(path) + else: + # 普通分组:只接受快捷方式/程序 + if ext in shortcut_exts: + clipboard_paths.append(path) + + if clipboard_paths: + label = f"📋 粘贴 {len(clipboard_paths)} 个文件" if len(clipboard_paths) > 1 \ + else f"📋 粘贴「{os.path.basename(clipboard_paths[0])}」" + paste_act = menu.addAction(label) + else: + act = menu.addAction("📋 粘贴(无可用文件)") + act.setEnabled(False) + else: + act = menu.addAction("📋 粘贴(剪贴板为空)") + act.setEnabled(False) + + action = menu.exec(event.globalPos()) + if action == refresh_act: + self.refresh() + elif paste_act and action == paste_act: + groups = database.get_groups() + group_info = next((g for g in groups if g["id"] == self.group_id), {}) + folder_path = group_info.get("folder_path", "") + is_folder_group = bool(folder_path) + + 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 # 复制失败就用原路径 + 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) + name = shortcut_target.item_name_from_sources(path, final_path) + + database.add_item(self.group_id, name, final_path) + self.refresh() + + +class GroupWidget(QWidget): + request_refresh_all = pyqtSignal() + + def __init__(self, group_data: dict, parent=None): + super().__init__(parent) + self.group_data = group_data + self.collapsed = False + self._build_ui() + self._apply_theme() + + def _build_ui(self): + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 4) + self.main_layout.setSpacing(0) + + self.header = QWidget() + self.header.setFixedHeight(32) + self.header.setCursor(Qt.CursorShape.PointingHandCursor) + self.header.mousePressEvent = lambda e: self._toggle() \ + if e.button() == Qt.MouseButton.LeftButton else None + + h_layout = QHBoxLayout(self.header) + h_layout.setContentsMargins(4, 0, 6, 0) + h_layout.setSpacing(4) + + # 拖拽排序手柄 + self._drag_handle = DragHandle(self) + + self.toggle_icon = QLabel() + self.toggle_icon.setFixedSize(14, 14) + self.toggle_icon.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + self.title_label = QLabel() + self.title_label.setStyleSheet("font-size:12px; font-weight:bold; background:transparent;") + self.title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + folder_path = self.group_data.get("folder_path", "") or "" + # 只显示最后两级路径,避免太长 + if folder_path: + parts = folder_path.replace("\\", "/").rstrip("/").split("/") + display_path = "/".join(parts[-2:]) if len(parts) >= 2 else folder_path + else: + display_path = "" + self.path_label = QLabel(display_path) + self.path_label.setStyleSheet("font-size:9px; color:#888; background:transparent;") + self.path_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.path_label.setVisible(bool(folder_path)) + self.path_label.setMaximumWidth(110) + self.path_label.setToolTip(folder_path) + self.path_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + + self._add_btn = QPushButton() + self._add_btn.setFixedSize(22, 22) + self._add_btn.setToolTip("添加程序") + self._add_btn.setStyleSheet("border:none; background:transparent;") + self._add_btn.clicked.connect(self._add_item_dialog) + self._add_btn.mousePressEvent = lambda e: ( + self._add_btn.click() if e.button() == Qt.MouseButton.LeftButton else None + ) + + self._more_btn = QPushButton() + self._more_btn.setFixedSize(22, 22) + self._more_btn.setStyleSheet("border:none; background:transparent;") + self._more_btn.clicked.connect(self._group_menu) + + h_layout.addWidget(self._drag_handle) + h_layout.addWidget(self.toggle_icon) + h_layout.addWidget(self.title_label) + h_layout.addStretch() + h_layout.addWidget(self.path_label) + h_layout.addWidget(self._add_btn) + h_layout.addWidget(self._more_btn) + + self.flow = FlowWidget(self.group_data["id"]) + self.flow.item_dropped.connect(lambda _: self.request_refresh_all.emit()) + self.flow.refreshed.connect(self._update_title_count) + self._update_title_count() + + self.main_layout.addWidget(self.header) + self.main_layout.addWidget(self.flow) + + def _update_title_count(self): + n = item_count_for_group(self.group_data["id"]) + self.title_label.setText(f"{self.group_data['name']} ({n})") + + def _apply_theme(self): + t = theme.current() + ic = "#cccccc" if theme.name() == "dark" else "#555555" + + self.header.setStyleSheet(f""" + QWidget {{ background:{t['header_bg']}; border-radius:5px; }} + QWidget:hover {{ background:{t['header_hover']}; }} + """) + self.title_label.setStyleSheet( + f"color:{t['item_name_color']}; font-size:12px; font-weight:bold; background:transparent;" + ) + # 展开/收缩图标 + arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right" + self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14)) + + self._add_btn.setIcon(qta.icon("fa5s.plus", color=ic)) + self._add_btn.setIconSize(QSize(11, 11)) + self._more_btn.setIcon(qta.icon("fa5s.ellipsis-h", color=ic)) + self._more_btn.setIconSize(QSize(11, 11)) + self._drag_handle.setPixmap(qta.icon("fa5s.grip-vertical", color=ic).pixmap(14, 14)) + path_color = "#777" if theme.name() == "dark" else "#999" + self.path_label.setStyleSheet( + f"font-size:9px; color:{path_color}; background:transparent;" + ) + + self.flow.apply_theme() + + def _toggle(self): + self.collapsed = not self.collapsed + self.flow.setVisible(not self.collapsed) + if not self.collapsed: + self.flow.updateGeometry() + self.updateGeometry() + # 更新箭头图标 + t = theme.current() + ic = "#cccccc" if theme.name() == "dark" else "#555555" + arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right" + self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14)) + + def _add_item_dialog(self): + path, _ = dialog_style.get_open_file_name( + self, + "选择程序或快捷方式", + "", + "程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)", + ) + if path: + store = shortcut_target.path_for_storage(path) + name = shortcut_target.item_name_from_sources(path, store) + database.add_item(self.group_data["id"], name, store) + self.flow.refresh() + + def _group_menu(self): + t = theme.current() + menu = QMenu(self) + menu.setStyleSheet(f""" + QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']}; + border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }} + QMenu::item {{ padding:5px 16px; border-radius:3px; }} + QMenu::item:selected {{ background:{t['menu_selected']}; }} + """) + rename_act = menu.addAction("重命名") + del_act = menu.addAction("删除分组") + action = menu.exec(QCursor.pos()) + if action == rename_act: + name, ok = dialog_style.get_text( + self, "重命名", "新名称:", text=self.group_data["name"] + ) + if ok and name.strip(): + database.rename_group(self.group_data["id"], name.strip()) + self.group_data["name"] = name.strip() + self._update_title_count() + elif action == del_act: + ret = dialog_style.question( + self, + "确认", + f"删除分组「{self.group_data['name']}」及其所有程序?", + default_button=QMessageBox.StandardButton.No, + ) + if ret == QMessageBox.StandardButton.Yes: + database.delete_group(self.group_data["id"]) + self.request_refresh_all.emit() + + def refresh(self, keyword=""): + self.flow.refresh(keyword) + + def filter(self, keyword: str): + self.flow.filter(keyword) + if keyword and self.collapsed: + self._toggle() diff --git a/ui/item.py b/ui/item.py new file mode 100644 index 0000000..2889565 --- /dev/null +++ b/ui/item.py @@ -0,0 +1,389 @@ +import os +import sys +import qtawesome as qta +from PyQt6.QtWidgets import (QWidget, QLabel, QVBoxLayout, QSizePolicy, + QMenu, QFileIconProvider, QMessageBox) +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 db import database +import ui.theme as theme +import ui.dialog_style as dialog_style +import shortcut_target + +_icon_provider = QFileIconProvider() + + +def _wrapped_line_count(text: str, font: QFont, width_px: int) -> int: + if width_px < 4 or not text: + return 0 if not text else 999 + tl = QTextLayout(text, font) + opt = QTextOption() + opt.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + tl.setTextOption(opt) + tl.beginLayout() + n = 0 + while True: + line = tl.createLine() + if not line.isValid(): + break + line.setLineWidth(width_px) + n += 1 + tl.endLayout() + return n + + +def two_line_display_text(text: str, font: QFont, width_px: int) -> str: + """最多按宽度折成 2 行;超出部分用省略号。""" + if not text: + return text + if _wrapped_line_count(text, font, width_px) <= 2: + return text + elide = "…" + lo, hi = 0, len(text) + best = elide + while lo <= hi: + mid = (lo + hi) // 2 + cand = text[:mid].rstrip() + elide + if _wrapped_line_count(cand, font, width_px) <= 2: + best = cand + lo = mid + 1 + else: + hi = mid - 1 + return best + + +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) + + +class ItemWidget(QWidget): + DRAG_THRESHOLD = 8 + + def __init__(self, item_data: dict, parent=None): + super().__init__(parent) + self.item_data = item_data + self.setFixedSize(68, 76) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self._drag_start_pos = QPoint() + self._selected = False + self._build_ui() + + def showEvent(self, event): + super().showEvent(event) + self._update_name_display() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_name_display() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 6, 4, 4) + 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.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + self.name_label = QLabel() + self.name_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignTop) + self.name_label.setWordWrap(True) + self.name_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self._apply_theme() + + layout.addWidget(self.icon_label) + layout.addWidget(self.name_label) + + def _update_name_display(self): + name = self.item_data.get("name") or "" + w = max(8, self.width() - 8) + font = self.name_label.font() + disp = two_line_display_text(name, font, w) + self.name_label.setText(disp) + path = self.item_data.get("path") or "" + self.setToolTip(f"{name}\n{path}" if path else name) + + def _apply_theme(self): + t = theme.current() + self.name_label.setStyleSheet(f"font-size:10px; color:{t['item_name_color']};") + fm = QFontMetrics(self.name_label.font()) + self.name_label.setMaximumHeight(int(fm.lineSpacing() * 2 + 4)) + self._update_name_display() + + 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 paintEvent(self, event): + if self._selected: + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setBrush(QColor(74, 158, 255, 50)) + p.setPen(QPen(QColor("#4a9eff"), 1.5)) + p.drawRoundedRect(1, 1, self.width() - 2, self.height() - 2, 6, 6) + p.end() + super().paintEvent(event) + + # ── 鼠标事件 ───────────────────────────────────────── + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._launch() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._drag_start_pos = event.pos() + fc = self.parent() + if fc and hasattr(fc, "_clear_selection_except"): + fc._clear_selection_except(self) + self._set_selected(True) + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + if not (event.buttons() & Qt.MouseButton.LeftButton): + return + if (event.pos() - self._drag_start_pos).manhattanLength() < self.DRAG_THRESHOLD: + return + self._set_selected(False) + drag = QDrag(self) + mime = QMimeData() + item_id = self.item_data.get("id") + id_bytes = str(item_id).encode() if item_id is not None else b"none" + mime.setData("application/x-item-id", QByteArray(id_bytes)) + # 也携带文件路径,方便跨组拖拽文件夹分组的 item + from PyQt6.QtCore import QUrl + mime.setUrls([QUrl.fromLocalFile(self.item_data["path"])]) + drag.setMimeData(mime) + + src = self.grab() + scale = 1.4 + pw, ph = int(src.width() * scale), int(src.height() * scale) + preview = QPixmap(pw, ph) + preview.fill(QColor(0, 0, 0, 0)) + painter = QPainter(preview) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.setBrush(QColor(60, 60, 60, 160)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(0, 0, pw, ph, 8, 8) + painter.setOpacity(0.92) + painter.drawPixmap(0, 0, pw, ph, src) + painter.end() + drag.setPixmap(preview) + drag.setHotSpot(QPoint(int(event.pos().x() * scale), int(event.pos().y() * scale))) + drag.exec(Qt.DropAction.MoveAction) + + def contextMenuEvent(self, event): + fc = self.parent() + if fc and hasattr(fc, "_clear_selection_except"): + if not self._selected: + fc._clear_selection_except(self) + self._set_selected(True) + else: + self._set_selected(True) + + t = theme.current() + menu_style = f""" + QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']}; + border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }} + QMenu::item {{ padding:6px 18px; border-radius:3px; }} + QMenu::item:selected {{ background:{t['menu_selected']}; }} + """ + menu = QMenu(self) + menu.setStyleSheet(menu_style) + + open_act = menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开") + menu.addSeparator() + + item_id = self.item_data.get("id") + is_folder_item = item_id is None + + rename_act = menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名") + if not is_folder_item: + repath_act = menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "更换路径") + else: + repath_act = None + menu.addSeparator() + del_act = menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除") + + # 菜单弹出时单键触发:O 打开 / M 重命名 / D 删除 + _ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut + open_act.setShortcut(QKeySequence(Qt.Key.Key_O)) + open_act.setShortcutContext(_ctx) + rename_act.setShortcut(QKeySequence(Qt.Key.Key_M)) + rename_act.setShortcutContext(_ctx) + del_act.setShortcut(QKeySequence(Qt.Key.Key_D)) + del_act.setShortcutContext(_ctx) + + action = menu.exec(event.globalPos()) + + if action == open_act: + self._launch() + elif action == rename_act: + self._rename() + elif repath_act and action == repath_act: + self._change_path() + elif action == del_act: + self._delete_item() + + # ── 操作 ───────────────────────────────────────────── + def _rename(self): + is_folder_item = self.item_data.get("id") is None + old_path = self.item_data["path"] + ext = os.path.splitext(old_path)[1] + + # 文件夹分组显示带后缀的完整文件名,普通分组只显示名称 + if is_folder_item: + current_display = self.item_data["name"] + ext + else: + current_display = self.item_data["name"] + + name, ok = dialog_style.get_text(self, "重命名", "新名称:", text=current_display) + if not ok or not name.strip() or name.strip() == current_display: + return + new_input = name.strip() + + if is_folder_item: + # 用户输入的就是完整文件名(含后缀) + new_filename = new_input + new_name = os.path.splitext(new_filename)[0] or new_filename + new_path = os.path.join(os.path.dirname(old_path), new_filename) + try: + os.rename(old_path, new_path) + self.item_data["path"] = new_path + self.item_data["name"] = new_name + self._update_name_display() + except Exception as e: + dialog_style.warning(self, "重命名失败", str(e)) + else: + conn = database.get_conn() + conn.execute("UPDATE items SET name=? WHERE id=?", + (new_input, self.item_data["id"])) + conn.commit() + conn.close() + self.item_data["name"] = new_input + self._update_name_display() + + def _change_path(self): + path, _ = dialog_style.get_open_file_name( + self, + "选择新路径", + os.path.dirname(self.item_data["path"]), + "程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)", + ) + if path: + store = shortcut_target.path_for_storage(path) + conn = database.get_conn() + conn.execute("UPDATE items SET path=? WHERE id=?", + (store, self.item_data["id"])) + conn.commit() + conn.close() + self.item_data["path"] = store + self.icon_label.setPixmap(extract_icon(store).pixmap(QSize(36, 36))) + self._update_name_display() + + def _delete_item(self, skip_confirm: bool = False, skip_refresh: bool = False): + item_path = self.item_data["path"] + item_id = self.item_data.get("id") + is_folder_item = item_id is None + + if is_folder_item and os.path.isfile(item_path): + if not skip_confirm: + ret = dialog_style.question( + self, + "删除确认", + f"确认删除文件?\n\n{item_path}", + buttons=QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.Cancel, + default_button=QMessageBox.StandardButton.Cancel, + ) + if ret != QMessageBox.StandardButton.Yes: + return + try: + try: + import send2trash + send2trash.send2trash(item_path) + except ImportError: + os.remove(item_path) + except Exception as e: + dialog_style.warning(self, "删除失败", str(e)) + return + else: + if not skip_confirm: + ret = dialog_style.question( + self, + "确认", + f"从分组中移除「{self.item_data['name']}」?", + default_button=QMessageBox.StandardButton.No, + ) + if ret != QMessageBox.StandardButton.Yes: + return + if item_id is not None: + database.delete_item(item_id) + + if skip_refresh: + return + p = self.parent() + while p and not hasattr(p, "refresh"): + p = p.parent() + if p: + p.refresh() + + def _launch(self): + path = (self.item_data.get("path") or "").strip() + if not path: + return + lp = path.lower() + if lp.startswith(("http://", "https://")): + import webbrowser + webbrowser.open(path) + return + + if sys.platform == "win32": + path = os.path.normpath(path) + + try: + if sys.platform == "win32": + os.startfile(path) + else: + import subprocess + subprocess.Popen(["xdg-open", path]) + except OSError: + try: + import subprocess + if sys.platform == "win32": + # shell=True 无法直接“运行” .lnk,需交给 start 做关联打开 + subprocess.Popen( + ["cmd", "/c", "start", "", path], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + else: + subprocess.Popen(["xdg-open", path]) + except Exception as e: + print(f"启动失败: {path!r} -> {e}") diff --git a/ui/settings_window.py b/ui/settings_window.py new file mode 100644 index 0000000..3281165 --- /dev/null +++ b/ui/settings_window.py @@ -0,0 +1,466 @@ +import sys +import os +from PyQt6.QtWidgets import ( + QDialog, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem, + QStackedWidget, QWidget, QLabel, QSlider, QPushButton, + QCheckBox, QGroupBox, QFormLayout, QSpinBox, QFrame +) +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QIcon, QPixmap +import qtawesome as qta +from db import database +import ui.theme as theme + + +class SettingsWindow(QDialog): + def __init__(self, panel, parent=None): + super().__init__(parent) + self._panel = panel + self.setWindowTitle("设置") + # 适当放大:让页面内容更舒展,并限制最小拖拽尺寸 + self.setMinimumSize(500, 500) + self.resize(760, 520) + self.setWindowFlags( + Qt.WindowType.Dialog | + Qt.WindowType.WindowCloseButtonHint + ) + self._build_ui() + self._apply_theme() + + def _build_ui(self): + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── 左侧导航 ───────────────────────────────────── + self.nav = QListWidget() + self.nav.setFixedWidth(130) + self.nav.setSpacing(2) + self.nav.setIconSize(QSize(18, 18)) + self.nav.setStyleSheet(""" + QListWidget { border:none; outline:none; padding:8px 4px; } + QListWidget::item { height:36px; border-radius:6px; + padding-left:10px; font-size:13px; } + QListWidget::item:selected { background:#4a9eff; color:white; } + QListWidget::item:hover:!selected { background:rgba(128,128,128,40); } + """) + + pages = [ + ("fa5s.paint-brush", "外观"), + ("fa5s.window-maximize", "窗口"), + ("fa5s.rocket", "启动"), + ("fa5s.heart", "捐赠"), + ] + for icon_name, label in pages: + try: + icon = qta.icon(icon_name, color="#888") + item = QListWidgetItem(icon, label) + except Exception: + # 防止图标名不存在导致设置页直接报错 + icon = qta.icon("fa5s.rocket", color="#888") + item = QListWidgetItem(icon, label) + self.nav.addItem(item) + self.nav.setCurrentRow(0) + self.nav.currentRowChanged.connect(self._on_nav) + + # ── 右侧内容区 ─────────────────────────────────── + self.stack = QStackedWidget() + self.stack.addWidget(self._page_appearance()) + self.stack.addWidget(self._page_window()) + self.stack.addWidget(self._page_startup()) + self.stack.addWidget(self._page_donate()) + + # 分割线 + line = QFrame() + line.setFrameShape(QFrame.Shape.VLine) + line.setStyleSheet("color: rgba(128,128,128,40);") + + root.addWidget(self.nav) + root.addWidget(line) + root.addWidget(self.stack) + + # ── 外观页 ─────────────────────────────────────────── + def _page_appearance(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(20) + + layout.addWidget(self._section_title("主题")) + + theme_row = QHBoxLayout() + theme_row.setSpacing(12) + + self._dark_btn = self._theme_card("🌙 暗色", "dark") + self._light_btn = self._theme_card("☀️ 亮色", "light") + self._dark_btn.clicked.connect(lambda: self._set_theme("dark")) + self._light_btn.clicked.connect(lambda: self._set_theme("light")) + theme_row.addWidget(self._dark_btn) + theme_row.addWidget(self._light_btn) + theme_row.addStretch() + layout.addLayout(theme_row) + self._update_theme_cards() + + layout.addWidget(self._divider()) + layout.addWidget(self._section_title("透明度")) + + opacity_row = QHBoxLayout() + opacity_row.setSpacing(12) + self._opacity_slider = QSlider(Qt.Orientation.Horizontal) + self._opacity_slider.setRange(30, 100) + saved = int(database.get_setting("panel_opacity", "100")) + self._opacity_slider.setValue(saved) + self._opacity_slider.setFixedWidth(220) + self._opacity_val = QLabel(f"{saved}%") + self._opacity_val.setFixedWidth(36) + self._opacity_slider.valueChanged.connect(self._on_opacity) + opacity_row.addWidget(self._opacity_slider) + opacity_row.addWidget(self._opacity_val) + opacity_row.addStretch() + layout.addLayout(opacity_row) + + layout.addStretch() + return page + + def _theme_card(self, text: str, theme_name: str) -> QPushButton: + btn = QPushButton(text) + btn.setFixedSize(110, 44) + btn.setCheckable(True) + btn.setProperty("theme_name", theme_name) + return btn + + def _update_theme_cards(self): + cur = theme.name() + for btn in (self._dark_btn, self._light_btn): + active = btn.property("theme_name") == cur + btn.setChecked(active) + if active: + btn.setStyleSheet(""" + QPushButton { background:#4a9eff; color:white; + border-radius:8px; font-size:13px; + border:2px solid #4a9eff; } + """) + else: + t = theme.current() + btn.setStyleSheet(f""" + QPushButton {{ background:{t['search_bg']}; color:{t['search_color']}; + border-radius:8px; font-size:13px; + border:1px solid {t['search_border']}; }} + QPushButton:hover {{ border-color:#4a9eff; }} + """) + + def _set_theme(self, name: str): + theme.set_theme(name) + self._panel._apply_theme() + self._update_theme_cards() + self._apply_theme() + + 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 _page_window(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(16) + + layout.addWidget(self._section_title("窗口行为")) + + self._pin_check = QCheckBox("固定面板(不自动隐藏)") + self._pin_check.setChecked(self._panel._pinned) + self._pin_check.toggled.connect(self._on_pin) + layout.addWidget(self._pin_check) + + layout.addWidget(self._divider()) + layout.addWidget(self._section_title("图标大小")) + + size_row = QHBoxLayout() + size_row.setSpacing(12) + size_lbl = QLabel("图标宽度:") + self._icon_size_spin = QSpinBox() + self._icon_size_spin.setRange(48, 120) + self._icon_size_spin.setSingleStep(4) + saved_size = int(database.get_setting("icon_width", "72")) + self._icon_size_spin.setValue(saved_size) + self._icon_size_spin.setSuffix(" px") + self._icon_size_spin.setFixedWidth(90) + apply_btn = QPushButton("应用") + apply_btn.setFixedWidth(60) + apply_btn.clicked.connect(self._on_icon_size) + size_row.addWidget(size_lbl) + size_row.addWidget(self._icon_size_spin) + size_row.addWidget(apply_btn) + size_row.addStretch() + layout.addLayout(size_row) + + layout.addStretch() + return page + + def _on_pin(self, checked: bool): + self._panel._pinned = checked + ic = "#cccccc" if theme.name() == "dark" else "#555555" + import qtawesome as qta + self._panel.pin_btn.setIcon( + qta.icon("fa5s.thumbtack", color="#f90" if checked else ic) + ) + + def _on_icon_size(self): + val = self._icon_size_spin.value() + database.set_setting("icon_width", str(val)) + # 通知所有 ItemWidget 更新尺寸 + from ui.flow_layout import ITEM_W + import ui.flow_layout as fl + fl.ITEM_W = val + self._panel.refresh_groups() + + # ── 启动页 ─────────────────────────────────────────── + def _page_startup(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(16) + + layout.addWidget(self._section_title("开机启动")) + + self._autostart_check = QCheckBox("开机自动启动") + self._autostart_check.setChecked(self._is_autostart_enabled()) + self._autostart_check.toggled.connect(self._on_autostart) + layout.addWidget(self._autostart_check) + + layout.addWidget(self._divider()) + layout.addWidget(self._section_title("关于")) + + about = QLabel("桌面文件整理 v1.0\n整理你的桌面快捷方式,保持桌面干净。") + about.setStyleSheet("color:#888; font-size:12px; line-height:1.6;") + layout.addWidget(about) + + layout.addStretch() + return page + + # ── 捐赠页 ─────────────────────────────────────────── + def _page_donate(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(16) + + layout.addWidget(self._section_title("捐赠支持")) + + info = QLabel("如果你喜欢这个软件,欢迎通过二维码支持开发者持续优化与维护。你的鼓励会成为重要的动力。
感谢你的支持!") + info.setWordWrap(True) + info.setStyleSheet("font-size:14px; line-height:1.1; background:transparent;") + layout.addWidget(info) + + layout.addWidget(self._divider()) + + card = QFrame() + card.setObjectName("donate_card") + + grid = QHBoxLayout(card) + grid.setContentsMargins(14, 14, 14, 14) + grid.setSpacing(18) + + donate_dir = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate" + ) + wx_path = os.path.join(donate_dir, "wx.jpg") + zfb_path = os.path.join(donate_dir, "zfb.jpg") + + def _qr_column(img_path: str, caption: str) -> QVBoxLayout: + col = QVBoxLayout() + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(8) + + img = QLabel() + img.setFixedSize(170, 170) + img.setAlignment(Qt.AlignmentFlag.AlignCenter) + img.setStyleSheet("background:transparent; border-radius:12px;") + + pm = QPixmap(img_path) + if not pm.isNull(): + img.setPixmap( + pm.scaled( + img.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + img.setCursor(Qt.CursorShape.PointingHandCursor) + img.setToolTip("点击放大") + # 直接覆盖点击事件:点击二维码弹出大图 + img.mousePressEvent = ( # type: ignore[assignment] + lambda e, p=img_path: self._open_donate_image(p) + ) + + cap = QLabel(caption) + cap.setAlignment(Qt.AlignmentFlag.AlignCenter) + cap.setStyleSheet("font-size:12px; font-weight:700; background:transparent;") + + col.addWidget(img) + col.addWidget(cap) + return col + + grid.addLayout(_qr_column(wx_path, "微信支持")) + grid.addLayout(_qr_column(zfb_path, "支付宝支持")) + layout.addWidget(card) + + # 供 _apply_theme 动态设置样式 + self._donate_card = card + return page + + def _open_donate_image(self, img_path: str): + """点击二维码后的弹窗大图查看。""" + t = theme.current() + + d = QDialog(self) + d.setWindowTitle("二维码放大查看") + d.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint) + d.setFixedSize(800, 600) + d.setStyleSheet(f""" + QDialog {{ + background: {t['panel_bg']}; + border: 1px solid {t['panel_border']}; + border-radius: 10px; + color: {t['search_color']}; + }} + """) + + layout = QVBoxLayout(d) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(0) + + lbl = QLabel() + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setStyleSheet("background:transparent;") + lbl.setScaledContents(False) # 不拉伸,保持等比 + pm = QPixmap(img_path) + if not pm.isNull(): + # 适配框体:保持等比缩放(不会被拉伸) + target_w = d.width() - 28 + target_h = d.height() - 28 + lbl.setPixmap( + pm.scaled( + target_w, + target_h, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + layout.addWidget(lbl) + + d.exec() + + def _is_autostart_enabled(self) -> bool: + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + 0, winreg.KEY_READ + ) + winreg.QueryValueEx(key, "DesktopOrganizer") + winreg.CloseKey(key) + return True + except Exception: + return False + + def _on_autostart(self, checked: bool): + from ui.dock import _set_autostart + _set_autostart(checked) + + # ── 工具方法 ───────────────────────────────────────── + def _section_title(self, text: str) -> QLabel: + lbl = QLabel(text) + lbl.setStyleSheet("font-size:14px; font-weight:bold;") + return lbl + + def _divider(self) -> QFrame: + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setStyleSheet("color: rgba(128,128,128,40);") + return line + + def _on_nav(self, index: int): + self.stack.setCurrentIndex(index) + + def _apply_theme(self): + t = theme.current() + is_dark = theme.name() == "dark" + # 把 rgba(r,g,b,a) 转成 rgb(r,g,b) + panel_bg = t["panel_bg"] + if panel_bg.startswith("rgba"): + tmp = panel_bg.replace("rgba", "rgb") + # "rgb(r,g,b,a)" -> 取前三项 "rgb(r,g,b" + head = tmp.rsplit(",", 1)[0] + panel_rgb = head + ")" + else: + panel_rgb = panel_bg + self.setStyleSheet(f""" + QDialog, QWidget {{ + background: {panel_rgb}; + color: {t['search_color']}; + }} + QListWidget {{ + background: {'rgba(0,0,0,30)' if is_dark else 'rgba(0,0,0,6)'}; + }} + 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; + }} + QCheckBox {{ font-size:13px; spacing:8px; }} + QCheckBox::indicator {{ + width:18px; height:18px; border-radius:4px; + border:1px solid {t['search_border']}; + background:{t['search_bg']}; + }} + QCheckBox::indicator:checked {{ + background:#4a9eff; border-color:#4a9eff; + image: url(none); + }} + QSpinBox {{ + background:{t['search_bg']}; color:{t['search_color']}; + border:1px solid {t['search_border']}; border-radius:5px; + padding:4px 8px; font-size:12px; + }} + QPushButton {{ + background:{t['search_bg']}; color:{t['search_color']}; + border:1px solid {t['search_border']}; border-radius:6px; + padding:6px 14px; font-size:12px; + }} + QPushButton:hover {{ border-color:#4a9eff; color:#4a9eff; }} + """) + # 导航背景 + nav_bg = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,6)" + self.nav.setStyleSheet(f""" + QListWidget {{ border:none; outline:none; padding:8px 4px; + background:{nav_bg}; }} + QListWidget::item {{ height:36px; border-radius:6px; + padding-left:10px; font-size:13px; + color:{t['search_color']}; }} + QListWidget::item:selected {{ background:#4a9eff; color:white; }} + QListWidget::item:hover:!selected {{ + background:{'rgba(255,255,255,10)' if is_dark else 'rgba(0,0,0,6)'}; }} + """) + self._update_theme_cards() + + # 捐赠卡片样式 + if hasattr(self, "_donate_card"): + card_bg = "rgba(0,0,0,25)" if is_dark else "rgba(255,255,255,10)" + self._donate_card.setStyleSheet(f""" + QFrame#donate_card {{ + background:{card_bg}; + border: 1px solid {t['panel_border']}; + border-radius:14px; + }} + """) diff --git a/ui/theme.py b/ui/theme.py new file mode 100644 index 0000000..2a1529d --- /dev/null +++ b/ui/theme.py @@ -0,0 +1,64 @@ +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", + "search_focus": "#4a9eff", + "btn_color": "#999", + "btn_hover": "#fff", + "scrollbar": "#555", + "menu_bg": "#2b2b2b", + "menu_color": "#eee", + "menu_border": "#555", + "menu_selected": "#3a3a3a", + }, + "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", + "search_focus": "#4a9eff", + "btn_color": "#555", + "btn_hover": "#000", + "scrollbar": "#bbb", + "menu_bg": "#f5f5f5", + "menu_color": "#222", + "menu_border": "#ccc", + "menu_selected": "#e0e0e0", + }, +} + +_current = "dark" + + +def load(): + """从数据库读取上次保存的主题,应在 init_db() 之后调用""" + global _current + saved = get_setting("theme", "dark") + _current = saved if saved in THEMES else "dark" + + +def current() -> dict: + return THEMES[_current] + + +def name() -> str: + return _current + + +def set_theme(theme_name: str): + global _current + if theme_name in THEMES: + _current = theme_name + set_setting("theme", theme_name)