跳过导航
返回

Excel批量工作表生成工具:用Python高效复制模板并替换数据

编辑页面

在日常数据处理中,我们经常需要根据一个模板工作表,替换其中的占位符并生成大量结构相同、内容各异的表格。例如:生成各分公司的预算表、不同部门的考勤统计表,或者按日期拆分的销售明细。手动复制、重命名、逐个修改单元格不仅枯燥,还容易出错。

本文介绍的 Excel批量工作表生成工具 正是为解决这一问题而生。它通过一个“配置表”来描述生成规则,支持两种输出模式:

工具内置内存监控,防止大文件导致系统卡死,并对工作表名、文件名以及占位符进行全面校验,确保生成过程可靠。

1. 工具概览

该工具使用 Python 标准库 tkinter 构建界面,openpyxl 操作 Excel,psutil 监控内存,并借助 logging 实现完整的运行日志。核心工作流程:

  1. 用户选择一个包含“配置”工作表的 Excel 文件。
  2. 程序读取配置表,解析模板工作表、输出名称、占位符等信息。
  3. 遍历配置行,依次复制模板工作表,在副本中替换占位符为指定内容。
  4. 根据所选模式,将结果保存为一个多工作表的 Excel 文件,或是一系列单独的 Excel 文件。
  5. 全程记录详细日志,遇错自动终止并弹出提示。

2. 技术实现细节

2.1 配置表设计

配置文件必须包含一个名为 配置 的工作表。其结构根据输出模式略有差异:

单文件模式(一个Excel内多个工作表):

模板工作表新工作表名占位符A占位符B
模板1北京分公司北京1200
模板1上海分公司上海980

多文件模式(每个配置生成独立Excel文件):

模板工作表输出文件名工作表名占位符A占位符B
模板1北京分公司预算表北京1200
模板1上海分公司预算表上海980

2.2 占位符索引与替换

替换的核心是先为每个模板工作表建立占位符位置索引,再逐行替换。

为提高查找效率并避免意外替换,程序还实现了占位符前缀冲突检查check_target_prefixes),确保没有占位符是另一个的前缀,比如同时存在 日期日期时间 会被拒绝,防止部分匹配导致的错误。

2.3 内存保护机制

批量生成大量工作表或文件时,内存占用可能飙升。工具利用 psutil 实时监控当前进程内存,用户可设置内存使用阈值(默认50%)。每当处理完指定数量(如100个工作表或50个文件)后检测一次,若超过系统总内存的阈值百分比则立即终止任务,并给出明确提示。这一机制有效避免了因内存溢出导致的程序崩溃或系统卡顿。

2.4 两种输出模式的实现

两种模式共享同一套占位符索引和替换逻辑,仅保存方式和数据读取顺序不同。

2.5 线程与日志系统

耗时生成任务在子线程中运行,避免阻塞GUI。日志通过自定义 TextHandlerlogging 消息安全地推送到界面的 ScrolledText 控件,使用 root.after(0, ...) 确保线程安全。同时设置了全局异常捕获钩子,未捕获的异常会记录到日志而非导致程序直接闪退。

3. 使用方法

3.1 准备配置文件

  1. 新建一个 Excel 文件,将其中一个工作表命名为 配置
  2. 在同一文件内准备一个或多个模板工作表,在需要动态填充的单元格中输入占位符文字(例如 分公司名称金额),占位符的名称应与配置表表头一致。
  3. 配置 工作表中按照上述结构填写数据:
    • 第一行:表头(从第二列开始为占位符标识,注意不同模式下列数含义不同)。
    • 后续行:每行为一次生成任务,填写模板工作表名、新名称以及各项替换内容。

3.2 运行程序

# 安装依赖
pip install openpyxl psutil

# 直接运行脚本
python excel_batch_generator.py
  1. 点击“浏览”选择准备好的 Excel 配置文件。
  2. 选择输出模式:
    • 单个Excel文件(多工作表):结果保存在一个文件中,适合关联性强的数据。
    • 多个独立Excel文件:每个配置行生成一个文件,适合分发给不同人员。
  3. 设置内存阈值(默认50%,可根据机器内存灵活调整)。
  4. 点击“生成并保存文件”,指定保存位置后,程序开始运行,日志区域会实时显示进度。

4. 完整代码

将以下代码保存为 .py 文件并运行即可。请确保已安装 openpyxlpsutil

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 批量生成工具将重复的“复制-重命名-改内容”操作自动化,特别适合需要从同一模板派生出大量差异表格的场景。通过简单的配置表,即可灵活定义生成逻辑,配合内存保护和冲突校验,即使处理数百个文件也能稳定运行。


编辑页面
分享到:

下一篇
Excel批量生成Word工具:用Python实现办公自动化