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: xls与xlsx格式转换排坑指南
java·前端·excel
金豆呀6 小时前
WPS批量提取Word文档内容生成固定格式Excel表格
word·excel·wps
Access开发易登软件7 小时前
Access 怎么做前后端分离?用 Web API 读写 SQL Server
前端·数据库·人工智能·microsoft·excel·access
红莲凪是1 天前
使用二次封装的Excel COM 组件操作Excel\WPS ET IExcelRange 高级应用
python·excel·wps
用户298698530141 天前
如何使用免费工具在线将 Excel 转换为 XML 格式
人工智能·后端·excel
开开心心就好1 天前
内存清理工具定时自动清理开机自启动
java·开发语言·elasticsearch·ocr·excel·音视频·big data
开开心心就好1 天前
电脑内存优化工具一键释放内存超简单
java·开发语言·macos·arcgis·ocr·excel·音视频
随手工具-Excel, pdf, SQL1 天前
没有 SSMS 导入向导?Excel/CSV 一键生成 SQL Server INSERT 脚本
excel·sql server·在线工具·insert·测试数据·ssms·t-sql
SunnyDays10112 天前
Python 将 Excel(XLS/XLSX)转换为 JSON:导出工作簿、工作表、单元格区域与自定义 JSON 结构
python·json·excel·excel 转 json·导出 excel 到 json·xlsx 转 json·xls 转 json
chatexcel2 天前
ChatExcel AI Word 升级:多模态读资料、按模板生成、智能排版配图,还能批注修订和联动Excel 复核
人工智能·ai·word·excel·ai写作