一个用于将多个小组填写的 Excel 统计表汇总到单个文件的 Windows 桌面工具,基于 Python + Tkinter 开发。
简介
日常工作中经常需要将多个部门 / 学院 / 小组分头填写的同名统计表(.xlsx / .xls)合并成一份总表。本工具可以:
- 自动扫描指定目录下的所有 Excel 文件,按文件名顺序合并
- 标题行只保留一次(以第一个文件为准,可配置标题行数)
- 完整复制第一个文件的 列宽、行高、字体、边框、填充、对齐方式 等格式
- 数据行同步保留源文件的单元格格式
- 支持 合并单元格(仅限标题行范围内的合并)
- 自动识别并转换 Excel 日期序列号 (如
35674→1997.09) - 智能 空行检测:遇到一行所有列都为空时,自动停止处理本文件后续行
- 支持 .xlsx 和 .xls 两种格式
- 后台线程执行合并,界面不卡顿
使用方法
方式一:直接使用 EXE(推荐)
- 在项目
dist目录中找到Excel合并工具.exe - 双击运行(无需安装 Python 环境)
- 在界面中依次完成:
- 点击「浏览 」选择输入目录(即包含待合并 Excel 文件的文件夹)
- 点击「浏览 」选择输出文件路径(合并后的新文件保存位置)
- 设置标题行数 (默认
1,如标题占两行可改为2)
- 点击「开始合并」按钮
- 在日志区域可实时查看处理进度
- 合并完成后会弹出提示框
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)