用 Python 写了一个【一键增量备份工具】,比手动复制省 80% 时间(附源码+APP下载)

22 阅读5分钟

一、简介

还在为文件复制来复制去而烦恼吗? 还在为误删文件、电脑崩溃而彻夜难眠? U盘插上却忘了手动复制? 云盘同步太慢、还怕隐私泄露?

别慌!今天给大家安利一款超实用的本地备份神器——「一键增量备份」,真正实现 “点一下,就安心”!

✅ 绿色免安装:双击即用,不用联网、不装软件

✅ 完全离线运行:不传数据、不偷隐私、不弹广告

✅ 智能增量备份:只复制新增或修改的文件,省时又省空间

✅ 支持中文路径:像 C:\我的文档\项目 2026 这种路径也能完美识别

✅ 自动建文件夹:目标路径不存在?程序自动创建!

✅ 多任务支持:工作资料、照片、孩子作业……一次全备份!

特别提醒: 部分杀毒软件可能会误报(因为是脚本打包的exe),但程序绝对干净安全!只需将它加入白名单即可放心使用。

使用超简单:

1、把 一键备份.exe 和配置文件 deploy_tasks.json 放在同一文件夹。
2、用记事本编辑 JSON 文件,填好你的“源文件夹”和“备份目标”(通常无需创建配置文件,因为我会将它一起打包,除非您弄不见了)。
3、双击运行 → 点“添加任务”(加载源路径和目标路径)→再点“执行所有任务” →查看执行日志→ 备份完成!

4、高级版本(更多功能):比如✅ 勾选“自动后台运行”,在弹窗中设置循环执行任务的间隔时间(分钟),关闭窗口即可后台运行,全程无需操作!

二、代码

1、支持库

import os
import json
import shutil
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pystray
from PIL import Image
from pathlib import Path

2、 定义函数

CONFIG_FILE = "copy_tasks.json"
ICON_PATH = "yyy.ico"


class CopyManagerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("一键复制管理器")
        self.root.geometry("900x700")
        self.root.protocol("WM_DELETE_WINDOW", self.minimize_to_tray)

        # 尝试加载窗口图标
        try:
            self.root.iconbitmap(ICON_PATH)
        except Exception as e:
            print(f"窗口图标加载失败: {e}")

        # 任务列表:[(source, target), ...]
        self.tasks = []
        self.load_tasks()

        # GUI
        self.setup_ui()

        # 启动托盘
        self.setup_tray()

    def setup_ui(self):
        frame = ttk.Frame(self.root, padding="10")
        frame.pack(fill=tk.BOTH, expand=True)

        # 按钮区域
        btn_frame = ttk.Frame(frame)
        btn_frame.pack(fill=tk.X, pady=(0, 10))

        # 修改为tk.Button并添加基本美化
        self.add_btn = tk.Button(btn_frame, text=" 添加任务 ", command=self.add_task,
                                 bg="#4CAF50", fg="white", font=("微软雅黑", 10),
                                 relief=tk.RAISED, bd=2, cursor="hand2")
        self.add_btn.pack(side=tk.LEFT, padx=10, pady=5, ipadx=5, ipady=3)

        self.run_btn = tk.Button(btn_frame, text=" 执行所有任务 ", command=self.run_all_tasks,
                                 bg="#2196F3", fg="white", font=("微软雅黑", 10),
                                 relief=tk.RAISED, bd=2, cursor="hand2")
        self.run_btn.pack(side=tk.LEFT, padx=10, pady=5, ipadx=5, ipady=3)

        self.clear_btn = tk.Button(btn_frame, text=" 清除所有任务 ", command=self.clear_tasks,
                                   bg="#f44336", fg="white", font=("微软雅黑", 10),
                                   relief=tk.RAISED, bd=2, cursor="hand2")
        self.clear_btn.pack(side=tk.LEFT, padx=10, pady=5, ipadx=5, ipady=3)

        # 任务列表
        tree_frame = ttk.Frame(frame)
        tree_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
        self.tree = ttk.Treeview(tree_frame, columns=("序号", "源路径", "目标路径"), show="headings")
        self.tree.heading("序号", text="序号")
        self.tree.heading("源路径", text="源路径")
        self.tree.heading("目标路径", text="目标路径")

        self.tree.column("序号", width=10, anchor=tk.CENTER)
        self.tree.column("源路径", width=380)
        self.tree.column("目标路径", width=380)
        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        # 右键菜单
        self.context_menu = tk.Menu(self.root, tearoff=0)
        self.context_menu.add_command(label="修改源路径", command=self.modify_source)
        self.context_menu.add_command(label="修改目标路径", command=self.modify_destination)
        self.context_menu.add_separator()
        self.context_menu.add_command(label="删除", command=self.delete_selected_task)
        self.tree.bind("<Button-3>", self.show_context_menu)

        # 日志区域
        log_label = ttk.Label(frame, text="执行日志:")
        log_label.pack(anchor=tk.W)

        self.log_text = tk.Text(frame, height=25, state=tk.DISABLED, wrap=tk.WORD,
                                foreground='white',   # 字体颜色:白色
                                background='black',   # 背景色:黑色
                                font=('Consolas', 10)
                                )
        log_scroll = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.log_text.yview)
        self.log_text.configure(yscrollcommand=log_scroll.set)

        log_frame = ttk.Frame(frame)
        log_frame.pack(fill=tk.BOTH, expand=True)
        self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        log_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        # 加载任务
        self.refresh_task_list()

    def log_message(self, msg):
        """线程安全的日志输出"""
        self.root.after(0, self._append_log, msg)

    def _append_log(self, msg):
        self.log_text.config(state=tk.NORMAL)
        self.log_text.insert(tk.END, msg + "\n")
        self.log_text.see(tk.END)
        self.log_text.config(state=tk.DISABLED)

    def refresh_task_list(self):
        for item in self.tree.get_children():
            self.tree.delete(item)
        # ✅ 关键修复:enumerate 从 1 开始,插入 (序号, 源, 目标)
        for idx, (src, dst) in enumerate(self.tasks, start=1):
            self.tree.insert("", tk.END, values=(idx, src, dst))

    def add_task(self):
        source = filedialog.askdirectory(title="选择源文件夹(可为网络路径)")
        if not source:
            return
        target = filedialog.askdirectory(title="选择目标文件夹")
        if not target:
            return

        self.tasks.append((source, target))
        self.save_tasks()
        self.refresh_task_list()

    def get_selected_index(self):
        selected = self.tree.focus()
        if not selected:
            messagebox.showwarning("警告", "请先选择一个任务。")
            return None
        return self.tree.index(selected)

    def modify_source(self):
        index = self.get_selected_index()
        if index is None:
            return
        old_src, old_dst = self.tasks[index]

        new_src = filedialog.askdirectory(
            title="修改源文件夹(可为网络路径)",
            initialdir=old_src if os.path.exists(old_src) else "/"
        )
        if not new_src:
            return

        self.tasks[index] = (new_src, old_dst)
        self.save_tasks()
        self.refresh_task_list()

    def modify_destination(self):
        index = self.get_selected_index()
        if index is None:
            return
        old_src, old_dst = self.tasks[index]

        new_dst = filedialog.askdirectory(
            title="修改目标文件夹",
            initialdir=old_dst if os.path.exists(old_dst) else "/"
        )
        if not new_dst:
            return

        self.tasks[index] = (old_src, new_dst)
        self.save_tasks()
        self.refresh_task_list()

    def delete_selected_task(self):
        index = self.get_selected_index()
        if index is None:
            return
        if messagebox.askyesno("确认删除", "确定要删除该任务吗?"):
            del self.tasks[index]
            self.save_tasks()
            self.refresh_task_list()

    def show_context_menu(self, event):
        item_id = self.tree.identify_row(event.y)
        if item_id:
            self.tree.selection_set(item_id)
            self.context_menu.post(event.x_root, event.y_root)
        else:
            self.tree.selection_remove(self.tree.selection())

    def run_all_tasks(self):
        if not self.tasks:
            messagebox.showinfo("提示", "没有任务可执行!")
            return
        self.run_btn.config(state=tk.DISABLED)
        self.add_btn.config(state=tk.DISABLED)
        self.clear_btn.config(state=tk.DISABLED)
        self.log_text.config(state=tk.NORMAL)
        self.log_text.delete(1.0, tk.END)
        self.log_text.config(state=tk.DISABLED)
        threading.Thread(target=self._do_copy_tasks, daemon=True).start()

    def _do_copy_tasks(self):
        total = len(self.tasks)
        self.log_message(f"开始执行 {total} 个复制任务...")
        success_count = 0
        for i, (src, dst) in enumerate(self.tasks, 1):
            self.log_message(f"[{i}/{total}] 正在复制: {src}{dst}")
            try:
                copied_files = self.copytree_skip_exists(src, dst)
                self.log_message(f"✓ 完成 ({copied_files} 个新文件)")
                success_count += 1
            except Exception as e:
                error_msg = f"✗ 失败: {str(e)}"
                self.log_message(error_msg)
                self.log_message("已停止执行后续任务。")
                break
        self.log_message(f"全部任务结束:成功 {success_count}/{total}")
        self.root.after(0, self._enable_buttons)

    def _enable_buttons(self):
        self.run_btn.config(state=tk.NORMAL)
        self.add_btn.config(state=tk.NORMAL)
        self.clear_btn.config(state=tk.NORMAL)

    def copytree_skip_exists(self, src, dst):
        """递归复制,跳过已存在文件,返回实际复制的文件数量"""
        src_path = Path(src)
        dst_path = Path(dst)
        copied_count = 0

        if not dst_path.exists():
            dst_path.mkdir(parents=True, exist_ok=True)

        for item in src_path.rglob("*"):
            rel_path = item.relative_to(src_path)
            target = dst_path / rel_path

            if item.is_file():
                if not target.exists():
                    target.parent.mkdir(parents=True, exist_ok=True)
                    shutil.copy2(item, target)
                    copied_count += 1
            elif item.is_dir():
                target.mkdir(exist_ok=True)

        return copied_count

    def clear_tasks(self):
        if messagebox.askyesno("确认", "确定要清除所有任务吗?"):
            self.tasks = []
            self.save_tasks()
            self.refresh_task_list()

    def save_tasks(self):
        with open(CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump(self.tasks, f, ensure_ascii=False, indent=2)

    def load_tasks(self):
        if os.path.exists(CONFIG_FILE):
            try:
                with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                    data = json.load(f)
                    self.tasks = [(item[0], item[1]) for item in data]
            except Exception as e:
                print(f"加载任务失败: {e}")
                self.tasks = []

    # ===== 托盘相关 =====
    def setup_tray(self):
        try:
            image = Image.open(ICON_PATH)
        except:
            image = Image.new('RGB', (16, 16), color='black')
        menu = (
            pystray.MenuItem("显示", self.show_window),
            pystray.MenuItem("退出", self.quit_app)
        )
        self.tray_icon = pystray.Icon("copy_manager", image, "一键复制管理器", menu)
        threading.Thread(target=self.tray_icon.run, daemon=True).start()

    def minimize_to_tray(self):
        self.root.withdraw()

    def show_window(self, icon=None, item=None):
        self.root.deiconify()

    def quit_app(self, icon=None, item=None):
        self.tray_icon.stop()
        self.root.destroy()

3、程序入口

if __name__ == "__main__":
    root = tk.Tk()
    app = CopyManagerApp(root)
    root.mainloop()

三、APP分享

腾讯微云下载:share.weiyun.com/C5514bFH