大家好!今天我想给大家分享一款实用的小工具——文件扩展名批量修改工具。这是一个我用Python开发的图形化应用程序,可以帮助大家快速、安全地批量修改文件扩展名。 为什么需要这个工具? 在日常工作中,我们可能会遇到这样的情况: 需要将一堆HTML文件转换为TXT文件以便进行文本处理 需要将某些文档的扩展名规范化 需要批量修改图片或视频文件的格式(仅扩展名) 其他任何需要批量更改文件扩展名的情况 虽然可以通过手动一个个重命名文件来完成这项工作,但当文件数量较多时,这种方式既耗时又容易出错。Windows自带的重命名功能也不够灵活,尤其是需要处理大量位于不同子文件夹中的文件时。 工具特点 这款工具具有以下特点: 简单直观的图形界面:操作简单,无需任何编程知识 强大的功能: 支持选择任意文件夹进行操作 可选择是否包含子文件夹中的文件 支持正则表达式进行复杂的匹配和替换 提供文件预览功能,操作前可以查看哪些文件将被修改 详细的操作日志记录 安全可靠: 安全模式:自动备份所有将被修改的文件 支持撤销操作:一键恢复上次修改 操作前确认机制,防止误操作 详细的错误处理和提示 自定义设置: 可自定义备份保留天数 自定义备份位置 自定义预览文件数量 等等... 使用方法 使用非常简单,只需几个步骤: 1. 基本操作 打开程序,选择要处理的文件夹 输入源扩展名(如 .html)和目标扩展名(如 .txt) 选择是否包含子文件夹 点击"扫描文件"查看匹配的文件 在预览标签页检查将要修改的文件 确认无误后,点击"开始转换" 2. 高级功能 正则表达式模式:勾选"使用正则表达式",可以使用更灵活的匹配和替换规则 安全模式:默认启用,会在操作前备份所有文件 撤销操作:如果对结果不满意,可以点击"撤销上次操作"一键恢复 日志查看:在"日志"标签页可以查看所有操作的详细记录 3. 自定义设置 在"设置"标签页可以自定义: 备份设置(是否自动备份、保留时间、备份位置) 界面设置(是否显示确认对话框、自动刷新预览等) 其他个性化选项 实际效果展示 下面是软件的几个主要界面截图: 主界面 预览页面 设置页面 适用场景举例 网页设计师/前端开发:批量将.html文件转为.txt或.md进行内容编辑 数据分析师:将.csv文件转换为.txt或其他格式 视频/图片处理:统一文件扩展名格式 教育工作者:整理大量课件文件,统一文件格式 任何需要批量整理文件的场景 下载与安装 为了方便大家使用,我已经将程序打包为exe可执行文件,无需安装Python环境,下载后即可使用。 下载地址:[附件] 文件扩展名批量修改工具v1.0.exe 使用须知: 适用于Windows系统(Windows 7/8/10/11) 无需安装,下载后双击即可运行 首次运行可能会有安全提示,请允许运行 技术实现 这个工具使用Python语言开发,主要用到了以下技术: tkinter库:实现图形用户界面 os和shutil库:处理文件操作 re库:实现正则表达式功能 threading:多线程处理,避免界面卡顿 json:保存配置和日志 完整源代码我已放在文章末尾,感兴趣的朋友可以自行查看或修改(略过详细代码部分,我会在文章后面粘贴完整代码)。 未来计划 这个工具还有很多可以改进的地方,计划在未来版本中添加: 批量文件内容替换功能 更多文件匹配规则 任务计划功能 更美观的界面 更多语言支持 结语 这个小工具虽然简单,但在很多场景下能大大提高工作效率。希望能对大家有所帮助! 如果你在使用过程中遇到任何问题,或者有功能建议,欢迎在评论区留言,我会尽力解答和改进。 最后,如果你觉得这个工具有用,请分享给可能需要的朋友。感谢大家的支持! 源代码(供感兴趣的朋友参考) import os import tkinter as tk from tkinter import filedialog, messagebox, ttk, scrolledtext import threading import json import datetime import shutil import re from pathlib import Path class FileExtensionChanger: def __init__(self, root): self.root = root self.root.title("文件扩展名批量修改工具") self.root.geometry("700x600") self.root.resizable(True, True) # 设置主题颜色 self.bg_color = "#f0f0f0" self.accent_color = "#4a7abc" self.root.configure(bg=self.bg_color) # 存储操作历史记录,用于撤销 self.operation_history = [] self.log_file = "file_extension_changer_log.json" # 创建主标签页 self.tab_control = ttk.Notebook(root) # 主操作页面 self.main_tab = tk.Frame(self.tab_control, bg=self.bg_color) # 预览页面 self.preview_tab = tk.Frame(self.tab_control, bg=self.bg_color) # 日志页面 self.log_tab = tk.Frame(self.tab_control, bg=self.bg_color) # 设置页面 self.settings_tab = tk.Frame(self.tab_control, bg=self.bg_color) self.tab_control.add(self.main_tab, text="主页") self.tab_control.add(self.preview_tab, text="预览") self.tab_control.add(self.log_tab, text="日志") self.tab_control.add(self.settings_tab, text="设置") self.tab_control.pack(expand=1, fill=tk.BOTH, padx=5, pady=5) # 初始化各页面 self.setup_main_tab() self.setup_preview_tab() self.setup_log_tab() self.setup_settings_tab() # 加载设置 self.load_settings() # 初始化变量 self.preview_files = [] self.is_scanning = False def setup_main_tab(self): """设置主操作页面""" main_frame = tk.Frame(self.main_tab, bg=self.bg_color) main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) # 添加标题 title_label = tk.Label( main_frame, text="文件扩展名批量修改工具", font=("Arial", 16, "bold"), bg=self.bg_color ) title_label.pack(pady=(0, 20)) # 文件夹选择框架 folder_frame = tk.Frame(main_frame, bg=self.bg_color) folder_frame.pack(fill=tk.X, pady=10) folder_label = tk.Label(folder_frame, text="选择文件夹:", bg=self.bg_color) folder_label.pack(side=tk.LEFT, padx=(0, 10)) self.folder_path = tk.StringVar() folder_entry = tk.Entry(folder_frame, textvariable=self.folder_path, width=40) folder_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) browse_button = tk.Button( folder_frame, text="浏览", command=self.browse_folder, bg=self.accent_color, fg="white", relief=tk.FLAT, padx=10 ) browse_button.pack(side=tk.LEFT, padx=(10, 0)) # 扩展名设置框架 ext_frame = tk.Frame(main_frame, bg=self.bg_color) ext_frame.pack(fill=tk.X, pady=10) # 源扩展名 from_label = tk.Label(ext_frame, text="源扩展名:", bg=self.bg_color) from_label.pack(side=tk.LEFT, padx=(0, 10)) self.from_ext = tk.StringVar(value=".html") from_entry = tk.Entry(ext_frame, textvariable=self.from_ext, width=10) from_entry.pack(side=tk.LEFT) # 目标扩展名 to_label = tk.Label(ext_frame, text="目标扩展名:", bg=self.bg_color) to_label.pack(side=tk.LEFT, padx=(20, 10)) self.to_ext = tk.StringVar(value=".txt") to_entry = tk.Entry(ext_frame, textvariable=self.to_ext, width=10) to_entry.pack(side=tk.LEFT) # 选项框架 options_frame = tk.Frame(main_frame, bg=self.bg_color) options_frame.pack(fill=tk.X, pady=10) # 递归选项 self.recursive = tk.BooleanVar(value=False) recursive_check = tk.Checkbutton( options_frame, text="包含子文件夹", variable=self.recursive, bg=self.bg_color ) recursive_check.pack(side=tk.LEFT, padx=(0, 20)) # 安全模式(先复制再重命名) self.safe_mode = tk.BooleanVar(value=True) safe_mode_check = tk.Checkbutton( options_frame, text="安全模式", variable=self.safe_mode, bg=self.bg_color ) safe_mode_check.pack(side=tk.LEFT) # 正则表达式模式 self.regex_mode = tk.BooleanVar(value=False) regex_check = tk.Checkbutton( options_frame, text="使用正则表达式", variable=self.regex_mode, bg=self.bg_color, command=self.toggle_regex_mode ) regex_check.pack(side=tk.LEFT, padx=(20, 0)) # 按钮框架 button_frame = tk.Frame(main_frame, bg=self.bg_color) button_frame.pack(fill=tk.X, pady=10) # 扫描按钮 self.scan_button = tk.Button( button_frame, text="扫描文件", command=self.scan_files, bg="#4caf50", fg="white", relief=tk.FLAT, padx=20, pady=5 ) self.scan_button.pack(side=tk.LEFT, padx=(0, 10)) # 执行按钮 self.execute_button = tk.Button( button_frame, text="开始转换", command=self.start_conversion, bg=self.accent_color, fg="white", relief=tk.FLAT, padx=20, pady=5, state=tk.DISABLED # 初始禁用,先扫描 ) self.execute_button.pack(side=tk.LEFT, padx=(0, 10)) # 撤销按钮 self.undo_button = tk.Button( button_frame, text="撤销上次操作", command=self.undo_last_operation, bg="#f44336", fg="white", relief=tk.FLAT, padx=20, pady=5, state=tk.DISABLED # 初始禁用 ) self.undo_button.pack(side=tk.LEFT) # 进度条 self.progress_var = tk.DoubleVar() self.progress_bar = ttk.Progressbar( main_frame, variable=self.progress_var, length=100, mode="determinate" ) self.progress_bar.pack(fill=tk.X, pady=(20, 10)) # 状态标签 self.status_var = tk.StringVar(value="准备就绪") status_label = tk.Label( main_frame, textvariable=self.status_var, bg=self.bg_color ) status_label.pack(fill=tk.X) # 统计信息 self.stats_frame = tk.Frame(main_frame, bg=self.bg_color) self.stats_frame.pack(fill=tk.X, pady=10) self.total_files = tk.StringVar(value="总文件数: 0") self.converted_files = tk.StringVar(value="已转换: 0") self.failed_files = tk.StringVar(value="失败数: 0") tk.Label(self.stats_frame, textvariable=self.total_files, bg=self.bg_color).pack(side=tk.LEFT, padx=(0, 20)) tk.Label(self.stats_frame, textvariable=self.converted_files, bg=self.bg_color).pack(side=tk.LEFT, padx=(0, 20)) tk.Label(self.stats_frame, textvariable=self.failed_files, bg=self.bg_color).pack(side=tk.LEFT) def setup_preview_tab(self): """设置预览页面""" preview_frame = tk.Frame(self.preview_tab, bg=self.bg_color) preview_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) # 预览说明 preview_label = tk.Label( preview_frame, text="文件预览(将要处理的文件)", font=("Arial", 12, "bold"), bg=self.bg_color ) preview_label.pack(pady=(0, 10)) # 预览列表 preview_container = tk.Frame(preview_frame, bg=self.bg_color) preview_container.pack(fill=tk.BOTH, expand=True) # 创建标题行 headers_frame = tk.Frame(preview_container, bg="#dddddd") headers_frame.pack(fill=tk.X, pady=(0, 1)) tk.Label(headers_frame, text="原文件名", width=40, bg="#dddddd", anchor="w", padx=5).pack(side=tk.LEFT) tk.Label(headers_frame, text="新文件名", width=40, bg="#dddddd", anchor="w", padx=5).pack(side=tk.LEFT) # 创建预览列表框 preview_canvas = tk.Canvas(preview_container, bg=self.bg_color) preview_scrollbar = ttk.Scrollbar(preview_container, orient="vertical", command=preview_canvas.yview) self.preview_list_frame = tk.Frame(preview_canvas, bg=self.bg_color) preview_canvas.configure(yscrollcommand=preview_scrollbar.set) preview_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) preview_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) preview_canvas.create_window((0, 0), window=self.preview_list_frame, anchor="nw") self.preview_list_frame.bind("<Configure>", lambda e: preview_canvas.configure(scrollregion=preview_canvas.bbox("all"))) # 占位信息 - 修复这里的引号问题 self.preview_placeholder = tk.Label( self.preview_list_frame, text='请先在主页面点击"扫描文件"按钮', bg=self.bg_color, padx=10, pady=20 ) self.preview_placeholder.pack() def setup_log_tab(self): """设置日志页面""" log_frame = tk.Frame(self.log_tab, bg=self.bg_color) log_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) # 日志标题 log_label = tk.Label( log_frame, text="操作日志", font=("Arial", 12, "bold"), bg=self.bg_color ) log_label.pack(pady=(0, 10)) # 日志文本区域 self.log_text = scrolledtext.ScrolledText(log_frame, width=80, height=20) self.log_text.pack(fill=tk.BOTH, expand=True) self.log_text.config(state=tk.DISABLED) # 日志操作按钮 log_buttons_frame = tk.Frame(log_frame, bg=self.bg_color) log_buttons_frame.pack(fill=tk.X, pady=10) refresh_log_button = tk.Button( log_buttons_frame, text="刷新日志", command=self.load_log, bg=self.accent_color, fg="white", relief=tk.FLAT, padx=10 ) refresh_log_button.pack(side=tk.LEFT, padx=(0, 10)) clear_log_button = tk.Button( log_buttons_frame, text="清空日志", command=self.clear_log, bg="#f44336", fg="white", relief=tk.FLAT, padx=10 ) clear_log_button.pack(side=tk.LEFT) def setup_settings_tab(self): """设置设置页面""" settings_frame = tk.Frame(self.settings_tab, bg=self.bg_color) settings_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) # 设置标题 settings_label = tk.Label( settings_frame, text="设置", font=("Arial", 12, "bold"), bg=self.bg_color ) settings_label.pack(pady=(0, 20)) # 备份设置 backup_frame = tk.LabelFrame(settings_frame, text="备份设置", bg=self.bg_color, padx=10, pady=10) backup_frame.pack(fill=tk.X, pady=10) # 自动备份 self.auto_backup = tk.BooleanVar(value=True) auto_backup_check = tk.Checkbutton( backup_frame, text="操作前自动备份文件", variable=self.auto_backup, bg=self.bg_color ) auto_backup_check.pack(anchor=tk.W) # 保留备份时长 backup_days_frame = tk.Frame(backup_frame, bg=self.bg_color) backup_days_frame.pack(fill=tk.X, pady=5) tk.Label(backup_days_frame, text="保留备份天数:", bg=self.bg_color).pack(side=tk.LEFT) self.backup_days = tk.IntVar(value=7) backup_days_spinbox = tk.Spinbox(backup_days_frame, from_=1, to=30, textvariable=self.backup_days, width=5) backup_days_spinbox.pack(side=tk.LEFT, padx=10) # 备份位置 backup_path_frame = tk.Frame(backup_frame, bg=self.bg_color) backup_path_frame.pack(fill=tk.X, pady=5) tk.Label(backup_path_frame, text="备份位置:", bg=self.bg_color).pack(side=tk.LEFT) self.backup_path = tk.StringVar(value="./backups") backup_path_entry = tk.Entry(backup_path_frame, textvariable=self.backup_path, width=30) backup_path_entry.pack(side=tk.LEFT, padx=10, fill=tk.X, expand=True) backup_path_button = tk.Button( backup_path_frame, text="浏览", command=self.browse_backup_folder, bg=self.accent_color, fg="white", relief=tk.FLAT ) backup_path_button.pack(side=tk.LEFT) # 界面设置 ui_frame = tk.LabelFrame(settings_frame, text="界面设置", bg=self.bg_color, padx=10, pady=10) ui_frame.pack(fill=tk.X, pady=10) # 确认对话框 self.confirm_operations = tk.BooleanVar(value=True) confirm_check = tk.Checkbutton( ui_frame, text="操作前显示确认对话框", variable=self.confirm_operations, bg=self.bg_color ) confirm_check.pack(anchor=tk.W) # 自动刷新预览 self.auto_refresh_preview = tk.BooleanVar(value=True) auto_preview_check = tk.Checkbutton( ui_frame, text="更改设置后自动刷新预览", variable=self.auto_refresh_preview, bg=self.bg_color ) auto_preview_check.pack(anchor=tk.W) # 最大预览文件数 preview_limit_frame = tk.Frame(ui_frame, bg=self.bg_color) preview_limit_frame.pack(fill=tk.X, pady=5) tk.Label(preview_limit_frame, text="最大预览文件数:", bg=self.bg_color).pack(side=tk.LEFT) self.preview_limit = tk.IntVar(value=100) preview_limit_spinbox = tk.Spinbox(preview_limit_frame, from_=10, to=500, textvariable=self.preview_limit, width=5) preview_limit_spinbox.pack(side=tk.LEFT, padx=10) # 保存设置按钮 save_settings_button = tk.Button( settings_frame, text="保存设置", command=self.save_settings, bg=self.accent_color, fg="white", relief=tk.FLAT, padx=20, pady=5 ) save_settings_button.pack(pady=20) def browse_folder(self): """浏览并选择要操作的文件夹""" folder_selected = filedialog.askdirectory() if folder_selected: self.folder_path.set(folder_selected) # 如果启用了自动刷新预览,则扫描文件 if self.auto_refresh_preview.get(): self.scan_files() def browse_backup_folder(self): """浏览并选择备份文件夹""" folder_selected = filedialog.askdirectory() if folder_selected: self.backup_path.set(folder_selected) def toggle_regex_mode(self): """切换正则表达式模式""" # 如果启用正则模式,更新界面提示 if self.regex_mode.get(): messagebox.showinfo("正则表达式模式", "已启用正则表达式模式。\n\n" "在源扩展名中可以使用正则表达式匹配文件名,\n" "在目标扩展名中可以使用\\1, \\2等引用捕获的组。") # 如果启用了自动刷新预览,重新扫描 if self.auto_refresh_preview.get(): self.scan_files() def scan_files(self): """扫描文件夹中的文件""" folder_path = self.folder_path.get() from_ext = self.from_ext.get() # 基本验证 if not folder_path: messagebox.showerror("错误", "请选择文件夹") return if not from_ext and not self.regex_mode.get(): messagebox.showerror("错误", "请输入源扩展名") return # 确保扩展名格式正确(非正则模式下) if not self.regex_mode.get() and not from_ext.startswith("."): from_ext = "." + from_ext self.from_ext.set(from_ext) # 禁用扫描按钮 self.scan_button.config(state=tk.DISABLED) self.status_var.set("正在扫描文件...") self.is_scanning = True # 在新线程中执行扫描,以避免界面冻结 thread = threading.Thread(target=self._scan_files_thread, args=(folder_path, from_ext)) thread.daemon = True thread.start() def _scan_files_thread(self, folder_path, from_ext): """在后台线程中扫描文件""" try: # 获取所有匹配的文件 file_list = [] if self.recursive.get(): for root, _, files in os.walk(folder_path): for file in files: file_path = os.path.join(root, file) if self._match_file(file, file_path, from_ext): file_list.append(file_path) else: for file in os.listdir(folder_path): file_path = os.path.join(folder_path, file) if os.path.isfile(file_path) and self._match_file(file, file_path, from_ext): file_list.append(file_path) # 更新统计 total = len(file_list) self.total_files.set(f"总文件数: {total}") # 更新预览 self.preview_files = file_list self.root.after(0, self._update_preview) if total == 0: self.status_var.set(f"未找到匹配的文件") self.execute_button.config(state=tk.DISABLED) else: self.status_var.set(f"扫描完成,找到 {total} 个文件") self.execute_button.config(state=tk.NORMAL) except Exception as e: self.status_var.set(f"扫描失败: {str(e)}") messagebox.showerror("扫描错误", f"扫描文件时出错:\n{str(e)}") finally: self.scan_button.config(state=tk.NORMAL) self.is_scanning = False def _match_file(self, filename, filepath, pattern): """检查文件是否匹配条件""" if self.regex_mode.get(): try: return bool(re.search(pattern, filename)) except re.error: # 正则表达式错误,返回False return False else: return filename.endswith(pattern) def _update_preview(self): """更新预览列表""" # 清空预览框 for widget in self.preview_list_frame.winfo_children(): widget.destroy() if not self.preview_files: self.preview_placeholder = tk.Label( self.preview_list_frame, text='没有找到匹配的文件', bg=self.bg_color, padx=10, pady=20 ) self.preview_placeholder.pack() return # 获取要显示的文件数量 display_limit = min(len(self.preview_files), self.preview_limit.get()) # 添加文件预览 for i in range(display_limit): file_path = self.preview_files[i] new_file_path = self._get_new_file_path(file_path) # 创建行容器 row_frame = tk.Frame(self.preview_list_frame, bg="#f5f5f5" if i % 2 == 0 else "#ebebeb") row_frame.pack(fill=tk.X, pady=1) # 提取相对路径,如果可能 base_dir = self.folder_path.get() try: rel_path = os.path.relpath(file_path, base_dir) rel_new_path = os.path.relpath(new_file_path, base_dir) except ValueError: # 如果在不同驱动器上,使用完整路径 rel_path = file_path rel_new_path = new_file_path # 添加原文件名和新文件名 tk.Label(row_frame, text=rel_path, width=40, anchor="w", padx=5, bg=row_frame["bg"]).pack(side=tk.LEFT) tk.Label(row_frame, text=rel_new_path, width=40, anchor="w", padx=5, bg=row_frame["bg"]).pack(side=tk.LEFT) # 如果有更多文件,显示提示 if len(self.preview_files) > display_limit: more_label = tk.Label( self.preview_list_frame, text=f"...还有 {len(self.preview_files) - display_limit} 个文件未显示", bg=self.bg_color, fg="#666666", padx=10, pady=5 ) more_label.pack(fill=tk.X) def _get_new_file_path(self, file_path): """根据当前设置计算新文件路径""" if self.regex_mode.get(): # 正则模式下的替换 filename = os.path.basename(file_path) directory = os.path.dirname(file_path) try: new_filename = re.sub(self.from_ext.get(), self.to_ext.get(), filename) return os.path.join(directory, new_filename) except re.error: # 正则表达式错误,返回原路径 return file_path else: # 普通模式下的替换 from_ext = self.from_ext.get() to_ext = self.to_ext.get() # 确保扩展名格式正确 if not from_ext.startswith("."): from_ext = "." + from_ext if not to_ext.startswith("."): to_ext = "." + to_ext return file_path[:-len(from_ext)] + to_ext def start_conversion(self): """开始转换文件扩展名""" if not self.preview_files: messagebox.showerror("错误", "没有找到要处理的文件") return # 确认对话框 if self.confirm_operations.get(): confirm = messagebox.askokcancel( "确认操作", f"即将修改 {len(self.preview_files)} 个文件的扩展名。\n\n" + "此操作将改变这些文件的格式。\n" + ("已启用安全模式,将先备份文件。\n" if self.safe_mode.get() else "") + "\n是否继续?" ) if not confirm: return # 禁用按钮 self.execute_button.config(state=tk.DISABLED) self.scan_button.config(state=tk.DISABLED) # 在新线程中执行转换 thread = threading.Thread(target=self._convert_files_thread) thread.daemon = True thread.start() def _convert_files_thread(self): """在后台线程中执行文件转换""" try: files_to_process = self.preview_files.copy() total = len(files_to_process) converted = 0 failed = 0 operation_log = [] # 创建备份记录,用于撤销 operation_record = { "timestamp": datetime.datetime.now().isoformat(), "files": [], "folder": self.folder_path.get(), "from_ext": self.from_ext.get(), "to_ext": self.to_ext.get(), "regex_mode": self.regex_mode.get(), "backup_path": None } # 创建备份(如果安全模式开启) if self.safe_mode.get() and self.auto_backup.get(): backup_folder = self._create_backup(files_to_process) operation_record["backup_path"] = backup_folder for i, file_path in enumerate(files_to_process): try: # 计算新文件名 new_file_path = self._get_new_file_path(file_path) # 记录操作 file_record = { "original": file_path, "new": new_file_path } operation_record["files"].append(file_record) # 执行重命名 os.rename(file_path, new_file_path) # 添加到日志 log_entry = f"[成功] {file_path} -> {new_file_path}" operation_log.append(log_entry) converted += 1 self.converted_files.set(f"已转换: {converted}") except Exception as e: error_msg = str(e) log_entry = f"[失败] {file_path}: {error_msg}" operation_log.append(log_entry) failed += 1 self.failed_files.set(f"失败数: {failed}") # 更新进度条 progress = (i + 1) / total * 100 self.progress_var.set(progress) self.status_var.set(f"正在转换: {i+1}/{total}") # 更新界面 self.root.update_idletasks() # 操作完成,保存记录 if converted > 0: self.operation_history.append(operation_record) self._save_operation_log(operation_log) # 启用撤销按钮 self.undo_button.config(state=tk.NORMAL) # 完成消息 self.status_var.set(f"完成! 成功转换 {converted} 个文件, 失败 {failed} 个文件") messagebox.showinfo("完成", f"文件扩展名修改完成\n成功: {converted}\n失败: {failed}") # 重新扫描文件夹 if converted > 0: self.scan_files() else: self.scan_button.config(state=tk.NORMAL) self.execute_button.config(state=tk.NORMAL) except Exception as e: self.status_var.set(f"转换失败: {str(e)}") messagebox.showerror("转换错误", f"转换文件时出错:\n{str(e)}") self.scan_button.config(state=tk.NORMAL) self.execute_button.config(state=tk.NORMAL) def _create_backup(self, files): """创建文件备份""" timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir = os.path.join(self.backup_path.get(), f"backup_{timestamp}") # 确保备份目录存在 os.makedirs(backup_dir, exist_ok=True) # 复制文件 for file_path in files: try: # 保持目录结构 rel_path = os.path.relpath(file_path, self.folder_path.get()) backup_file_path = os.path.join(backup_dir, rel_path) # 确保目标目录存在 os.makedirs(os.path.dirname(backup_file_path), exist_ok=True) # 复制文件 shutil.copy2(file_path, backup_file_path) except Exception as e: self.log_append(f"备份失败: {file_path} - {str(e)}") # 清理旧备份 self._cleanup_old_backups() return backup_dir def _cleanup_old_backups(self): """清理过期的备份文件""" if not os.path.exists(self.backup_path.get()): return now = datetime.datetime.now() max_age = datetime.timedelta(days=self.backup_days.get()) for item in os.listdir(self.backup_path.get()): item_path = os.path.join(self.backup_path.get(), item) # 只处理备份目录 if os.path.isdir(item_path) and item.startswith("backup_"): try: # 从目录名提取时间戳 timestamp = item.replace("backup_", "") date_obj = datetime.datetime.strptime(timestamp, "%Y%m%d_%H%M%S") # 检查是否过期 if now - date_obj > max_age: shutil.rmtree(item_path) except (ValueError, OSError): # 如果解析失败或删除失败,跳过 continue def undo_last_operation(self): """撤销上次重命名操作""" if not self.operation_history: messagebox.showinfo("提示", "没有可撤销的操作") return # 确认 if self.confirm_operations.get(): confirm = messagebox.askokcancel("确认撤销", "确定要撤销上次操作吗?") if not confirm: return # 获取最后一次操作记录 operation = self.operation_history.pop() # 禁用按钮 self.undo_button.config(state=tk.DISABLED) self.execute_button.config(state=tk.DISABLED) self.scan_button.config(state=tk.DISABLED) # 开始撤销 self.status_var.set("正在撤销上次操作...") # 在新线程中执行撤销 thread = threading.Thread(target=self._undo_operation_thread, args=(operation,)) thread.daemon = True thread.start() def _undo_operation_thread(self, operation): """在后台线程中执行撤销操作""" restored = 0 failed = 0 operation_log = [] try: # 从备份恢复 if operation["backup_path"] and os.path.exists(operation["backup_path"]): # 从备份目录恢复 for file_record in operation["files"]: try: # 如果新文件存在,删除它 if os.path.exists(file_record["new"]): os.remove(file_record["new"]) # 计算备份中的文件路径 rel_path = os.path.relpath(file_record["original"], operation["folder"]) backup_file = os.path.join(operation["backup_path"], rel_path) # 复制回原位置 os.makedirs(os.path.dirname(file_record["original"]), exist_ok=True) shutil.copy2(backup_file, file_record["original"]) restored += 1 log_entry = f"[恢复] {file_record['new']} -> {file_record['original']}" operation_log.append(log_entry) except Exception as e: failed += 1 log_entry = f"[恢复失败] {file_record['new']}: {str(e)}" operation_log.append(log_entry) else: # 直接重命名文件 for file_record in operation["files"]: try: # 如果新文件存在并且原文件不存在,则重命名回去 if os.path.exists(file_record["new"]) and not os.path.exists(file_record["original"]): os.rename(file_record["new"], file_record["original"]) restored += 1 log_entry = f"[恢复] {file_record['new']} -> {file_record['original']}" operation_log.append(log_entry) else: failed += 1 log_entry = f"[恢复失败] {file_record['new']}: 文件不存在或目标已存在" operation_log.append(log_entry) except Exception as e: failed += 1 log_entry = f"[恢复失败] {file_record['new']}: {str(e)}" operation_log.append(log_entry) # 保存撤销日志 self._save_operation_log(operation_log, is_undo=True) # 更新状态 self.status_var.set(f"撤销完成! 成功恢复 {restored} 个文件, 失败 {failed} 个文件") messagebox.showinfo("撤销完成", f"操作已撤销\n成功恢复: {restored}\n失败: {failed}") # 启用撤销按钮(如果还有操作历史) if self.operation_history: self.undo_button.config(state=tk.NORMAL) # 重新扫描 if restored > 0: self.scan_files() else: self.scan_button.config(state=tk.NORMAL) self.execute_button.config(state=tk.NORMAL) except Exception as e: self.status_var.set(f"撤销失败: {str(e)}") messagebox.showerror("撤销错误", f"撤销操作时出错:\n{str(e)}") # 恢复操作历史 self.operation_history.append(operation) # 重新启用按钮 if self.operation_history: self.undo_button.config(state=tk.NORMAL) self.scan_button.config(state=tk.NORMAL) self.execute_button.config(state=tk.NORMAL) def _save_operation_log(self, log_entries, is_undo=False): """保存操作日志""" timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") operation_type = "撤销操作" if is_undo else "文件扩展名修改" # 读取现有日志 log_data = [] if os.path.exists(self.log_file): try: with open(self.log_file, 'r', encoding='utf-8') as f: log_data = json.load(f) except (json.JSONDecodeError, UnicodeDecodeError): # 如果日志文件损坏,创建新的 log_data = [] # 添加新的日志条目 log_entry = { "timestamp": timestamp, "type": operation_type, "folder": self.folder_path.get(), "from_ext": self.from_ext.get(), "to_ext": self.to_ext.get(), "entries": log_entries } log_data.append(log_entry) # 保存日志 try: with open(self.log_file, 'w', encoding='utf-8') as f: json.dump(log_data, f, indent=2, ensure_ascii=False) except Exception as e: print(f"保存日志失败: {str(e)}") # 更新日志显示 self.load_log() def load_log(self): """加载并显示日志""" self.log_text.config(state=tk.NORMAL) self.log_text.delete(1.0, tk.END) if os.path.exists(self.log_file): try: with open(self.log_file, 'r', encoding='utf-8') as f: log_data = json.load(f) for entry in reversed(log_data): # 最新的日志显示在最上面 timestamp = entry["timestamp"] operation_type = entry["type"] folder = entry.get("folder", "未知文件夹") from_ext = entry.get("from_ext", "") to_ext = entry.get("to_ext", "") self.log_text.insert(tk.END, f"=== {timestamp} - {operation_type} ===\n") self.log_text.insert(tk.END, f"文件夹: {folder}\n") if operation_type != "撤销操作": self.log_text.insert(tk.END, f"从 {from_ext} 到 {to_ext}\n") self.log_text.insert(tk.END, "详细记录:\n") for log_line in entry.get("entries", []): self.log_text.insert(tk.END, f" {log_line}\n") self.log_text.insert(tk.END, "\n") except Exception as e: self.log_text.insert(tk.END, f"加载日志失败: {str(e)}\n") else: self.log_text.insert(tk.END, "没有找到日志文件。\n") self.log_text.config(state=tk.DISABLED) def clear_log(self): """清空日志""" if os.path.exists(self.log_file): confirm = messagebox.askokcancel("确认", "确定要清空所有日志吗?此操作不可撤销。") if not confirm: return try: os.remove(self.log_file) self.log_text.config(state=tk.NORMAL) self.log_text.delete(1.0, tk.END) self.log_text.insert(tk.END, "日志已清空。\n") self.log_text.config(state=tk.DISABLED) except Exception as e: messagebox.showerror("错误", f"清空日志失败: {str(e)}") def log_append(self, message): """向日志添加消息""" timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 添加到UI日志 self.log_text.config(state=tk.NORMAL) self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") self.log_text.see(tk.END) self.log_text.config(state=tk.DISABLED) def save_settings(self): """保存用户设置""" settings = { "auto_backup": self.auto_backup.get(), "backup_days": self.backup_days.get(), "backup_path": self.backup_path.get(), "confirm_operations": self.confirm_operations.get(), "auto_refresh_preview": self.auto_refresh_preview.get(), "preview_limit": self.preview_limit.get() } try: os.makedirs(os.path.dirname("settings.json"), exist_ok=True) with open("settings.json", 'w', encoding='utf-8') as f: json.dump(settings, f, indent=2, ensure_ascii=False) messagebox.showinfo("成功", "设置已保存") except Exception as e: messagebox.showerror("错误", f"保存设置失败: {str(e)}") def load_settings(self): """加载用户设置""" if os.path.exists("settings.json"): try: with open("settings.json", 'r', encoding='utf-8') as f: settings = json.load(f) if "auto_backup" in settings: self.auto_backup.set(settings["auto_backup"]) if "backup_days" in settings: self.backup_days.set(settings["backup_days"]) if "backup_path" in settings: self.backup_path.set(settings["backup_path"]) if "confirm_operations" in settings: self.confirm_operations.set(settings["confirm_operations"]) if "auto_refresh_preview" in settings: self.auto_refresh_preview.set(settings["auto_refresh_preview"]) if "preview_limit" in settings: self.preview_limit.set(settings["preview_limit"]) except Exception as e: print(f"加载设置失败: {str(e)}") # 确保备份目录存在 os.makedirs(self.backup_path.get(), exist_ok=True) def main(): root = tk.Tk() app = FileExtensionChanger(root) # 加载日志 app.load_log() root.mainloop() if __name__ == "__main__": main() 许可协议 该工具采用MIT许可协议,欢迎自由使用和修改。 ......查看全文