200字
使用 PyQt5 构建批量目录文件移动工具
2026-03-18
2026-03-18

使用 PyQt5 构建批量目录文件移动工具

image-wKKg.png

在日常工作中,我们经常会遇到需要将分散在多个文件夹中的大量文件汇总到一个统一目录的情况。手动复制粘贴不仅繁琐,还容易出错。为了解决这个问题,我开发了一个基于 Python 和 PyQt5 的桌面应用程序——文件批量整理工具。本文将带您了解这个工具的设计思路、核心功能实现以及界面美化技巧。

🚀 项目背景

随着时间的推移,电脑中可能会产生大量零散的文件夹,里面存放着各种各样的文件(如照片、文档、日志等)。为了方便归档或备份,我们需要一种能够快速将这些文件“平铺”到一个目标文件夹的工具。

✨ 核心功能

该工具具备以下主要特性:

  1. 拖拽支持:通过重写 QListWidget 的事件处理,实现了直观的文件拖拽添加功能。
  2. 多线程处理:利用 QThread 将耗时的文件操作放在后台线程执行,避免界面冻结。
  3. 智能防重名:自动检测目标目录中是否存在同名文件,并在文件名后追加编号(如 file_1.txt),确保数据安全。
  4. 操作模式切换:灵活选择“移动”或“复制”模式,满足不同场景需求。
  5. 现代化界面:通过 QSS (Qt Style Sheets) 对界面进行了深度美化,使其更加简洁易用。

🛠️ 技术实现详解

1. 拖拽功能的实现

为了让用户能够直接将文件夹拖入列表中,我们需要自定义一个 QListWidget 类,并重写 dragEnterEventdropEvent 方法。

class DragDropListWidget(QListWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAcceptDrops(True)  # 开启拖放支持
        self.setDragDropMode(QListWidget.DropOnly)

    def dragEnterEvent(self, event):
        # 仅接受包含文件路径的拖拽数据
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):
        if event.mimeData().hasUrls():
            for url in event.mimeData().urls():
                path = url.toLocalFile()
                if os.path.isdir(path):
                    # 添加目录路径到列表
                    if path not in [self.item(i).text() for i in range(self.count())]:
                        self.addItem(path)
            event.accept()

2. 多线程文件操作

文件移动是一个耗时操作,如果直接在主线程中执行,会导致界面无响应。我们通过继承 QThread 创建一个工作线程类 OperationWorker 来解决这个问题。

class OperationWorker(QThread):
    progress = pyqtSignal(int)  # 进度信号
    log = pyqtSignal(str)       # 日志信号
    finished = pyqtSignal()     # 完成信号

    def run(self):
        # 1. 扫描所有文件
        # 2. 遍历文件列表进行移动/复制操作
        # 3. 发送进度和日志信号更新 UI
        pass

3. 智能重命名逻辑

为了防止覆盖同名文件,我们在移动前会检查目标路径是否存在。

if os.path.exists(dest_path):
    base, ext = os.path.splitext(file_name)
    counter = 1
    # 循环查找可用的文件名
    while os.path.exists(dest_path):
        dest_path = os.path.join(self.dest_dir, f"{base}_{counter}{ext}")
        counter += 1

4. 界面美化 (QSS)

Qt 的样式表 (QSS) 类似于 CSS,可以极大地提升应用程序的颜值。本项目中,我们对按钮、列表框和分组框都进行了定制。

QPushButton#btnStart {
    background-color: #4CAF50; /* 绿色背景 */
    color: white;
    border: none;
    padding: 12px;
    font-size: 15px;
    font-weight: bold;
    border-radius: 6px; /* 圆角效果 */
}
QPushButton#btnStart:hover {
    background-color: #45a049; /* 悬停变色 */
}

📝 总结

通过这个项目,我们不仅实现了一个实用的文件管理工具,还深入实践了 PyQt5 的多线程编程、自定义控件以及界面美化技术。希望这个开源小工具能帮助大家提高工作效率!


项目源码: [GitHub Repository Link]
作者: [Your Name]
日期: 2023-10-27

完整代码

import sys
import os
import shutil
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, 
                             QHBoxLayout, QLabel, QListWidget, QPushButton, 
                             QFileDialog, QMessageBox, QProgressBar, QTextEdit,
                             QSplitter, QFrame, QRadioButton, QButtonGroup, QGroupBox)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl
from PyQt5.QtGui import QFont, QIcon

class OperationWorker(QThread):
    progress = pyqtSignal(int)
    log = pyqtSignal(str)
    finished = pyqtSignal()
    error = pyqtSignal(str)

    def __init__(self, source_dirs, dest_dir, mode='move'):
        super().__init__()
        self.source_dirs = source_dirs
        self.dest_dir = dest_dir
        self.mode = mode  # 'move' or 'copy'
        self.is_running = True

    def run(self):
        try:
            total_files = 0
            files_to_move = []

            # First pass: count files and collect paths
            self.log.emit("正在扫描文件...")
            for src_dir in self.source_dirs:
                if not os.path.exists(src_dir):
                    self.log.emit(f"警告: 目录不存在 {src_dir}")
                    continue
                
                for root, _, files in os.walk(src_dir):
                    for file in files:
                        file_path = os.path.join(root, file)
                        files_to_move.append(file_path)
            
            total_files = len(files_to_move)
            action_text = "移动" if self.mode == 'move' else "复制"
            self.log.emit(f"共发现 {total_files} 个文件需要{action_text}")

            if total_files == 0:
                self.finished.emit()
                return

            processed_count = 0
            for file_path in files_to_move:
                if not self.is_running:
                    break
                
                try:
                    file_name = os.path.basename(file_path)
                    dest_path = os.path.join(self.dest_dir, file_name)
                    
                    # Handle duplicate filenames
                    if os.path.exists(dest_path):
                        base, ext = os.path.splitext(file_name)
                        counter = 1
                        while os.path.exists(dest_path):
                            dest_path = os.path.join(self.dest_dir, f"{base}_{counter}{ext}")
                            counter += 1
                    
                    if self.mode == 'move':
                        shutil.move(file_path, dest_path)
                        self.log.emit(f"已移动: {file_name} -> {dest_path}")
                    else:
                        shutil.copy2(file_path, dest_path)
                        self.log.emit(f"已复制: {file_name} -> {dest_path}")
                        
                    processed_count += 1
                    self.progress.emit(int(processed_count / total_files * 100))
                    
                except Exception as e:
                    self.error.emit(f"{action_text}文件 {file_path} 失败: {str(e)}")

            self.log.emit(f"操作完成。成功{action_text} {processed_count} 个文件。")
            self.finished.emit()

        except Exception as e:
            self.error.emit(f"发生严重错误: {str(e)}")

    def stop(self):
        self.is_running = False

class DragDropListWidget(QListWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAcceptDrops(True)
        self.setDragDropMode(QListWidget.DropOnly)
        self.setSelectionMode(QListWidget.ExtendedSelection)

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    def dragMoveEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):
        if event.mimeData().hasUrls():
            for url in event.mimeData().urls():
                path = url.toLocalFile()
                if os.path.isdir(path):
                    # Check if already exists
                    items = [self.item(i).text() for i in range(self.count())]
                    if path not in items:
                        self.addItem(path)
            event.accept()
        else:
            event.ignore()

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("文件批量整理工具")
        self.resize(900, 650)
        self.setup_ui()
        self.apply_styles()
        
    def setup_ui(self):
        # Main widget and layout
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QHBoxLayout(central_widget)
        main_layout.setContentsMargins(20, 20, 20, 20)
        main_layout.setSpacing(20)

        # Left side: Source directories
        left_group = QGroupBox("源目录")
        left_layout = QVBoxLayout(left_group)
        
        left_hint = QLabel("💡 拖拽文件夹到此处")
        left_hint.setAlignment(Qt.AlignCenter)
        left_hint.setStyleSheet("color: #666; font-style: italic;")
        
        self.source_list = DragDropListWidget()
        
        btn_layout = QHBoxLayout()
        self.btn_add_src = QPushButton("添加目录")
        self.btn_add_src.setCursor(Qt.PointingHandCursor)
        self.btn_add_src.clicked.connect(self.add_source_dir)
        
        self.btn_remove_src = QPushButton("移除选中")
        self.btn_remove_src.setCursor(Qt.PointingHandCursor)
        self.btn_remove_src.clicked.connect(self.remove_selected_src)
        
        self.btn_clear_src = QPushButton("清空列表")
        self.btn_clear_src.setCursor(Qt.PointingHandCursor)
        self.btn_clear_src.clicked.connect(self.source_list.clear)
        
        btn_layout.addWidget(self.btn_add_src)
        btn_layout.addWidget(self.btn_remove_src)
        btn_layout.addWidget(self.btn_clear_src)
        
        left_layout.addWidget(left_hint)
        left_layout.addWidget(self.source_list)
        left_layout.addLayout(btn_layout)

        # Right side: Settings and Logs
        right_widget = QWidget()
        right_layout = QVBoxLayout(right_widget)
        right_layout.setContentsMargins(0, 0, 0, 0)
        
        # Target Directory Section
        dest_group = QGroupBox("目标设置")
        dest_layout = QVBoxLayout(dest_group)
        
        dest_label = QLabel("目标目录:")
        self.dest_display = QLabel("未选择")
        self.dest_display.setObjectName("destDisplay")
        
        self.btn_select_dest = QPushButton("选择目标目录")
        self.btn_select_dest.setCursor(Qt.PointingHandCursor)
        self.btn_select_dest.clicked.connect(self.select_dest_dir)
        
        # Operation Mode
        mode_label = QLabel("操作模式:")
        mode_layout = QHBoxLayout()
        self.radio_move = QRadioButton("移动文件 (Move)")
        self.radio_copy = QRadioButton("复制文件 (Copy)")
        self.radio_move.setChecked(True)
        self.mode_group = QButtonGroup()
        self.mode_group.addButton(self.radio_move)
        self.mode_group.addButton(self.radio_copy)
        
        mode_layout.addWidget(self.radio_move)
        mode_layout.addWidget(self.radio_copy)
        mode_layout.addStretch()
        
        dest_layout.addWidget(dest_label)
        dest_layout.addWidget(self.dest_display)
        dest_layout.addWidget(self.btn_select_dest)
        dest_layout.addSpacing(10)
        dest_layout.addWidget(mode_label)
        dest_layout.addLayout(mode_layout)
        
        # Action Section
        action_group = QGroupBox("执行操作")
        action_layout = QVBoxLayout(action_group)
        
        self.progress_bar = QProgressBar()
        self.progress_bar.setValue(0)
        self.progress_bar.setAlignment(Qt.AlignCenter)
        
        self.btn_start = QPushButton("开始执行")
        self.btn_start.setCursor(Qt.PointingHandCursor)
        self.btn_start.setObjectName("btnStart")
        self.btn_start.clicked.connect(self.start_operation)
        
        action_layout.addWidget(self.progress_bar)
        action_layout.addWidget(self.btn_start)
        
        # Log Section
        log_group = QGroupBox("操作日志")
        log_layout = QVBoxLayout(log_group)
        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        log_layout.addWidget(self.log_text)
        
        right_layout.addWidget(dest_group)
        right_layout.addWidget(action_group)
        right_layout.addWidget(log_group)

        # Splitter
        splitter = QSplitter(Qt.Horizontal)
        splitter.addWidget(left_group)
        splitter.addWidget(right_widget)
        splitter.setStretchFactor(0, 4)
        splitter.setStretchFactor(1, 5)

        main_layout.addWidget(splitter)
        
        self.worker = None

    def apply_styles(self):
        style_sheet = """
            QMainWindow {
                background-color: #f5f6fa;
            }
            QGroupBox {
                font-weight: bold;
                border: 1px solid #dcdde1;
                border-radius: 6px;
                margin-top: 12px;
                background-color: white;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 5px;
                color: #2f3640;
            }
            QListWidget {
                border: 1px solid #e1e1e1;
                border-radius: 4px;
                background-color: #ffffff;
                padding: 5px;
                font-size: 13px;
            }
            QListWidget::item {
                padding: 5px;
            }
            QListWidget::item:selected {
                background-color: #e3f2fd;
                color: #000;
                border: 1px solid #90caf9;
                border-radius: 3px;
            }
            QPushButton {
                background-color: #ffffff;
                border: 1px solid #dcdcdc;
                border-radius: 4px;
                padding: 6px 12px;
                color: #333;
                font-size: 13px;
            }
            QPushButton:hover {
                background-color: #f0f0f0;
                border-color: #c0c0c0;
            }
            QPushButton:pressed {
                background-color: #e0e0e0;
            }
            QPushButton#btnStart {
                background-color: #4CAF50;
                color: white;
                border: none;
                padding: 12px;
                font-size: 15px;
                font-weight: bold;
                border-radius: 6px;
            }
            QPushButton#btnStart:hover {
                background-color: #45a049;
            }
            QPushButton#btnStart:pressed {
                background-color: #3d8b40;
            }
            QPushButton#btnStart:disabled {
                background-color: #cccccc;
            }
            QLabel#destDisplay {
                border: 1px solid #e1e1e1;
                border-radius: 4px;
                background-color: #fafafa;
                padding: 8px;
                color: #555;
            }
            QProgressBar {
                border: 1px solid #e1e1e1;
                border-radius: 4px;
                text-align: center;
                height: 20px;
            }
            QProgressBar::chunk {
                background-color: #2196F3;
                border-radius: 3px;
            }
            QTextEdit {
                border: 1px solid #e1e1e1;
                border-radius: 4px;
                background-color: #fafafa;
                font-family: Consolas, Monaco, monospace;
                font-size: 12px;
            }
        """
        self.setStyleSheet(style_sheet)
        
        # Set application font
        font = QFont("Microsoft YaHei", 9)
        if not font.exactMatch():
            font = QFont("Segoe UI", 9)
        QApplication.setFont(font)

    def add_source_dir(self):
        dir_path = QFileDialog.getExistingDirectory(self, "选择源目录")
        if dir_path:
            items = [self.source_list.item(i).text() for i in range(self.source_list.count())]
            if dir_path not in items:
                self.source_list.addItem(dir_path)

    def remove_selected_src(self):
        for item in self.source_list.selectedItems():
            self.source_list.takeItem(self.source_list.row(item))

    def select_dest_dir(self):
        dir_path = QFileDialog.getExistingDirectory(self, "选择目标目录")
        if dir_path:
            self.dest_display.setText(dir_path)

    def start_operation(self):
        dest_dir = self.dest_display.text()
        if dest_dir == "未选择" or not os.path.exists(dest_dir):
            QMessageBox.warning(self, "错误", "请先选择有效的目标目录!")
            return

        if self.source_list.count() == 0:
            QMessageBox.warning(self, "错误", "请至少添加一个源目录!")
            return

        source_dirs = [self.source_list.item(i).text() for i in range(self.source_list.count())]
        mode = 'move' if self.radio_move.isChecked() else 'copy'
        
        self.btn_start.setEnabled(False)
        self.log_text.clear()
        self.progress_bar.setValue(0)
        
        self.worker = OperationWorker(source_dirs, dest_dir, mode)
        self.worker.progress.connect(self.progress_bar.setValue)
        self.worker.log.connect(self.append_log)
        self.worker.error.connect(self.append_error)
        self.worker.finished.connect(self.on_finished)
        self.worker.start()

    def append_log(self, message):
        self.log_text.append(message)

    def append_error(self, message):
        self.log_text.append(f"<font color='red'>{message}</font>")

    def on_finished(self):
        self.btn_start.setEnabled(True)
        self.log_text.append("<b>任务结束</b>")
        QMessageBox.information(self, "完成", "操作已完成!")

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())
使用 PyQt5 构建批量目录文件移动工具
作者
一晌小贪欢
发表于
2026-03-18
License
CC BY-NC-SA 4.0

评论