Excel 文件合并工具

一个用于将多个小组填写的 Excel 统计表汇总到单个文件的 Windows 桌面工具,基于 Python + Tkinter 开发。

简介

日常工作中经常需要将多个部门 / 学院 / 小组分头填写的同名统计表(.xlsx / .xls)合并成一份总表。本工具可以:

  • 自动扫描指定目录下的所有 Excel 文件,按文件名顺序合并
  • 标题行只保留一次(以第一个文件为准,可配置标题行数)
  • 完整复制第一个文件的 列宽、行高、字体、边框、填充、对齐方式 等格式
  • 数据行同步保留源文件的单元格格式
  • 支持 合并单元格(仅限标题行范围内的合并)
  • 自动识别并转换 Excel 日期序列号 (如 356741997.09
  • 智能 空行检测:遇到一行所有列都为空时,自动停止处理本文件后续行
  • 支持 .xlsx.xls 两种格式
  • 后台线程执行合并,界面不卡顿

使用方法

方式一:直接使用 EXE(推荐)

  1. 在项目 dist 目录中找到 Excel合并工具.exe
  2. 双击运行(无需安装 Python 环境)
  3. 在界面中依次完成:
    • 点击「浏览 」选择输入目录(即包含待合并 Excel 文件的文件夹)
    • 点击「浏览 」选择输出文件路径(合并后的新文件保存位置)
    • 设置标题行数 (默认 1,如标题占两行可改为 2
  4. 点击「开始合并」按钮
  5. 在日志区域可实时查看处理进度
  6. 合并完成后会弹出提示框
    Excel文件合并工具,exe可执行文件

方式二:命令行使用

bash 复制代码
python merge_excel.py <输入目录> <输出文件路径>

示例:

bash 复制代码
python merge_excel.py "I:\各学院报送材料\花名册" "I:\合并结果.xlsx"

方式三:运行源码(图形界面)

需要先安装依赖:

bash 复制代码
pip install openpyxl

然后运行:

bash 复制代码
python merge_excel_gui.py

实际使用场景示例

某工作中,需要汇总 12 个学院提交的「人员花名册」:

复制代码
I:\教师资格认定\各学院报送材料\花名册\
├── 计算机学院.xlsx
├── 电子学院.xlsx
├── 机械学院.xlsx
├── 汽车学院.xlsx
├── ...(共 12 个文件)
└── 财经学院.xlsx

只需指定该目录,工具即可自动合并成一个总表,标题合并单元格、列宽、字体 等与第一个文件保持一致,空行自动截断,最终生成 数据汇总。

合并规则说明

行为 说明
标题行处理 仅第一个文件的标题行被复制,后续文件跳过
合并单元格 仅复制标题行范围内的合并单元格,避免覆盖数据行
空行检测 当一行的所有列都为空(不含合并单元格)时,停止处理本文件
日期序列号 30000-50000 范围内的整数自动转换为 yyyy.MM 格式
错误处理 单个文件出错不影响其他文件,错误信息记录在日志中
文件顺序 按文件名升序合并

运行环境

  • 操作系统:Windows 7 / 8 / 10 / 11
  • Python 版本:3.7+(仅源码运行需要)
  • 依赖库openpyxl(仅源码运行需要)

项目文件

文件 说明
merge_excel.py 命令行版本源码
merge_excel_gui.py 图形界面版本源码
dist/Excel合并工具.exe 打包好的可执行文件(已优化,体积约 13MB)

常见问题

Q:合并后出生年月列变成了 35674 这种数字?

A:本工具已自动识别 30000-50000 范围内的日期序列号并转换为 1997.09 格式。合并时使用的就是源文件本身的格式。

Q:第一个文件的合并单元格没有保留?

A:工具仅复制标题行范围内的合并单元格,确保不会覆盖数据行。如有特殊需求,可调整源码中的 header_rows 参数。

Q:数据行复制错位 / 漏行?

A:检查源文件中是否存在「全空行」分隔。工具会在第一个全空行处停止处理本文件,避免将分页区域混入合并结果。

python 复制代码
import os
import sys
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from copy import copy
from openpyxl import load_workbook, Workbook
from openpyxl.cell.cell import MergedCell
from openpyxl.utils.datetime import from_excel
import threading


class ExcelMergerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Excel文件合并工具")
        self.root.geometry("600x500")
        self.root.resizable(False, False)

        self.input_dir = tk.StringVar()
        self.output_file = tk.StringVar()
        self.header_rows = tk.IntVar(value=1)
        self.processing = False

        self.create_widgets()

    def create_widgets(self):
        main_frame = ttk.Frame(self.root, padding="20")
        main_frame.pack(fill=tk.BOTH, expand=True)

        ttk.Label(main_frame, text="Excel文件合并工具", font=("Microsoft YaHei", 16, "bold")).pack(pady=(0, 20))

        ttk.Label(main_frame, text="输入目录(包含待合并的Excel文件):").pack(anchor=tk.W, pady=(0, 5))
        dir_frame = ttk.Frame(main_frame)
        dir_frame.pack(fill=tk.X)
        ttk.Entry(dir_frame, textvariable=self.input_dir, width=40).pack(side=tk.LEFT, fill=tk.X, expand=True)
        ttk.Button(dir_frame, text="浏览", command=self.browse_input_dir).pack(side=tk.RIGHT, padx=(10, 0))

        ttk.Label(main_frame, text="输出文件路径:").pack(anchor=tk.W, pady=(15, 5))
        output_frame = ttk.Frame(main_frame)
        output_frame.pack(fill=tk.X)
        ttk.Entry(output_frame, textvariable=self.output_file, width=40).pack(side=tk.LEFT, fill=tk.X, expand=True)
        ttk.Button(output_frame, text="浏览", command=self.browse_output_file).pack(side=tk.RIGHT, padx=(10, 0))

        ttk.Label(main_frame, text="标题行数(默认1行):").pack(anchor=tk.W, pady=(15, 5))
        header_frame = ttk.Frame(main_frame)
        header_frame.pack(fill=tk.X)
        ttk.Spinbox(header_frame, from_=1, to=20, textvariable=self.header_rows, width=10).pack(side=tk.LEFT)

        ttk.Button(main_frame, text="开始合并", command=self.start_merge, width=20).pack(pady=20)

        ttk.Label(main_frame, text="处理日志:").pack(anchor=tk.W, pady=(10, 5))
        self.log_text = tk.Text(main_frame, height=12, width=60, state=tk.DISABLED)
        self.log_text.pack(fill=tk.BOTH, expand=True)

        self.progress_bar = ttk.Progressbar(main_frame, mode='indeterminate')
        self.progress_bar.pack(fill=tk.X, pady=(10, 0))

    def browse_input_dir(self):
        dir_path = filedialog.askdirectory(title="选择包含Excel文件的目录")
        if dir_path:
            self.input_dir.set(dir_path)

    def browse_output_file(self):
        file_path = filedialog.asksaveasfilename(
            title="选择输出文件",
            defaultextension=".xlsx",
            filetypes=[("Excel文件", "*.xlsx"), ("所有文件", "*.*")]
        )
        if file_path:
            self.output_file.set(file_path)

    def log(self, message):
        self.log_text.config(state=tk.NORMAL)
        self.log_text.insert(tk.END, message + "\n")
        self.log_text.see(tk.END)
        self.log_text.config(state=tk.DISABLED)
        self.root.update_idletasks()

    def copy_cell_style(self, src_cell, dst_cell):
        if isinstance(src_cell, MergedCell):
            return
        # number_format 必须始终复制,即使 has_style 为 False(命名样式的情况)
        dst_cell.number_format = copy(src_cell.number_format)
        if src_cell.has_style:
            dst_cell.font = copy(src_cell.font)
            dst_cell.border = copy(src_cell.border)
            dst_cell.fill = copy(src_cell.fill)
            dst_cell.protection = copy(src_cell.protection)
            dst_cell.alignment = copy(src_cell.alignment)

    def row_is_all_empty(self, ws, row_idx, max_col):
        has_value = False
        for col_idx in range(1, max_col + 1):
            cell = ws.cell(row=row_idx, column=col_idx)
            if isinstance(cell, MergedCell):
                continue
            val = cell.value
            if val is not None and (not isinstance(val, str) or val.strip() != ''):
                has_value = True
                break
        return not has_value

    def merge_excel_files(self):
        input_dir = self.input_dir.get()
        output_file = self.output_file.get()
        header_rows = self.header_rows.get()

        if not input_dir:
            messagebox.showerror("错误", "请选择输入目录")
            return

        if not output_file:
            messagebox.showerror("错误", "请选择输出文件路径")
            return

        if not os.path.isdir(input_dir):
            messagebox.showerror("错误", f"目录 '{input_dir}' 不存在")
            return

        excel_files = [f for f in os.listdir(input_dir) if f.endswith(('.xlsx', '.xls'))]

        if not excel_files:
            messagebox.showerror("错误", "目录中没有找到Excel文件")
            return

        self.log(f"找到 {len(excel_files)} 个Excel文件:")
        for f in excel_files:
            self.log(f"  - {f}")

        wb_output = Workbook()
        ws_output = wb_output.active
        header_copied = False
        output_row = 1
        total_data_rows = 0

        for filename in excel_files:
            file_path = os.path.join(input_dir, filename)
            try:
                wb = load_workbook(file_path)
                ws = wb.active
                max_col = ws.max_column

                self.log(f"\n处理文件:{filename}")

                if not header_copied:
                    for r in range(1, header_rows + 1):
                        for c in range(1, max_col + 1):
                            src = ws.cell(row=r, column=c)
                            if isinstance(src, MergedCell):
                                continue
                            dst = ws_output.cell(row=r, column=c, value=src.value)
                            self.copy_cell_style(src, dst)
                        self.log(f"  添加标题行 {r}")

                    for col_letter, col_dim in ws.column_dimensions.items():
                        if col_dim.width:
                            ws_output.column_dimensions[col_letter].width = col_dim.width
                    for r in range(1, header_rows + 1):
                        if r in ws.row_dimensions and ws.row_dimensions[r].height:
                            ws_output.row_dimensions[r].height = ws.row_dimensions[r].height

                    for merged_range in ws.merged_cells.ranges:
                        if merged_range.max_row <= header_rows:
                            ws_output.merge_cells(str(merged_range))

                    header_copied = True
                    output_row = header_rows + 1

                for r in range(header_rows + 1, ws.max_row + 1):
                    if self.row_is_all_empty(ws, r, max_col):
                        self.log(f"  第 {r} 行所有列都为空,停止处理本文件")
                        break
                    for c in range(1, max_col + 1):
                        src = ws.cell(row=r, column=c)
                        if isinstance(src, MergedCell):
                            continue
                        dst = ws_output.cell(row=output_row, column=c)
                        if isinstance(dst, MergedCell):
                            continue

                        val = src.value
                        if isinstance(val, int) and not isinstance(val, bool) and 30000 <= val <= 50000:
                            try:
                                dt = from_excel(val)
                                dst.value = dt
                                self.copy_cell_style(src, dst)
                                dst.number_format = 'YYYY.MM'
                                continue
                            except Exception:
                                pass

                        dst.value = val
                        self.copy_cell_style(src, dst)
                    self.log(f"  添加数据行 {r}")
                    output_row += 1
                    total_data_rows += 1

                wb.close()
            except Exception as e:
                self.log(f"  处理文件 {filename} 时出错:{e}")
                import traceback
                self.log(traceback.format_exc())

        wb_output.save(output_file)
        self.log(f"\n合并完成!输出文件:{output_file}")
        self.log(f"共合并 {total_data_rows} 行数据")

        messagebox.showinfo("完成", f"合并完成!\n输出文件:{output_file}\n共合并 {total_data_rows} 行数据")

        self.progress_bar.stop()
        self.processing = False

    def start_merge(self):
        if self.processing:
            return

        self.processing = True
        self.log_text.config(state=tk.NORMAL)
        self.log_text.delete(1.0, tk.END)
        self.log_text.config(state=tk.DISABLED)

        self.progress_bar.start()

        thread = threading.Thread(target=self.merge_excel_files)
        thread.daemon = True
        thread.start()


if __name__ == "__main__":
    root = tk.Tk()
    app = ExcelMergerApp(root)
    root.mainloop()
python 复制代码
import os
import sys
from copy import copy
from openpyxl import load_workbook, Workbook
from openpyxl.cell.cell import MergedCell
from openpyxl.utils.datetime import from_excel


def copy_cell_style(src_cell, dst_cell):
    if isinstance(src_cell, MergedCell):
        return
    # number_format 必须始终复制,即使 has_style 为 False(命名样式的情况)
    dst_cell.number_format = copy(src_cell.number_format)
    if src_cell.has_style:
        dst_cell.font = copy(src_cell.font)
        dst_cell.border = copy(src_cell.border)
        dst_cell.fill = copy(src_cell.fill)
        dst_cell.protection = copy(src_cell.protection)
        dst_cell.alignment = copy(src_cell.alignment)


def row_is_all_empty(ws, row_idx, max_col):
    has_value = False
    for col_idx in range(1, max_col + 1):
        cell = ws.cell(row=row_idx, column=col_idx)
        if isinstance(cell, MergedCell):
            continue
        val = cell.value
        if val is not None and (not isinstance(val, str) or val.strip() != ''):
            has_value = True
            break
    return not has_value


def merge_excel_files(input_dir, output_file, header_rows=1):
    if not os.path.isdir(input_dir):
        print(f"错误:目录 '{input_dir}' 不存在")
        return

    excel_files = [f for f in os.listdir(input_dir) if f.endswith(('.xlsx', '.xls'))]

    if not excel_files:
        print("错误:目录中没有找到Excel文件")
        return

    print(f"找到 {len(excel_files)} 个Excel文件:")
    for f in excel_files:
        print(f"  - {f}")

    wb_output = Workbook()
    ws_output = wb_output.active
    header_copied = False
    output_row = 1

    for filename in excel_files:
        file_path = os.path.join(input_dir, filename)
        try:
            wb = load_workbook(file_path)
            ws = wb.active
            max_col = ws.max_column

            print(f"\n处理文件:{filename}")

            if not header_copied:
                for r in range(1, header_rows + 1):
                    for c in range(1, max_col + 1):
                        src = ws.cell(row=r, column=c)
                        if isinstance(src, MergedCell):
                            continue
                        dst = ws_output.cell(row=r, column=c, value=src.value)
                        copy_cell_style(src, dst)
                    print(f"  添加标题行 {r}")

                for col_letter, col_dim in ws.column_dimensions.items():
                    if col_dim.width:
                        ws_output.column_dimensions[col_letter].width = col_dim.width
                for r in range(1, header_rows + 1):
                    if r in ws.row_dimensions and ws.row_dimensions[r].height:
                        ws_output.row_dimensions[r].height = ws.row_dimensions[r].height

                for merged_range in ws.merged_cells.ranges:
                    if merged_range.max_row <= header_rows:
                        ws_output.merge_cells(str(merged_range))

                header_copied = True
                output_row = header_rows + 1

            for r in range(header_rows + 1, ws.max_row + 1):
                if row_is_all_empty(ws, r, max_col):
                    print(f"  第 {r} 行所有列都为空,停止处理本文件")
                    break
                for c in range(1, max_col + 1):
                    src = ws.cell(row=r, column=c)
                    if isinstance(src, MergedCell):
                        continue
                    dst = ws_output.cell(row=output_row, column=c)
                    if isinstance(dst, MergedCell):
                        continue

                    val = src.value
                    if isinstance(val, int) and not isinstance(val, bool) and 30000 <= val <= 50000:
                        try:
                            dt = from_excel(val)
                            dst.value = dt
                            copy_cell_style(src, dst)
                            dst.number_format = 'YYYY.MM'
                            continue
                        except Exception:
                            pass

                    dst.value = val
                    copy_cell_style(src, dst)
                print(f"  添加数据行 {r}")
                output_row += 1

            wb.close()
        except Exception as e:
            print(f"  处理文件 {filename} 时出错:{e}")
            import traceback
            traceback.print_exc()

    wb_output.save(output_file)
    print(f"\n合并完成!输出文件:{output_file}")
    print(f"共合并 {output_row - header_rows - 1} 行数据")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("用法:python merge_excel.py <输入目录> <输出文件路径>")
        print("示例:python merge_excel.py ./excel_files ./merged_output.xlsx")
        sys.exit(1)

    input_dir = sys.argv[1]
    output_file = sys.argv[2]
    
    merge_excel_files(input_dir, output_file)
相关推荐
未来之窗软件服务2 小时前
Excel物业台帐租金合同资产—万象EXCEL应用(28) —东方仙盟
excel·仙盟创梦ide·东方仙盟·万象excel
xurime15 小时前
Excelize 开源十周年,发布 2.11.0 版本
golang·开源·github·excel·导出·导入·excelize·基础库
wtsolutions1 天前
Excel-to-JSON本地化Excel插件发布 - 在Excel中安全离线转换数据
安全·json·excel
用户298698530141 天前
Python 实现 Excel 与 TXT 文本的高效转换与导出
后端·python·excel
程序员小郭831 天前
Java + POI 实现高性能Excel导出(支持普通List、MyBatis Cursor流式导出、VO适配)
spring boot·后端·excel
AI导出鸭PC端3 天前
告别导出难题:AI 导出鸭助力 Claude 输出 excel 一站式实现
人工智能·ai·excel·豆包·ai导出鸭
Am-Chestnuts3 天前
DeepSeek 表格如何导出 Word/Excel:Markdown 表格、CSV 与 DS随心转方案对比
word·excel
城数派3 天前
2000-2026年我国省市县三级逐月NDVI数据(Shp/Excel格式)
excel
许彰午4 天前
74_Python自动化办公之Excel操作
python·自动化·excel