first commit

This commit is contained in:
李志强 2026-04-03 21:50:36 +08:00
commit 10843a5bc5
43 changed files with 3958 additions and 0 deletions

View File

@ -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'],
)

Binary file not shown.

Binary file not shown.

BIN
assets/imgs/donate/wx.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
assets/imgs/donate/zfb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
assets/imgs/weather/晴.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
assets/imgs/weather/阴.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
assets/imgs/weather/霾.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data.db Normal file

Binary file not shown.

1
db/__init__.py Normal file
View File

@ -0,0 +1 @@
# db package

Binary file not shown.

Binary file not shown.

Binary file not shown.

230
db/database.py Normal file
View File

@ -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()

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

106
main.py Normal file
View File

@ -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()

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
PyQt6>=6.4.0

123
shortcut_target.py Normal file
View File

@ -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)

1
ui/__init__.py Normal file
View File

@ -0,0 +1 @@
# ui package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

206
ui/ball.py Normal file
View File

@ -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()

222
ui/dialog_style.py Normal file
View File

@ -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 ""

1365
ui/dock.py Normal file

File diff suppressed because it is too large Load Diff

191
ui/flow_layout.py Normal file
View File

@ -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()

546
ui/group.py Normal file
View File

@ -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()

389
ui/item.py Normal file
View File

@ -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}")

466
ui/settings_window.py Normal file
View File

@ -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("如果你喜欢这个软件,欢迎通过二维码支持开发者持续优化与维护。你的鼓励会成为重要的动力。<br>感谢你的支持!")
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;
}}
""")

64
ui/theme.py Normal file
View File

@ -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)