优化程序
This commit is contained in:
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.
@@ -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()
|
||||
|
||||
# 无边框拖动
|
||||
|
||||
Reference in new issue
Block a user