Python 写的Gitee 数据备份工具

0 阅读15分钟

image.png

Gitee 数据备份工具

一个用于将本地文件自动备份到 Gitee 仓库的桌面应用程序。

功能特性

  • 自动备份: 支持定时自动备份本地文件到 Gitee 仓库
  • 多任务管理: 可创建多个备份任务,每个任务独立配置
  • 系统托盘: 支持最小化到系统托盘,后台静默运行
  • 命令行模式: 支持 CLI 模式运行,可配合 Windows 任务计划程序使用
  • 实时日志: 显示备份进度和详细日志信息
  • 文件过滤: 自动跳过超大文件和常见忽略目录(.git, node_modules 等)

系统要求

  • Windows 10/11
  • Python 3.8+ (如需从源码运行)
  • Git (需已安装并添加到系统 PATH)

安装

方式一:使用打包好的 EXE 文件

直接运行 GiteeBackup.exe 即可。

方式二:从源码运行

  1. 克隆仓库:
git clone <repository-url>
  1. 安装依赖:
pip install -r requirements.txt
  1. 运行程序:
python main.py

使用说明

1. 配置 Gitee Access Token

  1. 登录 Gitee,进入 设置 -> 私有令牌 -> 生成新令牌
  2. 勾选所需权限(至少需要 projects 权限)
  3. 复制生成的 Token
  4. 在软件中填入 Token、用户名和邮箱,点击 保存配置

2. 创建备份任务

  1. 点击 添加任务 按钮
  2. 填写任务信息:
    • 任务名称: 便于识别的名称
    • 源路径: 要备份的本地文件夹路径
    • 仓库名称: Gitee 仓库名称(如不存在会自动创建)
  3. 可选:设置定时备份计划
  4. 点击 确定 保存任务

3. 执行备份

  • 手动备份: 选中任务后点击 立即备份
  • 定时备份: 勾选任务的 启用定时 选项,程序会在设定时间自动执行

4. 系统托盘

  • 点击窗口最小化按钮,程序会隐藏到系统托盘
  • 右键点击托盘图标可以:
    • 显示窗口: 重新打开主界面
    • 退出程序: 完全关闭程序

命令行模式

程序支持命令行模式运行,适合配合 Windows 任务计划程序使用:

# 运行所有启用了定时的备份任务
python main.py --cli

# 运行指定任务
python main.py --cli --task "任务名称"

# 列出所有任务
python main.py --list

配合 Windows 任务计划程序

  1. 打开 任务计划程序
  2. 创建基本任务
  3. 设置触发器(如每天凌晨 2 点)
  4. 操作选择 启动程序
  5. 程序路径填写 GiteeBackup.exe
  6. 参数填写 --cli

注意事项

  1. 文件大小限制: 单个文件最大支持 100MB(Gitee 限制)
  2. Token 安全: Token 会保存在本地配置文件中,请妥善保管
  3. 网络连接: 备份过程需要稳定的网络连接
  4. 仓库权限: 确保 Token 有创建和管理仓库的权限

依赖库

requests>=2.28.0
pystray
Pillow

源代码

main.py

import tkinter as tk
from tkinter import ttk, messagebox, filedialog, scrolledtext
import threading
from datetime import datetime
import sys
import argparse
from PIL import Image, ImageDraw
import pystray
import ctypes

from config import ConfigManager, BackupTask
from backup_manager import BackupManager

class GiteeBackupApp:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("Gitee 数据备份工具")
        self.root.geometry("900x700")
        self.root.minsize(800, 600)
        
        self.config_manager = ConfigManager()
        self.backup_manager = BackupManager(self.config_manager)
        self.backup_manager.set_progress_callback(self._on_progress)
        self.backup_manager.set_status_callback(self._on_status)
        self.backup_manager.set_log_callback(self._log)
        
        self._create_ui()
        self._load_config()
        
        self.backup_manager.start_scheduler()
        
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        
        self.tray_icon = None
        self._is_hidden = False
        self._create_tray_icon()
        
        self.root.bind("<Unmap>", self._on_unmap)
    
    def _create_ui(self):
        self._create_menu()
        
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.pack(fill=tk.BOTH, expand=True)
        
        self._create_settings_panel(main_frame)
        
        self._create_tasks_panel(main_frame)
        
        self._create_log_panel(main_frame)
    
    def _create_menu(self):
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)
        
        file_menu = tk.Menu(menubar, tearoff=0)
        menubar.add_cascade(label="文件", menu=file_menu)
        file_menu.add_command(label="新建备份任务", command=self._add_task_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="隐藏到托盘", command=self._hide_window)
        file_menu.add_separator()
        file_menu.add_command(label="退出", command=self._on_close)
        
        help_menu = tk.Menu(menubar, tearoff=0)
        menubar.add_cascade(label="帮助", menu=help_menu)
        help_menu.add_command(label="关于", command=self._show_about)
    
    def _create_settings_panel(self, parent):
        settings_frame = ttk.LabelFrame(parent, text="Gitee 配置", padding="10")
        settings_frame.pack(fill=tk.X, pady=(0, 10))
        
        ttk.Label(settings_frame, text="Access Token:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5)
        self.token_entry = ttk.Entry(settings_frame, width=50, show="*")
        self.token_entry.grid(row=0, column=1, padx=5, pady=5)
        
        ttk.Label(settings_frame, text="用户名:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5)
        self.username_entry = ttk.Entry(settings_frame, width=50)
        self.username_entry.grid(row=1, column=1, padx=5, pady=5)
        
        ttk.Label(settings_frame, text="邮箱:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=5)
        self.email_entry = ttk.Entry(settings_frame, width=50)
        self.email_entry.grid(row=2, column=1, padx=5, pady=5)
        
        btn_frame = ttk.Frame(settings_frame)
        btn_frame.grid(row=3, column=0, columnspan=2, pady=10)
        
        ttk.Button(btn_frame, text="保存配置", command=self._save_config).pack(side=tk.LEFT, padx=5)
        ttk.Button(btn_frame, text="测试连接", command=self._test_connection).pack(side=tk.LEFT, padx=5)
        
        self.connection_status = ttk.Label(btn_frame, text="")
        self.connection_status.pack(side=tk.LEFT, padx=10)
    
    def _create_tasks_panel(self, parent):
        tasks_frame = ttk.LabelFrame(parent, text="备份任务", padding="10")
        tasks_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
        
        toolbar = ttk.Frame(tasks_frame)
        toolbar.pack(fill=tk.X, pady=(0, 5))
        
        ttk.Button(toolbar, text="添加任务", command=self._add_task_dialog).pack(side=tk.LEFT, padx=2)
        ttk.Button(toolbar, text="编辑任务", command=self._edit_task_dialog).pack(side=tk.LEFT, padx=2)
        ttk.Button(toolbar, text="删除任务", command=self._delete_task).pack(side=tk.LEFT, padx=2)
        ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
        ttk.Button(toolbar, text="立即备份", command=self._manual_backup).pack(side=tk.LEFT, padx=2)
        
        columns = ("name", "source", "repo", "schedule", "last_backup", "enabled")
        self.tasks_tree = ttk.Treeview(tasks_frame, columns=columns, show="headings", height=8)
        
        self.tasks_tree.heading("name", text="任务名称")
        self.tasks_tree.heading("source", text="源路径")
        self.tasks_tree.heading("repo", text="仓库名称")
        self.tasks_tree.heading("schedule", text="定时计划")
        self.tasks_tree.heading("last_backup", text="上次备份")
        self.tasks_tree.heading("enabled", text="启用")
        
        self.tasks_tree.column("name", width=120)
        self.tasks_tree.column("source", width=200)
        self.tasks_tree.column("repo", width=120)
        self.tasks_tree.column("schedule", width=100)
        self.tasks_tree.column("last_backup", width=150)
        self.tasks_tree.column("enabled", width=60)
        
        scrollbar = ttk.Scrollbar(tasks_frame, orient=tk.VERTICAL, command=self.tasks_tree.yview)
        self.tasks_tree.configure(yscrollcommand=scrollbar.set)
        
        self.tasks_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
        
        self._refresh_tasks()
    
    def _create_log_panel(self, parent):
        log_frame = ttk.LabelFrame(parent, text="日志", padding="10")
        log_frame.pack(fill=tk.BOTH, expand=True)
        
        self.log_text = scrolledtext.ScrolledText(log_frame, height=8, state=tk.DISABLED)
        self.log_text.pack(fill=tk.BOTH, expand=True)
        
        self.progress_var = tk.DoubleVar()
        self.progress_bar = ttk.Progressbar(log_frame, variable=self.progress_var, maximum=100)
        self.progress_bar.pack(fill=tk.X, pady=(5, 0))
        
        self.status_label = ttk.Label(log_frame, text="就绪")
        self.status_label.pack(fill=tk.X, pady=(5, 0))
    
    def _load_config(self):
        config = self.config_manager.config
        self.token_entry.insert(0, config.gitee_token)
        self.username_entry.insert(0, config.gitee_username)
        self.email_entry.insert(0, config.gitee_email)
    
    def _save_config(self):
        token = self.token_entry.get().strip()
        username = self.username_entry.get().strip()
        email = self.email_entry.get().strip()
        
        if not token:
            messagebox.showwarning("警告", "请输入Access Token")
            return
        
        self.backup_manager.set_gitee_token(token, username, email)
        self._log("配置已保存")
        messagebox.showinfo("成功", "配置已保存")
    
    def _test_connection(self):
        token = self.token_entry.get().strip()
        if not token:
            messagebox.showwarning("警告", "请先输入Access Token")
            return
        
        self.backup_manager.gitee_api = type('GiteeAPI', (), {'get_user_info': lambda: None})()
        from gitee_api import GiteeAPI
        self.backup_manager.gitee_api = GiteeAPI(token)
        
        def test():
            success, result = self.backup_manager.test_connection()
            self.root.after(0, lambda: self._on_test_result(success, result))
        
        threading.Thread(target=test, daemon=True).start()
        self.connection_status.config(text="测试中...")
    
    def _on_test_result(self, success: bool, result: str):
        if success:
            self.connection_status.config(text=f"连接成功: {result}", foreground="green")
            self._log(f"连接测试成功,用户: {result}")
        else:
            self.connection_status.config(text=f"连接失败: {result}", foreground="red")
            self._log(f"连接测试失败: {result}")
    
    def _refresh_tasks(self):
        for item in self.tasks_tree.get_children():
            self.tasks_tree.delete(item)
        
        tasks = self.config_manager.get_backup_tasks()
        for task in tasks:
            schedule = f"{task.schedule_time}" if task.schedule_enabled else "未启用"
            enabled = "是" if task.schedule_enabled else "否"
            last_backup = task.last_backup or "从未备份"
            
            self.tasks_tree.insert("", tk.END, values=(
                task.name,
                task.source_path,
                task.repo_name,
                schedule,
                last_backup,
                enabled
            ))
    
    def _add_task_dialog(self):
        self._task_dialog(None)
    
    def _edit_task_dialog(self):
        selection = self.tasks_tree.selection()
        if not selection:
            messagebox.showwarning("警告", "请先选择一个任务")
            return
        
        index = self.tasks_tree.index(selection[0])
        tasks = self.config_manager.get_backup_tasks()
        if 0 <= index < len(tasks):
            self._task_dialog(index, tasks[index])
    
    def _task_dialog(self, edit_index: int = None, task: BackupTask = None):
        dialog = tk.Toplevel(self.root)
        dialog.title("编辑任务" if edit_index is not None else "添加任务")
        dialog.geometry("500x400")
        dialog.transient(self.root)
        dialog.grab_set()
        
        frame = ttk.Frame(dialog, padding="20")
        frame.pack(fill=tk.BOTH, expand=True)
        
        ttk.Label(frame, text="任务名称:").grid(row=0, column=0, sticky=tk.W, pady=5)
        name_entry = ttk.Entry(frame, width=40)
        name_entry.grid(row=0, column=1, pady=5)
        
        ttk.Label(frame, text="源路径:").grid(row=1, column=0, sticky=tk.W, pady=5)
        source_frame = ttk.Frame(frame)
        source_frame.grid(row=1, column=1, pady=5, sticky=tk.EW)
        source_entry = ttk.Entry(source_frame, width=32)
        source_entry.pack(side=tk.LEFT)
        ttk.Button(source_frame, text="浏览...", command=lambda: self._browse_folder(source_entry)).pack(side=tk.LEFT, padx=5)
        
        ttk.Label(frame, text="仓库名称:").grid(row=2, column=0, sticky=tk.W, pady=5)
        repo_entry = ttk.Entry(frame, width=40)
        repo_entry.grid(row=2, column=1, pady=5)
        
        ttk.Label(frame, text="定时备份:").grid(row=3, column=0, sticky=tk.W, pady=5)
        schedule_frame = ttk.Frame(frame)
        schedule_frame.grid(row=3, column=1, pady=5, sticky=tk.W)
        
        enabled_var = tk.BooleanVar()
        enabled_check = ttk.Checkbutton(schedule_frame, text="启用", variable=enabled_var)
        enabled_check.pack(side=tk.LEFT)
        
        ttk.Label(schedule_frame, text="时间:").pack(side=tk.LEFT, padx=(10, 5))
        time_entry = ttk.Entry(schedule_frame, width=8)
        time_entry.pack(side=tk.LEFT)
        time_entry.insert(0, "00:00")
        
        ttk.Label(frame, text="重复日期:").grid(row=4, column=0, sticky=tk.W, pady=5)
        days_frame = ttk.Frame(frame)
        days_frame.grid(row=4, column=1, pady=5, sticky=tk.W)
        
        days_vars = []
        day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
        for i, day in enumerate(day_names):
            var = tk.BooleanVar(value=True)
            days_vars.append(var)
            ttk.Checkbutton(days_frame, text=day, variable=var).pack(side=tk.LEFT)
        
        if task:
            name_entry.insert(0, task.name)
            source_entry.insert(0, task.source_path)
            repo_entry.insert(0, task.repo_name)
            enabled_var.set(task.schedule_enabled)
            time_entry.delete(0, tk.END)
            time_entry.insert(0, task.schedule_time)
            for i, var in enumerate(days_vars):
                var.set(i in (task.schedule_days or []))
        
        def save():
            name = name_entry.get().strip()
            source = source_entry.get().strip()
            repo = repo_entry.get().strip()
            
            if not name or not source or not repo:
                messagebox.showwarning("警告", "请填写所有必填项")
                return
            
            if not repo.replace('-', '').replace('_', '').isalnum():
                messagebox.showwarning("警告", "仓库名称只能包含字母、数字、-和_")
                return
            
            days = [i for i, var in enumerate(days_vars) if var.get()]
            
            new_task = BackupTask(
                name=name,
                source_path=source,
                repo_name=repo,
                schedule_enabled=enabled_var.get(),
                schedule_time=time_entry.get(),
                schedule_days=days,
                last_backup=task.last_backup if task else None
            )
            
            if edit_index is not None:
                self.config_manager.update_backup_task(edit_index, new_task)
            else:
                self.config_manager.add_backup_task(new_task)
            
            self._refresh_tasks()
            self._log(f"任务已{'更新' if edit_index is not None else '添加'}: {name}")
            dialog.destroy()
        
        btn_frame = ttk.Frame(frame)
        btn_frame.grid(row=5, column=0, columnspan=2, pady=20)
        
        ttk.Button(btn_frame, text="保存", command=save).pack(side=tk.LEFT, padx=10)
        ttk.Button(btn_frame, text="取消", command=dialog.destroy).pack(side=tk.LEFT, padx=10)
    
    def _browse_folder(self, entry: ttk.Entry):
        folder = filedialog.askdirectory()
        if folder:
            entry.delete(0, tk.END)
            entry.insert(0, folder)
    
    def _delete_task(self):
        selection = self.tasks_tree.selection()
        if not selection:
            messagebox.showwarning("警告", "请先选择一个任务")
            return
        
        if messagebox.askyesno("确认", "确定要删除选中的任务吗?"):
            index = self.tasks_tree.index(selection[0])
            self.config_manager.remove_backup_task(index)
            self._refresh_tasks()
            self._log("任务已删除")
    
    def _manual_backup(self):
        selection = self.tasks_tree.selection()
        if not selection:
            messagebox.showwarning("警告", "请先选择一个任务")
            return
        
        index = self.tasks_tree.index(selection[0])
        
        def backup():
            self.backup_manager.manual_backup(index)
        
        threading.Thread(target=backup, daemon=True).start()
    
    def _on_progress(self, current: int, total: int, message: str):
        if total > 0:
            progress = (current / total) * 100
            self.root.after(0, lambda: self.progress_var.set(progress))
        self.root.after(0, lambda: self.status_label.config(text=message))
    
    def _on_status(self, status: str):
        self._log(status)
        self.root.after(0, lambda: self.status_label.config(text=status))
        self.root.after(0, self._refresh_tasks)
    
    def _log(self, message: str):
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        log_message = f"[{timestamp}] {message}\n"
        
        def append_log():
            self.log_text.config(state=tk.NORMAL)
            self.log_text.insert(tk.END, log_message)
            self.log_text.see(tk.END)
            self.log_text.config(state=tk.DISABLED)
        
        self.root.after(0, append_log)
    
    def _show_about(self):
        messagebox.showinfo("关于", 
            "Gitee 数据备份工具\n\n"
            "版本: 1.0.0\n\n"
            "功能:\n"
            "- 支持手动备份\n"
            "- 支持定时备份\n"
            "- 多任务管理\n"
            "- 最小化到系统托盘\n\n"
            "使用Gitee API进行数据备份"
        )
    
    def _create_tray_icon(self):
        def create_image():
            width = 64
            height = 64
            color = (70, 130, 180)
            image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
            dc = ImageDraw.Draw(image)
            dc.ellipse([8, 8, 56, 56], fill=color, outline=(50, 100, 150), width=2)
            dc.text((14, 18), 'Gitee', fill='white')
            dc.text((14, 36), 'Backup', fill='white')
            return image
        
        menu = pystray.Menu(
            pystray.MenuItem('显示窗口', self._show_window, default=True),
            pystray.Menu.SEPARATOR,
            pystray.MenuItem('退出程序', self._on_tray_exit)
        )
        
        self.tray_icon = pystray.Icon('gitee-backup', create_image(), 'Gitee 备份工具', menu)
        
        def run_tray():
            self.tray_icon.run()
        
        tray_thread = threading.Thread(target=run_tray, daemon=True)
        tray_thread.start()
    
    def _on_unmap(self, event):
        if event.widget == self.root:
            if self.root.state() == 'iconic':
                self.root.after(100, self._hide_window)
    
    def _hide_window(self):
        self.root.withdraw()
        self._is_hidden = True
    
    def _show_window(self, icon=None, item=None):
        self.root.after(0, self._do_show_window)
    
    def _do_show_window(self):
        self.root.deiconify()
        self.root.state('normal')
        self.root.lift()
        self.root.focus_force()
        self._is_hidden = False
    
    def _on_tray_exit(self, icon=None, item=None):
        if self.tray_icon:
            self.tray_icon.stop()
        self.root.after(0, self._do_close)
    
    def _on_close(self):
        self._hide_window()
    
    def _do_close(self):
        self.backup_manager.stop_scheduler()
        self.root.destroy()

def run_cli_backup(task_name: str = None):
    print("=" * 50)
    print("Gitee 备份工具 - 命令行模式")
    print("=" * 50)
    
    config_manager = ConfigManager()
    backup_manager = BackupManager(config_manager)
    
    def log_callback(msg):
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] {msg}")
    
    backup_manager.set_log_callback(log_callback)
    
    tasks = config_manager.get_backup_tasks()
    
    if not tasks:
        print("错误: 没有配置任何备份任务")
        return 1
    
    if task_name:
        for i, task in enumerate(tasks):
            if task.name == task_name:
                print(f"执行任务: {task.name}")
                success = backup_manager.backup_task(task, i)
                return 0 if success else 1
        print(f"错误: 未找到任务 '{task_name}'")
        return 1
    else:
        print(f"共有 {len(tasks)} 个任务")
        success_count = 0
        for i, task in enumerate(tasks):
            print(f"\n执行任务 [{i+1}/{len(tasks)}]: {task.name}")
            if backup_manager.backup_task(task, i):
                success_count += 1
        print(f"\n完成: {success_count}/{len(tasks)} 个任务成功")
        return 0 if success_count == len(tasks) else 1

def main():
    parser = argparse.ArgumentParser(description="Gitee 数据备份工具")
    parser.add_argument('--cli', action='store_true', help='命令行模式运行')
    parser.add_argument('--task', type=str, help='指定要执行的任务名称')
    parser.add_argument('--all', action='store_true', help='执行所有任务')
    parser.add_argument('--list', action='store_true', help='列出所有任务')
    
    args = parser.parse_args()
    
    if args.list:
        config_manager = ConfigManager()
        tasks = config_manager.get_backup_tasks()
        print("备份任务列表:")
        print("-" * 40)
        for i, task in enumerate(tasks):
            status = "启用" if task.schedule_enabled else "禁用"
            print(f"{i+1}. {task.name}")
            print(f"   源路径: {task.source_path}")
            print(f"   仓库: {task.repo_name}")
            print(f"   定时: {status} - {task.schedule_time}")
            print(f"   上次备份: {task.last_backup or '从未'}")
            print()
        return
    
    if args.cli or args.all or args.task:
        sys.exit(run_cli_backup(args.task))
    
    root = tk.Tk()
    app = GiteeBackupApp(root)
    root.mainloop()

if __name__ == "__main__":
    main()

config.py

import json
import os
from dataclasses import dataclass, asdict
from typing import Optional, List
from datetime import datetime

CONFIG_FILE = "config.json"

@dataclass
class BackupTask:
    name: str
    source_path: str
    repo_name: str
    schedule_enabled: bool = False
    schedule_time: str = "00:00"
    schedule_days: List[int] = None
    last_backup: Optional[str] = None
    
    def __post_init__(self):
        if self.schedule_days is None:
            self.schedule_days = [0, 1, 2, 3, 4, 5, 6]

@dataclass
class Config:
    gitee_token: str = ""
    gitee_username: str = ""
    gitee_email: str = ""
    backup_tasks: List[dict] = None
    
    def __post_init__(self):
        if self.backup_tasks is None:
            self.backup_tasks = []

class ConfigManager:
    def __init__(self, config_dir: str = None):
        if config_dir:
            self.config_file = os.path.join(config_dir, CONFIG_FILE)
        else:
            self.config_file = CONFIG_FILE
        self.config = self._load_config()
    
    def _load_config(self) -> Config:
        if os.path.exists(self.config_file):
            try:
                with open(self.config_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    return Config(**data)
            except Exception as e:
                print(f"加载配置失败: {e}")
                return Config()
        return Config()
    
    def save_config(self):
        with open(self.config_file, 'w', encoding='utf-8') as f:
            json.dump(asdict(self.config), f, ensure_ascii=False, indent=2)
    
    def set_gitee_credentials(self, token: str, username: str, email: str):
        self.config.gitee_token = token
        self.config.gitee_username = username
        self.config.gitee_email = email
        self.save_config()
    
    def add_backup_task(self, task: BackupTask):
        self.config.backup_tasks.append(asdict(task))
        self.save_config()
    
    def update_backup_task(self, index: int, task: BackupTask):
        if 0 <= index < len(self.config.backup_tasks):
            self.config.backup_tasks[index] = asdict(task)
            self.save_config()
    
    def remove_backup_task(self, index: int):
        if 0 <= index < len(self.config.backup_tasks):
            self.config.backup_tasks.pop(index)
            self.save_config()
    
    def get_backup_tasks(self) -> List[BackupTask]:
        return [BackupTask(**task) for task in self.config.backup_tasks]
    
    def update_task_last_backup(self, index: int):
        if 0 <= index < len(self.config.backup_tasks):
            self.config.backup_tasks[index]['last_backup'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            self.save_config()

backup_manager.py

import os
import shutil
import subprocess
import tempfile
import threading
import time
from datetime import datetime
from typing import Callable, Optional, List

from config import ConfigManager, BackupTask
from gitee_api import GiteeAPI

class BackupManager:
    MAX_FILE_SIZE = 100 * 1024 * 1024
    
    def __init__(self, config_manager: ConfigManager):
        self.config_manager = config_manager
        self.gitee_api: Optional[GiteeAPI] = None
        self.scheduler_thread: Optional[threading.Thread] = None
        self.running = False
        self.progress_callback: Optional[Callable] = None
        self.status_callback: Optional[Callable] = None
        self.log_callback: Optional[Callable] = None
        self._init_gitee_api()
    
    def _init_gitee_api(self):
        token = self.config_manager.config.gitee_token
        if token:
            self.gitee_api = GiteeAPI(token)
    
    def set_gitee_token(self, token: str, username: str, email: str):
        self.config_manager.set_gitee_credentials(token, username, email)
        self.gitee_api = GiteeAPI(token)
    
    def set_progress_callback(self, callback: Callable):
        self.progress_callback = callback
    
    def set_status_callback(self, callback: Callable):
        self.status_callback = callback
    
    def set_log_callback(self, callback: Callable):
        self.log_callback = callback
    
    def _report_log(self, message: str):
        if self.log_callback:
            self.log_callback(message)
    
    def _report_progress(self, current: int, total: int, message: str = ""):
        if self.progress_callback:
            self.progress_callback(current, total, message)
    
    def _report_status(self, status: str):
        if self.status_callback:
            self.status_callback(status)
    
    def _run_git_command(self, cmd: List[str], cwd: str) -> tuple:
        try:
            env = os.environ.copy()
            env['GIT_TERMINAL_PROMPT'] = '0'
            env['GIT_ASKPASS'] = ''
            env['GCM_INTERACTIVE'] = 'never'
            
            result = subprocess.run(
                cmd,
                cwd=cwd,
                capture_output=True,
                text=True,
                encoding='utf-8',
                errors='replace',
                env=env
            )
            return result.returncode == 0, result.stdout, result.stderr
        except Exception as e:
            return False, "", str(e)
    
    def _get_repo_url(self, owner: str, repo: str) -> str:
        token = self.config_manager.config.gitee_token
        return f"https://oauth2:{token}@gitee.com/{owner}/{repo}.git"
    
    def _collect_files(self, source_path: str, exclude_patterns: List[str] = None) -> tuple:
        files = []
        skipped_files = []
        exclude_patterns = exclude_patterns or ['.git', '__pycache__', 'node_modules', '.idea', '.vscode']
        
        for root, dirs, filenames in os.walk(source_path):
            dirs[:] = [d for d in dirs if d not in exclude_patterns]
            for filename in filenames:
                filepath = os.path.join(root, filename)
                file_size = os.path.getsize(filepath)
                if file_size <= self.MAX_FILE_SIZE:
                    files.append(filepath)
                else:
                    skipped_files.append((filepath, file_size))
        
        return files, skipped_files
    
    def backup_task(self, task: BackupTask, task_index: int = -1) -> bool:
        if not self.gitee_api:
            self._report_status("错误: 未配置Gitee Token")
            return False
        
        if not os.path.exists(task.source_path):
            self._report_status(f"错误: 源路径不存在 - {task.source_path}")
            return False
        
        owner = self.config_manager.config.gitee_username
        if not owner:
            self._report_status("错误: 未配置Gitee用户名")
            return False
        
        temp_dir = None
        
        try:
            self._report_status(f"开始备份: {task.name}")
            self._report_log(f"开始备份: {task.name}")
            
            if not self.gitee_api.repo_exists(owner, task.repo_name):
                self._report_log(f"创建仓库: {task.repo_name}")
                self.gitee_api.create_repo(task.repo_name, f"备份: {task.name}")
            
            temp_dir = tempfile.mkdtemp(prefix="gitee_backup_")
            self._report_log(f"工作目录: {temp_dir}")
            
            repo_url = self._get_repo_url(owner, task.repo_name)
            
            self._report_progress(0, 5, "克隆仓库...")
            self._report_log("克隆仓库...")
            success, stdout, stderr = self._run_git_command(
                ["git", "clone", repo_url, temp_dir],
                temp_dir
            )
            if not success and not os.path.exists(os.path.join(temp_dir, ".git")):
                self._report_log(f"克隆失败,尝试初始化新仓库: {stderr}")
                self._run_git_command(["git", "init"], temp_dir)
                self._run_git_command(["git", "remote", "add", "origin", repo_url], temp_dir)
            
            git_dir = os.path.join(temp_dir, ".git")
            if not os.path.exists(git_dir):
                self._run_git_command(["git", "init"], temp_dir)
                self._run_git_command(["git", "remote", "add", "origin", repo_url], temp_dir)
            
            self._report_progress(1, 5, "清理旧文件...")
            self._report_log("清理旧文件...")
            for item in os.listdir(temp_dir):
                if item == ".git":
                    continue
                item_path = os.path.join(temp_dir, item)
                if os.path.isdir(item_path):
                    shutil.rmtree(item_path)
                else:
                    os.remove(item_path)
            
            self._report_progress(2, 5, "复制文件...")
            files, skipped_files = self._collect_files(task.source_path)
            
            if skipped_files:
                self._report_log(f"跳过 {len(skipped_files)} 个超大文件 (>100MB):")
                for filepath, size in skipped_files[:5]:
                    size_mb = size / (1024 * 1024)
                    self._report_log(f"  - {filepath} ({size_mb:.1f}MB)")
                if len(skipped_files) > 5:
                    self._report_log(f"  ... 还有 {len(skipped_files) - 5} 个文件")
            
            self._report_log(f"复制 {len(files)} 个文件...")
            copied = 0
            for filepath in files:
                relative_path = os.path.relpath(filepath, task.source_path)
                dest_path = os.path.join(temp_dir, relative_path)
                os.makedirs(os.path.dirname(dest_path), exist_ok=True)
                shutil.copy2(filepath, dest_path)
                copied += 1
                if copied % 50 == 0:
                    self._report_progress(2, 5, f"复制文件 {copied}/{len(files)}")
            
            self._report_progress(3, 5, "配置Git...")
            username = self.config_manager.config.gitee_username or "backup"
            email = self.config_manager.config.gitee_email or "backup@local"
            self._run_git_command(["git", "config", "user.name", username], temp_dir)
            self._run_git_command(["git", "config", "user.email", email], temp_dir)
            self._run_git_command(["git", "branch", "-M", "master"], temp_dir)
            
            self._report_progress(4, 5, "提交更改...")
            self._report_log("添加文件到暂存区...")
            self._run_git_command(["git", "add", "-A"], temp_dir)
            
            commit_msg = f"备份: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
            success, stdout, stderr = self._run_git_command(
                ["git", "commit", "-m", commit_msg],
                temp_dir
            )
            
            if not success:
                if "nothing to commit" in stderr or "nothing to commit" in stdout:
                    self._report_log("没有变更,跳过推送")
                    self._report_status("备份完成: 无变更")
                    return True
                self._report_log(f"提交警告: {stderr}")
            
            self._report_progress(5, 5, "推送到远程...")
            self._report_log("推送到 Gitee...")
            
            success, stdout, stderr = self._run_git_command(
                ["git", "branch", "--show-current"],
                temp_dir
            )
            current_branch = stdout.strip() if success else "master"
            if not current_branch:
                current_branch = "master"
            
            self._report_log(f"当前分支: {current_branch}")
            
            success, stdout, stderr = self._run_git_command(
                ["git", "push", "-u", "origin", current_branch, "--force"],
                temp_dir
            )
            
            if not success:
                self._report_log(f"推送 {current_branch} 失败,尝试 main...")
                success, stdout, stderr = self._run_git_command(
                    ["git", "push", "-u", "origin", "main", "--force"],
                    temp_dir
                )
            
            if not success:
                self._report_log(f"推送失败: {stderr}")
                self._report_status(f"备份失败: {stderr}")
                return False
            
            if task_index >= 0:
                self.config_manager.update_task_last_backup(task_index)
            
            self._report_log(f"备份完成: {task.name} ({len(files)} 个文件)")
            self._report_status(f"备份完成: {task.name}")
            return True
            
        except Exception as e:
            error_msg = f"备份失败: {str(e)}"
            self._report_log(error_msg)
            self._report_status(error_msg)
            return False
        
        finally:
            if temp_dir and os.path.exists(temp_dir):
                try:
                    shutil.rmtree(temp_dir)
                except:
                    pass
    
    def start_scheduler(self):
        if self.scheduler_thread and self.scheduler_thread.is_alive():
            return
        
        self.running = True
        self.scheduler_thread = threading.Thread(target=self._scheduler_loop, daemon=True)
        self.scheduler_thread.start()
    
    def stop_scheduler(self):
        self.running = False
        if self.scheduler_thread:
            self.scheduler_thread.join(timeout=5)
    
    def _scheduler_loop(self):
        while self.running:
            now = datetime.now()
            tasks = self.config_manager.get_backup_tasks()
            
            for i, task in enumerate(tasks):
                if not task.schedule_enabled:
                    continue
                
                try:
                    schedule_hour, schedule_minute = map(int, task.schedule_time.split(':'))
                    
                    if (now.weekday() in task.schedule_days and
                        now.hour == schedule_hour and 
                        now.minute == schedule_minute):
                        
                        last_backup = task.last_backup
                        if last_backup:
                            last_dt = datetime.strptime(last_backup, "%Y-%m-%d %H:%M:%S")
                            if (now - last_dt).total_seconds() < 3600:
                                continue
                        
                        self.backup_task(task, i)
                except Exception as e:
                    print(f"定时任务执行失败: {e}")
            
            time.sleep(60)
    
    def manual_backup(self, task_index: int) -> bool:
        tasks = self.config_manager.get_backup_tasks()
        if 0 <= task_index < len(tasks):
            return self.backup_task(tasks[task_index], task_index)
        return False
    
    def test_connection(self) -> tuple:
        if not self.gitee_api:
            return False, "未配置Gitee API"
        
        try:
            user_info = self.gitee_api.get_user_info()
            return True, user_info.get('login', 'Unknown')
        except Exception as e:
            return False, str(e)

gitee_api.py

import requests
import base64
import os
from typing import Optional, List, Dict

class GiteeAPI:
    BASE_URL = "https://gitee.com/api/v5"
    
    def __init__(self, token: str):
        self.token = token
        self.session = requests.Session()
        self.session.params = {'access_token': token}
    
    def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
        url = f"{self.BASE_URL}/{endpoint}"
        if 'params' not in kwargs:
            kwargs['params'] = {}
        kwargs['params']['access_token'] = self.token
        
        response = self.session.request(method, url, **kwargs)
        
        if response.status_code >= 400:
            try:
                error_data = response.json()
                error_msg = error_data.get('message', error_data.get('error', response.text))
            except:
                error_msg = response.text or f"HTTP {response.status_code}"
            raise Exception(f"HTTP {response.status_code}: {error_msg}")
        
        response.raise_for_status()
        return response.json()
    
    def get_user_info(self) -> Dict:
        return self._request('GET', 'user')
    
    def get_repos(self, username: str = None) -> List[Dict]:
        if username:
            return self._request('GET', f'users/{username}/repos')
        return self._request('GET', 'user/repos')
    
    def create_repo(self, name: str, description: str = "", private: bool = True) -> Dict:
        data = {
            'name': name,
            'description': description,
            'private': private,
            'has_issues': False,
            'has_wiki': False,
            'auto_init': True
        }
        return self._request('POST', 'user/repos', json=data)
    
    def get_repo(self, owner: str, repo: str) -> Dict:
        return self._request('GET', f'repos/{owner}/{repo}')
    
    def delete_repo(self, owner: str, repo: str) -> bool:
        try:
            self._request('DELETE', f'repos/{owner}/{repo}')
            return True
        except Exception:
            return False
    
    def list_contents(self, owner: str, repo: str, path: str = '') -> List[Dict]:
        return self._request('GET', f'repos/{owner}/{repo}/contents/{path}')
    
    def get_file_content(self, owner: str, repo: str, path: str) -> Dict:
        return self._request('GET', f'repos/{owner}/{repo}/contents/{path}')
    
    def create_file(self, owner: str, repo: str, path: str, content: str, 
                    message: str = "Add file") -> Dict:
        encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8')
        data = {
            'content': encoded_content,
            'message': message
        }
        return self._request('POST', f'repos/{owner}/{repo}/contents/{path}', json=data)
    
    def update_file(self, owner: str, repo: str, path: str, content: str,
                    sha: str, message: str = "Update file") -> Dict:
        encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8')
        data = {
            'content': encoded_content,
            'message': message,
            'sha': sha
        }
        return self._request('PUT', f'repos/{owner}/{repo}/contents/{path}', json=data)
    
    def delete_file(self, owner: str, repo: str, path: str, sha: str,
                    message: str = "Delete file") -> Dict:
        data = {
            'message': message,
            'sha': sha
        }
        return self._request('DELETE', f'repos/{owner}/{repo}/contents/{path}', json=data)
    
    def upload_file(self, owner: str, repo: str, local_path: str, 
                    remote_path: str, message: str = None) -> Dict:
        with open(local_path, 'rb') as f:
            content = f.read()
        
        encoded_content = base64.b64encode(content).decode('utf-8')
        
        if message is None:
            message = f"Upload {os.path.basename(local_path)}"
        
        try:
            existing = self.get_file_content(owner, repo, remote_path)
            sha = existing['sha']
            data = {
                'content': encoded_content,
                'message': message,
                'sha': sha
            }
            return self._request('PUT', f'repos/{owner}/{repo}/contents/{remote_path}', json=data)
        except Exception:
            data = {
                'content': encoded_content,
                'message': message
            }
            return self._request('POST', f'repos/{owner}/{repo}/contents/{remote_path}', json=data)
    
    def create_or_update_file(self, owner: str, repo: str, path: str, 
                               content: bytes, message: str) -> Dict:
        encoded_content = base64.b64encode(content).decode('utf-8')
        
        try:
            existing = self.get_file_content(owner, repo, path)
            sha = existing['sha']
            data = {
                'content': encoded_content,
                'message': message,
                'sha': sha
            }
            return self._request('PUT', f'repos/{owner}/{repo}/contents/{path}', json=data)
        except Exception:
            data = {
                'content': encoded_content,
                'message': message
            }
            return self._request('POST', f'repos/{owner}/{repo}/contents/{path}', json=data)
    
    def repo_exists(self, owner: str, repo: str) -> bool:
        try:
            self.get_repo(owner, repo)
            return True
        except Exception:
            return False

requirements.txt

requests>=2.28.0
pystray
Pillow

注:需要安装好git

build.bat

@echo off
chcp 65001 >nul
echo 正在打包 Gitee 备份工具...
echo.

pyinstaller --onefile ^
    --windowed ^
    --name "GiteeBackup" ^
    --icon=NONE ^
    --add-data "config.json;." ^
    --hidden-import=requests ^
    --hidden-import=urllib3 ^
    --hidden-import=charset_normalizer ^
    --hidden-import=certifi ^
    --collect-all requests ^
    main.py

echo.
if exist "dist\GiteeBackup.exe" (
    echo 打包成功!
    echo 输出文件: dist\GiteeBackup.exe
) else (
    echo 打包失败!
)

pause