'''将大量文件按名称新建文件夹,并可迁移进新建的文件夹中。
特殊功能:1.根据剪切板内容命名,即复制一批名字,会按每行内容生成文件夹。
2.添加、删除内容。
3.按指定数量批量复制文件夹并自动添加序号。例如某村某组_1、某组_2。
'''
import os
import json
import shutil
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".batch_folder_creator.json")
class BatchFolderCreator:
def init(self, root):
self.root = root
self.root.title("批量新建文件夹工具")
self.root.minsize(660, 600)
变量
self.last_output_folder = tk.StringVar() # 记忆上次输出位置
self.name_mode = tk.StringVar(value="file") # file / clipboard
self.create_new_folder = tk.BooleanVar(value=True)
self.file_action = tk.StringVar(value="none") # none / copy / move
self.prefix_content = tk.StringVar()
self.suffix_content = tk.StringVar()
self.add_serial = tk.BooleanVar(value=False) # 默认不选中
self.serial_sep = tk.StringVar(value="_") # 序号前连接符
self.max_serial = tk.StringVar(value="10")
self.del_left = tk.StringVar(value="0")
self.del_right = tk.StringVar(value="0")
self.selected_files = \[\] # 完整路径列表(保持顺序)
self._load_config()
self._build_ui()
---------- 配置持久化 ----------
def _load_config(self):
if not os.path.isfile(CONFIG_FILE):
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = json.load(f)
self.last_output_folder.set(cfg.get("last_output_folder", ""))
self.name_mode.set(cfg.get("name_mode", "file"))
self.create_new_folder.set(cfg.get("create_new_folder", True))
self.file_action.set(cfg.get("file_action", "none"))
self.prefix_content.set(cfg.get("prefix_content", ""))
self.suffix_content.set(cfg.get("suffix_content", ""))
self.add_serial.set(cfg.get("add_serial", False))
self.serial_sep.set(cfg.get("serial_sep", "_"))
self.max_serial.set(cfg.get("max_serial", "10"))
self.del_left.set(cfg.get("del_left", "0"))
self.del_right.set(cfg.get("del_right", "0"))
except Exception:
pass
def _save_config(self):
cfg = {
"last_output_folder": self.last_output_folder.get(),
"name_mode": self.name_mode.get(),
"create_new_folder": self.create_new_folder.get(),
"file_action": self.file_action.get(),
"prefix_content": self.prefix_content.get(),
"suffix_content": self.suffix_content.get(),
"add_serial": self.add_serial.get(),
"serial_sep": self.serial_sep.get(),
"max_serial": self.max_serial.get(),
"del_left": self.del_left.get(),
"del_right": self.del_right.get(),
}
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
except Exception:
pass
---------- UI ----------
def _build_ui(self):
pad = {"padx": 10, "pady": 5}
顶部容器
frame_top = ttk.Frame(self.root)
frame_top.pack(side="top", fill="both", expand=True)
1. 源文件 + 命名依据 + 执行按钮(同一行)
frame_src = ttk.Frame(frame_top)
frame_src.pack(fill="x", padx=10, pady=5)
ttk.Button(frame_src, text="执行", command=self.execute).pack(
side="left", padx=(0, 5)
)
ttk.Button(frame_src, text="选择源文件...", command=self.choose_source_files).pack(
side="left", padx=5
)
ttk.Button(frame_src, text="清空", command=self.clear_files).pack(
side="left", padx=5
)
ttk.Separator(frame_src, orient="vertical").pack(side="left", fill="y", padx=10)
ttk.Radiobutton(
frame_src, text="按文件命名",
variable=self.name_mode, value="file",
).pack(side="left", padx=5)
ttk.Radiobutton(
frame_src, text="按剪切板内容命名:每行1个",
variable=self.name_mode, value="clipboard",
).pack(side="left", padx=5)
文件列表(无标题)
frame_list = ttk.Frame(frame_top)
frame_list.pack(fill="both", expand=True, padx=10, pady=5)
list_container = ttk.Frame(frame_list)
list_container.pack(fill="both", expand=True)
self.file_listbox = tk.Listbox(list_container, selectmode="extended", height=4)
self.file_listbox.pack(side="left", fill="both", expand=True)
scrollbar = ttk.Scrollbar(list_container, orient="vertical", command=self.file_listbox.yview)
scrollbar.pack(side="right", fill="y")
self.file_listbox.config(yscrollcommand=scrollbar.set)
3. 新建文件夹
frame_new = ttk.LabelFrame(frame_top, text="3. 输出位置")
frame_new.pack(fill="x", **pad)
row1 = ttk.Frame(frame_new); row1.pack(fill="x", padx=5, pady=3)
ttk.Checkbutton(
row1, text="记住输出位置,下次执行直接使用(不弹窗)",
variable=self.create_new_folder
).pack(side="left")
ttk.Label(row1, text=" | ").pack(side="left")
ttk.Label(row1, text="源文件:").pack(side="left")
ttk.Radiobutton(row1, text="不处理", variable=self.file_action, value="none").pack(side="left", padx=3)
ttk.Radiobutton(row1, text="复制到对应文件夹", variable=self.file_action, value="copy").pack(side="left", padx=3)
ttk.Radiobutton(row1, text="剪切到对应文件夹", variable=self.file_action, value="move").pack(side="left", padx=3)
4. 添加内容(前/后 同一行)
frame_content = ttk.LabelFrame(frame_top, text="4. 在原文件名上添加内容")
frame_content.pack(fill="x", **pad)
ttk.Label(frame_content, text="在前添加:").pack(side="left", padx=(5, 2), pady=3)
ttk.Entry(frame_content, textvariable=self.prefix_content, width=18).pack(
side="left", padx=2, pady=3
)
ttk.Label(frame_content, text="在后添加:").pack(side="left", padx=(15, 2), pady=3)
ttk.Entry(frame_content, textvariable=self.suffix_content, width=18).pack(
side="left", padx=2, pady=3
)
5. 删除字数(左/右两个独立输入框)
frame_del = ttk.LabelFrame(frame_top, text="5. 删除原名称字数")
frame_del.pack(fill="x", **pad)
ttk.Label(frame_del, text="从左往右删除:").pack(side="left", padx=5, pady=3)
ttk.Entry(frame_del, textvariable=self.del_left, width=8).pack(
side="left", padx=5, pady=3
)
ttk.Label(frame_del, text="从右往左删除:").pack(side="left", padx=(20, 5), pady=3)
ttk.Entry(frame_del, textvariable=self.del_right, width=8).pack(
side="left", padx=5, pady=3
)
ttk.Label(frame_del, text="(0 表示不删除)").pack(side="left", padx=10)
6. 添加序号
frame_serial = ttk.LabelFrame(frame_top, text="6. 添加序号")
frame_serial.pack(fill="x", **pad)
ttk.Checkbutton(
frame_serial, text="添加序号", variable=self.add_serial
).pack(side="left", padx=5, pady=3)
ttk.Label(frame_serial, text="序号前符号:").pack(side="left", padx=5)
ttk.Entry(frame_serial, textvariable=self.serial_sep, width=4).pack(
side="left", padx=5
)
ttk.Label(frame_serial, text="最大序号:").pack(side="left", padx=(15, 5))
ttk.Entry(frame_serial, textvariable=self.max_serial, width=8).pack(
side="left", padx=5
)
ttk.Label(frame_serial, text="(0 表示不编号)").pack(side="left", padx=5)
---------- 动作 ----------
def choose_source_files(self):
files = filedialog.askopenfilenames(title="选择源文件(可多选)")
if not files:
return
按文件名排序,但保持完整路径
sorted_pairs = sorted(
(os.path.basename(p), p) for p in files\], key=lambda x: x\[0
)
self.selected_files = p for _, p in sorted_pairs
self.file_listbox.delete(0, tk.END)
for name, _ in sorted_pairs:
self.file_listbox.insert(tk.END, name)
def clear_files(self):
self.selected_files = \[\]
self.file_listbox.delete(0, tk.END)
def _pick_output_folder(self, mode):
"""
勾选(记住输出位置):
-
有有效记忆 → 直接用,不弹窗
-
无记忆(第一次运行)→ 弹窗让用户选一次,之后会记住
未勾选:
- 每次都弹窗让用户选
"""
remembered = self.last_output_folder.get().strip()
remembered_ok = bool(remembered) and os.path.isdir(remembered)
if self.create_new_folder.get() and remembered_ok:
勾选 + 有记忆 → 直接用,不弹窗
return remembered
其余情况全部弹窗(第一次运行必须选一次位置)
initialdir 优先用记忆位置,其次源文件目录,最后用户主目录
initial = remembered if remembered_ok else os.path.expanduser("~")
if initial == os.path.expanduser("~") and mode == "file" and self.selected_files:
src_dir = os.path.dirname(self.selected_files0)
if src_dir and os.path.isdir(src_dir):
initial = src_dir
folder = filedialog.askdirectory(title="选择输出文件夹(文件夹将创建在此目录下)", initialdir=initial)
return folder
def _parse_int(self, text, field_name, allow_zero=True):
text = text.strip()
if text == "":
return 0
try:
value = int(text)
except ValueError:
raise ValueError(f"{field_name}必须是整数")
if value < 0 or (not allow_zero and value == 0):
raise ValueError(f"{field_name}不能为负数" + ("或 0" if not allow_zero else ""))
return value
def execute(self):
先拿到命名模式(_pick_output_folder 需要知道它来做 fallback)
mode = self.name_mode.get()
目标文件夹
folder = self._pick_output_folder(mode)
if not folder:
return
if not os.path.isdir(folder):
messagebox.showwarning("提示", "所选输出位置无效")
return
基础名称来源
source_paths = \[\] # 完整源路径,与 base_names 一一对应(仅 file 模式)
if mode == "file":
if not self.selected_files:
messagebox.showwarning("提示", "请先选择用于命名的源文件")
return
source_paths = list(self.selected_files)
base_names = os.path.splitext(os.path.basename(p))\[0 for p in source_paths]
else: # clipboard
try:
clip = self.root.clipboard_get()
except Exception:
messagebox.showwarning("提示", "剪切板为空或无法读取")
return
base_names = line.strip() for line in clip.splitlines() if line.strip()
if not base_names:
messagebox.showwarning("提示", "剪切板内容为空")
return
prefix = self.prefix_content.get()
suffix = self.suffix_content.get()
删除字数
try:
del_left = self._parse_int(self.del_left.get(), "从左往右删除字数")
del_right = self._parse_int(self.del_right.get(), "从右往左删除字数")
except ValueError as e:
messagebox.showwarning("提示", str(e))
return
序号
try:
max_serial = self._parse_int(self.max_serial.get(), "最大序号")
except ValueError as e:
messagebox.showwarning("提示", str(e))
return
add_serial = self.add_serial.get()
serial_sep = self.serial_sep.get()
if add_serial and max_serial == 0:
messagebox.showwarning("提示", "勾选添加序号时,最大序号不能为 0")
return
文件处理前置校验
file_action = self.file_action.get()
if file_action in ("copy", "move") and mode != "file":
messagebox.showwarning("提示", "复制/剪切源文件需要用『按文件命名』模式")
return
生成名称
names_to_create = \[\] # 每个元素是 (原始base_index, 最终文件夹名)
for idx, base in enumerate(base_names):
base = base.strip()
if not base:
continue
先删左、再删右
if del_left > 0:
base = basedel_left: if del_left < len(base) else ""
if del_right > 0 and base:
base = base:-del_right if del_right < len(base) else ""
加上前后添加内容
name_part = f"{prefix}{base}{suffix}"
if not name_part:
continue
if add_serial and max_serial > 0:
for i in range(1, max_serial + 1):
names_to_create.append((idx, f"{name_part}{serial_sep}{i}"))
else:
names_to_create.append((idx, name_part))
if not names_to_create:
messagebox.showwarning("提示", "没有生成任何文件夹名称")
return
去重(保留首次出现的)
unique_names = \[\]
seen = set()
for idx, name in names_to_create:
if name not in seen:
seen.add(name)
unique_names.append((idx, name))
检查已存在
existing = n for _, n in unique_names if os.path.exists(os.path.join(folder, n))
if existing:
msg = "以下文件夹已存在,将跳过:\n" + "\n".join(existing:10)
if len(existing) > 10:
msg += f"\n... 共 {len(existing)} 个"
if not messagebox.askyesno("确认", msg + "\n\n是否继续?"):
return
创建
created = 0
skipped = 0
errors = \[\]
for idx, name in unique_names:
target = os.path.join(folder, name)
if os.path.exists(target):
skipped += 1
continue
try:
os.makedirs(target)
created += 1
except Exception as e:
errors.append(f"{name}: {e}")
复制/剪切源文件到对应文件夹
copy + 序号:每个 (idx, name) 对都 copy source_pathsidx → 天然实现一份源复制到所有序号文件夹
move + 序号:先按 idx 分组,同一源文件先全部 copy 到各序号文件夹,成功后再删原文件
move + 非序号:原有 shutil.move 一对一逻辑
file_op_ok = 0
file_op_fail = \[\]
if file_action in ("copy", "move"):
先把每个 (idx, name) 对变成完整的 (src_path, dst_dir, dst_path, folder_name)
ops = \[\]
for idx, name in unique_names:
src = source_pathsidx
dst_dir = os.path.join(folder, name)
if not os.path.isdir(dst_dir):
continue
if not os.path.isfile(src):
file_op_fail.append(f"{os.path.basename(src)} → {name}(源文件不存在)")
continue
dst_path = os.path.join(dst_dir, os.path.basename(src))
if os.path.exists(dst_path):
file_op_fail.append(f"{os.path.basename(src)} → {name}(目标已存在)")
continue
ops.append((src, dst_dir, dst_path, name))
if not ops:
pass
elif file_action == "copy":
复制模式:直接全部 copy
for src, dst_dir, dst_path, name in ops:
try:
shutil.copy2(src, dst_path)
file_op_ok += 1
except Exception as e:
file_op_fail.append(f"{os.path.basename(src)} → {name}: {e}")
elif add_serial:
剪切 + 序号:先全部 copy 到各文件夹,成功后再删原文件
按源文件分组,确保同一源文件的所有目标文件夹都收到副本
from collections import defaultdict
groups = defaultdict(list)
for src, dst_dir, dst_path, name in ops:
groupssrc.append((dst_path, name))
for src_path, targets in groups.items():
all_copied = True
for dst_path, name in targets:
try:
shutil.copy2(src_path, dst_path)
file_op_ok += 1
except Exception as e:
file_op_fail.append(
f"{os.path.basename(src_path)} → {name}: {e}"
)
all_copied = False
全部副本到位后,删除原文件(实现"剪切到多份"的语义)
if all_copied:
try:
os.remove(src_path)
except Exception as e:
file_op_fail.append(
f"删除源文件 {os.path.basename(src_path)} 失败: {e}"
)
else:
剪切 + 非序号:原有 shutil.move 一对一逻辑
for src, dst_dir, dst_path, name in ops:
try:
shutil.move(src, dst_path)
file_op_ok += 1
except Exception as e:
file_op_fail.append(f"{os.path.basename(src)} → {name}: {e}")
保存配置
self.last_output_folder.set(folder)
self._save_config()
result = f"创建完成!\n成功:{created} 个\n跳过(已存在):{skipped} 个"
if errors:
result += f"\n创建失败:{len(errors)} 个\n" + "\n".join(errors:5)
if file_action in ("copy", "move"):
label = "复制" if file_action == "copy" else "剪切"
result += f"\n\n源文件{label}:成功 {file_op_ok} 个"
if file_op_fail:
result += f",失败 {len(file_op_fail)} 个\n" + "\n".join(file_op_fail:5)
messagebox.showinfo("结果", result)
if name == "main":
root = tk.Tk()
app = BatchFolderCreator(root)
root.mainloop()