优化程序
This commit is contained in:
1 parent
68e8eb91a7
commit
6df29c9254
27 files changed
+1974
-203
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -2,7 +2,8 @@
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = [('assets', 'assets'), ('logo.png', '.'), ('logo.ico', '.')]
|
||||
binaries = []
|
||||
# update_helper.exe 打包进 onefile,运行时解压到 _MEIPASS
|
||||
binaries = [('update_helper.exe', '.')] if __import__('os').path.exists('update_helper.exe') else []
|
||||
hiddenimports = ['qtawesome']
|
||||
tmp_ret = collect_all('qtawesome')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 编译命令
|
||||
python -m PyInstaller CleanDesktopOrganizer.spec
|
||||
# 打包成 onefile
|
||||
|
||||
# 清除缓存重新打包命令
|
||||
```powershell
|
||||
# 第一步:先打 update_helper(主程序依赖它,CleanDesktopOrganizer.spec 会自动带上)
|
||||
python -m PyInstaller update_helper.spec --clean
|
||||
|
||||
# 第二步:打 onefile 主程序
|
||||
python -m PyInstaller CleanDesktopOrganizer.spec --clean
|
||||
```
|
||||
|
||||
产物:`dist\CleanDesktopOrganizer.exe`(单文件,约 100MB,含 update_helper)
|
||||
|
||||
更新日志:`%TEMP%\update.log`(更新失败时查这个)
|
||||
Binary file not shown.
+1
-1
@@ -1,5 +1,5 @@
|
||||
APP_NAME = "牛马软件柜"
|
||||
__VERSION__ = "0.0.4"
|
||||
__VERSION__ = "0.0.5"
|
||||
|
||||
|
||||
def app_title() -> str:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+180
-1
@@ -5,7 +5,7 @@ import shutil
|
||||
# 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录)
|
||||
APP_NAME = "CleanDesktopOrganizer"
|
||||
_SCHEMA_VERSION_KEY = "schema_version"
|
||||
LATEST_SCHEMA_VERSION = 1
|
||||
LATEST_SCHEMA_VERSION = 7
|
||||
|
||||
_PROJECT_DB_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "data.db"
|
||||
@@ -119,8 +119,68 @@ def _apply_migrations(conn: sqlite3.Connection):
|
||||
if not _table_has_column(conn, "groups", "folder_path"):
|
||||
conn.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''")
|
||||
|
||||
def m2():
|
||||
# 桌面文件框
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS file_boxes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '文件框',
|
||||
x INTEGER DEFAULT 100,
|
||||
y INTEGER DEFAULT 100,
|
||||
w INTEGER DEFAULT 240,
|
||||
h INTEGER DEFAULT 320,
|
||||
collapsed INTEGER DEFAULT 0,
|
||||
position INTEGER DEFAULT 0,
|
||||
opacity REAL DEFAULT 0.5
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS file_box_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
box_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (box_id) REFERENCES file_boxes(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def m3():
|
||||
# 给已存在的 file_boxes 表补 opacity 字段
|
||||
if not _table_has_column(conn, "file_boxes", "opacity"):
|
||||
conn.execute("ALTER TABLE file_boxes ADD COLUMN opacity REAL DEFAULT 0.5")
|
||||
|
||||
def m4():
|
||||
# folder_path:不为空时 FileBox 变成文件夹浏览模式,实时显示该目录内容
|
||||
if not _table_has_column(conn, "file_boxes", "folder_path"):
|
||||
conn.execute("ALTER TABLE file_boxes ADD COLUMN folder_path TEXT DEFAULT ''")
|
||||
|
||||
def m5():
|
||||
# view_mode:拖入模式下的视图方式 —— "icon"(默认)或 "list"
|
||||
if not _table_has_column(conn, "file_boxes", "view_mode"):
|
||||
conn.execute("ALTER TABLE file_boxes ADD COLUMN view_mode TEXT DEFAULT 'icon'")
|
||||
|
||||
def m6():
|
||||
# 把所有已存在的 file_boxes 的 opacity 刷成 0.5(之前是 1.0)
|
||||
conn.execute("UPDATE file_boxes SET opacity=0.5 WHERE opacity IS NULL OR opacity >= 0.99")
|
||||
|
||||
def m7():
|
||||
# group_id:关联主程序分组,非 NULL 时 FileBox 进入"分组联动模式"
|
||||
if not _table_has_column(conn, "file_boxes", "group_id"):
|
||||
conn.execute("ALTER TABLE file_boxes ADD COLUMN group_id INTEGER DEFAULT NULL")
|
||||
|
||||
migrations = {
|
||||
1: m1,
|
||||
2: m2,
|
||||
3: m3,
|
||||
4: m4,
|
||||
5: m5,
|
||||
6: m6,
|
||||
7: m7,
|
||||
}
|
||||
|
||||
cur = _get_schema_version(conn)
|
||||
@@ -320,3 +380,122 @@ def reset_groups_and_items(recreate_default_group: bool = True):
|
||||
conn.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── FileBoxes ──────────────────────────────────────────
|
||||
def get_file_boxes():
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM file_boxes ORDER BY position").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_file_box(title: str, x: int = 100, y: int = 100, w: int = 240, h: int = 320) -> int:
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT COALESCE(MAX(position), -1) FROM file_boxes").fetchone()
|
||||
pos = (row[0] if row else -1) + 1
|
||||
cur = conn.execute(
|
||||
"INSERT INTO file_boxes (title, x, y, w, h, position) VALUES (?,?,?,?,?,?)",
|
||||
(title, x, y, w, h, pos),
|
||||
)
|
||||
conn.commit()
|
||||
box_id = cur.lastrowid
|
||||
conn.close()
|
||||
return box_id
|
||||
|
||||
|
||||
def update_file_box(box_id: int, **kwargs):
|
||||
"""更新 file_boxes 记录,支持 title/x/y/w/h/collapsed/group_id/folder_path/view_mode/opacity。"""
|
||||
if not kwargs:
|
||||
return
|
||||
allowed = ("title", "x", "y", "w", "h", "collapsed", "group_id", "folder_path", "view_mode", "opacity")
|
||||
fields = [k for k in kwargs.keys() if k in allowed]
|
||||
if not fields:
|
||||
return
|
||||
conn = get_conn()
|
||||
sets = ", ".join(f"{f}=?" for f in fields)
|
||||
conn.execute(
|
||||
f"UPDATE file_boxes SET {sets} WHERE id=?",
|
||||
[kwargs[f] for f in fields] + [box_id],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_file_box_from_group(group_id: int) -> int | None:
|
||||
"""把已有分组变成桌面文件框(group_id 关联)。返回新 file_boxes.id,失败返回 None。"""
|
||||
conn = get_conn()
|
||||
# 检查分组存在
|
||||
group = conn.execute("SELECT id, name FROM groups WHERE id=?", (group_id,)).fetchone()
|
||||
if not group:
|
||||
conn.close(); return None
|
||||
# 检查是否已有关联的 FileBox(避免重复)
|
||||
existing = conn.execute("SELECT id FROM file_boxes WHERE group_id=?", (group_id,)).fetchone()
|
||||
if existing:
|
||||
conn.close(); return existing["id"]
|
||||
pos_row = conn.execute("SELECT COALESCE(MAX(position), -1) FROM file_boxes").fetchone()
|
||||
pos = (pos_row[0] if pos_row else -1) + 1
|
||||
cur = conn.execute(
|
||||
"INSERT INTO file_boxes (title, x, y, w, h, position, opacity, group_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(group["name"], 150, 150, 260, 340, pos, 0.5, group_id),
|
||||
)
|
||||
conn.commit()
|
||||
box_id = cur.lastrowid
|
||||
conn.close()
|
||||
return box_id
|
||||
|
||||
|
||||
def delete_file_box(box_id: int):
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM file_box_items WHERE box_id=?", (box_id,))
|
||||
conn.execute("DELETE FROM file_boxes WHERE id=?", (box_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_file_box_items(box_id: int):
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM file_box_items WHERE box_id=? ORDER BY position", (box_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_file_box_item(box_id: int, name: str, path: str) -> int:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM file_box_items WHERE box_id=?", (box_id,)
|
||||
).fetchone()
|
||||
pos = (row[0] if row else -1) + 1
|
||||
cur = conn.execute(
|
||||
"INSERT INTO file_box_items (box_id, name, path, position) VALUES (?,?,?,?)",
|
||||
(box_id, name, path, pos),
|
||||
)
|
||||
conn.commit()
|
||||
item_id = cur.lastrowid
|
||||
conn.close()
|
||||
return item_id
|
||||
|
||||
|
||||
def delete_file_box_item(item_id: int):
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM file_box_items WHERE id=?", (item_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_file_box_item(item_id: int, **kwargs):
|
||||
if not kwargs:
|
||||
return
|
||||
fields = [k for k in kwargs.keys() if k in ("name", "path", "position")]
|
||||
if not fields:
|
||||
return
|
||||
conn = get_conn()
|
||||
sets = ", ".join(f"{f}=?" for f in fields)
|
||||
conn.execute(
|
||||
f"UPDATE file_box_items SET {sets} WHERE id=?",
|
||||
[kwargs[f] for f in fields] + [item_id],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -78,10 +78,16 @@ def _install_requirements_on_startup() -> None:
|
||||
|
||||
|
||||
class GlobalHotkey(QAbstractNativeEventFilter):
|
||||
"""用 Win32 RegisterHotKey 注册真正的全局快捷键 Alt+`"""
|
||||
_HOTKEY_ID = 0x4E4D # 任意唯一 ID
|
||||
_MOD_ALT = 0x0001
|
||||
_VK_BACKTICK = 0xC0 # VK_OEM_3 = ` 键
|
||||
"""
|
||||
Win32 RegisterHotKey 注册两个全局快捷键:
|
||||
- Alt+` : 唤醒/折叠面板(在"面板↔悬浮球"间切换)
|
||||
- Ctrl+` : 缩小成悬浮球(任何状态都最小化到悬浮球)
|
||||
"""
|
||||
_HK_ALT = 0x4E4D # Alt+`
|
||||
_HK_CTRL = 0x4E4E # Ctrl+`
|
||||
_MOD_ALT = 0x0001
|
||||
_MOD_CTRL = 0x0002
|
||||
_VK_BACKTICK = 0xC0 # ` 键
|
||||
|
||||
def __init__(self, panel: "PanelWindow", ball: "FloatBall"):
|
||||
super().__init__()
|
||||
@@ -91,15 +97,16 @@ class GlobalHotkey(QAbstractNativeEventFilter):
|
||||
def install(self):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
# 注册线程级别的全局热键,避免因主窗口 hide/show 导致 HWND 重建/失效
|
||||
ctypes.windll.user32.RegisterHotKey(
|
||||
None, self._HOTKEY_ID, self._MOD_ALT, self._VK_BACKTICK
|
||||
)
|
||||
user32 = ctypes.windll.user32
|
||||
user32.RegisterHotKey(None, self._HK_ALT, self._MOD_ALT, self._VK_BACKTICK)
|
||||
user32.RegisterHotKey(None, self._HK_CTRL, self._MOD_CTRL, self._VK_BACKTICK)
|
||||
QApplication.instance().installNativeEventFilter(self)
|
||||
|
||||
def uninstall(self):
|
||||
if sys.platform == "win32":
|
||||
ctypes.windll.user32.UnregisterHotKey(None, self._HOTKEY_ID)
|
||||
user32 = ctypes.windll.user32
|
||||
user32.UnregisterHotKey(None, self._HK_ALT)
|
||||
user32.UnregisterHotKey(None, self._HK_CTRL)
|
||||
QApplication.instance().removeNativeEventFilter(self)
|
||||
|
||||
def nativeEventFilter(self, event_type, message):
|
||||
@@ -107,18 +114,32 @@ class GlobalHotkey(QAbstractNativeEventFilter):
|
||||
import ctypes
|
||||
msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents
|
||||
WM_HOTKEY = 0x0312
|
||||
if msg.message == WM_HOTKEY and msg.wParam == self._HOTKEY_ID:
|
||||
self._toggle()
|
||||
if msg.message == WM_HOTKEY:
|
||||
if msg.wParam == self._HK_ALT:
|
||||
self._toggle_alt()
|
||||
elif msg.wParam == self._HK_CTRL:
|
||||
self._toggle_ctrl()
|
||||
return False, 0
|
||||
|
||||
def _toggle(self):
|
||||
if self._panel.is_really_visible():
|
||||
self._panel.minimize_to_ball()
|
||||
def _toggle_alt(self):
|
||||
"""Alt+`:只唤醒面板(如果已显示就忽略,不再缩小)"""
|
||||
if self._panel.isVisible():
|
||||
# 已经显示着了 —— 不做任何事(取消原来的缩小行为)
|
||||
# 只尝试把它 raise 到最前
|
||||
self._panel.raise_()
|
||||
self._panel.activateWindow()
|
||||
else:
|
||||
# 悬浮球状态 → 唤醒面板
|
||||
self._panel.show_near(self._ball.pos(), BALL_SIZE)
|
||||
self._panel.raise_()
|
||||
self._panel.activateWindow()
|
||||
|
||||
def _toggle_ctrl(self):
|
||||
"""Ctrl+`:强制缩小成悬浮球(任何状态都生效)"""
|
||||
if self._panel.isVisible():
|
||||
self._panel.minimize_to_ball()
|
||||
# 已经是悬浮球状态,什么都不做
|
||||
|
||||
# ===================== 打包兼容核心函数 =====================
|
||||
def get_resource_path(relative_path):
|
||||
"""
|
||||
@@ -182,7 +203,9 @@ def _wake_existing_or_exit() -> bool:
|
||||
|
||||
|
||||
def main():
|
||||
if not _wake_existing_or_exit():
|
||||
# 外部更新器启动时带 --from-update,跳过单实例检测(旧 mutex 可能还没完全释放)
|
||||
from_update = "--from-update" in sys.argv
|
||||
if not from_update and not _wake_existing_or_exit():
|
||||
return
|
||||
|
||||
_install_requirements_on_startup()
|
||||
@@ -216,6 +239,7 @@ def main():
|
||||
|
||||
panel._ball_ref = ball
|
||||
panel._apply_pin_window_layer()
|
||||
panel._load_file_boxes()
|
||||
|
||||
ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE))
|
||||
ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos))
|
||||
|
||||
+24
-4
@@ -1,12 +1,35 @@
|
||||
"""将用户选中的 .lnk / .url 解析为实际要存储的路径(目标程序或 URL)。"""
|
||||
import base64
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _decode_subprocess_output(raw: bytes) -> str:
|
||||
"""
|
||||
解码子进程输出:优先用系统默认编码,兼容中文 Windows 上 PowerShell 的 GBK 输出,
|
||||
同时也能正确处理 UTF-8 等情况。
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
# 先尝试系统默认编码(中文 Windows 上是 GBK)
|
||||
encodings_to_try = []
|
||||
sys_enc = locale.getpreferredencoding(False)
|
||||
if sys_enc:
|
||||
encodings_to_try.append(sys_enc)
|
||||
# 再尝试 UTF-8(带/不带 BOM)
|
||||
encodings_to_try.extend(["utf-8-sig", "utf-8", "gbk"])
|
||||
for enc in encodings_to_try:
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _looks_like_shell_lnk(path: str) -> bool:
|
||||
"""Windows Shell Link 文件头为 4C 00 00 00(扩展名异常时仍可识别)。"""
|
||||
try:
|
||||
@@ -50,14 +73,11 @@ def _resolve_windows_lnk(lnk_path: str) -> str | None:
|
||||
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")
|
||||
raw = _decode_subprocess_output(r.stdout or b"").strip().lstrip("\ufeff")
|
||||
if not raw:
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
|
||||
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.
@@ -0,0 +1,954 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""桌面文件框:可拖动、可缩放、可吸附、可调透明度、可显示文件夹内容的独立桌面窗口。"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QMenu, QListView,
|
||||
QMessageBox, QApplication, QSizePolicy, QSlider, QSpinBox, QDialog,
|
||||
QDialogButtonBox, QFormLayout, QAbstractItemView, QFileDialog,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
Qt, QPoint, QRect, QRectF, QSize, QPropertyAnimation, QEasingCurve, QTimer,
|
||||
QDir, QModelIndex, QUrl,
|
||||
)
|
||||
from PyQt6.QtGui import (
|
||||
QPainter, QColor, QPen, QBrush, QIcon, QPixmap, QRegion, QPainterPath,
|
||||
QFileSystemModel, QStandardItemModel, QStandardItem,
|
||||
)
|
||||
import qtawesome as qta
|
||||
|
||||
from db import database
|
||||
from ui.item import extract_icon
|
||||
import ui.theme as theme
|
||||
import ui.dialog_style as dialog_style
|
||||
import shortcut_target
|
||||
|
||||
|
||||
# ── 文件夹模式:双击条目打开文件/进入子目录 ────────────
|
||||
class FolderListView(QListView):
|
||||
"""显示文件夹内容的列表视图(文件夹浏览模式用)。"""
|
||||
|
||||
def __init__(self, model: QFileSystemModel, root_path: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setModel(model)
|
||||
self.setRootIndex(model.index(root_path))
|
||||
self.setViewMode(QListView.ViewMode.IconMode)
|
||||
self.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
self.setMovement(QListView.Movement.Static)
|
||||
self.setUniformItemSizes(True)
|
||||
self.setIconSize(QSize(40, 40))
|
||||
self.setGridSize(QSize(72, 88))
|
||||
self.setSpacing(4)
|
||||
self.setWordWrap(True)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.doubleClicked.connect(self._on_double_clicked)
|
||||
# customContextMenuPolicy 由 FileBox._install_drag_filter_recursive 统一设置
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QListView { background:transparent; color:#ddd; border:none;
|
||||
font-size:11px; outline:none; }
|
||||
QListView::item { padding:4px; border-radius:4px; }
|
||||
QListView::item:hover { background:rgba(74,158,255,30); }
|
||||
QListView::item:selected { background:rgba(74,158,255,50); }
|
||||
"""
|
||||
)
|
||||
|
||||
def _on_double_clicked(self, index: QModelIndex):
|
||||
model = self.model()
|
||||
path = model.filePath(index)
|
||||
if not path or not os.path.exists(path): return
|
||||
if os.path.isdir(path):
|
||||
self.setRootIndex(index)
|
||||
else:
|
||||
try: os.startfile(path)
|
||||
except Exception:
|
||||
try: subprocess.Popen([path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
except Exception as e: dialog_style.warning(self, "打开失败", str(e))
|
||||
|
||||
|
||||
_MENU_QSS = """
|
||||
QMenu { background:#2b2b2b; color:#ddd; border:1px solid #444; padding:4px; border-radius:6px; }
|
||||
QMenu::item { padding:6px 16px; border-radius:3px; }
|
||||
QMenu::item:selected { background:#3a5a8a; }
|
||||
"""
|
||||
|
||||
|
||||
# ── FileBox 窗口 ───────────────────────────────────────
|
||||
# ── 全局圆角常量(QSS 必须一致) ──────────────────────────
|
||||
# 8px 与 Win11 DWMWCP_ROUND 的系统裁剪半径贴合:
|
||||
# QSS 半径 > DWM 半径时(如 12px),圆弧外 8~12px 环带会露出方形亚克力底
|
||||
RADIUS = 8
|
||||
|
||||
|
||||
class FileBox(QWidget):
|
||||
"""桌面文件框:两种模式 —— 拖入卡片 / 文件夹浏览。"""
|
||||
|
||||
MIN_W = 180
|
||||
MIN_H = 200
|
||||
_DRAG_MARGIN = 8
|
||||
_SNAP_THRESHOLD = 15
|
||||
_BOX_GAP = 2
|
||||
|
||||
_instances: list["FileBox"] = []
|
||||
_panel_ref = None
|
||||
|
||||
def __init__(self, box_data: dict):
|
||||
super().__init__()
|
||||
self._box_data = box_data
|
||||
self._box_id = box_data["id"]
|
||||
self._collapsed = bool(box_data.get("collapsed", 0))
|
||||
self._opacity = float(box_data.get("opacity", 0.5))
|
||||
self._folder_path = (box_data.get("folder_path") or "").strip()
|
||||
self._group_id = box_data.get("group_id") # None 或 int
|
||||
# 模式优先级:group_id > folder_path > 拖入
|
||||
self._is_group_linked = self._group_id is not None
|
||||
self._is_folder_mode = (not self._is_group_linked) and bool(self._folder_path)
|
||||
self._view_mode = (box_data.get("view_mode") or "icon").strip()
|
||||
if self._view_mode not in ("icon", "list"):
|
||||
self._view_mode = "icon"
|
||||
|
||||
self._drag_pos: QPoint | None = None
|
||||
self._resize_edge: str | None = None
|
||||
self._resize_start_global: QPoint | None = None
|
||||
self._resize_start_geo: QRect | None = None
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(500)
|
||||
self._save_timer.timeout.connect(self._save_all)
|
||||
|
||||
# ⚠️ 顺序关键:先设 flags,再设透明属性
|
||||
# setWindowFlags() 会重建原生窗口并丢掉 WA_TranslucentBackground,
|
||||
# 属性若设在 flags 之前 = 透明失效 = QSS 圆角画在不透明方形窗口上(直角根因)
|
||||
self._apply_flags()
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAcceptDrops(True)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
w = max(self.MIN_W, int(box_data.get("w", 240)))
|
||||
h = max(self.MIN_H, int(box_data.get("h", 320)))
|
||||
self.setGeometry(int(box_data.get("x", 100)), int(box_data.get("y", 100)), w, h)
|
||||
|
||||
self._build_ui()
|
||||
self._apply_theme()
|
||||
# 窗口本体强制透明(防止 Qt/palette 画默认背景色透出来)
|
||||
self.setStyleSheet("FileBox { background: transparent; }")
|
||||
# Windows 11: 让 DWM 在 OS 层强制圆角(物理裁剪,不依赖 QSS/半透明)
|
||||
self._apply_win11_round_corners()
|
||||
# 圆角和透明度现在完全靠 QSS(跟 dock.py 同款 container 方案),
|
||||
# 不再需要 setWindowOpacity / _update_mask / paintEvent 自绘
|
||||
|
||||
FileBox._instances.append(self)
|
||||
|
||||
if self._is_folder_mode:
|
||||
self._setup_folder_model()
|
||||
|
||||
if self._collapsed:
|
||||
self._collapse_content()
|
||||
|
||||
# ── Flags ──────────────────────────────────────────────
|
||||
def _apply_flags(self):
|
||||
# Tool:不出现在任务栏、不在 Alt+Tab 切换列表里
|
||||
# FramelessWindowHint:无边框
|
||||
# NoDropShadowWindowHint 不加——它会杀死 layered window 路径 → WA_TranslucentBackground 失效
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool)
|
||||
|
||||
def _apply_win11_round_corners(self):
|
||||
"""Windows 11: DWMWA_WINDOW_CORNER_PREFERENCE(33) = DWMWCP_ROUND(2)。
|
||||
让 DWM 在 OS 层物理裁剪窗口四角 —— 无边框工具窗口默认是直角,
|
||||
这是唯一 100% 生效的圆角方案(dock 主面板靠 Win32 合成 API 也拿到了同样效果)。
|
||||
Windows 10 无此 API,静默失败,由 QSS border-radius + 半透明兜底。"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
hwnd = int(self.winId()) # 强制创建原生窗口
|
||||
pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
hwnd, 33, ctypes.byref(pref), ctypes.sizeof(pref)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
# 保险:窗口若被重建(flags 变更/系统回收),透明属性、DWM 圆角、
|
||||
# Win32 磨砂合成全部会丢,每次 show 重新应用(幂等)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self._apply_win11_round_corners()
|
||||
self._apply_win32_blur(theme.name() == "dark")
|
||||
|
||||
# ── UI 构建 ────────────────────────────────────────────
|
||||
def _build_ui(self):
|
||||
# FileBox 自身:透明背景(靠 WA_TranslucentBackground)
|
||||
# 里面 container 用 QSS 画圆角 + 背景 + border(跟 dock.py 同款)
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(1, 1, 1, 1); outer.setSpacing(0)
|
||||
|
||||
self._container = QWidget()
|
||||
self._container.setObjectName("container")
|
||||
root = QVBoxLayout(self._container)
|
||||
root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0)
|
||||
outer.addWidget(self._container)
|
||||
|
||||
self._title_bar = QWidget()
|
||||
self._title_bar.setObjectName("title_bar")
|
||||
self._title_bar.setFixedHeight(28); self._title_bar.setAcceptDrops(True)
|
||||
h = QHBoxLayout(self._title_bar); h.setContentsMargins(10, 0, 6, 0); h.setSpacing(2)
|
||||
|
||||
self._title_label = QLabel(self._box_data.get("title", "文件框"))
|
||||
self._title_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._title_label.setAcceptDrops(True)
|
||||
self._title_label.mouseDoubleClickEvent = self._rename_box_title
|
||||
h.addWidget(self._title_label); h.addStretch()
|
||||
|
||||
if self._is_folder_mode:
|
||||
h.addWidget(self._mk_title_btn("fa5s.folder-open", "选择文件夹", self._choose_folder))
|
||||
h.addWidget(self._mk_title_btn("fa5s.times", "切换为拖入模式", self._convert_to_drag_mode))
|
||||
else:
|
||||
h.addWidget(self._mk_title_btn("fa5s.pen", "重命名", self._rename_box_title))
|
||||
# 视图切换按钮(只在拖入模式显示)
|
||||
self._view_btn = self._mk_title_btn(
|
||||
"fa5s.th-large" if self._view_mode == "list" else "fa5s.th",
|
||||
"切换列表/图标视图", self._toggle_view_mode,
|
||||
)
|
||||
h.addWidget(self._view_btn)
|
||||
|
||||
h.addWidget(self._mk_title_btn("fa5s.adjust", "透明度(所有框)", self._open_opacity_dialog))
|
||||
h.addWidget(self._mk_title_btn("fa5s.minus", "最小化", self._toggle_collapse))
|
||||
|
||||
self._close_btn = QPushButton()
|
||||
self._close_btn.setFixedSize(22, 22); self._close_btn.setAcceptDrops(True)
|
||||
self._close_btn.setStyleSheet(
|
||||
"QPushButton { border:none; background:transparent; border-radius:4px; }"
|
||||
"QPushButton:hover { background:#e55; color:white; }"
|
||||
)
|
||||
self._close_btn.setIcon(qta.icon("fa5s.times", color="#aaa"))
|
||||
self._close_btn.setIconSize(QSize(11, 11)); self._close_btn.setToolTip("关闭")
|
||||
self._close_btn.clicked.connect(self._close_box)
|
||||
h.addWidget(self._close_btn)
|
||||
|
||||
root.addWidget(self._title_bar)
|
||||
|
||||
self._content = QWidget(); self._content.setAcceptDrops(True)
|
||||
self._content.setStyleSheet("QWidget { background: transparent; }")
|
||||
self._content_layout = QVBoxLayout(self._content)
|
||||
self._content_layout.setContentsMargins(6, 4, 6, 6); self._content_layout.setSpacing(4)
|
||||
|
||||
if self._is_folder_mode:
|
||||
self._build_folder_content()
|
||||
else:
|
||||
self._build_drag_content()
|
||||
|
||||
root.addWidget(self._content, 1)
|
||||
self._install_drag_filter_recursive()
|
||||
|
||||
# ── 拖入卡片内容(QListView + QStandardItemModel) ──────
|
||||
def _build_drag_content(self):
|
||||
"""拖入卡片模式:用 QListView + QStandardItemModel,支持图标/列表两种视图。"""
|
||||
self._item_model = QStandardItemModel(self)
|
||||
self._item_list = QListView(self._content)
|
||||
self._item_list.setModel(self._item_model)
|
||||
# customContextMenuPolicy 由 FileBox._install_drag_filter_recursive 统一设置
|
||||
self._item_list.doubleClicked.connect(self._on_item_double_clicked)
|
||||
self._item_list.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self._item_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # ⚠️ 必须 ExtendedSelection 才支持拖框选
|
||||
self._item_list.setDragEnabled(False) # 关掉内部拖(防止和外部文件拖放冲突)
|
||||
self._item_list.setAcceptDrops(True)
|
||||
self._item_list.setStyleSheet(
|
||||
"""
|
||||
QListView { background:transparent; color:#ddd; border:none;
|
||||
font-size:11px; outline:none; }
|
||||
QListView::item { padding:3px; border-radius:4px; }
|
||||
QListView::item:hover { background:rgba(74,158,255,30); }
|
||||
QListView::item:selected { background:rgba(74,158,255,50); }
|
||||
"""
|
||||
)
|
||||
self._apply_view_mode()
|
||||
self._content_layout.addWidget(self._item_list)
|
||||
|
||||
# 空提示
|
||||
self._empty_label = QLabel("拖文件进来")
|
||||
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._empty_label.setStyleSheet("color:#555; font-size:11px; background:transparent;")
|
||||
self._content_layout.addWidget(self._empty_label)
|
||||
self.refresh_items()
|
||||
|
||||
def _apply_view_mode(self):
|
||||
"""根据 self._view_mode 切换 QListView 显示方式。"""
|
||||
if self._view_mode == "list":
|
||||
self._item_list.setViewMode(QListView.ViewMode.ListMode)
|
||||
self._item_list.setIconSize(QSize(16, 16))
|
||||
self._item_list.setGridSize(QSize())
|
||||
self._item_list.setMovement(QListView.Movement.Static)
|
||||
self._item_list.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
self._item_list.setSpacing(2)
|
||||
self._item_list.setWordWrap(False)
|
||||
else:
|
||||
self._item_list.setViewMode(QListView.ViewMode.IconMode)
|
||||
self._item_list.setIconSize(QSize(28, 28))
|
||||
self._item_list.setGridSize(QSize(72, 88))
|
||||
self._item_list.setMovement(QListView.Movement.Static)
|
||||
self._item_list.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
self._item_list.setSpacing(4)
|
||||
self._item_list.setWordWrap(True)
|
||||
|
||||
def _toggle_view_mode(self):
|
||||
self._view_mode = "list" if self._view_mode == "icon" else "icon"
|
||||
self._apply_view_mode()
|
||||
# 换按钮图标
|
||||
icon_name = "fa5s.th-large" if self._view_mode == "list" else "fa5s.th"
|
||||
self._view_btn.setIcon(qta.icon(icon_name, color="#888"))
|
||||
self._save_timer.start()
|
||||
|
||||
# ── 文件夹浏览内容 ──────────────────────────────────────
|
||||
def _build_folder_content(self):
|
||||
self._fs_model = QFileSystemModel(self)
|
||||
self._fs_model.setFilter(QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot)
|
||||
self._fs_model.setReadOnly(True)
|
||||
self._list_view = FolderListView(self._fs_model, self._folder_path, self._content)
|
||||
self._content_layout.addWidget(self._list_view)
|
||||
self._empty_label = QLabel("文件夹不存在")
|
||||
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._empty_label.setStyleSheet("color:#555; font-size:11px; background:transparent;")
|
||||
if not os.path.isdir(self._folder_path):
|
||||
self._list_view.hide(); self._empty_label.show()
|
||||
self._content_layout.addWidget(self._empty_label)
|
||||
|
||||
def _setup_folder_model(self): pass
|
||||
def _refresh_folder(self):
|
||||
if not self._is_folder_mode or not os.path.isdir(self._folder_path): return
|
||||
self._fs_model.setRootPath(self._folder_path)
|
||||
self._list_view.setRootIndex(self._fs_model.index(self._folder_path))
|
||||
|
||||
def _convert_to_drag_mode(self):
|
||||
ret = dialog_style.question(self, "确认", "切换为拖入模式?(文件夹路径会被清除)", default_button=QMessageBox.StandardButton.No)
|
||||
if ret != QMessageBox.StandardButton.Yes: return
|
||||
database.update_file_box(self._box_id, folder_path="")
|
||||
self._folder_path = ""; self._is_folder_mode = False
|
||||
while self._content_layout.count():
|
||||
item = self._content_layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w: w.setParent(None); w.deleteLater()
|
||||
self._build_drag_content()
|
||||
self._install_drag_filter_recursive()
|
||||
# 重建标题栏按钮
|
||||
while self._title_bar.layout().count():
|
||||
item = self._title_bar.layout().takeAt(0)
|
||||
w = item.widget()
|
||||
if w: w.setParent(None); w.deleteLater()
|
||||
self._build_title_bar_after_convert()
|
||||
|
||||
def _build_title_bar_after_convert(self):
|
||||
h = self._title_bar.layout()
|
||||
self._title_label.setParent(None)
|
||||
# 重新构建:label + stretch + rename + view + opacity + minimize + close
|
||||
h.addWidget(self._title_label); h.addStretch()
|
||||
h.addWidget(self._mk_title_btn("fa5s.pen", "重命名", self._rename_box_title))
|
||||
self._view_btn = self._mk_title_btn(
|
||||
"fa5s.th-large" if self._view_mode == "list" else "fa5s.th",
|
||||
"切换列表/图标视图", self._toggle_view_mode,
|
||||
)
|
||||
h.addWidget(self._view_btn)
|
||||
h.addWidget(self._mk_title_btn("fa5s.adjust", "透明度(所有框)", self._open_opacity_dialog))
|
||||
h.addWidget(self._mk_title_btn("fa5s.minus", "最小化", self._toggle_collapse))
|
||||
h.addWidget(self._close_btn)
|
||||
|
||||
def _choose_folder(self):
|
||||
path = QFileDialog.getExistingDirectory(self, "选择文件夹", self._folder_path or os.path.expanduser("~"))
|
||||
if not path: return
|
||||
self._folder_path = path
|
||||
self._box_data["title"] = os.path.basename(path) or path
|
||||
self._title_label.setText(self._box_data["title"])
|
||||
database.update_file_box(self._box_id, folder_path=path, title=self._box_data["title"])
|
||||
self._refresh_folder()
|
||||
|
||||
def _mk_title_btn(self, icon_name, tip, callback):
|
||||
btn = QPushButton(); btn.setFixedSize(22, 22); btn.setAcceptDrops(True)
|
||||
btn.setStyleSheet(
|
||||
"QPushButton { border:none; background:transparent; border-radius:4px; }"
|
||||
"QPushButton:hover { background:rgba(255,255,255,30); }"
|
||||
)
|
||||
btn.setIcon(qta.icon(icon_name, color="#888"))
|
||||
btn.setIconSize(QSize(11, 11)); btn.setToolTip(tip)
|
||||
btn.clicked.connect(callback)
|
||||
return btn
|
||||
|
||||
# ── FileBox 右键菜单(CustomContextMenu 信号 —— 覆盖所有子控件) ──
|
||||
def _install_drag_filter_recursive(self):
|
||||
# 统一用 CustomContextMenu 策略:Qt 会调用 customContextMenuRequested(global_pos)
|
||||
# 对 FileBox 和所有子控件设 CustomContextMenuPolicy,然后统一转发到 FileBox._on_box_context_menu
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._on_box_context_menu)
|
||||
|
||||
for child in self.findChildren(QWidget):
|
||||
child.setAcceptDrops(True)
|
||||
child.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
# lambda 默认参数 trick 绑定当前 child(避免循环变量 late-binding bug)
|
||||
child.customContextMenuRequested.connect(
|
||||
lambda pos, c=child: self.customContextMenuRequested.emit(c.mapToGlobal(pos))
|
||||
)
|
||||
|
||||
# 顶层容器装拖放 filter(只拦 DragEnter/Drop,不拦鼠标)
|
||||
for w in (self._content, self._title_bar):
|
||||
if w: w.installEventFilter(self)
|
||||
|
||||
self.installEventFilter(self)
|
||||
|
||||
# ── 主题 ────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _with_opacity(rgba_str: str, opacity: float) -> str:
|
||||
"""给 rgba(...) 字符串替换 alpha 值。opacity 是 0~1 的浮点数。
|
||||
如果输入是 #rrggbb 也先转换成 rgba(r,g,b,a)。"""
|
||||
import re
|
||||
s = rgba_str.strip().lower()
|
||||
nums = [int(x) for x in re.findall(r"\d+", s)]
|
||||
if len(nums) >= 3:
|
||||
a = max(0, min(255, int(opacity * 255)))
|
||||
return f"rgba({nums[0]},{nums[1]},{nums[2]},{a})"
|
||||
return rgba_str
|
||||
|
||||
def _apply_theme(self):
|
||||
t = theme.current(); is_dark = theme.name() == "dark"
|
||||
# ── container:圆角 + 半透明背景 + border(跟 dock.py 同款) ──
|
||||
# panel_bg 的 alpha 由 _opacity 控制(覆盖 theme 自带 alpha)
|
||||
panel_bg_rgba = self._with_opacity(t.get("panel_bg", "#1e1e1e"), self._opacity)
|
||||
# panel_border 保留 theme 自带 alpha(装饰线,不二次乘)
|
||||
panel_border_rgba = t.get("panel_border", "#888")
|
||||
self._container.setStyleSheet(
|
||||
f"""
|
||||
QWidget#container {{
|
||||
background: {panel_bg_rgba};
|
||||
border: 1px solid {panel_border_rgba};
|
||||
border-radius: {RADIUS}px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
# ── title_bar:比 container 稍深/稍浅,顶部圆角对齐 ──
|
||||
# 自己造 header 颜色(不用 theme.header_bg,它是叠加色语义怪)
|
||||
if is_dark:
|
||||
header_rgb = (50, 50, 50) # 比 dark panel_bg 稍亮
|
||||
else:
|
||||
header_rgb = (230, 230, 230) # 比 light panel_bg 稍深
|
||||
header_a = max(0, min(255, int(self._opacity * 255)))
|
||||
title_fg = t.get("search_color", "#ddd")
|
||||
self._title_bar.setStyleSheet(
|
||||
f"""
|
||||
QWidget#title_bar {{
|
||||
background: rgba({header_rgb[0]},{header_rgb[1]},{header_rgb[2]},{header_a});
|
||||
border-top-left-radius: {RADIUS}px;
|
||||
border-top-right-radius: {RADIUS}px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self._title_label.setStyleSheet(
|
||||
f"color:{title_fg}; font-size:12px; font-weight:bold; background:transparent;"
|
||||
)
|
||||
# item 卡片文字色跟随主题
|
||||
if hasattr(self, "_item_list"):
|
||||
item_fg = t.get("item_name_color", "#ddd")
|
||||
self._item_list.setStyleSheet(
|
||||
f"""
|
||||
QListView {{ background:transparent; color:{item_fg}; border:none;
|
||||
font-size:11px; outline:none; }}
|
||||
QListView::item {{ padding:3px; border-radius:4px; }}
|
||||
QListView::item:hover {{ background:rgba(74,158,255,30); }}
|
||||
QListView::item:selected {{ background:rgba(74,158,255,50); }}
|
||||
"""
|
||||
)
|
||||
# ── 关键:Win32 磨砂合成(跟 dock.py 完全同款) ──
|
||||
# dock 的半透明+圆角从来不靠 Qt 的 WA_TranslucentBackground,
|
||||
# 而是靠这个 accent API:DWM 把窗口变成磨砂材质 + Win11 自动圆角。
|
||||
# 没有它,FileBox 就是黑色不透明直角窗口(Qt 透明路径在本机不生效)。
|
||||
self._apply_win32_blur(is_dark)
|
||||
|
||||
def _apply_win32_blur(self, is_dark: bool):
|
||||
"""给窗口应用 Win32 磨砂合成(ACCENT_ENABLE_ACRYLICBLURBEHIND)。
|
||||
复刻 dock.py 的做法:窗口获得 DWM 半透明磨砂材质,Win11 自动圆角。"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import windll, sizeof, byref, c_int, Structure
|
||||
|
||||
class ACCENT_POLICY(Structure):
|
||||
_fields_ = [
|
||||
("AccentState", c_int),
|
||||
("AccentFlags", c_int),
|
||||
("GradientColor", c_int),
|
||||
("AnimationId", c_int),
|
||||
]
|
||||
|
||||
class WINDOWCOMPOSITIONATTRIBDATA(Structure):
|
||||
_fields_ = [
|
||||
("Attribute", c_int),
|
||||
("Data", ctypes.c_void_p),
|
||||
("SizeOfData", c_int),
|
||||
]
|
||||
|
||||
hwnd = int(self.winId())
|
||||
accent = ACCENT_POLICY()
|
||||
accent.AccentState = 4 # ACCENT_ENABLE_ACRYLICBLURBEHIND
|
||||
# 磨砂底色:主题色 + 固定 alpha(跟 dock 一致用 0x90);
|
||||
# 精细透明度仍由 QSS container 层的 _opacity 控制(两层叠加)
|
||||
if is_dark:
|
||||
accent.GradientColor = 0x901E1E1E # ABGR: 56% + RGB(30,30,30)
|
||||
else:
|
||||
accent.GradientColor = 0x90FFFFFF # ABGR: 56% + RGB(255,255,255)
|
||||
accent.AccentFlags = 2
|
||||
|
||||
data = WINDOWCOMPOSITIONATTRIBDATA()
|
||||
data.Attribute = 19 # WCA_ACCENT_POLICY
|
||||
data.Data = ctypes.cast(byref(accent), ctypes.c_void_p)
|
||||
data.SizeOfData = sizeof(accent)
|
||||
|
||||
windll.user32.SetWindowCompositionAttribute(hwnd, byref(data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 事件过滤器 ──────────────────────────────────────
|
||||
# 只拦两类事件:1) 拖放(所有子控件);2) 边缘 resize(仅 FileBox 自身)
|
||||
# 中间区域的鼠标事件 → 完全放行给 QListView 处理框选/选择
|
||||
def eventFilter(self, obj, event):
|
||||
et = event.type()
|
||||
|
||||
# ✅ 所有子控件的拖放事件 → 统一转给 FileBox
|
||||
if et == event.Type.DragEnter: self.dragEnterEvent(event); return True
|
||||
if et == event.Type.DragMove: self.dragMoveEvent(event); return True
|
||||
if et == event.Type.Drop: self.dropEvent(event); return True
|
||||
|
||||
# ✅ FileBox 自身边缘区域的鼠标 → resize / 全局 cursor
|
||||
if obj is self:
|
||||
if et == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
|
||||
local = event.pos()
|
||||
edge = self._edge_at(local)
|
||||
if edge or self._resize_edge:
|
||||
self.mousePressEvent(event); return True
|
||||
if et == event.Type.MouseMove:
|
||||
if self._resize_edge:
|
||||
self.mouseMoveEvent(event); return True
|
||||
edge = self._edge_at(event.pos())
|
||||
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,
|
||||
}
|
||||
self.setCursor(CUR.get(edge, Qt.CursorShape.ArrowCursor))
|
||||
if et == event.Type.MouseButtonRelease and self._resize_edge:
|
||||
self.mouseReleaseEvent(event); return True
|
||||
|
||||
# ❌ 子控件的所有鼠标事件 → 放行!QListView 需要处理框选/选择
|
||||
# ❌ FileBox 中间区域的鼠标 → 放行!(标题栏/子控件自己处理)
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _handle_drop(self, event):
|
||||
if self._is_folder_mode:
|
||||
target = self._folder_path
|
||||
added = False
|
||||
for url in event.mimeData().urls():
|
||||
src = url.toLocalFile()
|
||||
if not src: continue
|
||||
try:
|
||||
dest = os.path.join(target, os.path.basename(src))
|
||||
os.rename(src, dest); added = True
|
||||
except Exception:
|
||||
try:
|
||||
import shutil; shutil.move(src, dest); added = True
|
||||
except Exception: pass
|
||||
if added: self._refresh_folder()
|
||||
else:
|
||||
added = False
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile()
|
||||
if not path: continue
|
||||
store = shortcut_target.path_for_storage(path)
|
||||
name = shortcut_target.item_name_from_sources(path, store)
|
||||
if self._is_group_linked:
|
||||
database.add_item(self._group_id, name, store); added = True
|
||||
else:
|
||||
database.add_file_box_item(self._box_id, name, store); added = True
|
||||
if added:
|
||||
self.refresh_items()
|
||||
self._notify_panel_refresh()
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _notify_panel_refresh(self):
|
||||
"""通知主程序刷新分组(分组联动模式下 FileBox 改动需要同步到主面板)。"""
|
||||
if FileBox._panel_ref and hasattr(FileBox._panel_ref, "refresh_groups"):
|
||||
try: FileBox._panel_ref.refresh_groups()
|
||||
except Exception: pass
|
||||
|
||||
# ── 拖入 / 分组模式:刷新卡片到 QStandardItemModel ───────
|
||||
def refresh_items(self):
|
||||
if self._is_folder_mode or not hasattr(self, "_item_model"): return
|
||||
if self._is_group_linked:
|
||||
items = database.get_items(self._group_id)
|
||||
else:
|
||||
items = database.get_file_box_items(self._box_id)
|
||||
self._item_model.clear()
|
||||
for it in items:
|
||||
icon = extract_icon(it["path"])
|
||||
item = QStandardItem(icon, it["name"])
|
||||
item.setData(it["path"], Qt.ItemDataRole.UserRole)
|
||||
item.setData(it["id"], Qt.ItemDataRole.UserRole + 1)
|
||||
self._item_model.appendRow(item)
|
||||
count = len(items)
|
||||
self._empty_label.setVisible(count == 0)
|
||||
self._item_list.setVisible(count > 0)
|
||||
|
||||
def _open_item(self, idx: QModelIndex):
|
||||
"""打开 QListView 里的某个 item(双击 or 右键)。"""
|
||||
path = idx.data(Qt.ItemDataRole.UserRole)
|
||||
if not path or not os.path.exists(path):
|
||||
dialog_style.warning(self, "打不开", f"文件不存在:\n{path}"); return
|
||||
try: os.startfile(path)
|
||||
except Exception:
|
||||
try: subprocess.Popen([path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
except Exception as e: dialog_style.warning(self, "启动失败", str(e))
|
||||
|
||||
def _on_item_double_clicked(self, idx: QModelIndex):
|
||||
self._open_item(idx)
|
||||
|
||||
def _toggle_collapse(self):
|
||||
self._collapsed = not self._collapsed
|
||||
if self._collapsed: self._content.hide(); self.setFixedHeight(36)
|
||||
else:
|
||||
self._content.show(); self.setMinimumHeight(self.MIN_H); self.setMaximumHeight(16777215)
|
||||
self.resize(self.width(), max(self.MIN_H, int(self._box_data.get("h", 320))))
|
||||
self._save_timer.start()
|
||||
|
||||
def _rename_box_title(self, _event=None):
|
||||
title, ok = dialog_style.get_text(self, "重命名文件框", "新标题:", text=self._box_data.get("title", "文件框"))
|
||||
if not ok or not title.strip(): return
|
||||
title = title.strip(); self._box_data["title"] = title; self._title_label.setText(title)
|
||||
database.update_file_box(self._box_id, title=title)
|
||||
|
||||
# ── 透明度(所有框联动) ──────────────────────────────
|
||||
def _open_opacity_dialog(self, _event=None):
|
||||
all_boxes = [b for b in FileBox._instances if b.isVisible()]
|
||||
if not all_boxes: return
|
||||
dlg = QDialog(self); dlg.setWindowTitle("所有文件框透明度")
|
||||
dlg.setStyleSheet(
|
||||
"""
|
||||
QDialog { background:#2b2b2b; color:#ddd; border:1px solid #444; }
|
||||
QLabel { color:#ddd; }
|
||||
QSlider::groove:horizontal { height:6px; background:#444; border-radius:3px; }
|
||||
QSlider::handle:horizontal { background:#4a9eff; width:14px; height:14px; margin:-4px 0; border-radius:7px; }
|
||||
QSpinBox { background:#3a3a3a; color:#ddd; border:1px solid #555; border-radius:4px; padding:3px; width:60px; }
|
||||
QPushButton { background:#3a3a3a; color:#ddd; border:1px solid #555; border-radius:4px; padding:5px 16px; }
|
||||
QPushButton:hover { background:#4a4a4a; }
|
||||
"""
|
||||
)
|
||||
form = QFormLayout(dlg)
|
||||
slider = QSlider(Qt.Orientation.Horizontal); slider.setRange(20, 100); slider.setValue(int(self._opacity * 100))
|
||||
spin = QSpinBox(); spin.setRange(20, 100); spin.setSuffix(" %"); spin.setValue(int(self._opacity * 100))
|
||||
def apply(v: int):
|
||||
"""改 _opacity + 重设 QSS(透明度现在在 QSS rgba 里)。"""
|
||||
for b in FileBox._instances:
|
||||
b._opacity = v / 100.0
|
||||
b._apply_theme()
|
||||
slider.valueChanged.connect(lambda v: (spin.setValue(v), apply(v)))
|
||||
spin.valueChanged.connect(lambda v: (slider.setValue(v), apply(v)))
|
||||
form.addRow("背景不透明度:", slider); form.addRow("数值:", spin)
|
||||
hint = QLabel("只影响文件框背景色,标题和图标始终清晰"); hint.setStyleSheet("color:#888; font-size:10px;")
|
||||
form.addRow("", hint)
|
||||
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
btns.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||
btns.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
||||
btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); form.addRow(btns)
|
||||
original = {b._box_id: b._opacity for b in FileBox._instances}
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||||
opacity = spin.value() / 100.0
|
||||
for b in FileBox._instances:
|
||||
b._opacity = opacity
|
||||
database.update_file_box(b._box_id, opacity=opacity)
|
||||
else:
|
||||
for b in FileBox._instances:
|
||||
b._opacity = original.get(b._box_id, 1.0)
|
||||
b._apply_theme()
|
||||
|
||||
def _close_box(self):
|
||||
ret = dialog_style.question(
|
||||
self, "确认",
|
||||
f"关闭文件框「{self._box_data.get('title', '')}」?\n文件框会保存,下次启动仍然显示。",
|
||||
default_button=QMessageBox.StandardButton.No,
|
||||
)
|
||||
if ret != QMessageBox.StandardButton.Yes: return
|
||||
database.update_file_box(self._box_id, collapsed=0); self.close()
|
||||
|
||||
def _delete_box(self):
|
||||
"""彻底删除文件框:DB 里删掉所有记录 + 从 _instances 移除 + 关闭窗口。"""
|
||||
ret = dialog_style.question(
|
||||
self, "确认删除",
|
||||
f"确定删除文件框「{self._box_data.get('title', '')}」?\n⚠️ 不可恢复,里面的条目也会一并删除。",
|
||||
default_button=QMessageBox.StandardButton.No,
|
||||
)
|
||||
if ret != QMessageBox.StandardButton.Yes: return
|
||||
database.delete_file_box(self._box_id)
|
||||
if self in FileBox._instances: FileBox._instances.remove(self)
|
||||
self.close() # 触发 closeEvent → _save_all + remove from instances(重复 remove 安全)
|
||||
|
||||
# ── 鼠标事件 ──────────────────────────────────────────
|
||||
def _edge_at(self, local: QPoint) -> str | None:
|
||||
x, y = local.x(), local.y(); w, h = self.width(), self.height(); m = self._DRAG_MARGIN
|
||||
l, r, t, b = x <= m, x >= w - m, y <= m, y >= h - m
|
||||
if t and l: return "tl"
|
||||
if t and r: return "tr"
|
||||
if b and l: return "bl"
|
||||
if b and r: return "br"
|
||||
if t: return "t"
|
||||
if b: return "b"
|
||||
if l: return "l"
|
||||
if r: return "r"
|
||||
return None
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() != Qt.MouseButton.LeftButton: return super().mousePressEvent(event)
|
||||
local = event.pos()
|
||||
if self._title_bar.geometry().contains(local):
|
||||
self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft(); return
|
||||
edge = self._edge_at(local)
|
||||
if edge:
|
||||
self._resize_edge = edge
|
||||
self._resize_start_global = event.globalPosition().toPoint()
|
||||
self._resize_start_geo = QRect(self.geometry()); return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
gp = event.globalPosition().toPoint()
|
||||
if self._resize_edge and event.buttons() & Qt.MouseButton.LeftButton: self._do_resize(gp); return
|
||||
if self._drag_pos and event.buttons() & Qt.MouseButton.LeftButton: self.move(gp - self._drag_pos); return
|
||||
edge = self._edge_at(event.pos())
|
||||
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,
|
||||
}
|
||||
self.setCursor(CUR.get(edge, Qt.CursorShape.ArrowCursor))
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._drag_pos = None; self._resize_edge = None
|
||||
self._resize_start_global = None; self._resize_start_geo = None
|
||||
self._save_timer.start(); self._do_snap()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
# ── FileBox 右键菜单(统一入口:所有子控件都转发到这里) ──
|
||||
def _on_box_context_menu(self, global_pos):
|
||||
"""global_pos 是屏幕坐标。根据点到的位置决定弹哪个菜单:
|
||||
- QListView item 上 → item 级菜单(打开/重命名/删除)
|
||||
- 空白处 → FileBox 级菜单(刷新/新增/关闭)
|
||||
"""
|
||||
# 先检测:点在哪个 QListView 上?
|
||||
for lv, is_folder_mode_lv in (
|
||||
(getattr(self, "_item_list", None), False),
|
||||
(getattr(self, "_list_view", None), True),
|
||||
):
|
||||
if lv is None: continue
|
||||
local_lv = lv.mapFromGlobal(global_pos)
|
||||
if not lv.rect().contains(local_lv): continue
|
||||
|
||||
# 在 QListView 上 → 判断是 item 还是空白
|
||||
idx = lv.indexAt(local_lv)
|
||||
if idx.isValid():
|
||||
# ✅ item 上右键 → 弹 item 级菜单
|
||||
if is_folder_mode_lv:
|
||||
self._show_folder_lv_item_menu(lv, idx, global_pos)
|
||||
else:
|
||||
self._show_item_list_menu(idx, global_pos)
|
||||
return
|
||||
# QListView 空白处也弹 FileBox 菜单(让用户方便新增)
|
||||
break # 跳出循环,继续往下弹 FileBox 菜单
|
||||
|
||||
# ✅ 标题栏按钮 / 标题栏空白 / FileBox 背景 / QListView 空白
|
||||
menu = QMenu(self); menu.setStyleSheet(_MENU_QSS)
|
||||
if self._is_folder_mode:
|
||||
menu.addAction(qta.icon("fa5s.sync", color="#4a9eff"), "刷新").triggered.connect(self._refresh_folder)
|
||||
menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "选择文件夹").triggered.connect(self._choose_folder)
|
||||
else:
|
||||
menu.addAction(qta.icon("fa5s.sync", color="#4a9eff"), "刷新").triggered.connect(self.refresh_items)
|
||||
menu.addAction(qta.icon("fa5s.file", color="#aaa"), "新增文件...").triggered.connect(self._pick_files)
|
||||
menu.addAction(qta.icon("fa5s.folder", color="#aaa"), "新增文件夹...").triggered.connect(self._pick_folders)
|
||||
menu.addSeparator()
|
||||
menu.addAction(qta.icon("fa5s.plus-square", color="#aaa"), "新建文件夹").triggered.connect(self._create_new_folder_in_box)
|
||||
menu.addSeparator()
|
||||
menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名文件框").triggered.connect(self._rename_box_title)
|
||||
menu.addSeparator()
|
||||
menu.addAction(qta.icon("fa5s.window-close", color="#888"), "关闭文件框(保存)").triggered.connect(self._close_box)
|
||||
menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除文件框(不可恢复)").triggered.connect(self._delete_box)
|
||||
menu.exec(global_pos)
|
||||
|
||||
def _show_item_list_menu(self, idx: QModelIndex, global_pos):
|
||||
"""拖入 / 分组模式:QListView 卡片级右键菜单。"""
|
||||
path = idx.data(Qt.ItemDataRole.UserRole)
|
||||
item_id = idx.data(Qt.ItemDataRole.UserRole + 1)
|
||||
name = idx.data()
|
||||
if not path: return
|
||||
is_linked = self._is_group_linked
|
||||
menu = QMenu(self); menu.setStyleSheet(_MENU_QSS)
|
||||
|
||||
def open_folder():
|
||||
import ctypes
|
||||
ctypes.windll.shell32.ShellExecuteW(None, "open", "explorer.exe", f'/select,"{path}"', None, 1)
|
||||
|
||||
def rename():
|
||||
nonlocal idx, name
|
||||
new_name, ok = dialog_style.get_text(self, "重命名", "新名称:", text=name)
|
||||
if not ok or not new_name.strip(): return
|
||||
new_name = new_name.strip()
|
||||
if is_linked: database.update_item(item_id, name=new_name)
|
||||
else: database.update_file_box_item(item_id, name=new_name)
|
||||
self._item_model.itemFromIndex(idx).setText(new_name)
|
||||
self._notify_panel_refresh()
|
||||
|
||||
def delete():
|
||||
nonlocal idx
|
||||
ret = dialog_style.question(self, "确认", f"删除「{name}」?", default_button=QMessageBox.StandardButton.No)
|
||||
if ret != QMessageBox.StandardButton.Yes: return
|
||||
if is_linked: database.delete_item(item_id)
|
||||
else: database.delete_file_box_item(item_id)
|
||||
self._item_model.removeRow(idx.row())
|
||||
if self._item_model.rowCount() == 0:
|
||||
self._empty_label.show(); self._item_list.hide()
|
||||
self._notify_panel_refresh()
|
||||
|
||||
menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开").triggered.connect(lambda: self._open_item(idx))
|
||||
menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置").triggered.connect(open_folder)
|
||||
menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名").triggered.connect(rename)
|
||||
menu.addSeparator()
|
||||
menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除").triggered.connect(delete)
|
||||
menu.exec(global_pos)
|
||||
|
||||
def _show_folder_lv_item_menu(self, lv, idx: QModelIndex, global_pos):
|
||||
"""文件夹浏览模式:QListView 条目级右键菜单。"""
|
||||
path = lv.model().filePath(idx)
|
||||
if not path or not os.path.exists(path): return
|
||||
menu = QMenu(self); menu.setStyleSheet(_MENU_QSS)
|
||||
menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开").triggered.connect(lambda: os.startfile(path))
|
||||
def open_folder():
|
||||
import ctypes
|
||||
ctypes.windll.shell32.ShellExecuteW(None, "open", "explorer.exe", f'/select,"{path}"', None, 1)
|
||||
menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置").triggered.connect(open_folder)
|
||||
menu.exec(global_pos)
|
||||
|
||||
def _pick_files(self):
|
||||
files, _ = QFileDialog.getOpenFileNames(self, "选择文件")
|
||||
if not files: return
|
||||
added = 0
|
||||
for p in files:
|
||||
store = shortcut_target.path_for_storage(p)
|
||||
name = shortcut_target.item_name_from_sources(p, store)
|
||||
database.add_file_box_item(self._box_id, name, store); added += 1
|
||||
if added: self.refresh_items()
|
||||
|
||||
def _pick_folders(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
|
||||
if not folder: return
|
||||
store = shortcut_target.path_for_storage(folder)
|
||||
name = os.path.basename(folder) or folder
|
||||
database.add_file_box_item(self._box_id, name, store)
|
||||
self.refresh_items()
|
||||
|
||||
def _create_new_folder_in_box(self):
|
||||
"""在拖入模式下创建一个真实文件夹并添加到文件框。"""
|
||||
name, ok = dialog_style.get_text(self, "新建文件夹", "文件夹名称:", text="新建文件夹")
|
||||
if not ok or not name.strip(): return
|
||||
# 先选父目录
|
||||
parent = QFileDialog.getExistingDirectory(self, "选择创建位置")
|
||||
if not parent: return
|
||||
new_path = os.path.join(parent, name.strip())
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except Exception as e:
|
||||
dialog_style.warning(self, "失败", str(e)); return
|
||||
store = shortcut_target.path_for_storage(new_path)
|
||||
database.add_file_box_item(self._box_id, name.strip(), store)
|
||||
self.refresh_items()
|
||||
|
||||
def _do_resize(self, gp: QPoint):
|
||||
if not self._resize_start_geo or not self._resize_start_global: return
|
||||
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
|
||||
min_w, min_h = self.MIN_W, 28 if self._collapsed else self.MIN_H
|
||||
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 _do_snap(self):
|
||||
t = self._SNAP_THRESHOLD; gap = self._BOX_GAP
|
||||
sx, sy, sw, sh = self.x(), self.y(), self.width(), self.height(); changed = False
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen: screen = QApplication.primaryScreen()
|
||||
geom = screen.availableGeometry()
|
||||
candidates = []
|
||||
if abs(sy - geom.top()) <= t: candidates.append(("top", geom.top()))
|
||||
if abs((sy + sh) - geom.bottom()) <= t: candidates.append(("bottom", geom.bottom() - sh))
|
||||
if abs(sx - geom.left()) <= t: candidates.append(("left", geom.left()))
|
||||
if abs((sx + sw) - geom.right()) <= t: candidates.append(("right", geom.right() - sw))
|
||||
if candidates:
|
||||
edge, pos = min(candidates, key=lambda c: (abs(c[1] - sy) if c[0] in ("top", "bottom") else abs(c[1] - sx)))
|
||||
if edge in ("top", "bottom"): sy = pos
|
||||
else: sx = pos
|
||||
changed = True
|
||||
for other in FileBox._instances:
|
||||
if other is self: continue
|
||||
ox, oy, ow, oh = other.x(), other.y(), other.width(), other.height()
|
||||
x_c = [("ll", abs(sx - ox), ox), ("lr", abs(sx - (ox + ow)), ox + ow + gap),
|
||||
("rl", abs((sx + sw) - ox), ox - sw - gap), ("rr", abs((sx + sw) - (ox + ow)), ox + ow - sw),
|
||||
("cx", abs((sx + sw/2) - (ox + ow/2)), ox + ow/2 - sw/2)]
|
||||
y_c = [("tt", abs(sy - oy), oy), ("tb", abs(sy - (oy + oh)), oy + oh + gap),
|
||||
("bt", abs((sy + sh) - oy), oy - sh - gap), ("bb", abs((sy + sh) - (oy + oh)), oy + oh - sh),
|
||||
("cy", abs((sy + sh/2) - (oy + oh/2)), oy + oh/2 - sh/2)]
|
||||
x_best = min(x_c, key=lambda c: c[1])
|
||||
if x_best[1] <= t:
|
||||
sx = int(x_best[2]); changed = True
|
||||
if x_best[0] not in ("lr", "rl"): sw = max(sw, ow)
|
||||
y_best = min(y_c, key=lambda c: c[1])
|
||||
if y_best[1] <= t:
|
||||
sy = int(y_best[2]); changed = True
|
||||
if y_best[0] not in ("tb", "bt"): sh = max(sh, oh)
|
||||
sx = max(geom.left(), min(sx, geom.right() - sw))
|
||||
sy = max(geom.top(), min(sy, geom.bottom() - sh))
|
||||
if changed or sx != self.x() or sy != self.y() or sw != self.width() or sh != self.height():
|
||||
self._snap_anim = QPropertyAnimation(self, b"geometry")
|
||||
self._snap_anim.setDuration(150); self._snap_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
self._snap_anim.setStartValue(self.geometry())
|
||||
self._snap_anim.setEndValue(QRect(sx, sy, sw, sh))
|
||||
self._snap_anim.start()
|
||||
|
||||
# ── 持久化 ────────────────────────────────────────────
|
||||
def _save_all(self):
|
||||
database.update_file_box(
|
||||
self._box_id,
|
||||
x=self.x(), y=self.y(), w=self.width(), h=self.height(),
|
||||
collapsed=1 if self._collapsed else 0,
|
||||
opacity=self._opacity,
|
||||
folder_path=self._folder_path if self._is_folder_mode else "",
|
||||
view_mode=self._view_mode if not self._is_folder_mode else "icon",
|
||||
title=self._box_data.get("title", ""),
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self in FileBox._instances: FileBox._instances.remove(self)
|
||||
self._save_all(); event.accept()
|
||||
|
||||
# ── 顶层窗口兜底拖放 ─────────────────────────────────
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls(): event.acceptProposedAction()
|
||||
else: event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
if event.mimeData().hasUrls(): event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
self._handle_drop(event)
|
||||
+139
-9
@@ -432,6 +432,8 @@ class PanelWindow(QWidget):
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._apply_win32_blur(theme.name() == "dark")
|
||||
# 亚克力方形底靠 DWM 圆角裁剪盖掉;窗口重建后属性丢失,每次 show 重应用
|
||||
self._apply_win11_round_corners()
|
||||
if not self._screen_hooked:
|
||||
wh = self.windowHandle()
|
||||
if wh is not None:
|
||||
@@ -571,6 +573,8 @@ class PanelWindow(QWidget):
|
||||
每项: (tooltip, qtawesome_icon, callback)
|
||||
排除重启/退出。"""
|
||||
return [
|
||||
("新建桌面文件框", "fa5s.window-restore", self._create_file_box),
|
||||
("新建文件夹框", "fa5s.folder-open", self._create_folder_box),
|
||||
("微信多开", "fa5b.weixin", self._open_wechat_multi),
|
||||
("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd),
|
||||
("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell),
|
||||
@@ -840,7 +844,7 @@ class PanelWindow(QWidget):
|
||||
f"""
|
||||
QWidget#container {{
|
||||
background: {t['panel_bg']};
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid {t['panel_border']};
|
||||
}}
|
||||
"""
|
||||
@@ -964,7 +968,7 @@ class PanelWindow(QWidget):
|
||||
f"""
|
||||
QWidget#bottom_bar {{
|
||||
background: {bar_bg2};
|
||||
border-radius: 0 0 12px 12px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border-top: 1px solid {t['panel_border']};
|
||||
}}
|
||||
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
|
||||
@@ -993,6 +997,14 @@ class PanelWindow(QWidget):
|
||||
if item and item.widget():
|
||||
item.widget()._apply_theme()
|
||||
|
||||
# ✅ 主题变化时通知所有 FileBox 重绘
|
||||
try:
|
||||
from ui.box import FileBox
|
||||
for b in FileBox._instances:
|
||||
b._apply_theme() # 重设 QSS(container + title_bar 都会刷新)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_tooltip_theme(self, t: dict, is_dark: bool):
|
||||
"""让所有控件 tooltip 跟随主题(含快捷栏按钮)。"""
|
||||
from PyQt6.QtGui import QPalette, QColor
|
||||
@@ -1093,6 +1105,54 @@ class PanelWindow(QWidget):
|
||||
dlg = WechatMultiDialog(self)
|
||||
dlg.exec()
|
||||
|
||||
# ── FileBoxes ─────────────────────────────────────────
|
||||
_file_boxes: list = []
|
||||
|
||||
def _create_file_box(self):
|
||||
"""新建一个空的拖入卡片文件框。"""
|
||||
self._create_box_common(title="文件框", folder_path="")
|
||||
|
||||
def _create_folder_box(self):
|
||||
"""新建文件夹框 — 先让用户选文件夹。"""
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "选择文件夹", os.path.expanduser("~"))
|
||||
if not folder: return
|
||||
title = os.path.basename(folder) or folder
|
||||
self._create_box_common(title=title, folder_path=folder)
|
||||
|
||||
def _create_box_common(self, title: str, folder_path: str):
|
||||
from ui.box import FileBox
|
||||
# 注册 panel 引用 —— FileBox 在 mousePress 时会 raise_ 主面板
|
||||
FileBox._panel_ref = self
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
idx = len(self._file_boxes)
|
||||
x = screen.center().x() - 120 + idx * 20
|
||||
y = screen.center().y() - 160 + idx * 20
|
||||
new_id = database.add_file_box(title, x=x, y=y, w=240, h=320)
|
||||
database.update_file_box(new_id, folder_path=folder_path) # 文件夹框模式
|
||||
box = FileBox({
|
||||
"id": new_id, "title": title, "x": x, "y": y,
|
||||
"w": 240, "h": 320, "folder_path": folder_path,
|
||||
})
|
||||
self._file_boxes.append(box)
|
||||
box.show(); box.raise_(); box.activateWindow()
|
||||
# 主面板始终在 FileBox 之上
|
||||
self.raise_()
|
||||
|
||||
def _load_file_boxes(self):
|
||||
"""启动时从 DB 恢复所有文件框。"""
|
||||
from ui.box import FileBox
|
||||
FileBox._panel_ref = self
|
||||
for d in database.get_file_boxes():
|
||||
box = FileBox(d)
|
||||
self._file_boxes.append(box)
|
||||
box.show()
|
||||
|
||||
def _close_all_file_boxes(self):
|
||||
for b in list(self._file_boxes):
|
||||
b.close()
|
||||
self._file_boxes.clear()
|
||||
|
||||
def _toggle_theme(self):
|
||||
theme.set_theme("light" if theme.name() == "dark" else "dark")
|
||||
self._apply_theme()
|
||||
@@ -1210,6 +1270,18 @@ class PanelWindow(QWidget):
|
||||
self._open_url_in_browser("https://www.doubao.com/chat/", "豆包")
|
||||
|
||||
def refresh_groups(self):
|
||||
# 同步刷新所有分组联动的 FileBox(双向联动)
|
||||
try:
|
||||
from ui.box import FileBox
|
||||
for b in FileBox._instances:
|
||||
if b._is_group_linked:
|
||||
b.refresh_items()
|
||||
# 如果分组被删了 → 自动解除关联
|
||||
if not database.get_items(b._group_id):
|
||||
pass # 分组可能空了,正常
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 记录当前折叠状态
|
||||
collapsed_ids = set()
|
||||
for i in range(self.groups_layout.count() - 1):
|
||||
@@ -1308,6 +1380,8 @@ class PanelWindow(QWidget):
|
||||
self.setGeometry(geo)
|
||||
self.show()
|
||||
self._apply_win32_blur(theme.name() == "dark")
|
||||
# setWindowFlags 重建了原生窗口,DWM 圆角属性丢失,重新应用
|
||||
self._apply_win11_round_corners()
|
||||
ball = getattr(self, "_ball_ref", None)
|
||||
if ball is not None:
|
||||
ball.set_stays_on_top(on_top)
|
||||
@@ -1653,7 +1727,8 @@ class PanelWindow(QWidget):
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
|
||||
self._dock_state = "none"
|
||||
self._is_collapsed = False
|
||||
self.move(gp - self._win_drag_pos)
|
||||
raw_pos = gp - self._win_drag_pos
|
||||
self.move(self._magnetic_snap(raw_pos))
|
||||
return True
|
||||
# 悬停时更新光标
|
||||
self._update_cursor(gp)
|
||||
@@ -1676,7 +1751,8 @@ class PanelWindow(QWidget):
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
|
||||
self._dock_state = "none"
|
||||
self._is_collapsed = False
|
||||
self.move(event.globalPosition().toPoint() - self._win_drag_pos)
|
||||
raw_pos = event.globalPosition().toPoint() - self._win_drag_pos
|
||||
self.move(self._magnetic_snap(raw_pos))
|
||||
return
|
||||
self._update_cursor(event.globalPosition().toPoint())
|
||||
|
||||
@@ -1764,6 +1840,39 @@ class PanelWindow(QWidget):
|
||||
self.show_near(self._ball_ref.pos(), self._ball_ref.width())
|
||||
|
||||
# ── 边缘吸附与收起 ─────────────────────────────────────
|
||||
_SNAP_THRESHOLD = 30 # 拖拽中磁吸触发阈值(像素)
|
||||
|
||||
def _magnetic_snap(self, pos: QPoint) -> QPoint:
|
||||
"""拖拽中实时磁吸:窗口接近边缘时自动吸附到边缘,给用户"磁性"手感。"""
|
||||
screen = QApplication.screenAt(pos)
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
geom = screen.availableGeometry()
|
||||
x, y, w, h = pos.x(), pos.y(), self.width(), self.height()
|
||||
t = self._SNAP_THRESHOLD
|
||||
|
||||
# 优先吸附距离最近的边缘
|
||||
top_dist = abs(y - geom.top())
|
||||
bottom_dist = abs((y + h) - geom.bottom())
|
||||
left_dist = abs(x - geom.left())
|
||||
right_dist = abs((x + w) - geom.right())
|
||||
min_dist = min(top_dist, bottom_dist, left_dist, right_dist)
|
||||
|
||||
if min_dist <= t:
|
||||
if min_dist == top_dist:
|
||||
y = geom.top()
|
||||
elif min_dist == bottom_dist:
|
||||
y = geom.bottom() - h
|
||||
elif min_dist == left_dist:
|
||||
x = geom.left()
|
||||
else:
|
||||
x = geom.right() - w
|
||||
|
||||
# 限制在屏幕范围内
|
||||
x = max(geom.left(), min(x, geom.right() - w))
|
||||
y = max(geom.top(), min(y, geom.bottom() - h))
|
||||
return QPoint(x, y)
|
||||
|
||||
def _check_dock_state(self):
|
||||
if self._pinned:
|
||||
self._dock_state = "none"
|
||||
@@ -1772,10 +1881,11 @@ class PanelWindow(QWidget):
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geom = screen.geometry()
|
||||
# 用 availableGeometry 排除任务栏区域,避免吸附到任务栏上
|
||||
screen_geom = screen.availableGeometry()
|
||||
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
threshold = 20
|
||||
threshold = 30 # 吸附阈值(像素)
|
||||
|
||||
# 检测顶部吸附
|
||||
if abs(y - screen_geom.top()) <= threshold:
|
||||
@@ -1803,7 +1913,7 @@ class PanelWindow(QWidget):
|
||||
if not is_hovered:
|
||||
screen = QApplication.screenAt(cursor_pos)
|
||||
if screen:
|
||||
screen_geom = screen.geometry()
|
||||
screen_geom = screen.availableGeometry()
|
||||
tolerance = 3
|
||||
if self._dock_state == "top" and cursor_pos.y() <= screen_geom.top() + tolerance:
|
||||
if geom.left() <= cursor_pos.x() <= geom.right():
|
||||
@@ -1831,7 +1941,7 @@ class PanelWindow(QWidget):
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geom = screen.geometry()
|
||||
screen_geom = screen.availableGeometry()
|
||||
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
target_x, target_y = x, y
|
||||
@@ -1854,7 +1964,7 @@ class PanelWindow(QWidget):
|
||||
screen = QApplication.screenAt(self.geometry().center())
|
||||
if not screen:
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geom = screen.geometry()
|
||||
screen_geom = screen.availableGeometry()
|
||||
|
||||
x, y, w, h = self.x(), self.y(), self.width(), self.height()
|
||||
target_x, target_y = x, y
|
||||
@@ -1881,6 +1991,26 @@ class PanelWindow(QWidget):
|
||||
self._dock_anim.finished.connect(self.raise_)
|
||||
self._dock_anim.start()
|
||||
|
||||
def _apply_win11_round_corners(self):
|
||||
"""Windows 11: DWMWA_WINDOW_CORNER_PREFERENCE(33) = DWMWCP_ROUND(2)。
|
||||
|
||||
必须调用:亚克力合成层(SetWindowCompositionAttribute)作用于**整个方形窗口**,
|
||||
QSS border-radius 只能裁 Qt 内容层,裁不掉 OS 层的方形亚克力 →
|
||||
四角圆弧外露出方形底座("圆角下面垫着直角"的根因)。
|
||||
DWMWCP_ROUND 让 DWM 在 OS 层物理裁剪窗口四角,亚克力方形一起被裁掉。
|
||||
setWindowFlags 重建窗口后 DWM 属性会丢,需在 showEvent / 置顶切换后重应用。"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
hwnd = int(self.winId())
|
||||
pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
hwnd, 33, ctypes.byref(pref), ctypes.sizeof(pref)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_win32_blur(self, is_dark: bool):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
@@ -25,6 +25,7 @@ class FlowContainer(QWidget):
|
||||
|
||||
def add_widget(self, w: QWidget):
|
||||
w.setParent(self)
|
||||
w.show() # ⚠️ 关键:必须显式 show,否则从 clear_widgets 里 hide() 后不会自动显示
|
||||
self._widgets.append(w)
|
||||
if self.width() > 0:
|
||||
self._relayout()
|
||||
|
||||
+88
@@ -486,6 +486,13 @@ class GroupWidget(QWidget):
|
||||
self._add_btn.click() if e.button() == Qt.MouseButton.LeftButton else None
|
||||
)
|
||||
|
||||
# 🖥️ 变成桌面文件框按钮(放在加号左边)
|
||||
self._dock_btn = QPushButton()
|
||||
self._dock_btn.setFixedSize(22, 22)
|
||||
self._dock_btn.setToolTip("变成桌面文件框")
|
||||
self._dock_btn.setStyleSheet("border:none; background:transparent;")
|
||||
self._dock_btn.clicked.connect(self._toggle_dock_box)
|
||||
|
||||
self._more_btn = QPushButton()
|
||||
self._more_btn.setFixedSize(22, 22)
|
||||
self._more_btn.setStyleSheet("border:none; background:transparent;")
|
||||
@@ -496,6 +503,7 @@ class GroupWidget(QWidget):
|
||||
h_layout.addWidget(self.title_label)
|
||||
h_layout.addStretch()
|
||||
h_layout.addWidget(self.path_label)
|
||||
h_layout.addWidget(self._dock_btn)
|
||||
h_layout.addWidget(self._add_btn)
|
||||
h_layout.addWidget(self._more_btn)
|
||||
|
||||
@@ -511,10 +519,65 @@ class GroupWidget(QWidget):
|
||||
n = item_count_for_group(self.group_data["id"])
|
||||
self.title_label.setText(f"{self.group_data['name']} ({n})")
|
||||
|
||||
def _has_docked_box(self) -> bool:
|
||||
"""检查本分组是否已有关联的桌面文件框。"""
|
||||
conn = database.get_conn()
|
||||
row = conn.execute("SELECT id FROM file_boxes WHERE group_id=?",
|
||||
(self.group_data["id"],)).fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
|
||||
def _toggle_dock_box(self):
|
||||
"""点击 🖥️ 按钮:创建或恢复/聚焦已关联的桌面文件框。"""
|
||||
from db import database as db_mod
|
||||
from ui.box import FileBox
|
||||
|
||||
gid = self.group_data["id"]
|
||||
box_id = db_mod.create_file_box_from_group(gid)
|
||||
if box_id is None:
|
||||
# 已存在 → 找出来 show + raise
|
||||
conn = db_mod.get_conn()
|
||||
row = conn.execute("SELECT id FROM file_boxes WHERE group_id=?", (gid,)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
box_id = row["id"]
|
||||
|
||||
# 拿到 PanelWindow 引用
|
||||
main = self.window() if hasattr(self, "window") else None
|
||||
from ui.dock import PanelWindow
|
||||
if isinstance(main, PanelWindow):
|
||||
main._load_file_boxes()
|
||||
else:
|
||||
# 直接构造一个 FileBox 并 show
|
||||
conn = db_mod.get_conn()
|
||||
box_row = conn.execute("SELECT * FROM file_boxes WHERE id=?", (box_id,)).fetchone()
|
||||
conn.close()
|
||||
if box_row:
|
||||
box_data = dict(box_row)
|
||||
# 如果 FileBox._panel_ref 还没设,尝试找一个
|
||||
if FileBox._panel_ref is None:
|
||||
# 扫描 QApplication topLevelWidgets 找 PanelWindow
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, PanelWindow):
|
||||
FileBox._panel_ref = w; break
|
||||
box = FileBox(box_data)
|
||||
box.show()
|
||||
|
||||
self._apply_theme() # 刷新按钮状态色
|
||||
|
||||
def _apply_theme(self):
|
||||
t = theme.current()
|
||||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||||
|
||||
# 已关联文件框 → 用蓝色高亮按钮
|
||||
if self._has_docked_box():
|
||||
dock_color = "#4a9eff"
|
||||
self._dock_btn.setToolTip("已在桌面文件框(点击聚焦)")
|
||||
else:
|
||||
dock_color = ic
|
||||
self._dock_btn.setToolTip("变成桌面文件框")
|
||||
|
||||
self.header.setStyleSheet(f"""
|
||||
QWidget {{ background:{t['header_bg']}; border-radius:5px; }}
|
||||
QWidget:hover {{ background:{t['header_hover']}; }}
|
||||
@@ -526,6 +589,8 @@ class GroupWidget(QWidget):
|
||||
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._dock_btn.setIcon(qta.icon("fa5s.desktop", color=dock_color))
|
||||
self._dock_btn.setIconSize(QSize(11, 11))
|
||||
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))
|
||||
@@ -573,6 +638,7 @@ class GroupWidget(QWidget):
|
||||
QMenu::item:selected {{ background:{t['menu_selected']}; }}
|
||||
""")
|
||||
rename_act = menu.addAction("重命名")
|
||||
dock_act = menu.addAction("🖥️ 变成桌面文件框")
|
||||
del_act = menu.addAction("删除分组")
|
||||
action = menu.exec(QCursor.pos())
|
||||
if action == rename_act:
|
||||
@@ -583,6 +649,28 @@ class GroupWidget(QWidget):
|
||||
database.rename_group(self.group_data["id"], name.strip())
|
||||
self.group_data["name"] = name.strip()
|
||||
self._update_title_count()
|
||||
elif action == dock_act:
|
||||
from db import database as db_mod
|
||||
from ui.dock import PanelWindow # 延迟 import
|
||||
box_id = db_mod.create_file_box_from_group(self.group_data["id"])
|
||||
if box_id is None:
|
||||
dialog_style.information(self, "提示", "该分组已关联桌面文件框")
|
||||
else:
|
||||
# 通知主面板立即加载这个新 FileBox
|
||||
main_window = self.window()
|
||||
if isinstance(main_window, PanelWindow):
|
||||
main_window._load_file_boxes()
|
||||
# 找到刚创建的 FileBox 并 show
|
||||
from ui.box import FileBox
|
||||
for b in FileBox._instances:
|
||||
if b._group_id == self.group_data["id"] and not b.isVisible():
|
||||
b.show()
|
||||
break
|
||||
dialog_style.information(
|
||||
self, "成功",
|
||||
f"分组「{self.group_data['name']}」已变成桌面文件框 ✅\n"
|
||||
f"两者数据实时联动"
|
||||
)
|
||||
elif action == del_act:
|
||||
ret = dialog_style.question(
|
||||
self,
|
||||
|
||||
+35
-5
@@ -265,6 +265,7 @@ class ItemWidget(QWidget):
|
||||
menu.setStyleSheet(menu_style)
|
||||
|
||||
open_act = menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开")
|
||||
open_folder_act = menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置")
|
||||
menu.addSeparator()
|
||||
|
||||
item_id = self.item_data.get("id")
|
||||
@@ -291,6 +292,8 @@ class ItemWidget(QWidget):
|
||||
|
||||
if action == open_act:
|
||||
self._launch()
|
||||
elif action == open_folder_act:
|
||||
self._open_folder()
|
||||
elif action == rename_act:
|
||||
self._rename()
|
||||
elif repath_act and action == repath_act:
|
||||
@@ -423,13 +426,40 @@ class ItemWidget(QWidget):
|
||||
except OSError:
|
||||
try:
|
||||
import subprocess
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if sys.platform == "win32":
|
||||
# shell=True 无法直接“运行” .lnk,需交给 start 做关联打开
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "", path],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
# 尝试直接启动(适用于 .exe / .bat / .cmd)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".exe", ".bat", ".cmd"):
|
||||
subprocess.Popen([path], creationflags=creationflags)
|
||||
else:
|
||||
# .lnk / .url 等走 start 关联打开
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "", path],
|
||||
creationflags=creationflags,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
except Exception as e:
|
||||
print(f"启动失败: {path!r} -> {e}")
|
||||
|
||||
def _open_folder(self):
|
||||
"""在系统资源管理器中打开文件所在文件夹并选中该文件。"""
|
||||
path = (self.item_data.get("path") or "").strip()
|
||||
if not path:
|
||||
return
|
||||
path = os.path.normpath(path)
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# explorer /select,"路径" —— /select,"..." 必须作为一个整体参数
|
||||
import ctypes
|
||||
ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "open", "explorer.exe",
|
||||
'/select,"{}"'.format(path),
|
||||
None, 1, # SW_SHOWNORMAL
|
||||
)
|
||||
else:
|
||||
folder = os.path.dirname(path)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
except Exception as e:
|
||||
print(f"打开文件夹失败: {path!r} -> {e}")
|
||||
+154
-52
@@ -1,16 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
软件自动更新模块
|
||||
- 检查版本:GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware
|
||||
- 下载新版 exe,替换当前程序后重启
|
||||
软件自动更新模块 —— 主程序负责"下载 + 启动外部更新器 + 退出",
|
||||
真正的替换/重启由 update_helper.py 完成(独立进程,不随主程序死)。
|
||||
|
||||
更新包格式(自动识别):
|
||||
.exe → onefile 直接替换启动器,清理遗留 _internal/ 死目录
|
||||
.zip → 解压整包,备份旧目录 → 原子替换 → 清理
|
||||
|
||||
检查接口: GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import json
|
||||
import ctypes
|
||||
import base64
|
||||
import argparse # noqa: F401 # update_helper 用 PyQt6 环境也行
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication
|
||||
import urllib.request
|
||||
@@ -77,57 +81,152 @@ class _DownloadWorker(QThread):
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
def _replace_and_restart(new_exe: str):
|
||||
if not getattr(sys, "frozen", False):
|
||||
subprocess.Popen([new_exe])
|
||||
# ────────── 启动外部更新器 ─────────────────────────────────
|
||||
def _launch_update_helper(downloaded_path: str) -> None:
|
||||
"""下载完成 → 启动 update_helper 独立进程 → 主程序退出。
|
||||
update_helper 负责:等待主进程退出 → 替换文件/目录 → 启动新版本 → 清理。"""
|
||||
is_frozen = getattr(sys, "frozen", False)
|
||||
|
||||
if not is_frozen:
|
||||
# 开发态:无法自更新(运行时不能替换自己),只提示用户下载
|
||||
dialog_style.information(
|
||||
None, "开发模式",
|
||||
"开发环境无法自动更新。\n请手动下载新版本,解压后替换原目录。",
|
||||
)
|
||||
QApplication.quit()
|
||||
return
|
||||
|
||||
current_exe = sys.executable
|
||||
# ─── onefile vs onedir 路径差异 ────────────────────────
|
||||
# onefile 模式:sys.executable = _MEIPASS 里的临时副本,需要用 GetModuleFileNameW 找原始 exe
|
||||
# onedir 模式:sys.executable 就是真实路径,直接用
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# onefile:原始 exe 路径
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH)
|
||||
ctypes.windll.kernel32.GetModuleFileNameW(None, buf, wintypes.MAX_PATH)
|
||||
dst_exe = buf.value
|
||||
else:
|
||||
dst_exe = sys.executable
|
||||
|
||||
# ─── 包类型匹配检查 ───────────────────────────────────
|
||||
# 防止 onefile(100MB) ↔ onedir 启动器(几MB) 混更导致更新后跑不起来
|
||||
try:
|
||||
cur_size = os.path.getsize(dst_exe)
|
||||
new_size = os.path.getsize(downloaded_path)
|
||||
size_diff_abs = abs(cur_size - new_size)
|
||||
# onefile 通常 > 50MB,onedir 启动器通常 < 10MB,差 > 30MB 就报警
|
||||
if size_diff_abs > 30 * 1024 * 1024:
|
||||
ret = dialog_style.warning(
|
||||
None, "更新包格式异常",
|
||||
f"下载的更新包大小({new_size / 1024 / 1024:.1f} MB)"
|
||||
f"与当前程序({cur_size / 1024 / 1024:.1f} MB)差异较大,"
|
||||
f"可能是包格式不匹配(如 onefile vs onedir 混更)。\n\n"
|
||||
f"继续更新可能导致程序无法启动。是否仍要继续?",
|
||||
)
|
||||
if ret != QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
os.remove(downloaded_path)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ─── 找 update_helper.exe ─────────────────────────────
|
||||
# onedir: 与主 exe 同级
|
||||
# onefile: 打包进 onefile exe 解压到 _MEIPASS,需复制到系统 temp 防止清理
|
||||
app_dir = os.path.dirname(os.path.abspath(dst_exe))
|
||||
helper_exe = os.path.join(app_dir, "update_helper.exe")
|
||||
helper_py = os.path.join(app_dir, "_internal", "update_helper.py")
|
||||
|
||||
# onefile 解压目录里的 helper
|
||||
meipass_helper = None
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
meipass_helper = os.path.join(sys._MEIPASS, "update_helper.exe")
|
||||
if not os.path.exists(helper_exe) and os.path.exists(meipass_helper):
|
||||
# 从 _MEIPASS 复制到系统 temp(_MEIPASS 在主进程退出后可能被清理)
|
||||
import tempfile
|
||||
tmp_helper = os.path.join(tempfile.gettempdir(), "niumasoft_update_helper.exe")
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(meipass_helper, tmp_helper)
|
||||
helper_exe = tmp_helper
|
||||
print(f"[updater] copied helper from _MEIPASS to {tmp_helper}")
|
||||
except Exception as e:
|
||||
print(f"[updater] copy helper failed: {e}")
|
||||
helper_exe = meipass_helper # 退而求其次,直接用 _MEIPASS 里的
|
||||
|
||||
# 如果 exe 不存在,尝试用 python 跑 py 脚本(开发态兜底)
|
||||
if not os.path.exists(helper_exe) and not os.path.exists(helper_py):
|
||||
# 最后兜底:找项目根目录的 update_helper.py
|
||||
fallback = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"update_helper.py")
|
||||
if os.path.exists(fallback):
|
||||
helper_py = fallback
|
||||
|
||||
pid = os.getpid()
|
||||
|
||||
ps_script = f"""
|
||||
$pid_target = {pid}
|
||||
$src = '{new_exe.replace(chr(92), chr(92)*2)}'
|
||||
$dst = '{current_exe.replace(chr(92), chr(92)*2)}'
|
||||
if os.path.exists(helper_exe):
|
||||
cmd = [
|
||||
helper_exe,
|
||||
"--src", downloaded_path,
|
||||
"--dst", dst_exe,
|
||||
"--pid", str(pid),
|
||||
"--restart",
|
||||
"--mode", "auto",
|
||||
]
|
||||
elif os.path.exists(helper_py):
|
||||
cmd = [
|
||||
sys.executable if os.path.exists(sys.executable) and sys.executable.lower().endswith("python.exe")
|
||||
else "python",
|
||||
helper_py,
|
||||
"--src", downloaded_path,
|
||||
"--dst", dst_exe,
|
||||
"--pid", str(pid),
|
||||
"--restart",
|
||||
"--mode", "auto",
|
||||
]
|
||||
else:
|
||||
# 什么都找不到,放弃
|
||||
dialog_style.warning(None, "更新失败", "找不到 update_helper 更新器组件。")
|
||||
return
|
||||
|
||||
$waited = 0
|
||||
while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{
|
||||
Start-Sleep -Milliseconds 500
|
||||
$waited += 0.5
|
||||
}}
|
||||
_log_update_request(downloaded_path, dst_exe, pid)
|
||||
|
||||
$ok = $false
|
||||
for ($i = 0; $i -lt 10; $i++) {{
|
||||
try {{
|
||||
Copy-Item -Path $src -Destination $dst -Force
|
||||
$ok = $true
|
||||
break
|
||||
}} catch {{
|
||||
Start-Sleep -Milliseconds 800
|
||||
}}
|
||||
}}
|
||||
try:
|
||||
# CREATE_NEW_CONSOLE:子进程创建独立控制台(不受父进程生命周期影响)
|
||||
# CREATE_NO_WINDOW: 控制台窗口不显示(静默运行)
|
||||
# 不用 DETACHED_PROCESS——它在 Python GUI 程序里不可靠,父进程退出时子进程可能被系统回收
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
except Exception as e:
|
||||
dialog_style.warning(None, "更新失败", f"无法启动更新器:\n{e}")
|
||||
return
|
||||
|
||||
if ($ok) {{
|
||||
Remove-Item $src -Force -ErrorAction SilentlyContinue
|
||||
Start-Process $dst
|
||||
}}
|
||||
"""
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".ps1", mode="w", encoding="utf-8"
|
||||
)
|
||||
tmp.write(ps_script)
|
||||
tmp.close()
|
||||
|
||||
subprocess.Popen(
|
||||
["powershell", "-WindowStyle", "Hidden",
|
||||
"-NonInteractive", "-ExecutionPolicy", "Bypass",
|
||||
"-File", tmp.name],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
# 给 update_helper 一点时间就位(OpenProcess 还没拿到句柄就退了主进程,
|
||||
# WaitForSingleObject 会立刻返回 0,这其实正好是我们想要的,但留 1 秒保险)
|
||||
import time as _t
|
||||
_t.sleep(0.5)
|
||||
QApplication.quit()
|
||||
|
||||
|
||||
def _log_update_request(src: str, dst: str, pid: int) -> None:
|
||||
"""在 update_helper 同目录写一份请求 JSON,方便计划任务/管理员提权模式读取。"""
|
||||
try:
|
||||
app_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
req = {
|
||||
"src": src, "dst": dst, "pid": pid,
|
||||
"restart": True, "ts": __import__("time").time(),
|
||||
}
|
||||
with open(os.path.join(app_dir, "update_request.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(req, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Updater(QObject):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -143,7 +242,7 @@ class Updater(QObject):
|
||||
def _on_check_result(self, data: dict):
|
||||
current = _current_version()
|
||||
latest = data.get("latestVersion", "")
|
||||
url = data.get("downloadUrl", "")
|
||||
url = data.get("downloadUrls", {}).get("windows") or data.get("downloadUrl", "")
|
||||
|
||||
if not _is_newer(latest, current):
|
||||
if not self._silent:
|
||||
@@ -167,11 +266,14 @@ class Updater(QObject):
|
||||
dialog_style.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}")
|
||||
|
||||
def _download(self, url: str):
|
||||
if getattr(sys, "frozen", False):
|
||||
dest_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
dest_dir = tempfile.gettempdir()
|
||||
dest = os.path.join(dest_dir, "_update_new.exe")
|
||||
# 统一下载到系统 temp 目录(不污染安装目录、避免文件锁冲突)
|
||||
tmp = tempfile.gettempdir()
|
||||
# 根据 URL 后缀决定文件名
|
||||
name = os.path.basename(url.split("?")[0]) or "_niumasoft_update"
|
||||
if not os.path.splitext(name)[1]:
|
||||
# 不知道是什么,先存成 zip(update_helper 会自动识别)
|
||||
name += ".zip"
|
||||
dest = os.path.join(tmp, name)
|
||||
|
||||
self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget)
|
||||
self._progress.setWindowTitle("下载更新")
|
||||
@@ -187,7 +289,7 @@ class Updater(QObject):
|
||||
|
||||
def _on_download_done(self, path: str):
|
||||
self._progress.close()
|
||||
_replace_and_restart(path)
|
||||
_launch_update_helper(path)
|
||||
|
||||
def _on_download_error(self, err: str):
|
||||
self._progress.close()
|
||||
|
||||
+5
-16
@@ -3,7 +3,6 @@
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
@@ -203,22 +202,12 @@ class WechatMultiDialog(QDialog):
|
||||
database.set_setting(_DB_KEY_PATH, exe)
|
||||
database.set_setting(_DB_KEY_COUNT, str(count))
|
||||
|
||||
# 写临时 bat,连续 start 多次
|
||||
lines = ["@echo off"]
|
||||
# 直接多次启动,避免 bat 文件中文路径编码问题
|
||||
for _ in range(count):
|
||||
lines.append(f'start "" "{exe}"')
|
||||
bat_content = "\r\n".join(lines) + "\r\n"
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".bat", mode="w", encoding="gbk"
|
||||
)
|
||||
tmp.write(bat_content)
|
||||
tmp.close()
|
||||
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", tmp.name],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW
|
||||
)
|
||||
subprocess.Popen(
|
||||
[exe],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
self.accept()
|
||||
|
||||
# 无边框拖动
|
||||
|
||||
+336
-96
@@ -1,141 +1,381 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
以“计划任务(最高权限)”方式执行静默覆盖更新。
|
||||
外部独立更新器 —— 主进程退出后完成真正的替换/重启。
|
||||
|
||||
工作流:
|
||||
- 主程序(普通权限)下载新 exe 到可写目录(建议 ProgramData)并写入 update_request.json
|
||||
- 计划任务以 SYSTEM/管理员身份运行本程序
|
||||
- 本程序读取请求文件 -> 等待主程序退出 -> 覆盖安装目录 exe -> 重启 -> 清理请求文件
|
||||
不随主进程死、不被主进程文件锁影响、失败可回滚。
|
||||
|
||||
用法(主程序 Popen 启动):
|
||||
update_helper.exe --src <下载包路径> --dst <目标exe路径> --pid <当前PID> [--restart] [--mode auto|onefile|zip] [--keepbackup]
|
||||
|
||||
不依赖计划任务,普通用户权限即可覆盖自己的安装目录。
|
||||
如果用户装在 C:\\Program Files 等受限目录,需配合计划任务提权(保留 update_helper.spec 打包为 exe 的能力)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
import zipfile
|
||||
|
||||
|
||||
APP_NAME = "CleanDesktopOrganizer"
|
||||
REQUEST_FILE = "update_request.json"
|
||||
LOG_FILE = "update.log"
|
||||
|
||||
|
||||
def _programdata_dir() -> str:
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
|
||||
return os.path.join(base, APP_NAME)
|
||||
|
||||
|
||||
def _request_path() -> str:
|
||||
return os.path.join(_programdata_dir(), REQUEST_FILE)
|
||||
|
||||
|
||||
def _log(msg: str):
|
||||
# ────────── 日志 ────────────────────────────────────────────
|
||||
def _log(msg: str) -> None:
|
||||
try:
|
||||
os.makedirs(_programdata_dir(), exist_ok=True)
|
||||
p = os.path.join(_programdata_dir(), LOG_FILE)
|
||||
p = os.path.join(tempfile.gettempdir(), LOG_FILE)
|
||||
with open(p, "a", encoding="utf-8") as f:
|
||||
f.write(msg.rstrip() + "\n")
|
||||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_request() -> dict:
|
||||
p = _request_path()
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _pid_exists(pid: int) -> bool:
|
||||
# ────────── 等待进程退出(Win32 精准版) ───────────────────
|
||||
def _wait_process_exit(pid: int, timeout_sec: float = 90.0) -> bool:
|
||||
"""用 OpenProcess + WaitForSingleObject 等进程退出。
|
||||
比 tasklist 轮询精准:能检测 zombie(已退出但句柄未释放)、
|
||||
能被进程句柄自动释放(不依赖命令行解析)。"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
_log("wait: no pid, skip")
|
||||
return True
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
SYNCHRONIZE = 0x00100000
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
handle = kernel32.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
# OpenProcess 失败通常意味着进程已不存在(访问拒绝)
|
||||
_log(f"wait: OpenProcess pid={pid} failed, assume gone")
|
||||
return True
|
||||
try:
|
||||
# tasklist 不需要管理员权限,SYSTEM 下也可用
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
).stdout
|
||||
return str(pid) in out
|
||||
except Exception:
|
||||
return False
|
||||
waited = 0.0
|
||||
step = 0.5
|
||||
while True:
|
||||
rc = kernel32.WaitForSingleObject(handle, int(step * 1000))
|
||||
waited += step
|
||||
if rc == 0:
|
||||
_log(f"wait: pid={pid} exited after {waited:.1f}s")
|
||||
return True
|
||||
if waited >= timeout_sec:
|
||||
_log(f"wait: pid={pid} TIMEOUT after {timeout_sec}s")
|
||||
return False
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _copy_with_retries(src: str, dst: str, retries: int = 10) -> bool:
|
||||
for _ in range(max(1, retries)):
|
||||
# ────────── 文件操作(带重试 + 原子替换) ────────────────────
|
||||
def _atomic_replace(src: str, dst: str, backup: str | None = None, retries: int = 12) -> bool:
|
||||
"""原子替换:备份旧文件 → os.replace 覆盖 → 失败则恢复备份。"""
|
||||
# 1. 备份旧文件
|
||||
old_exists = os.path.exists(dst)
|
||||
if old_exists and backup:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if os.path.exists(dst):
|
||||
try:
|
||||
os.chmod(dst, 0o666)
|
||||
except Exception:
|
||||
pass
|
||||
# 用二进制流复制,避免权限/锁问题时直接抛错
|
||||
with open(src, "rb") as rf:
|
||||
data = rf.read()
|
||||
with open(dst, "wb") as wf:
|
||||
wf.write(data)
|
||||
os.replace(dst, backup)
|
||||
_log(f"backup: {dst} -> {backup}")
|
||||
except Exception as e:
|
||||
_log(f"backup failed: {e}")
|
||||
return False # 连备份都做不了 = 没权限
|
||||
|
||||
# 2. 尝试替换(os.replace 在 Windows 上对目标进程已退出的文件是原子的)
|
||||
for i in range(max(1, retries)):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
_log(f"replace: ok ({i} attempts)")
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.8)
|
||||
except Exception as e:
|
||||
_log(f"replace attempt {i + 1}/{retries} failed: {e}")
|
||||
time.sleep(0.7)
|
||||
|
||||
# 3. 全部失败 → 恢复备份
|
||||
if backup and os.path.exists(backup) and not os.path.exists(dst):
|
||||
try:
|
||||
os.replace(backup, dst)
|
||||
_log("rollback: restored backup")
|
||||
except Exception as e:
|
||||
_log(f"rollback failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def _clean_onedir_dead_sibling(target_exe: str) -> None:
|
||||
"""onefile 覆盖 onedir 启动器后,同级遗留的 _internal/ 等死目录清理。"""
|
||||
exe_dir = os.path.dirname(os.path.abspath(target_exe))
|
||||
if not exe_dir:
|
||||
return
|
||||
for name in ("_internal", "__pycache__"):
|
||||
dead = os.path.join(exe_dir, name)
|
||||
if os.path.isdir(dead):
|
||||
try:
|
||||
shutil.rmtree(dead, ignore_errors=True)
|
||||
_log(f"cleaned dead dir: {dead}")
|
||||
except Exception as e:
|
||||
_log(f"clean dead dir failed: {e}")
|
||||
|
||||
|
||||
# ────────── zip 解压 + 路径归一 ─────────────────────────────
|
||||
def _extract_zip_normalized(zip_path: str, dest_dir: str) -> str:
|
||||
"""解压 zip 到 dest_dir,自动 strip 单一顶层目录(zip 内嵌套一层目录)。
|
||||
返回实际 payload 根目录(可能是 dest_dir 本身,也可能是 strip 后的子目录)。"""
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
zf.extractall(dest_dir)
|
||||
# 检测是否所有文件都在同一顶层目录下
|
||||
entries = set()
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
# strip 开头的 ./
|
||||
parts = name.replace("\\", "/").lstrip("./").split("/")
|
||||
if parts[0]:
|
||||
entries.add(parts[0])
|
||||
if len(entries) == 1:
|
||||
top = next(iter(entries))
|
||||
candidate = os.path.join(dest_dir, top)
|
||||
if os.path.isdir(candidate):
|
||||
_log(f"zip: strip top folder '{top}', payload={candidate}")
|
||||
return candidate
|
||||
_log(f"zip: no single top folder, payload={dest_dir}")
|
||||
return dest_dir
|
||||
|
||||
|
||||
# ────────── 目录整体替换(zip 模式) ──────────────────────────
|
||||
def _replace_directory(old_dir: str, new_payload: str, keep_backup: bool = False) -> bool:
|
||||
"""备份旧目录为 .bak.时间戳 → 移动新 payload 到旧目录位置 → 清理备份(默认保留最近 3 个)。"""
|
||||
old_dir = os.path.abspath(old_dir)
|
||||
if not os.path.isdir(old_dir):
|
||||
_log(f"replace dir: old_dir {old_dir} not found, treat as fresh install")
|
||||
os.makedirs(old_dir, exist_ok=True)
|
||||
_copy_tree(new_payload, old_dir)
|
||||
return True
|
||||
|
||||
parent = os.path.dirname(old_dir)
|
||||
base = os.path.basename(old_dir)
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_dir = os.path.join(parent, f"{base}.bak.{ts}")
|
||||
|
||||
# 1. 备份旧目录
|
||||
try:
|
||||
req = _read_request()
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
os.rename(old_dir, backup_dir)
|
||||
_log(f"backup dir: {old_dir} -> {backup_dir}")
|
||||
except Exception as e:
|
||||
_log(f"backup dir failed: {e}")
|
||||
return False
|
||||
|
||||
# 2. 移动新 payload 到目标位置
|
||||
try:
|
||||
if os.path.isdir(new_payload):
|
||||
# 直接 rename 最快,但可能跨盘符 → 用 shutil.move
|
||||
moved = shutil.move(new_payload, old_dir)
|
||||
_log(f"move: {new_payload} -> {moved}")
|
||||
else:
|
||||
_log(f"new_payload {new_payload} is not a dir")
|
||||
os.rename(backup_dir, old_dir) # 恢复
|
||||
return False
|
||||
except Exception as e:
|
||||
_log(f"move failed: {e}")
|
||||
try:
|
||||
os.rename(backup_dir, old_dir)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# 3. 清理旧备份(保留最近 3 个)
|
||||
if not keep_backup:
|
||||
_prune_old_backups(parent, base, keep=3)
|
||||
return True
|
||||
|
||||
|
||||
def _copy_tree(src: str, dst: str) -> None:
|
||||
"""递归复制目录树,覆盖目标文件。"""
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
for item in os.listdir(src):
|
||||
s = os.path.join(src, item)
|
||||
d = os.path.join(dst, item)
|
||||
if os.path.isdir(s):
|
||||
_copy_tree(s, d)
|
||||
else:
|
||||
shutil.copy2(s, d)
|
||||
|
||||
|
||||
def _prune_old_backups(parent: str, base: str, keep: int = 3) -> None:
|
||||
"""清理旧的 .bak.* 目录,只保留最近 keep 个。"""
|
||||
try:
|
||||
backups = sorted(
|
||||
[d for d in os.listdir(parent) if d.startswith(f"{base}.bak.")],
|
||||
reverse=True,
|
||||
)
|
||||
for old in backups[keep:]:
|
||||
old_path = os.path.join(parent, old)
|
||||
try:
|
||||
shutil.rmtree(old_path, ignore_errors=True)
|
||||
_log(f"prune backup: {old_path}")
|
||||
except Exception as e:
|
||||
_log(f"prune failed: {e}")
|
||||
except Exception:
|
||||
_log("ERROR: cannot read request file")
|
||||
pass
|
||||
|
||||
|
||||
# ────────── 启动新进程 ──────────────────────────────────────
|
||||
def _launch_new_exe(exe_path: str) -> bool:
|
||||
try:
|
||||
# --from-update: 让新进程跳过单实例检测(旧 mutex 可能还没完全释放)
|
||||
# CREATE_NEW_CONSOLE:新进程创建独立控制台(真正脱离 update_helper)
|
||||
# CREATE_NO_WINDOW: 控制台窗口不显示
|
||||
subprocess.Popen(
|
||||
[exe_path, "--from-update"],
|
||||
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
_log(f"launched: {exe_path} --from-update")
|
||||
return True
|
||||
except Exception as e:
|
||||
_log(f"launch failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ────────── 包类型判断 ──────────────────────────────────────
|
||||
def _detect_mode(src: str) -> str:
|
||||
ext = os.path.splitext(src)[1].lower()
|
||||
if ext == ".zip":
|
||||
return "zip"
|
||||
if ext in (".exe",):
|
||||
return "onefile"
|
||||
# 尝试打开 zip 看看
|
||||
try:
|
||||
with zipfile.ZipFile(src, "r"):
|
||||
return "zip"
|
||||
except Exception:
|
||||
pass
|
||||
return "onefile"
|
||||
|
||||
|
||||
# ────────── 主流程 ───────────────────────────────────────────
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="外部独立更新器")
|
||||
parser.add_argument("--src", required=True, help="下载包路径(onefile exe 或 zip)")
|
||||
parser.add_argument("--dst", required=True, help="目标 exe 路径")
|
||||
parser.add_argument("--pid", type=int, default=0, help="当前运行进程 PID,用于等待退出")
|
||||
parser.add_argument("--restart", action="store_true", help="替换完成后启动新进程")
|
||||
parser.add_argument("--mode", choices=["auto", "onefile", "zip"], default="auto")
|
||||
parser.add_argument("--keepbackup", action="store_true", help="保留备份不清理")
|
||||
parser.add_argument("--zip-root", default=None,
|
||||
help="zip 包内的 exe 相对路径(如 niumasoftware/niumasoftware.exe),"
|
||||
"不传则自动在解压结果中查找同名 exe")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
_log(f"=== START args={json.dumps(vars(args))}")
|
||||
|
||||
src = os.path.abspath(args.src)
|
||||
dst = os.path.abspath(args.dst)
|
||||
mode = args.mode if args.mode != "auto" else _detect_mode(src)
|
||||
|
||||
if not os.path.exists(src):
|
||||
_log(f"ERROR: src not found: {src}")
|
||||
return 2
|
||||
|
||||
src = str(req.get("src") or "")
|
||||
dst = str(req.get("dst") or "")
|
||||
pid = int(req.get("pid") or 0)
|
||||
restart = bool(req.get("restart", True))
|
||||
# 1. 等待主进程退出
|
||||
if args.pid > 0:
|
||||
if not _wait_process_exit(args.pid, timeout_sec=90):
|
||||
_log("WARN: main process still alive after timeout, attempt replacement anyway")
|
||||
|
||||
if not src or not dst:
|
||||
_log(f"ERROR: bad request src={src!r} dst={dst!r} pid={pid}")
|
||||
return 3
|
||||
if mode == "onefile":
|
||||
# ─── onefile 模式 ────────────────────────────────────
|
||||
_log("mode: onefile exe")
|
||||
dst_dir = os.path.dirname(dst)
|
||||
backup_exe = os.path.join(dst_dir, os.path.basename(dst) + ".bak")
|
||||
|
||||
_log(f"START: pid={pid} src={src} dst={dst}")
|
||||
# 原子替换
|
||||
if not _atomic_replace(src, dst, backup=backup_exe):
|
||||
_log("FATAL: onefile replace failed, cannot continue")
|
||||
return 3
|
||||
|
||||
# 等待主程序退出,最多 60 秒
|
||||
waited = 0.0
|
||||
while _pid_exists(pid) and waited < 60.0:
|
||||
time.sleep(0.5)
|
||||
waited += 0.5
|
||||
# 清理遗留的 onedir 死目录
|
||||
_clean_onedir_dead_sibling(dst)
|
||||
|
||||
ok = _copy_with_retries(src, dst, retries=15)
|
||||
_log(f"COPY: ok={ok}")
|
||||
try:
|
||||
os.remove(src)
|
||||
except Exception:
|
||||
pass
|
||||
# 删备份(保留 1 个版本足够回滚)
|
||||
if os.path.exists(backup_exe):
|
||||
try:
|
||||
# 新 exe 启动正常后用户可以手动删,这里只删旧的 .bak
|
||||
os.remove(backup_exe)
|
||||
_log("backup cleaned")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 清理请求文件(成功/失败都尽量清掉,避免重复执行)
|
||||
try:
|
||||
os.remove(_request_path())
|
||||
except Exception:
|
||||
pass
|
||||
if args.restart:
|
||||
if not _launch_new_exe(dst):
|
||||
return 4
|
||||
|
||||
if ok and restart:
|
||||
elif mode == "zip":
|
||||
# ─── zip 整包模式 ───────────────────────────────────
|
||||
_log("mode: zip full-package")
|
||||
tmp_extract = tempfile.mkdtemp(prefix="niumasoft_update_")
|
||||
try:
|
||||
subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW)
|
||||
_log("RESTART: started")
|
||||
except Exception:
|
||||
_log("RESTART: failed")
|
||||
pass
|
||||
return 0
|
||||
payload_root = _extract_zip_normalized(src, tmp_extract)
|
||||
|
||||
_log("DONE: failed")
|
||||
return 4
|
||||
# 确定旧目录和目标 exe 在旧目录里
|
||||
old_dir = os.path.dirname(dst)
|
||||
new_exe_in_payload = dst # 默认同名 exe 相对路径
|
||||
if args.zip_root:
|
||||
new_exe_in_payload = os.path.join(payload_root, args.zip_root)
|
||||
if not os.path.exists(new_exe_in_payload):
|
||||
_log(f"ERROR: --zip-root exe not found: {new_exe_in_payload}")
|
||||
return 5
|
||||
else:
|
||||
# 自动在 payload 里找同名 exe
|
||||
target_name = os.path.basename(dst)
|
||||
found = None
|
||||
for root, _, files in os.walk(payload_root):
|
||||
if target_name in files:
|
||||
found = os.path.join(root, target_name)
|
||||
break
|
||||
if not found:
|
||||
_log(f"ERROR: cannot find {target_name!r} in payload {payload_root}")
|
||||
return 5
|
||||
new_exe_in_payload = found
|
||||
_log(f"auto-found exe in payload: {new_exe_in_payload}")
|
||||
|
||||
# payload 根目录 = exe 所在目录的父级(可能就是 payload_root,也可能子级)
|
||||
payload_exe_dir = os.path.dirname(new_exe_in_payload)
|
||||
if os.path.abspath(payload_exe_dir) != os.path.abspath(payload_root):
|
||||
_log(f"payload exe dir != payload root, using exe dir as new dir root")
|
||||
new_payload = payload_exe_dir
|
||||
else:
|
||||
new_payload = payload_root
|
||||
|
||||
# 替换整个旧目录
|
||||
ok = _replace_directory(old_dir, new_payload, keep_backup=args.keepbackup)
|
||||
if not ok:
|
||||
_log("FATAL: directory replace failed")
|
||||
return 3
|
||||
|
||||
new_dst = os.path.join(old_dir, os.path.basename(dst))
|
||||
if args.restart:
|
||||
if not _launch_new_exe(new_dst):
|
||||
return 4
|
||||
finally:
|
||||
# 清理临时解压目录
|
||||
shutil.rmtree(tmp_extract, ignore_errors=True)
|
||||
_log(f"cleaned temp: {tmp_extract}")
|
||||
|
||||
else:
|
||||
_log(f"FATAL: unknown mode {mode}")
|
||||
return 1
|
||||
|
||||
_log("=== DONE")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
try:
|
||||
sys.exit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
_log(f"UNCAUGHT: {traceback.format_exc()}")
|
||||
sys.exit(99)
|
||||
Reference in new issue
Block a user