一、问题提出
在整理自己的歌曲U盘时,发现歌曲的排序很混乱,有的是歌曲-演唱家;有的是反过来的;并且在车上播放的时候,车机显示歌曲的名称也不标识序号,想我这样的整洁的人,必须要把这些歌曲文件整理好,而且我也需要在车机上播放时,想知道在播放的是U盘中第几首的歌。
二、解决方案,利用PYTHON语言的特性,快速开发一个批量重命名的工具,解决需求
list_files.py
import os
import sys
def list_files_to_txt(folder_path: str, output_txt: str = None) -> str:
"""
读取 folder_path 第一层的所有文件(不含子文件夹),
按「原文件名|新文件名」格式写入 TXT。
:param folder_path: 要扫描的文件夹路径
:param output_txt: 输出 TXT 路径,默认为 folder_path/rename_mapping.txt
:return: 输出 TXT 的完整路径
"""
folder_path = os.path.abspath(folder_path)
if not os.path.isdir(folder_path):
raise NotADirectoryError(f"路径不是有效的文件夹:{folder_path}")
if output_txt is None:
output_txt = os.path.join(folder_path, "rename_mapping.txt")
# 只取第一层的文件,排除子目录;按名称排序便于查看
file_names = []
with os.scandir(folder_path) as entries:
for entry in entries:
if entry.is_file():
file_names.append(entry.name)
file_names.sort()
# 以 utf-8-sig 写入,确保 Windows 记事本正确显示中文
with open(output_txt, "w", encoding="utf-8-sig") as f:
for name in file_names:
f.write(f"{name}|{name}\n")
print(f"共扫描到 {len(file_names)} 个文件,已写入:{output_txt}")
return output_txt
def main():
if len(sys.argv) >= 2:
folder = sys.argv[1]
else:
folder = input("请输入要扫描的文件夹路径:").strip().strip('"').strip("'")
try:
list_files_to_txt(folder)
except Exception as e:
print(f"错误:{e}")
sys.exit(1)
if __name__ == "__main__":
main()rename_files.py
"""
批量重命名工具 - 第二步:读取 TXT 映射并执行重命名。
TXT 每行格式:原文件名|新文件名
- 以 # 开头的行视为注释,会被跳过
- 空行会被跳过
- 原文件名 == 新文件名 的行会被跳过(无需改名)
- TXT 文件本身不会被改名(自动跳过)
安全机制:
1. 全局冲突检测:执行前检查目标名是否重复、是否已被其他文件占用
2. 干跑预览(默认):只打印计划改名列表,不实际执行
3. --execute 标志:确认无误后加此参数才真正改名
4. 临时名中转:A→B 和 B→A 不会互相覆盖
5. 第一阶段保护:临时改名中途失败会自动回滚所有已改名文件
6. 回滚日志:每次执行后写 rollback_log.txt,加 --rollback 即可一键撤销
用法:
python rename_files.py "D:\目标文件夹" # 干跑(预览)
python rename_files.py "D:\目标文件夹" --execute # 真正执行
python rename_files.py "D:\目标文件夹" --rollback # 读取 rollback_log.txt 撤销上次改名
python rename_files.py "D:\目标文件夹" --rollback --execute # 实际撤销
"""
import os
import sys
import argparse
from datetime import datetime
# Windows 下不允许出现在文件名中的字符
INVALID_CHARS = '<>:"/\\|?*'
def parse_mapping(txt_path: str, skip_names: set) -> list:
"""解析 TXT 映射表,返回 [(old_name, new_name), ...] 列表。"""
mappings = []
with open(txt_path, "r", encoding="utf-8-sig") as f:
for lineno, raw in enumerate(f, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|", 1)
if len(parts) != 2:
print(f" [跳过] 第 {lineno} 行格式错误(缺少 | 分隔符):{raw!r}")
continue
old = parts[0].strip()
new = parts[1].strip()
if not old or not new:
print(f" [跳过] 第 {lineno} 行原文件名或新文件名为空")
continue
if old in skip_names:
print(f" [跳过] 受保护文件:{old}")
continue
if old == new:
continue # 无变化,跳过
mappings.append((old, new))
return mappings
def preflight_check(mappings: list, folder: str) -> bool:
"""执行前检查,返回 True 表示全部通过。"""
ok = True
# 1. 源文件是否存在
folder_files = set(os.listdir(folder))
missing = {old for old, _ in mappings if old not in folder_files}
if missing:
print(f"\n❌ 以下 {len(missing)} 个原文件在文件夹中找不到:")
for name in sorted(missing):
print(f" - {name}")
ok = False
# 2. 新文件名是否含非法字符
invalid = [(old, new) for old, new in mappings if any(c in new for c in INVALID_CHARS)]
if invalid:
print(f"\n❌ 以下 {len(invalid)} 个新文件名含非法字符({INVALID_CHARS}):")
for old, new in invalid:
print(f" - {new} ← 来自 {old}")
ok = False
# 3. 目标名在映射内部是否重复
target_counts = {}
for _, new in mappings:
target_counts[new] = target_counts.get(new, 0) + 1
dupes = {n: c for n, c in target_counts.items() if c > 1}
if dupes:
print(f"\n❌ 目标文件名在映射内部重复:")
for n, c in sorted(dupes.items()):
print(f" - {n} 出现 {c} 次")
ok = False
# 4. 目标名是否已被现有文件占用(且该文件不是映射表中的源文件)
target_set = set(target_counts.keys())
source_set = {old for old, _ in mappings}
occupied = target_set & folder_files - source_set
if occupied:
print(f"\n❌ 目标文件名已被现有文件占用(且不在改名计划内):")
for name in sorted(occupied):
print(f" - {name}")
ok = False
return ok
def dry_run(mappings: list, label: str) -> None:
"""打印预览列表。"""
print(f"\n📋 【{label}】以下是将要执行的操作(共 {len(mappings)} 个):")
print("-" * 60)
for i, (old, new) in enumerate(mappings, 1):
print(f" {i:>3}. {old}")
print(f" → {new}")
print("-" * 60)
print("⚠️ 当前为【干跑预览】,未实际改名。")
print(" 确认无误后,加 --execute 参数真正执行。")
def execute_rename(mappings: list, folder: str, rollback_mode: bool = False) -> None:
"""按安全顺序执行重命名,并写回滚日志。"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
rollback_entries = [] # [(新文件名, 原文件名), ...] ← 回滚时按此方向反向改名
temp_files = [] # [(idx, temp_name, original_old_name), ...]
# ── 第一轮:所有源文件 → 临时名 ──
# 关键:先整体把所有源文件挪到临时名,避免 A→B 和 B→A 循环冲突。
# 同时必须做异常保护,中途失败则全部回滚。
print(" [阶段 1/2] 将所有源文件挪到临时名 ...")
try:
for idx, (old, _) in enumerate(mappings, 1):
old_path = os.path.join(folder, old)
temp_name = f".__rename_tmp_{timestamp}_{idx}__"
temp_path = os.path.join(folder, temp_name)
os.rename(old_path, temp_path)
temp_files.append((idx, temp_name, old))
except Exception as e:
print(f"\n❌ 第一阶段出错:{e}")
print(" 正在回滚已改名文件到原状态 ...")
for idx, temp_name, original_old in temp_files:
try:
os.rename(os.path.join(folder, temp_name), os.path.join(folder, original_old))
print(f" ↺ {temp_name} → {original_old}")
except Exception as rollback_err:
print(f" ⚠️ 回滚失败(需手动处理):{temp_name} → {original_old} ({rollback_err})")
print("\n🛑 已终止,文件夹应恢复原样。请修正问题后重试。")
return
# ── 第二轮:临时名 → 目标名 ──
print(" [阶段 2/2] 将临时名改为目标名 ...\n")
success = 0
errors = []
# 建一个 old → new 的快速查找
old_to_new = {old: new for old, new in mappings}
for idx, temp_name, original_old in temp_files:
target_new = old_to_new[original_old]
temp_path = os.path.join(folder, temp_name)
new_path = os.path.join(folder, target_new)
try:
os.rename(temp_path, new_path)
# 回滚日志方向:写「当前的新名」→「原旧名」,下次 --rollback 可直接用
rollback_entries.append((target_new, original_old))
success += 1
print(f" ✅ {original_old} → {target_new}")
except Exception as e:
errors.append((original_old, target_new, str(e)))
print(f" ❌ {original_old} → {target_new} 失败:{e}")
# 失败也要把临时文件恢复成原名,避免残留 .__rename_tmp_*
try:
os.rename(temp_path, os.path.join(folder, original_old))
except Exception:
pass
# ── 写回滚日志(仅在非回滚模式下写) ──
if not rollback_mode and rollback_entries:
rollback_path = os.path.join(folder, "rollback_log.txt")
with open(rollback_path, "w", encoding="utf-8-sig") as f:
f.write(f"# 回滚日志 生成时间:{datetime.now().isoformat(timespec='seconds')}\n")
f.write(f"# 撤销方法:python rename_files.py \"{folder}\" --rollback --execute\n")
f.write(f"# 以下每行格式:当前文件名|原文件名\n")
for curr_name, orig_name in rollback_entries:
f.write(f"{curr_name}|{orig_name}\n")
print(f"\n🗒️ 回滚日志已写入:{rollback_path}")
print(f" 如需撤销本次改名,运行:python rename_files.py \"{folder}\" --rollback --execute")
print(f"\n完成:成功 {success} 个,失败 {len(errors)} 个")
if errors:
print("失败条目:")
for old, new, err in errors:
print(f" - {old} → {new} ({err})")
def main():
parser = argparse.ArgumentParser(description="根据 TXT 映射表批量重命名文件夹内文件")
parser.add_argument("folder", help="目标文件夹路径")
parser.add_argument("--execute", action="store_true", help="实际执行重命名(默认仅干跑预览)")
parser.add_argument("--rollback", action="store_true",
help="读取 rollback_log.txt 撤销上次改名(方向自动反转)")
args = parser.parse_args()
folder = os.path.abspath(args.folder)
if not os.path.isdir(folder):
print(f"❌ 路径不是有效的文件夹:{folder}")
sys.exit(1)
# 确定读哪个 TXT,以及哪些文件名在解析时要跳过
if args.rollback:
txt_path = os.path.join(folder, "rollback_log.txt")
if not os.path.isfile(txt_path):
print(f"❌ 找不到 rollback_log.txt:{txt_path}")
print(" 没有可撤销的记录。")
sys.exit(1)
protected = {"rename_mapping.txt", "rollback_log.txt"}
label = "回滚(撤销上次改名)"
else:
txt_path = os.path.join(folder, "rename_mapping.txt")
if not os.path.isfile(txt_path):
print(f"❌ 找不到 rename_mapping.txt:{txt_path}")
print(" 请先运行 list_files.py 生成映射表。")
sys.exit(1)
protected = {"rename_mapping.txt", "rollback_log.txt"}
label = "重命名"
print(f"📂 目标文件夹:{folder}")
print(f"📄 读取映射表:{os.path.basename(txt_path)}")
mappings = parse_mapping(txt_path, protected)
if not mappings:
print("\n⚠️ 没有需要处理的条目(所有行都跳过或原名==新名)。")
sys.exit(0)
print(f" 解析得到 {len(mappings)} 条计划")
# 前置检查
if not preflight_check(mappings, folder):
print("\n❌ 前置检查未通过,请先修正上述问题后重试。")
sys.exit(1)
# 干跑 or 执行
if not args.execute:
dry_run(mappings, label)
else:
print(f"\n🚀 开始【{label}】 ...")
execute_rename(mappings, folder, rollback_mode=args.rollback)
if __name__ == "__main__":
main()
batch_rename_gui.py
"""
批量重命名工具 - GUI 版
功能:
1. 选择文件夹 → 自动列出第一层所有文件
2. 表格中「原文件名」只读,「新文件名」可直接编辑(均不显示后缀,后缀自动保留)
3. 干跑预览 → 检查冲突 + 显示计划
4. 执行重命名 → 临时名中转,自动写回滚日志
5. 撤销回滚 → 读取 rollback_log.txt 一键还原
依赖:仅用 Python 标准库 tkinter,无需安装第三方包。
运行:python batch_rename_gui.py
"""
import os
import sys
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from datetime import datetime
# Windows 下不允许出现在文件名中的字符
INVALID_CHARS = '<>:"/\\|?*'
# ────────────────────────── 核心重命名逻辑(复用 rename_files.py) ──────────────────────────
def preflight_check(mappings, folder):
"""执行前检查,返回 (ok: bool, errors: list[str])。"""
errors = []
folder_files = set(os.listdir(folder))
missing = {old for old, _ in mappings if old not in folder_files}
if missing:
errors.append(f"以下 {len(missing)} 个原文件在文件夹中找不到:\n "
+ "\n ".join(sorted(missing)))
invalid = [(old, new) for old, new in mappings if any(c in new for c in INVALID_CHARS)]
if invalid:
errors.append(f"以下 {len(invalid)} 个新文件名含非法字符({INVALID_CHARS}):\n "
+ "\n ".join(f"{new} ← 来自 {old}" for old, new in invalid))
target_counts = {}
for _, new in mappings:
target_counts[new] = target_counts.get(new, 0) + 1
dupes = {n: c for n, c in target_counts.items() if c > 1}
if dupes:
errors.append("目标文件名在映射内部重复:\n "
+ "\n ".join(f"{n} 出现 {c} 次" for n, c in sorted(dupes.items())))
target_set = set(target_counts.keys())
source_set = {old for old, _ in mappings}
occupied = target_set & folder_files - source_set
if occupied:
errors.append("目标文件名已被现有文件占用(且不在改名计划内):\n "
+ "\n ".join(sorted(occupied)))
return (len(errors) == 0, errors)
def execute_rename(mappings, folder, rollback_mode=False):
"""执行重命名。返回 (success_count, error_list, rollback_path_or_None)。"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
rollback_entries = []
temp_files = []
# 第一轮:源文件 → 临时名
try:
for idx, (old, _) in enumerate(mappings, 1):
old_path = os.path.join(folder, old)
temp_name = f".__rename_tmp_{timestamp}_{idx}__"
temp_path = os.path.join(folder, temp_name)
os.rename(old_path, temp_path)
temp_files.append((idx, temp_name, old))
except Exception as e:
# 回滚已改名的临时文件
for _, temp_name, original_old in temp_files:
try:
os.rename(os.path.join(folder, temp_name), os.path.join(folder, original_old))
except Exception:
pass
raise RuntimeError(f"第一阶段失败,已回滚:{e}")
# 第二轮:临时名 → 目标名
errors = []
success = 0
old_to_new = {old: new for old, new in mappings}
for _, temp_name, original_old in temp_files:
target_new = old_to_new[original_old]
temp_path = os.path.join(folder, temp_name)
new_path = os.path.join(folder, target_new)
try:
os.rename(temp_path, new_path)
rollback_entries.append((target_new, original_old))
success += 1
except Exception as e:
errors.append(f"{original_old} → {target_new} 失败:{e}")
try:
os.rename(temp_path, os.path.join(folder, original_old))
except Exception:
pass
rollback_path = None
if not rollback_mode and rollback_entries:
rollback_path = os.path.join(folder, "rollback_log.txt")
with open(rollback_path, "w", encoding="utf-8-sig") as f:
f.write(f"# 回滚日志 生成时间:{datetime.now().isoformat(timespec='seconds')}\n")
f.write("# 格式:当前文件名|原文件名\n")
for curr_name, orig_name in rollback_entries:
f.write(f"{curr_name}|{orig_name}\n")
return (success, errors, rollback_path)
# ────────────────────────── GUI ──────────────────────────
class BatchRenameApp:
def __init__(self, root):
self.root = root
self.root.title("批量重命名工具")
self.root.geometry("900x600")
self.root.minsize(700, 400)
self.folder = tk.StringVar()
self.rows = [] # [(old_full, ext, new_var, row_frame), ...]
self.removed = [] # 已从界面移除的文件 [(old_full, ext), ...]
# ── 批量序号设置 ──
self.num_position = tk.StringVar(value="前缀") # 前缀 / 后缀
self.num_start = tk.StringVar(value="1") # 起始号
self.num_step = tk.StringVar(value="1") # 步长
self.num_pad = tk.StringVar(value="3") # 补零位数(0 表示不补零)
self.num_sep = tk.StringVar(value="_") # 序号与原名之间的分隔符
self._build_ui()
def _build_ui(self):
# ── 顶部:文件夹选择 ──
top = ttk.Frame(self.root, padding=8)
top.pack(fill="x")
ttk.Label(top, text="目标文件夹:").pack(side="left")
ttk.Entry(top, textvariable=self.folder).pack(
side="left", fill="x", expand=True, padx=4)
ttk.Button(top, text="选择...", command=self.choose_folder).pack(side="left", padx=2)
ttk.Button(top, text="刷新", command=self.refresh).pack(side="left", padx=2)
# ── 序号控制面板 ──
num_frame = ttk.LabelFrame(self.root, text="批量添加序号", padding=8)
num_frame.pack(fill="x", padx=8, pady=(0, 4))
ttk.Label(num_frame, text="位置:").pack(side="left")
ttk.Combobox(num_frame, textvariable=self.num_position,
values=["前缀", "后缀"], width=6, state="readonly").pack(side="left", padx=(0, 8))
ttk.Label(num_frame, text="起始号:").pack(side="left")
ttk.Entry(num_frame, textvariable=self.num_start, width=6).pack(side="left", padx=(0, 8))
ttk.Label(num_frame, text="步长:").pack(side="left")
ttk.Entry(num_frame, textvariable=self.num_step, width=6).pack(side="left", padx=(0, 8))
ttk.Label(num_frame, text="补零位数:").pack(side="left")
ttk.Entry(num_frame, textvariable=self.num_pad, width=6).pack(side="left", padx=(0, 8))
ttk.Label(num_frame, text="分隔符:").pack(side="left")
ttk.Entry(num_frame, textvariable=self.num_sep, width=6).pack(side="left", padx=(0, 8))
ttk.Button(num_frame, text="应用到全部", command=self.apply_numbering).pack(side="left")
ttk.Button(num_frame, text="清空新名", command=self.clear_new_names).pack(side="left", padx=(4, 0))
# ── 中部:可滚动表格 ──
mid = ttk.Frame(self.root)
mid.pack(fill="both", expand=True, padx=8, pady=(0, 8))
# 表头
header = ttk.Frame(mid)
header.pack(fill="x")
ttk.Label(header, text="操作", width=6,
font=("Microsoft YaHei", 9, "bold")).pack(side="left", padx=(0, 4))
ttk.Label(header, text="原文件名(不含后缀)", width=30,
font=("Microsoft YaHei", 9, "bold")).pack(side="left", padx=(0, 4))
ttk.Label(header, text="新文件名(可编辑,不含后缀)",
font=("Microsoft YaHei", 9, "bold")).pack(side="left", fill="x", expand=True)
# 滚动区域
canvas = tk.Canvas(mid, highlightthickness=0)
vsb = ttk.Scrollbar(mid, orient="vertical", command=canvas.yview)
self.table_frame = ttk.Frame(canvas)
self.table_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
# 关键:让内嵌窗口随 Canvas 宽度变化,否则新文件名列无法撑满
self.table_window = canvas.create_window((0, 0), window=self.table_frame, anchor="nw")
canvas.bind(
"<Configure>",
lambda e: canvas.itemconfig(self.table_window, width=e.width)
)
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
# 鼠标滚轮
canvas.bind_all("<MouseWheel>",
lambda e: canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"))
# ── 底部:操作按钮 ──
bottom = ttk.Frame(self.root, padding=8)
bottom.pack(fill="x")
ttk.Button(bottom, text="干跑预览", command=self.dry_run).pack(side="left", padx=4)
ttk.Button(bottom, text="执行重命名", command=self.execute).pack(side="left", padx=4)
ttk.Button(bottom, text="撤销回滚", command=self.rollback).pack(side="left", padx=4)
ttk.Button(bottom, text="恢复已移除", command=self.restore_removed).pack(side="left", padx=4)
self.status = tk.StringVar(value="就绪")
ttk.Label(bottom, textvariable=self.status, foreground="#555").pack(side="right")
# ── 文件夹扫描与表格填充 ──
def choose_folder(self):
path = filedialog.askdirectory(title="选择要重命名文件所在的文件夹")
if path:
self.folder.set(path)
self.refresh()
def refresh(self):
folder = self.folder.get().strip()
if not folder or not os.path.isdir(folder):
messagebox.showwarning("提示", "请先选择一个有效的文件夹")
return
# 清空旧行
for child in self.table_frame.winfo_children():
child.destroy()
self.rows.clear()
self.removed.clear()
file_names = sorted(
e.name for e in os.scandir(folder) if e.is_file()
)
if not file_names:
self.status.set(f"文件夹中没有文件:{folder}")
return
for name in file_names:
stem, ext = os.path.splitext(name)
self._create_row(name, ext, stem)
self.status.set(f"已加载 {len(self.rows)} 个文件(后缀自动保留,无需手动输入)")
# ── 单行创建 / 移除 / 恢复 ──
def _create_row(self, name, ext, stem):
"""在表格中创建一行(操作按钮 + 原名 + 新名输入框)。"""
row = ttk.Frame(self.table_frame)
row.pack(fill="x", pady=1)
remove_btn = ttk.Button(
row, text="移除", width=6,
command=lambda r=row: self.remove_row(r)
)
remove_btn.pack(side="left", padx=(0, 4))
old_lbl = ttk.Label(row, text=stem, width=30, anchor="w",
background="#f0f0f0", relief="sunken")
old_lbl.pack(side="left", padx=(0, 4))
new_var = tk.StringVar(value=stem)
new_entry = ttk.Entry(row, textvariable=new_var)
new_entry.pack(side="left", fill="x", expand=True)
# 存储:原名(含后缀)、后缀、新名主体变量、行框架
self.rows.append((name, ext, new_var, row))
def remove_row(self, row_frame):
"""从界面移除一行(该文件不参与后续重命名)。"""
for i, (name, ext, new_var, frame) in enumerate(self.rows):
if frame is row_frame:
self.rows.pop(i)
self.removed.append((name, ext))
frame.destroy()
self.status.set(
f"已移除 {name}(剩余 {len(self.rows)} 个文件,"
f"已移除 {len(self.removed)} 个)"
)
return
def restore_removed(self):
"""恢复所有已从界面移除的文件行。"""
if not self.removed:
messagebox.showinfo("提示", "没有已移除的文件")
return
for name, ext in self.removed:
stem = os.path.splitext(name)[0]
self._create_row(name, ext, stem)
count = len(self.removed)
self.removed.clear()
self.status.set(f"已恢复 {count} 个文件(当前共 {len(self.rows)} 个)")
# ── 批量添加序号 ──
def apply_numbering(self):
"""按设置为所有文件生成带序号的新文件名,填入「新文件名」列。"""
if not self.rows:
messagebox.showwarning("提示", "请先选择文件夹并加载文件")
return
try:
start = int(self.num_start.get())
except ValueError:
messagebox.showerror("参数错误", "起始号必须是整数")
return
try:
step = int(self.num_step.get())
except ValueError:
messagebox.showerror("参数错误", "步长必须是整数")
return
try:
pad = int(self.num_pad.get())
if pad < 0:
raise ValueError
except ValueError:
messagebox.showerror("参数错误", "补零位数必须是非负整数")
return
sep = self.num_sep.get()
position = self.num_position.get()
for i, (old_full, ext, new_var, _) in enumerate(self.rows):
stem = os.path.splitext(old_full)[0]
number = start + i * step
num_str = f"{number:0{pad}d}" if pad > 0 else str(number)
if position == "前缀":
new_stem = f"{num_str}{sep}{stem}"
else: # 后缀
new_stem = f"{stem}{sep}{num_str}"
new_var.set(new_stem)
self.status.set(
f"已为 {len(self.rows)} 个文件生成{position}序号"
f"(起始 {start},步长 {step},补零 {pad} 位,分隔符「{sep}」)"
)
def clear_new_names(self):
"""将所有「新文件名」恢复为原文件名主体。"""
if not self.rows:
return
for old_full, ext, new_var, _ in self.rows:
new_var.set(os.path.splitext(old_full)[0])
self.status.set(f"已清空 {len(self.rows)} 个文件的新文件名(恢复为原名)")
# ── 收集当前表格中的映射 ──
def collect_mappings(self):
"""返回 [(old_full, new_full), ...],后缀自动拼回,跳过原名==新名的条目。"""
mappings = []
for old_full, ext, new_var, _ in self.rows:
new_stem = new_var.get().strip()
if not new_stem:
continue
new_full = new_stem + ext # 后缀自动拼回,用户无法误删
if old_full == new_full:
continue
mappings.append((old_full, new_full))
return mappings
# ── 干跑预览 ──
def dry_run(self):
folder = self.folder.get().strip()
if not folder:
messagebox.showwarning("提示", "请先选择文件夹")
return
mappings = self.collect_mappings()
if not mappings:
messagebox.showinfo("提示", "没有需要改名的条目(新文件名与原名相同或为空)")
return
ok, errors = preflight_check(mappings, folder)
if not ok:
messagebox.showerror("前置检查未通过", "\n\n".join(errors))
return
preview = "\n".join(f"{i+1:>3}. {old}\n → {new}"
for i, (old, new) in enumerate(mappings))
messagebox.showinfo(
"干跑预览(未实际改名)",
f"共 {len(mappings)} 个改名计划:\n\n{preview}\n\n"
"确认无误后点击「执行重命名」。"
)
# ── 执行重命名 ──
def execute(self):
folder = self.folder.get().strip()
if not folder:
messagebox.showwarning("提示", "请先选择文件夹")
return
mappings = self.collect_mappings()
if not mappings:
messagebox.showinfo("提示", "没有需要改名的条目")
return
ok, errors = preflight_check(mappings, folder)
if not ok:
messagebox.showerror("前置检查未通过", "\n\n".join(errors))
return
if not messagebox.askyesno("确认", f"即将对 {len(mappings)} 个文件执行重命名,确定继续吗?"):
return
try:
success, errs, rollback_path = execute_rename(mappings, folder)
except RuntimeError as e:
messagebox.showerror("执行失败", str(e))
return
msg = f"完成:成功 {success} 个,失败 {len(errs)} 个"
if errs:
msg += "\n\n失败条目:\n" + "\n".join(errs)
if rollback_path:
msg += f"\n\n回滚日志:{rollback_path}\n可点击「撤销回滚」还原。"
messagebox.showinfo("执行结果", msg)
self.status.set(f"完成:成功 {success} 个,失败 {len(errs)} 个")
self.refresh() # 刷新表格显示新文件名
# ── 撤销回滚 ──
def rollback(self):
folder = self.folder.get().strip()
if not folder:
messagebox.showwarning("提示", "请先选择文件夹")
return
rollback_path = os.path.join(folder, "rollback_log.txt")
if not os.path.isfile(rollback_path):
messagebox.showinfo("提示", "没有找到 rollback_log.txt,无可撤销记录")
return
mappings = []
with open(rollback_path, "r", encoding="utf-8-sig") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|", 1)
if len(parts) == 2:
old, new = parts[0].strip(), parts[1].strip()
if old and new and old != new:
mappings.append((old, new))
if not mappings:
messagebox.showinfo("提示", "回滚日志中没有可执行的条目")
return
ok, errors = preflight_check(mappings, folder)
if not ok:
messagebox.showerror("回滚前置检查未通过", "\n\n".join(errors))
return
if not messagebox.askyesno("确认回滚",
f"即将撤销 {len(mappings)} 个文件的改名,确定继续吗?"):
return
try:
success, errs, _ = execute_rename(mappings, folder, rollback_mode=True)
except RuntimeError as e:
messagebox.showerror("回滚失败", str(e))
return
msg = f"回滚完成:成功 {success} 个,失败 {len(errs)} 个"
if errs:
msg += "\n\n失败条目:\n" + "\n".join(errs)
messagebox.showinfo("回滚结果", msg)
self.status.set(f"回滚完成:成功 {success} 个")
self.refresh()
def main():
root = tk.Tk()
# Windows 下让 ttk 主题更好看
try:
ttk.Style().theme_use("vista")
except tk.TclError:
pass
BatchRenameApp(root)
root.mainloop()
if __name__ == "__main__":
main()
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。