优化程序

This commit is contained in:
hero920103 committed 2026-10-12 14:57:46 +08:00
1 parent 68e8eb91a7
commit 6df29c9254
27 files changed
+1974 -203

No files matched your search

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.
+954
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
# 无边框拖动