在日常数据处理中,我们经常需要根据一个模板工作表,替换其中的占位符并生成大量结构相同、内容各异的表格。例如:生成各分公司的预算表、不同部门的考勤统计表,或者按日期拆分的销售明细。手动复制、重命名、逐个修改单元格不仅枯燥,还容易出错。
本文介绍的 Excel批量工作表生成工具 正是为解决这一问题而生。它通过一个“配置表”来描述生成规则,支持两种输出模式:
- 单个Excel文件多工作表:所有结果放在同一个文件中,每个生成的结果作为一个独立的工作表。
- 多个独立Excel文件:每行配置生成一个单独的
.xlsx文件,并可自定义每个文件内的工作表名。
工具内置内存监控,防止大文件导致系统卡死,并对工作表名、文件名以及占位符进行全面校验,确保生成过程可靠。
1. 工具概览
该工具使用 Python 标准库 tkinter 构建界面,openpyxl 操作 Excel,psutil 监控内存,并借助 logging 实现完整的运行日志。核心工作流程:
- 用户选择一个包含“配置”工作表的 Excel 文件。
- 程序读取配置表,解析模板工作表、输出名称、占位符等信息。
- 遍历配置行,依次复制模板工作表,在副本中替换占位符为指定内容。
- 根据所选模式,将结果保存为一个多工作表的 Excel 文件,或是一系列单独的 Excel 文件。
- 全程记录详细日志,遇错自动终止并弹出提示。
2. 技术实现细节
2.1 配置表设计
配置文件必须包含一个名为 配置 的工作表。其结构根据输出模式略有差异:
单文件模式(一个Excel内多个工作表):
| 模板工作表 | 新工作表名 | 占位符A | 占位符B | … |
|---|---|---|---|---|
| 模板1 | 北京分公司 | 北京 | 1200 | … |
| 模板1 | 上海分公司 | 上海 | 980 | … |
多文件模式(每个配置生成独立Excel文件):
| 模板工作表 | 输出文件名 | 工作表名 | 占位符A | 占位符B | … |
|---|---|---|---|---|---|
| 模板1 | 北京分公司 | 预算表 | 北京 | 1200 | … |
| 模板1 | 上海分公司 | 预算表 | 上海 | 980 | … |
- 表头第一行定义占位符标识,程序将根据这些标识在模板工作表中查找对应单元格。
- 第一列始终为模板工作表名称,第二列在单文件模式下为新工作表名,在多文件模式下为输出文件名。
- 多文件模式下,第三列为每个输出文件内的工作表名;单文件模式则直接从第二列开始存放替换数据。
2.2 占位符索引与替换
替换的核心是先为每个模板工作表建立占位符位置索引,再逐行替换。
build_cells函数遍历配置表,对每个模板工作表中的所有单元格扫描,记录包含指定占位符文本的单元格坐标(如A1、B3)。- 索引以
dict形式存储:{模板工作表名: {占位符: [单元格地址列表]}}。 replace_in_sheet在执行替换时,直接根据索引找到对应单元格,将占位符替换为配置行中的对应数据。若单元格内容等于占位符本身且替换值为纯数字,则保留数值类型;否则进行字符串替换。
为提高查找效率并避免意外替换,程序还实现了占位符前缀冲突检查(check_target_prefixes),确保没有占位符是另一个的前缀,比如同时存在 日期 和 日期时间 会被拒绝,防止部分匹配导致的错误。
2.3 内存保护机制
批量生成大量工作表或文件时,内存占用可能飙升。工具利用 psutil 实时监控当前进程内存,用户可设置内存使用阈值(默认50%)。每当处理完指定数量(如100个工作表或50个文件)后检测一次,若超过系统总内存的阈值百分比则立即终止任务,并给出明确提示。这一机制有效避免了因内存溢出导致的程序崩溃或系统卡顿。
2.4 两种输出模式的实现
- 单文件模式:直接在已打开的源文件工作簿上执行
copy_worksheet,生成的新工作表追加到当前工作簿,最后删除原始模板和配置工作表,仅保留结果,保存为一个文件。 - 多文件模式:由于每个文件需要独立的模板,因此在循环中每次重新
load_workbook打开源文件,仅保留目标模板工作表,删除其他工作表,替换后另存为新文件。为防止影响全局,主工作簿在进入多文件模式前被关闭,处理完毕后统一清理。
两种模式共享同一套占位符索引和替换逻辑,仅保存方式和数据读取顺序不同。
2.5 线程与日志系统
耗时生成任务在子线程中运行,避免阻塞GUI。日志通过自定义 TextHandler 将 logging 消息安全地推送到界面的 ScrolledText 控件,使用 root.after(0, ...) 确保线程安全。同时设置了全局异常捕获钩子,未捕获的异常会记录到日志而非导致程序直接闪退。
3. 使用方法
3.1 准备配置文件
- 新建一个 Excel 文件,将其中一个工作表命名为
配置。 - 在同一文件内准备一个或多个模板工作表,在需要动态填充的单元格中输入占位符文字(例如
分公司名称、金额),占位符的名称应与配置表表头一致。 - 在
配置工作表中按照上述结构填写数据:- 第一行:表头(从第二列开始为占位符标识,注意不同模式下列数含义不同)。
- 后续行:每行为一次生成任务,填写模板工作表名、新名称以及各项替换内容。
3.2 运行程序
# 安装依赖
pip install openpyxl psutil
# 直接运行脚本
python excel_batch_generator.py
- 点击“浏览”选择准备好的 Excel 配置文件。
- 选择输出模式:
- 单个Excel文件(多工作表):结果保存在一个文件中,适合关联性强的数据。
- 多个独立Excel文件:每个配置行生成一个文件,适合分发给不同人员。
- 设置内存阈值(默认50%,可根据机器内存灵活调整)。
- 点击“生成并保存文件”,指定保存位置后,程序开始运行,日志区域会实时显示进度。
4. 完整代码
将以下代码保存为 .py 文件并运行即可。请确保已安装 openpyxl 和 psutil。
import sys
import os
import logging
import psutil
import threading
import tkinter as tk
from tkinter import filedialog, scrolledtext, messagebox
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
from typing import Dict, List
def sanitize_filename(filename):
"""清理文件名中的非法字符,避免保存失败"""
illegal_chars = '\\/:*?"<>|'
for c in illegal_chars:
filename = filename.replace(c, '_')
return filename.strip()
class TextHandler(logging.Handler):
"""日志重定向到GUI文本框,线程安全"""
def __init__(self, text_widget):
super().__init__()
self.text_widget = text_widget
self.setFormatter(logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
def emit(self, record):
msg = self.format(record)
def append():
self.text_widget.config(state="normal")
self.text_widget.insert(tk.END, msg + "\n")
self.text_widget.see(tk.END)
self.text_widget.config(state="disabled")
self.text_widget.after(0, append)
class ExcelBatchApp:
def __init__(self, root):
self.root = root
self.root.title("Excel批量工作表生成工具")
self.root.geometry("680x520")
self.root.minsize(600, 460)
# 界面绑定变量
self.excel_path = tk.StringVar()
self.memory_threshold = tk.IntVar(value=50)
self.mode_var = tk.StringVar(value="single") # single=单文件多sheet, multi=多文件独立输出
# 运行时全局状态
self.origin_wb = None
self.target_index: Dict[str, Dict[str, List[str]]] = {}
self.memory_total = 8 * 1024 * 1024 * 1024
self._build_ui()
self._setup_logging()
self._install_global_exception_handler()
def _install_global_exception_handler(self):
"""全局异常捕获,避免未捕获异常导致闪退"""
def handle_exception(exc_type, exc_value, exc_traceback):
err_msg = f"未捕获异常: {exc_type.__name__}: {exc_value}"
logging.error(err_msg)
sys.excepthook = handle_exception
def _build_ui(self):
container = tk.Frame(self.root, padx=12, pady=12)
container.pack(fill=tk.BOTH, expand=True)
# 第1行:Excel配置文件选择
tk.Label(container, text="Excel配置文件 (.xlsx):").grid(row=0, column=0, sticky="w", pady=6)
tk.Entry(container, textvariable=self.excel_path).grid(row=0, column=1, padx=6, pady=6, sticky="we")
tk.Button(container, text="浏览", command=self.browse_excel, width=8).grid(row=0, column=2, pady=6)
# 第2行:输出模式选择
tk.Label(container, text="输出模式:").grid(row=1, column=0, sticky="w", pady=6)
mode_frame = tk.Frame(container)
mode_frame.grid(row=1, column=1, columnspan=2, sticky="w", padx=6)
tk.Radiobutton(
mode_frame, text="单个Excel文件(多工作表)",
variable=self.mode_var, value="single"
).pack(side=tk.LEFT, padx=(0, 20))
tk.Radiobutton(
mode_frame, text="多个独立Excel文件",
variable=self.mode_var, value="multi"
).pack(side=tk.LEFT)
# 第3行:内存阈值设置
tk.Label(container, text="内存阈值(%):").grid(row=2, column=0, sticky="w", pady=6)
tk.Entry(container, textvariable=self.memory_threshold, width=10).grid(row=2, column=1, padx=6, pady=6, sticky="w")
tk.Label(container, text="超过阈值自动终止,防止大文件内存溢出", fg="gray").grid(row=2, column=1, padx=(80, 0), sticky="w")
# 第4行:生成按钮
self.generate_btn = tk.Button(
container, text="生成并保存文件",
command=self.start_process, height=2
)
self.generate_btn.grid(row=3, column=0, columnspan=3, pady=16, sticky="we")
# 第5行:日志标题
tk.Label(container, text="运行日志:").grid(row=4, column=0, sticky="w", pady=(0, 4))
# 第6行:日志区域
self.log_box = scrolledtext.ScrolledText(container, height=18, state="disabled", wrap=tk.WORD)
self.log_box.grid(row=5, column=0, columnspan=3, sticky="nsew")
# 自适应布局
container.grid_columnconfigure(1, weight=1)
container.grid_rowconfigure(5, weight=1)
def _setup_logging(self):
"""配置日志输出到界面文本框"""
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.handlers.clear()
root_logger.addHandler(TextHandler(self.log_box))
# ========== 核心业务逻辑 ==========
def check_name_unique(self, name_list: List[str]) -> bool:
"""检查名称是否重复"""
seen = set()
for name in name_list:
if name in seen:
return False
seen.add(name)
return True
def build_cells(self, configs: List[List[str]], targets: List[str]):
"""构建目标单元格索引,传入对应模式的占位符列表"""
for i, config in enumerate(configs):
if i == 0:
continue
template_sheet = config[0]
if template_sheet not in self.origin_wb.sheetnames:
raise RuntimeError(f"模板工作表不存在: {template_sheet}")
ws = self.origin_wb[template_sheet]
if template_sheet not in self.target_index:
self.target_index[template_sheet] = {}
# 遍历每个占位符,分别查找单元格位置
for target in targets:
if not target.strip():
continue
cell_addrs = []
for row_idx, row in enumerate(ws.iter_rows(values_only=True), 1):
for col_idx, cell_value in enumerate(row, 1):
if cell_value and target in str(cell_value):
coord = f"{get_column_letter(col_idx)}{row_idx}"
cell_addrs.append(coord)
self.target_index[template_sheet][target] = cell_addrs
def check_target_prefixes(self, targets: List[str]):
"""检查占位符前缀冲突,过滤空占位符"""
# 先过滤空占位符
valid_targets = [t.strip() for t in targets if t.strip()]
for i in range(len(valid_targets)):
for j in range(len(valid_targets)):
if i != j and valid_targets[j].startswith(valid_targets[i]):
raise RuntimeError(f"占位符存在前缀冲突: '{valid_targets[i]}' 是 '{valid_targets[j]}' 的前缀")
def check_memory_usage(self) -> float:
"""检查内存占用,超阈值则终止"""
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
used_mb = mem_info.rss / 1024 / 1024
if mem_info.rss > self.memory_total * self.memory_threshold.get() / 100:
raise RuntimeError(
f"内存占用超出阈值: 已使用 {mem_info.rss / self.memory_total * 100:.2f}% "
f"(阈值 {self.memory_threshold.get()}%)"
)
return used_mb
def init_memory(self):
"""初始化系统总内存"""
mem = psutil.virtual_memory()
self.memory_total = mem.total
logging.info(f"系统总内存: {self.memory_total / 1024 / 1024 / 1024:.2f}GB")
def replace_in_sheet(self, ws, template_name, targets, row_data, offset=2):
"""在指定工作表中执行占位符替换,offset控制数据起始列"""
for j in range(len(targets)):
target = targets[j].strip()
if not target:
continue
if template_name not in self.target_index:
continue
if target not in self.target_index[template_name]:
continue
addrs = self.target_index[template_name][target]
data_idx = j + offset
new_value = row_data[data_idx] if data_idx < len(row_data) else ""
for cell_addr in addrs:
try:
cell = ws[cell_addr]
value = str(cell.value) if cell.value is not None else ""
# 纯数字保持数值类型
try:
f = float(new_value)
if value == target:
cell.value = f
continue
except ValueError:
pass
# 字符串替换
cell.value = value.replace(target, new_value) if value else new_value
except Exception as e:
logging.warning(f"单元格 {cell_addr} 处理失败: {str(e)}")
# ========== 界面交互函数 ==========
def browse_excel(self):
path = filedialog.askopenfilename(
title="选择包含配置表的Excel文件",
filetypes=[("Excel 文件", "*.xlsx")],
initialdir=os.getcwd()
)
if path:
self.excel_path.set(path)
def start_process(self):
excel_path = self.excel_path.get().strip()
mode = self.mode_var.get()
# 基础校验
if not excel_path or not os.path.isfile(excel_path):
messagebox.showwarning("提示", "请先选择有效的Excel文件")
return
mem_val = self.memory_threshold.get()
if mem_val <= 0 or mem_val > 100:
messagebox.showwarning("提示", "内存阈值请设置为 1-100 之间的整数")
return
# 根据模式弹出对应保存对话框
if mode == "single":
base_name = os.path.splitext(os.path.basename(excel_path))[0]
save_path = filedialog.asksaveasfilename(
defaultextension=".xlsx",
filetypes=[("Excel 文件", "*.xlsx")],
initialdir=os.path.dirname(excel_path),
initialfile=f"{base_name}_结果文件.xlsx",
title="保存生成后的Excel文件"
)
if not save_path:
logging.info("用户取消保存")
return
else:
save_path = filedialog.askdirectory(
title="选择多个Excel文件的保存目录",
initialdir=os.path.dirname(excel_path)
)
if not save_path:
logging.info("用户取消选择目录")
return
# 禁用按钮防止重复点击
self.generate_btn.config(state="disabled", text="生成中,请稍候...")
# 清空历史日志
self.log_box.config(state="normal")
self.log_box.delete("1.0", tk.END)
self.log_box.config(state="disabled")
# 子线程执行耗时任务
threading.Thread(
target=self._run_task,
args=(excel_path, save_path, mode),
daemon=True
).start()
def _run_task(self, excel_path, save_path, mode):
try:
# 重置运行状态
self.origin_wb = None
self.target_index = {}
logging.info("程序启动,开始初始化...")
self.init_memory()
logging.info(f"内存阈值设置为: {self.memory_threshold.get()}%")
# 打开源文件读取配置、构建索引
self.origin_wb = load_workbook(excel_path)
logging.info(f"成功读取源文件: {os.path.basename(excel_path)}")
# 读取配置表
if "配置" not in self.origin_wb.sheetnames:
raise RuntimeError("未找到名为「配置」的工作表")
config_ws = self.origin_wb["配置"]
configs = []
for row in config_ws.iter_rows(values_only=True):
configs.append([str(cell) if cell is not None else "" for cell in row])
if len(configs) < 2:
raise RuntimeError("配置表至少需要2行(表头 + 数据行)")
old_sheets = self.origin_wb.sheetnames.copy()
header_row = configs[0]
# 根据模式截取正确占位符列表
if mode == "single":
# 单文件:表头0模板、1工作表名、2开始占位符
targets = header_row[2:]
data_offset = 2
else:
# 多文件:表头0模板、1文件名、2工作表名、3开始占位符
targets = header_row[3:]
data_offset = 3
# 分模式读取校验数据
valid_configs = []
name_check_list = []
if mode == "single":
# 单文件模式:第1列模板表,第2列工作表名
for i, row in enumerate(configs[1:], start=2):
if len(row) < 2 or not row[1].strip():
logging.warning(f"跳过无效配置行: 第{i}行")
continue
name_check_list.append(row[1].strip())
valid_configs.append(row)
else:
# 多文件模式:第1模板表,第2输出文件名,第3工作表名
for i, row in enumerate(configs[1:], start=2):
if len(row) < 3 or not row[1].strip():
logging.warning(f"跳过无效配置行: 第{i}行,文件名为空")
continue
fname_raw = row[1].strip()
safe_fname = sanitize_filename(fname_raw)
name_check_list.append(safe_fname)
valid_configs.append(row)
if not valid_configs:
raise RuntimeError("没有有效的配置数据行")
# 名称唯一性校验
if not self.check_name_unique(name_check_list):
raise RuntimeError("校验不通过:存在重复的名称(工作表名/输出文件名)")
# 占位符前缀校验(传入当前模式正确占位符)
if targets:
self.check_target_prefixes(targets)
logging.info("开始校验配置并构建单元格索引……")
self.build_cells(configs, targets)
logging.info("配置校验完成")
# 分模式处理
if mode == "single":
self._handle_single_mode(valid_configs, targets, old_sheets, save_path, data_offset)
else:
# 多文件模式先关闭当前工作簿,循环中独立打开处理
self.origin_wb.close()
self.origin_wb = None
self._handle_multi_mode(excel_path, valid_configs, targets, save_path, data_offset)
except RuntimeError as e:
logging.error(f"运行失败: {str(e)}")
except Exception as e:
logging.error(f"程序异常: {str(e)}")
finally:
if self.origin_wb:
self.origin_wb.close()
# 恢复按钮状态
self.root.after(
0,
lambda: self.generate_btn.config(state="normal", text="生成并保存文件")
)
def _handle_single_mode(self, valid_configs, targets, old_sheets, save_path, data_offset):
"""单文件多工作表模式
配置行:[模板表名, 新工作表名, 替换值1, 替换值2...]
"""
logging.info("开始批量生成工作表……")
total = len(valid_configs)
for i, row in enumerate(valid_configs):
template_sheet_name = row[0]
new_sheet_name = row[1].strip()
if template_sheet_name not in self.origin_wb.sheetnames:
raise RuntimeError(f"模板工作表不存在: {template_sheet_name}")
template_ws = self.origin_wb[template_sheet_name]
new_ws = self.origin_wb.copy_worksheet(template_ws)
new_ws.title = new_sheet_name
self.replace_in_sheet(new_ws, template_sheet_name, targets, row, offset=data_offset)
if (i + 1) % 100 == 0:
current_mem = self.check_memory_usage()
logging.info(f"已处理 {i+1}/{total} 个工作表,内存占用: {current_mem:.2f}MB")
final_mem = self.check_memory_usage()
logging.info(f"工作表生成完毕,当前内存占用: {final_mem:.2f}MB")
logging.info("正在清理原始配置与模板工作表……")
for sheet_name in old_sheets:
try:
self.origin_wb.remove(self.origin_wb[sheet_name])
except Exception as e:
logging.warning(f"删除工作表 {sheet_name} 失败: {str(e)}")
logging.info("正在保存文件……")
self.origin_wb.save(save_path)
logging.info(f"✅ 全部处理完成!文件已保存至:\n {save_path}")
def _handle_multi_mode(self, excel_path, valid_configs, targets, save_dir, data_offset):
"""多文件独立输出模式
配置行格式:
row[0] 模板工作表名
row[1] 输出Excel文件名
row[2] 单个文件内工作表名称
row[3:] 占位符替换内容
"""
total = len(valid_configs)
logging.info(f"▶ 正在生成 {total} 个独立Excel文件...")
success_count = 0
for i, row in enumerate(valid_configs):
template_sheet_name = row[0].strip()
file_name_raw = row[1].strip()
sheet_name_raw = row[2].strip()
file_name = sanitize_filename(file_name_raw) + ".xlsx"
save_file = os.path.join(save_dir, file_name)
wb = None
try:
if not template_sheet_name:
raise Exception("第1列模板工作表名称不能为空")
wb = load_workbook(excel_path)
if template_sheet_name not in wb.sheetnames:
raise Exception(f"模板工作表不存在:{template_sheet_name}")
ws = wb[template_sheet_name]
# 删除其余工作表只保留模板
for sheet_name in wb.sheetnames.copy():
if sheet_name != template_sheet_name:
wb.remove(wb[sheet_name])
ws.title = sheet_name_raw
self.replace_in_sheet(ws, template_sheet_name, targets, row, offset=data_offset)
wb.save(save_file)
success_count += 1
except Exception as e:
logging.warning(f"生成失败 {file_name}:{str(e)}")
finally:
if wb:
wb.close()
if (i + 1) % 50 == 0:
current_mem = self.check_memory_usage()
logging.info(f"已处理 {i+1}/{total} 个文件,内存占用: {current_mem:.2f}MB")
logging.info(f"✔ 生成完成!成功生成 {success_count}/{total} 个文件")
logging.info(f" 保存目录:\n {save_dir}")
if __name__ == "__main__":
root = tk.Tk()
ExcelBatchApp(root)
root.mainloop()
5. 结语
这个 Excel 批量生成工具将重复的“复制-重命名-改内容”操作自动化,特别适合需要从同一模板派生出大量差异表格的场景。通过简单的配置表,即可灵活定义生成逻辑,配合内存保护和冲突校验,即使处理数百个文件也能稳定运行。