首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >批量重命名工具(自我使用AI编程的记录)

批量重命名工具(自我使用AI编程的记录)

原创
作者头像
用户4648146
发布于 2026-10-31 11:29:24
发布于 2026-10-31 11:29:24
291
举报

一、问题提出

在整理自己的歌曲U盘时,发现歌曲的排序很混乱,有的是歌曲-演唱家;有的是反过来的;并且在车上播放的时候,车机显示歌曲的名称也不标识序号,想我这样的整洁的人,必须要把这些歌曲文件整理好,而且我也需要在车机上播放时,想知道在播放的是U盘中第几首的歌。

二、解决方案,利用PYTHON语言的特性,快速开发一个批量重命名的工具,解决需求

  1. 首先,要读取文件夹内的文件名,我的歌曲内容只在第一层,并把所有的文件名写入txt,而且我需要重命名,故txt加一个分隔符,用来区分原文件名|新文件名;可以看下面第一张图的局部代码
  2. 然后我就可以修改这个txt文件,然后批量重命名了。到这里,我就不会了;求助AI后,给了我一大片代码,AI 还是厉害,哈哈哈
  3. 然后既然都用AI了,我就要求给我出了一个PYTHON做的界面,最后帮我打包程exe文件,解决了我想一键修改U盘里的歌曲的软件,现在分享出来;不过exe的内容就不放出来了,我放3个文件的代码,有需要或想学习的可以自行下载。
  4. 大家可以复制代码,然后用PyInstaller打包成exe用,也可以复制3个文件的代码后,再用PYTHON直接运行第3个文件。

list_files.py

代码语言:txt
复制
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 每行格式:原文件名|新文件名
  - 以 # 开头的行视为注释,会被跳过
  - 空行会被跳过
  - 原文件名 == 新文件名 的行会被跳过(无需改名)
  - 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

代码语言:txt
复制
"""
批量重命名工具 - 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 删除。

问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档