脚本批量按表修改文件名。
# -*- coding: utf-8 -*-
"""
文件批量命名工作流
第一步:选择目录,递归扫描文件
第二步:导出 TXT 文件清单
第三步:自动生成 XLSX 人工核对表
第四步:人工填写 B 列新文件名
第五步:重新选择 XLSX,预览并批量重命名
A列:原文件名
B列:新文件名
C列:原文件完整路径(辅助定位)
"""
import os
import csv
import uuid
import tkinter as tk
from pathlib import Path
from datetime import datetime
from collections import defaultdict
from tkinter import filedialog, messagebox, ttk
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
class RenameTool:
def __init__(self, root):
self.root = root
self.root.title("Excel 批量文件命名工具")
self.root.geometry("950x720")
self.folder = ""
self.xlsx = ""
self.plans = []
self.issues = []
self.build_ui()
def build_ui(self):
main = ttk.Frame(self.root, padding=15)
main.pack(fill="both", expand=True)
ttk.Label(
main,
text="文件批量命名工作流",
font=("Microsoft YaHei", 16, "bold")
).pack(anchor="w", pady=10)
ttk.Label(
main,
text="第一步:选择需要整理的目录,导出 TXT 和 XLSX"
).pack(anchor="w")
row1 = ttk.Frame(main)
row1.pack(fill="x", pady=6)
ttk.Button(
row1,
text="① 选择目录并导出清单",
command=self.export_files
).pack(side="left")
ttk.Label(
main,
text="第二步:选择人工核对、修改后的 XLSX 表格"
).pack(anchor="w", pady=(20, 0))
row2 = ttk.Frame(main)
row2.pack(fill="x", pady=6)
self.xlsx_label = ttk.Label(
row2,
text="尚未选择 XLSX 文件"
)
self.xlsx_label.pack(side="left", fill="x", expand=True)
ttk.Button(
row2,
text="选择 XLSX",
command=self.select_xlsx
).pack(side="left", padx=5)
row3 = ttk.Frame(main)
row3.pack(fill="x", pady=12)
ttk.Button(
row3,
text="② 预览重命名",
command=self.preview
).pack(side="left", padx=5)
ttk.Button(
row3,
text="③ 确认批量改名",
command=self.execute
).pack(side="left", padx=5)
ttk.Button(
row3,
text="清空日志",
command=self.clear_log
).pack(side="left", padx=5)
self.status = ttk.Label(
main,
text="等待操作"
)
self.status.pack(anchor="w", pady=5)
self.output = tk.Text(
main,
font=("Consolas", 10),
wrap="none"
)
self.output.pack(fill="both", expand=True)
scroll = ttk.Scrollbar(
self.output,
orient="vertical",
command=self.output.yview
)
scroll.pack(side="right", fill="y")
self.output.configure(
yscrollcommand=scroll.set
)
def log(self, msg):
self.output.insert("end", msg + "\n")
self.output.see("end")
self.root.update_idletasks()
def clear_log(self):
self.output.delete("1.0", "end")
# ==========================
# 第一步:导出 TXT 和 XLSX
# ==========================
def export_files(self):
folder = filedialog.askdirectory(
title="选择需要扫描的文件夹"
)
if not folder:
return
self.folder = str(Path(folder).resolve())
root_path = Path(self.folder)
self.clear_log()
self.log("开始递归扫描目录...")
files = []
excluded = {
"文件清单.txt",
"重命名核对表.xlsx"
}
for current, dirs, filenames in os.walk(root_path):
# 排除常见隐藏目录
dirs[:] = [
d for d in dirs
if d not in {"$RECYCLE.BIN", "System Volume Information"}
]
for filename in filenames:
if filename in excluded:
continue
path = Path(current) / filename
try:
if path.is_file():
files.append(path.resolve())
except OSError:
continue
files.sort(key=lambda p: str(p).lower())
if not files:
messagebox.showwarning(
"提示",
"目录中没有找到文件"
)
return
txt_path = root_path / "文件清单.txt"
xlsx_path = root_path / "重命名核对表.xlsx"
try:
# 导出 TXT
with open(
txt_path,
"w",
encoding="utf-8-sig"
) as f:
for path in files:
f.write(str(path) + "\n")
# 生成 XLSX
wb = Workbook()
ws = wb.active
ws.title = "重命名核对表"
headers = [
"原文件名",
"新文件名(人工填写)",
"原文件完整路径"
]
ws.append(headers)
for path in files:
ws.append([
path.name,
"",
str(path)
])
# 表格样式
header_fill = PatternFill(
"solid",
fgColor="2878B5"
)
for cell in ws[1]:
cell.font = Font(
bold=True,
color="FFFFFF"
)
cell.fill = header_fill
cell.alignment = Alignment(
horizontal="center"
)
ws.column_dimensions["A"].width = 35
ws.column_dimensions["B"].width = 40
ws.column_dimensions["C"].width = 90
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
# B列设置醒目的人工填写颜色
for row in range(2, len(files) + 2):
ws.cell(row, 2).fill = PatternFill(
"solid",
fgColor="FFF2CC"
)
wb.save(xlsx_path)
wb.close()
self.xlsx = str(xlsx_path)
self.xlsx_label.config(
text=str(xlsx_path)
)
self.log("========== 导出完成 ==========")
self.log(f"扫描目录:{root_path}")
self.log(f"文件总数:{len(files)}")
self.log(f"TXT:{txt_path}")
self.log(f"XLSX:{xlsx_path}")
self.log("")
self.log("请打开 XLSX,人工填写 B 列新文件名。")
self.log("A 列和 C 列不要修改。")
self.status.config(
text=f"清单导出完成,共 {len(files)} 个文件"
)
messagebox.showinfo(
"导出完成",
f"扫描文件:{len(files)} 个\n\n"
f"已生成:\n"
f"1. 文件清单.txt\n"
f"2. 重命名核对表.xlsx\n\n"
f"请人工填写 Excel 的 B 列。"
)
except Exception as e:
messagebox.showerror(
"导出失败",
str(e)
)
# ==========================
# 第二步:选择 XLSX
# ==========================
def select_xlsx(self):
path = filedialog.askopenfilename(
title="选择人工核对后的 XLSX",
filetypes=[("Excel 文件", "*.xlsx")]
)
if path:
self.xlsx = path
self.xlsx_label.config(text=path)
# ==========================
# 读取 Excel
# ==========================
def read_excel(self):
if not self.xlsx:
raise ValueError("请先选择 XLSX 文件")
wb = load_workbook(
self.xlsx,
read_only=True,
data_only=True
)
ws = wb.active
plans = []
issues = []
try:
for row_num, row in enumerate(
ws.iter_rows(
min_row=2,
min_col=1,
max_col=3,
values_only=True
),
start=2
):
old, new, full_path = row
if old is None:
continue
old = str(old).strip()
if not old:
continue
# B列为空,视为不需要改名
if new is None or not str(new).strip():
continue
new = str(new).strip()
# 优先使用 C 列完整路径定位
if full_path and str(full_path).strip():
source = Path(str(full_path).strip())
else:
issues.append(
f"[缺少原路径] Excel 第 {row_num} 行"
)
continue
# 防止路径或文件名非法
if (
Path(new).name != new
or "/" in new
or "\\" in new
or any(c in new for c in '<>:"|?*')
or new in {".", ".."}
):
issues.append(
f"[新文件名非法] 第 {row_num} 行:{new}"
)
continue
# 如果新名称没有扩展名,保留原扩展名
if not Path(new).suffix:
new += source.suffix
target = source.with_name(new)
if source.name != old:
issues.append(
f"[原文件名与路径不一致] {source}"
)
continue
if not source.is_file():
issues.append(
f"[文件不存在] {source}"
)
continue
if source == target:
issues.append(
f"[无需改名] {old}"
)
continue
plans.append({
"old": old,
"new": new,
"source": source,
"target": target,
"row": row_num
})
finally:
wb.close()
return plans, issues
# ==========================
# 第三步:预览
# ==========================
def preview(self):
self.clear_log()
try:
plans, issues = self.read_excel()
self.issues = issues
# 检查目标路径重复
target_groups = defaultdict(list)
for p in plans:
key = os.path.normcase(
os.path.abspath(str(p["target"]))
)
target_groups[key].append(p)
duplicate_targets = {
key for key, group in target_groups.items()
if len(group) > 1
}
source_set = {
os.path.normcase(
os.path.abspath(str(p["source"]))
)
for p in plans
}
valid = []
for p in plans:
target_key = os.path.normcase(
os.path.abspath(str(p["target"]))
)
if target_key in duplicate_targets:
issues.append(
f"[目标文件名重复] {p['new']}"
)
continue
# 已存在的目标文件,如果不是本批次参与改名的源文件则跳过
if (
p["target"].exists()
and target_key not in source_set
):
issues.append(
f"[目标文件已存在] {p['target']}"
)
continue
valid.append(p)
self.plans = valid
self.log("========== 重命名预览 ==========")
for p in valid:
self.log(
f"[待改名] {p['old']} -> {p['new']}"
)
for issue in issues:
self.log(issue)
self.log("--------------------------------")
self.log(f"待改名:{len(valid)}")
self.log(f"跳过/异常:{len(issues)}")
self.status.config(
text=f"预览完成:{len(valid)} 个待改名"
)
except Exception as e:
messagebox.showerror(
"读取失败",
str(e)
)
# ==========================
# 第四步:执行改名
# ==========================
def execute(self):
if not self.plans:
messagebox.showwarning(
"提示",
"请先预览重命名清单"
)
return
if not messagebox.askyesno(
"确认执行",
f"本次准备修改 {len(self.plans)} 个文件。\n\n"
"请确认 Excel 已经核对无误。\n"
"建议提前备份原文件。\n\n"
"确定开始吗?"
):
return
staged = []
completed = []
try:
# 第一阶段:全部改成临时名称
# 允许批量交换文件名,例如 A->B、B->A
for p in self.plans:
source = p["source"]
if not source.is_file():
raise FileNotFoundError(
f"源文件不存在:{source}"
)
temp = source.with_name(
"__rename_tmp_"
+ uuid.uuid4().hex
+ ".tmp"
)
source.rename(temp)
staged.append({
"temp": temp,
"source": source,
"target": p["target"],
"plan": p
})
# 第二阶段:临时文件改成最终名称
for item in staged:
item["temp"].rename(item["target"])
completed.append(item)
self.log("")
self.log("========== 全部改名成功 ==========")
for item in completed:
self.log(
f"[成功] {item['plan']['old']} "
f"-> {item['plan']['new']}"
)
self.save_log(completed, "成功")
self.status.config(
text=f"执行完成:成功 {len(completed)} 个"
)
messagebox.showinfo(
"完成",
f"批量重命名完成!\n\n"
f"成功:{len(completed)} 个文件\n"
f"日志已保存到原目录。"
)
self.plans = []
except Exception as e:
self.log(f"[执行失败] {e}")
self.log("[正在尝试回滚]")
# 回滚已经完成的目标文件和临时文件
for item in reversed(staged):
try:
if item["target"].exists():
item["target"].rename(item["source"])
elif item["temp"].exists():
item["temp"].rename(item["source"])
except Exception as rollback_error:
self.log(
f"[回滚失败] {item['source']}:"
f"{rollback_error}"
)
messagebox.showerror(
"执行失败",
f"{e}\n\n已尝试回滚,请检查日志和文件。"
)
self.status.config(
text="执行失败,请检查日志"
)
# ==========================
# 保存日志
# ==========================
def save_log(self, completed, status):
if self.folder:
output_dir = Path(self.folder)
else:
output_dir = Path(self.xlsx).parent
log_path = output_dir / (
"批量重命名日志_"
+ datetime.now().strftime("%Y%m%d_%H%M%S")
+ ".csv"
)
with open(
log_path,
"w",
newline="",
encoding="utf-8-sig"
) as f:
writer = csv.writer(f)
writer.writerow([
"原文件名",
"新文件名",
"原文件路径",
"新文件路径",
"状态"
])
for item in completed:
p = item["plan"]
writer.writerow([
p["old"],
p["new"],
str(item["source"]),
str(item["target"]),
status
])
self.log(f"[日志] {log_path}")
if __name__ == "__main__":
root = tk.Tk()
app = RenameTool(root)
root.mainloop()