Excel多Sheet合并工具:彻底解放双手,告别无效加班
真的搞不懂,为什么合并Excel表格永远是职场里最磨人的“体力活”?
明明数据都在,却要一个个打开文件,复制粘贴再对齐。稍微遇到表头不一致、格式不一样的情况,整个人都陷入僵局——改格式、对列名,来来回回折腾半天,大文件稍微大一点,Excel直接卡死,前功尽弃。这种反复重复的机械操作,既耗精力又易出错,完全就是在浪费时间。
这款Excel多Sheet合并工具,就是为了解决这些糟心事而生的。它不用你手动操作,选好文件夹、配好参数,一键就能搞定所有表格合并,把你从繁琐的工作里解放出来。

一、核心功能,精准直击需求
-
1. 批量识别合并:自动扫描指定文件夹,一次性识别.xlsx、.xls、.csv三种格式文件,无需逐个筛选,直接批量处理,几十上百个文件也能快速搞定。 -
2. 表头智能适配:支持自动识别所有表格的共有表头,也能手动指定需要合并的表头列,彻底解决“表头名称不一致”“列错位”的问题,合并后数据整齐不乱。 -
3. 灵活跳过设置:可根据表格格式,自定义跳过表头行数(比如跳过1-3行标题),避免重复表头干扰,适配不同规范的表格文件。 -
4. 进度实时可视:合并过程中同步显示进度条和操作日志,清晰记录每个文件的处理状态,让你随时掌握进度,不用干等。 -
5. 保留原格式:合并时自动保留各表格的单元格格式、字体、边框、颜色等,合并完成后无需二次调整格式,直接可用。 -
6. 便捷参数备份:支持配置导入/导出,下次使用相同设置时直接加载,不用重复配置,省心又高效。
二、使用操作说明
1. 准备工作
打开工具后,先确认目标文件夹里,存放了需要合并的所有Excel/CSV文件,整理好放在同一目录下,避免文件混乱。
2. 选择目标文件夹
点击工具界面上的“浏览”按钮,在电脑中找到存放表格的文件夹,选中并确认,工具会自动填充文件夹路径。
3. 基础参数设置
-
• 跳过行数:根据表格表头情况填写,比如表格第一行是标题、第二行是真正表头,就填“2”;没有多余标题,直接填“1”。 -
• 包含CSV:如果文件夹里有CSV文件,勾选这个选项,工具会自动识别并合并;只有Excel文件,就保持默认不勾选。 -
• 合并模式:选择“全列合并”,工具会自动匹配所有相同列名的数据,精准合并。
4. 表头配置
如果表格表头名称差异较大,可手动添加需要合并的表头名称:
-
• 点击“+ 添加”按钮,输入表头名称,比如“姓名”“销售额”; -
• 多余的表头可点击“- 删除”或“清空”按钮调整,确保配置的表头和表格中列名一致。
5. 启动合并
确认所有参数设置无误后,点击绿色的“开始合并”按钮,工具会自动开始处理文件,此时耐心等待进度条走完,日志区域会显示每个文件的处理结果。
6. 查看结果
合并完成后,工具会弹出提示框告知“合并成功”,并显示合并后文件的保存路径(默认在目标文件夹下,命名为“合并后的总表.xlsx”),直接打开即可使用。
7. 异常处理
如果合并过程中出现提示“读取文件失败”,检查文件是否损坏、是否有读取权限;若提示“无有效数据”,确认文件夹内是否有符合格式的表格文件,简单调整后重新尝试即可。
8.完整代码
"""Excel 表格合并工具 V2.0支持多 sheet、自定义表头、跳过行数配置、共同列/全列合并模式增强对各种表格格式的兼容性"""import sysimport osimport pandas as pdfrom pathlib import Pathfrom datetime import datetimefrom typing importList, Dict, Optional, Setimport warningsfrom PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QTableWidget, QTableWidgetItem, QCheckBox, QSpinBox, QGroupBox, QTextEdit, QFileDialog, QMessageBox, QHeaderView, QAbstractItemView, QSplitter, QFrame, QProgressBar, QComboBox, QDialog, QDialogButtonBox, QListWidget, QListWidgetItem, QScrollArea, QSizePolicy, QStyledItemDelegate, QStyleOptionProgressBar, QStyle, QStyleFactory)from PySide6.QtCore import Qt, QThread, Signal, QSettings, QSize, QTimerfrom PySide6.QtGui import QFont, QIcon, QColor, QPalette, QPainter, QBrush# 忽略所有警告warnings.filterwarnings('ignore')classMergeWorker(QThread):"""后台合并工作线程 - 增强版,支持多种表格格式""" progress = Signal(int) log = Signal(str) finished_signal = Signal(bool, str)def__init__(self, folder_path: str, headers: List[str], skip_rows: int, merge_mode: str, include_csv: bool = False, parent=None):super().__init__(parent)self.folder_path = folder_pathself.headers = headersself.skip_rows = skip_rowsself.merge_mode = merge_mode # 'common' 或 'all'self.include_csv = include_csvself.is_running = Truedefstop(self):self.is_running = Falsedefclean_column_name(self, col):"""清理列名 - 处理各种格式"""if pd.isna(col):returnf"未命名列_{id(col)}"ifisinstance(col, (int, float)):returnstr(int(col)) iffloat(col).is_integer() elsestr(col)ifisinstance(col, datetime):return col.strftime("%Y-%m-%d %H:%M:%S")# 转为字符串并去除空格 col_str = str(col).strip()# 处理空白字符串ifnot col_str or col_str.lower() in ['nan', 'none', 'null', '']:returnf"未命名列_{hash(str(col))}"return col_strdefclean_dataframe_values(self, df):"""清理 DataFrame 中的所有值""" df_clean = df.copy()for col in df_clean.columns:# 将 NaN 和 None 替换为空字符串 df_clean[col] = df_clean[col].apply(lambda x: ''if pd.isna(x) orstr(x).lower() in ['nan', 'none', 'null', ''] else x )# 转换日期等特殊类型 df_clean[col] = df_clean[col].apply(lambda x: x.strftime("%Y-%m-%d %H:%M:%S") ifisinstance(x, datetime) else x )return df_cleandefread_excel_safely(self, file_path, sheet_name, skip_rows):"""安全读取 Excel 文件"""try:# 首先尝试使用引擎读取for engine in ['openpyxl', 'xlrd']:try:ifstr(file_path).lower().endswith('.csv'): df = pd.read_csv(str(file_path), header=skip_rows, encoding='utf-8-sig')else: df = pd.read_excel(str(file_path), sheet_name=sheet_name, header=skip_rows, engine=engine)return dfexcept Exception:continue# 最后的备选方案ifstr(file_path).lower().endswith('.csv'):return pd.read_csv(str(file_path), header=skip_rows, encoding='utf-8-sig', on_bad_lines='skip')else:return pd.read_excel(str(file_path), sheet_name=sheet_name, header=skip_rows, on_bad_lines='skip')except Exception as e:raise Exception(f"读取失败: {str(e)}")defrun(self):try:self.log.emit("🔍 开始扫描目录...")# 获取所有支持的文件 file_patterns = ['*.xlsx', '*.xls']ifself.include_csv: file_patterns.append('*.csv') all_files = []for pattern in file_patterns: all_files.extend(Path(self.folder_path).rglob(pattern))# 去重(处理大小写不同但实际相同的文件) seen = set() excel_files = []for f in all_files: normalized = str(f).lower()if normalized notin seen: seen.add(normalized) excel_files.append(f)ifnot excel_files:self.finished_signal.emit(False, "未找到支持的文件(.xlsx, .xls" + (", .csv)"ifself.include_csv else")"))returnself.log.emit(f"📁 找到 {len(excel_files)} 个文件") all_data = [] all_columns: Set[str] = set() file_columns: Dict[str, Set[str]] = {} stats = {'success': 0,'failed': 0,'empty_sheets': 0,'total_rows': 0 }# 第一遍:收集所有列信息self.log.emit("\n📊 正在分析文件结构...")for i, file_path inenumerate(excel_files):ifnotself.is_running:self.finished_signal.emit(False, "⚠️ 操作已取消")returntry:# 处理不同文件类型ifstr(file_path).lower().endswith('.csv'):# CSV 文件处理try:# 尝试不同编码for encoding in ['utf-8-sig', 'utf-8', 'gbk', 'gb2312']:try: df_sample = pd.read_csv(str(file_path), nrows=5, encoding=encoding)breakexcept:continue file_cols = set()for col in df_sample.columns: file_cols.add(self.clean_column_name(col)) file_columns[str(file_path)] = file_cols all_columns.update(file_cols)self.log.emit(f" 📄 {file_path.name} ({len(file_cols)} 列)")except Exception as e:self.log.emit(f" ❌ {file_path.name}: {str(e)}") stats['failed'] += 1else:# Excel 文件处理 xl = pd.ExcelFile(str(file_path)) file_cols = set()for sheet_name in xl.sheet_names:try:# 只读取表头行 df_sample = pd.read_excel(str(file_path), sheet_name=sheet_name, header=self.skip_rows, nrows=0 )# 清理列名for col in df_sample.columns: cleaned = self.clean_column_name(col) file_cols.add(cleaned)except Exception as e:self.log.emit(f" ⚠️ Sheet '{sheet_name}': {str(e)}")if file_cols: file_columns[str(file_path)] = file_cols all_columns.update(file_cols)self.log.emit(f" 📊 {file_path.name} ({len(file_cols)} 列)")else:self.log.emit(f" ⚠️ {file_path.name}: 未找到有效列") stats['failed'] += 1except Exception as e:self.log.emit(f" ❌ {file_path.name}: {str(e)}") stats['failed'] += 1# 更新进度 progress_value = int((i + 1) / len(excel_files) * 25)self.progress.emit(progress_value)# 处理列名 all_columns_clean = {self.clean_column_name(col) for col in all_columns if col}ifnot all_columns_clean:self.finished_signal.emit(False, "未找到任何有效的列名")return# 确定要合并的列self.log.emit(f"\n📋 共发现 {len(all_columns_clean)} 个不同列")ifself.merge_mode == 'common':# 共同列模式if file_columns:iflen(file_columns) == 1: common_cols_clean = set(list(file_columns.values())[0])self.log.emit(f"ℹ️ 单文件模式,使用所有 {len(common_cols_clean)} 列")else: common_cols_clean = set.intersection(*file_columns.values())self.log.emit(f"✓ 多文件模式,找到 {len(common_cols_clean)} 个共同列")ifself.headers: headers_clean = [self.clean_column_name(h) for h inself.headers] target_cols = [h for h in headers_clean if h in common_cols_clean]ifnot target_cols:self.log.emit(f"⚠️ 配置表头不在共同列中,使用共同列") target_cols = sorted(list(common_cols_clean))else:self.log.emit(f"✓ 使用 {len(target_cols)} 个配置的表头")else: target_cols = sorted(list(common_cols_clean))else: target_cols = []else:# 全列模式ifself.headers: headers_clean = [self.clean_column_name(h) for h inself.headers] target_cols = [h for h in headers_clean if h in all_columns_clean]if target_cols:self.log.emit(f"✓ 使用 {len(target_cols)} 个配置的表头")else: target_cols = sorted(list(all_columns_clean))self.log.emit(f"✓ 使用全部 {len(target_cols)} 列")# 显示要合并的列if target_cols:self.log.emit(f"\n✅ 将合并以下 {len(target_cols)} 列:")for i inrange(0, len(target_cols), 6):self.log.emit(f" • {', '.join(target_cols[i:i+6])}")else: error_msg = "❌ 未找到可合并的列\n\n可能原因:\n" error_msg += "1. 配置的表头与实际列名不匹配\n" error_msg += "2. 跳过行数设置不当\n" error_msg += f"\n实际列名 ({len(all_columns_clean)} 个):\n" error_msg += '\n'.join([f" • {c}"for c insorted(list(all_columns_clean))[:20]])iflen(all_columns_clean) > 20: error_msg += f"\n ... 还有 {len(all_columns_clean) - 20} 列"self.finished_signal.emit(False, error_msg)return# 第二遍:读取实际数据self.log.emit("\n📥 正在读取数据...") total_rows = 0for i, file_path inenumerate(excel_files):ifnotself.is_running:self.finished_signal.emit(False, "⚠️ 操作已取消")returntry:ifstr(file_path).lower().endswith('.csv'):# CSV 文件try: df = pd.read_csv(str(file_path), encoding='utf-8-sig', on_bad_lines='skip') df.columns = [self.clean_column_name(col) for col in df.columns] df = self.clean_dataframe_values(df) available_cols = [col for col in target_cols if col in df.columns]if available_cols: df_filtered = df[available_cols].copy() df_filtered['__来源文件__'] = file_path.name df_filtered['__来源Sheet__'] = 'CSV' all_data.append(df_filtered) total_rows += len(df_filtered) stats['success'] += 1self.log.emit(f" ✓ {file_path.name}: {len(df_filtered)} 行")else:self.log.emit(f" ⚠️ {file_path.name}: 无匹配列") stats['empty_sheets'] += 1except Exception as e:self.log.emit(f" ❌ {file_path.name}: {str(e)}") stats['failed'] += 1else:# Excel 文件 xl = pd.ExcelFile(str(file_path))for sheet_name in xl.sheet_names:try: df = self.read_excel_safely(file_path, sheet_name, self.skip_rows)# 清理列名 df.columns = [self.clean_column_name(col) for col in df.columns] df = self.clean_dataframe_values(df)# 只保留目标列 available_cols = [col for col in target_cols if col in df.columns]if available_cols andlen(df) > 0: df_filtered = df[available_cols].copy() df_filtered['__来源文件__'] = file_path.name df_filtered['__来源Sheet__'] = sheet_name all_data.append(df_filtered) total_rows += len(df_filtered) stats['success'] += 1self.log.emit(f" ✓ {file_path.name} - {sheet_name}: {len(df_filtered)} 行")else: stats['empty_sheets'] += 1except Exception as e:self.log.emit(f" ❌ {file_path.name} - {sheet_name}: {str(e)}")except Exception as e:self.log.emit(f" ❌ {file_path.name}: {str(e)}") stats['failed'] += 1# 更新进度 progress_value = int(25 + (i + 1) / len(excel_files) * 55)self.progress.emit(progress_value)ifnot all_data:self.finished_signal.emit(False, "未读取到有效数据,请检查文件格式")return# 合并数据self.log.emit(f"\n🔄 正在合并 {len(all_data)} 个数据块...") merged_df = pd.concat(all_data, ignore_index=True)# 生成输出文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_path = Path(self.folder_path) / f"合并结果_{timestamp}.xlsx"# 保存self.log.emit(f"💾 正在保存到: {output_path.name}")try:with pd.ExcelWriter(str(output_path), engine='openpyxl') as writer: merged_df.to_excel(writer, sheet_name='合并数据', index=False)# 添加汇总信息 summary_data = [ ['统计项目', '数值'], ['合并文件数', len(excel_files)], ['成功处理', stats['success']], ['处理失败', stats['failed']], ['空表格数', stats['empty_sheets']], ['总数据行数', total_rows], ['合并列数', len(target_cols)], ['合并模式', '共同列'ifself.merge_mode == 'common'else'全列'], ['跳过行数', self.skip_rows], ['生成时间', datetime.now().strftime("%Y-%m-%d %H:%M:%S")] ] summary_df = pd.DataFrame(summary_data[1:], columns=summary_data[0]) summary_df.to_excel(writer, sheet_name='汇总信息', index=False)self.progress.emit(100)# 成功日志self.log.emit("\n" + "="*60)self.log.emit(f"✅ 合并成功!")self.log.emit(f" 📁 输出文件: {output_path.name}")self.log.emit(f" 📊 总行数: {total_rows}")self.log.emit(f" 📋 列数: {len(target_cols)}")self.log.emit(f" ✓ 成功: {stats['success']} | ✗ 失败: {stats['failed']} | ⚠ 空表: {stats['empty_sheets']}")self.log.emit("="*60)self.finished_signal.emit(True, str(output_path))except Exception as e:self.finished_signal.emit(False, f"保存失败: {str(e)}")except Exception as e:import tracebackself.finished_signal.emit(False, f"合并失败: {str(e)}\n{traceback.format_exc()}")classHeaderDialog(QDialog):"""添加表头对话框"""def__init__(self, parent=None):super().__init__(parent)self.setWindowTitle("添加表头")self.setMinimumWidth(300)self.setup_ui()defsetup_ui(self): layout = QVBoxLayout(self) layout.addWidget(QLabel("表头名称:"))self.header_input = QLineEdit()self.header_input.setPlaceholderText("请输入表头名称") layout.addWidget(self.header_input) buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons)defget_header(self) -> str:returnself.header_input.text().strip()classExcelMergerApp(QMainWindow):def__init__(self):super().__init__()self.setWindowTitle("Excel 表格合并工具 V2.0")# DPI 适配:根据屏幕缩放设置合适的窗口大小 screen = QApplication.primaryScreen()if screen: dpi = screen.logicalDotsPerInch() scale_factor = dpi / 96.0# 基准 DPIelse: scale_factor = 1.0# 根据缩放因子调整窗口大小(紧凑设计) base_width = int(900 * min(scale_factor, 1.3)) base_height = int(750 * min(scale_factor, 1.3))self.setMinimumSize(base_width, base_height)self.resize(base_width, base_height)self.settings = QSettings("ExcelMerger", "Config")self.worker = Noneself.setup_ui()self.load_settings()self.center_window()# 自动调整字体大小以适配 DPIself.adjust_font_for_dpi(scale_factor)defadjust_font_for_dpi(self, scale_factor):"""根据 DPI 调整字体大小""" base_font_size = 10 new_size = max(8, int(base_font_size * scale_factor)) font = self.font() font.setPointSize(new_size)self.setFont(font)defsetup_ui(self):# 主窗口部件 central_widget = QWidget()self.setCentralWidget(central_widget)# 主布局(紧凑设计) main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(int(10 * self.get_scale_factor())) main_layout.setContentsMargins(int(15 * self.get_scale_factor()),int(12 * self.get_scale_factor()),int(15 * self.get_scale_factor()),int(10 * self.get_scale_factor()) )# 标题区域 title_widget = QWidget() title_layout = QVBoxLayout(title_widget) title_layout.setContentsMargins(0, 0, 0, int(8 * self.get_scale_factor())) title_top_layout = QHBoxLayout() title_top_layout.addStretch() about_btn = QPushButton("ℹ️ 关于") about_btn.setFont(QFont("Microsoft YaHei", int(8 * self.get_scale_factor()))) about_btn.setStyleSheet(self.get_button_style('secondary')) about_btn.setFixedSize(int(70 * self.get_scale_factor()), int(24 * self.get_scale_factor())) about_btn.clicked.connect(self.show_about) title_top_layout.addWidget(about_btn) title_layout.addLayout(title_top_layout) title_label = QLabel("📊 Excel 表格合并工具") title_font = QFont("Microsoft YaHei", int(16 * self.get_scale_factor()), QFont.Bold) title_label.setFont(title_font) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("color: #2196F3; margin-bottom: 3px;") title_layout.addWidget(title_label) dev_label = QLabel("【昨天软件】开发") dev_font = QFont("Microsoft YaHei", int(8 * self.get_scale_factor())) dev_label.setFont(dev_font) dev_label.setAlignment(Qt.AlignCenter) dev_label.setStyleSheet("color: #FF5722; font-weight: bold;") title_layout.addWidget(dev_label) version_label = QLabel("V2.0 - 增强版") version_font = QFont("Microsoft YaHei", int(8 * self.get_scale_factor())) version_label.setFont(version_font) version_label.setAlignment(Qt.AlignCenter) version_label.setStyleSheet("color: #999999;") title_layout.addWidget(version_label) main_layout.addWidget(title_widget)# 分隔线 line = QFrame() line.setFrameShape(QFrame.HLine) line.setStyleSheet("background-color: #E0E0E0;") line.setFixedHeight(1) main_layout.addWidget(line)# 上部分:配置区域 config_group = QGroupBox("⚙️ 配置选项") config_group.setStyleSheet(self.get_group_style()) config_layout = QVBoxLayout(config_group) config_layout.setSpacing(int(8 * self.get_scale_factor()))# 目录选择 dir_layout = QHBoxLayout() dir_label = QLabel("📁 目标目录:") dir_label.setStyleSheet("font-weight: bold; font-size: 10px;") dir_layout.addWidget(dir_label)self.dir_input = QLineEdit()self.dir_input.setPlaceholderText("请选择包含 Excel/CSV 文件的目录...")self.dir_input.setStyleSheet(self.get_input_style())self.dir_input.setFixedHeight(int(28 * self.get_scale_factor())) dir_layout.addWidget(self.dir_input) browse_btn = QPushButton("浏览...") browse_btn.setStyleSheet(self.get_button_style('primary')) browse_btn.setFont(QFont("Microsoft YaHei", int(9 * self.get_scale_factor()))) browse_btn.setFixedSize(int(80 * self.get_scale_factor()), int(28 * self.get_scale_factor())) browse_btn.clicked.connect(self.browse_directory) dir_layout.addWidget(browse_btn) config_layout.addLayout(dir_layout)# 选项区域(更紧凑) options_layout = QHBoxLayout() options_layout.setSpacing(int(20 * self.get_scale_factor()))# 跳过行数self.skip_spin = QSpinBox()self.skip_spin.setRange(0, 20)self.skip_spin.setValue(1)self.skip_spin.setFixedSize(int(70 * self.get_scale_factor()), int(28 * self.get_scale_factor()))self.skip_spin.setStyleSheet(self.get_spinbox_style()) skip_label = QLabel("⏭️ 跳过行数:") skip_label.setStyleSheet("font-weight: bold; font-size: 10px;") skip_layout = QHBoxLayout() skip_layout.addWidget(skip_label) skip_layout.addWidget(self.skip_spin) options_layout.addLayout(skip_layout)# 合并模式self.merge_mode_combo = QComboBox()self.merge_mode_combo.addItem("📌 共同列", "common")self.merge_mode_combo.addItem("📋 全列", "all")self.merge_mode_combo.setFixedSize(int(130 * self.get_scale_factor()), int(28 * self.get_scale_factor()))self.merge_mode_combo.setStyleSheet(self.get_combobox_style()) mode_label = QLabel("🔀 合并模式:") mode_label.setStyleSheet("font-weight: bold; font-size: 10px;") mode_layout = QHBoxLayout() mode_layout.addWidget(mode_label) mode_layout.addWidget(self.merge_mode_combo) options_layout.addLayout(mode_layout)# CSV 选项self.csv_checkbox = QCheckBox("📄 包含CSV")self.csv_checkbox.setStyleSheet("font-size: 10px;") options_layout.addWidget(self.csv_checkbox) options_layout.addStretch() config_layout.addLayout(options_layout) main_layout.addWidget(config_group)# 中间部分:表头配置 header_group = QGroupBox("📋 表头配置(可选)") header_group.setStyleSheet(self.get_group_style()) header_layout = QVBoxLayout(header_group) header_layout.setSpacing(int(6 * self.get_scale_factor()))# 第一行:提示 + 按钮水平布局 top_layout = QHBoxLayout()# 左侧提示 hint_label = QLabel("💡 提示:手动添加需要合并的表头,留空则自动识别所有表头") hint_label.setStyleSheet(f"color: #666666; font-size: {int(9*self.get_scale_factor())}px;") hint_label.setWordWrap(True) top_layout.addWidget(hint_label, 1)# 右侧按钮(更小更紧凑) btn_layout = QHBoxLayout() btn_layout.setSpacing(int(5 * self.get_scale_factor())) add_header_btn = QPushButton("➕") add_header_btn.setStyleSheet(self.get_button_style('primary')) add_header_btn.setFont(QFont("Microsoft YaHei", int(8 * self.get_scale_factor()))) add_header_btn.setFixedSize(int(55 * self.get_scale_factor()), int(24 * self.get_scale_factor())) add_header_btn.clicked.connect(self.add_header) btn_layout.addWidget(add_header_btn) import_header_btn = QPushButton("📥") import_header_btn.setStyleSheet(self.get_button_style('secondary')) import_header_btn.setFont(QFont("Microsoft YaHei", int(8 * self.get_scale_factor()))) import_header_btn.setFixedSize(int(55 * self.get_scale_factor()), int(24 * self.get_scale_factor())) import_header_btn.clicked.connect(self.import_headers) import_header_btn.setToolTip("从Excel文件导入表头") btn_layout.addWidget(import_header_btn) clear_header_btn = QPushButton("🗑️") clear_header_btn.setStyleSheet(self.get_button_style('danger')) clear_header_btn.setFont(QFont("Microsoft YaHei", int(8 * self.get_scale_factor()))) clear_header_btn.setFixedSize(int(55 * self.get_scale_factor()), int(24 * self.get_scale_factor())) clear_header_btn.clicked.connect(self.clear_headers) btn_layout.addWidget(clear_header_btn) top_layout.addLayout(btn_layout) header_layout.addLayout(top_layout)# 第二行:表头表格self.header_table = QTableWidget()self.header_table.setColumnCount(2)self.header_table.setHorizontalHeaderLabels(["表头名称", "操作"])self.header_table.horizontalHeader().setStretchLastSection(True)self.header_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)self.header_table.setSelectionBehavior(QAbstractItemView.SelectRows)self.header_table.setStyleSheet(self.get_table_style())self.header_table.verticalHeader().setDefaultSectionSize(int(28 * self.get_scale_factor()))self.header_table.setFixedHeight(int(140 * self.get_scale_factor())) header_layout.addWidget(self.header_table) main_layout.addWidget(header_group)# 下部分:日志和进度 log_group = QGroupBox("📝 合并日志") log_group.setStyleSheet(self.get_group_style()) log_layout = QVBoxLayout(log_group) log_layout.setSpacing(int(6 * self.get_scale_factor()))self.log_text = QTextEdit()self.log_text.setReadOnly(True)self.log_text.setStyleSheet(self.get_log_style())self.log_text.setFixedHeight(int(150 * self.get_scale_factor())) log_layout.addWidget(self.log_text)# 进度条self.progress_bar = QProgressBar()self.progress_bar.setRange(0, 100)self.progress_bar.setValue(0)self.progress_bar.setTextVisible(True)self.progress_bar.setFixedHeight(int(20 * self.get_scale_factor()))self.progress_bar.setStyleSheet(self.get_progress_style()) log_layout.addWidget(self.progress_bar) main_layout.addWidget(log_group)# 底部按钮(更紧凑) bottom_layout = QHBoxLayout() bottom_layout.setSpacing(int(10 * self.get_scale_factor()))self.merge_btn = QPushButton("🚀 开始合并")self.merge_btn.setFont(QFont("Microsoft YaHei", int(11 * self.get_scale_factor()), QFont.Bold))self.merge_btn.setFixedHeight(int(38 * self.get_scale_factor()))self.merge_btn.setStyleSheet(self.get_button_style('success'))self.merge_btn.clicked.connect(self.start_merge) bottom_layout.addWidget(self.merge_btn)self.cancel_btn = QPushButton("❌")self.cancel_btn.setFixedHeight(int(38 * self.get_scale_factor()))self.cancel_btn.setFixedWidth(int(50 * self.get_scale_factor()))self.cancel_btn.setEnabled(False)self.cancel_btn.setStyleSheet(self.get_button_style('danger'))self.cancel_btn.clicked.connect(self.cancel_merge) bottom_layout.addWidget(self.cancel_btn) clear_log_btn = QPushButton("🧹") clear_log_btn.setFixedHeight(int(38 * self.get_scale_factor())) clear_log_btn.setFixedWidth(int(50 * self.get_scale_factor())) clear_log_btn.setStyleSheet(self.get_button_style('secondary')) clear_log_btn.clicked.connect(self.clear_log) bottom_layout.addWidget(clear_log_btn) main_layout.addLayout(bottom_layout)# 状态栏self.statusBar().showMessage("✅ 就绪 - 准备就绪")self.statusBar().setStyleSheet(self.get_statusbar_style())# 初始日志self.log("欢迎使用 Excel 表格合并工具 V2.0!")self.log("支持处理 .xlsx, .xls 和 .csv 文件")self.log("-" * 60)defget_scale_factor(self):"""获取缩放因子""" screen = QApplication.primaryScreen()if screen: dpi = screen.logicalDotsPerInch()returnmax(0.8, min(dpi / 96.0, 1.5))return1.0defget_group_style(self):"""获取分组框样式"""return""" QGroupBox { font-weight: bold; font-size: 11px; border: 1px solid #E0E0E0; border-radius: 6px; margin-transform: translateY( 8px; padding: 12px 8px 8px 8px; background-color: white; } QGroupBox::title { subcontrol-origin: margin; left: 8px; padding: 0 4px; color: #2196F3; } """defget_input_style(self):"""获取输入框样式"""return""" QLineEdit { padding: 5px 8px; border: 1px solid #CCCCCC; border-radius: 3px; background-color: #FAFAFA; font-size: 10px; } QLineEdit:focus { border: 2px solid #2196F3; background-color: white; } """defget_button_style(self, btn_type='primary'):"""获取按钮样式""" styles = {'primary': """ QPushButton { padding: 4px 10px; background-color: #2196F3; color: white; border: none; border-radius: 3px; font-weight: bold; } QPushButton:hover { background-color: #1976D2; } QPushButton:pressed { background-color: #1565C0; } """,'success': """ QPushButton { padding: 6px 20px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #45A049; } QPushButton:pressed { background-color: #3E8E41; } QPushButton:disabled { background-color: #CCCCCC; color: #888888; } """,'danger': """ QPushButton { padding: 4px 10px; background-color: #f44336; color: white; border: none; border-radius: 3px; font-weight: bold; } QPushButton:hover { background-color: #d32f2f; } QPushButton:pressed { background-color: #C62828; } QPushButton:disabled { background-color: #CCCCCC; color: #888888; } """,'secondary': """ QPushButton { padding: 4px 10px; background-color: #757575; color: white; border: none; border-radius: 3px; font-weight: bold; } QPushButton:hover { background-color: #616161; } QPushButton:pressed { background-color: #424242; } """ }return styles.get(btn_type, styles['primary'])defget_spinbox_style(self):"""获取微调框样式"""return""" QSpinBox { padding: 3px 5px; border: 1px solid #CCCCCC; border-radius: 3px; font-size: 10px; } QSpinBox:focus { border: 2px solid #2196F3; } """defget_combobox_style(self):"""获取下拉框样式"""return""" QComboBox { padding: 3px 5px; border: 1px solid #CCCCCC; border-radius: 3px; font-size: 10px; } QComboBox:focus { border: 2px solid #2196F3; } QComboBox::drop-down { border: none; width: 20px; } QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 4px solid #666666; margin-right: 4px; } """defget_table_style(self):"""获取表格样式"""return""" QTableWidget { border: 1px solid #E0E0E0; border-radius: 3px; gridline-color: #E0E0E0; font-size: 10px; } QHeaderView::section { background-color: #F5F5F5; padding: 5px; border: none; border-bottom: 2px solid #E0E0E0; font-weight: bold; color: #333333; font-size: 10px; } QTableWidget::item) { padding: 3px; } QTableWidget::item:selected { background-color: #E3F2FD; color: #333333; } """defget_log_style(self):"""获取日志样式"""return""" QTextEdit { border: 1px solid #E0E0E0; border-radius: 3px; background-color: #FAFAFA; font-family: 'Consolas', 'Monaco', monospace; font-size: 10px; padding: 8px; } """defget_progress_style(self):"""获取进度条样式"""return""" QProgressBar { border: 1px solid #E0E0E0; border-radius: 3px; text-align: center; font-weight: bold; font-size: 9px; } QProgressBar::chunk { background-color: qlineargradient( x1: 0, y1: 0, x2: 1, y2: 0, stransform: translateY( 0 #4CAF50, stop: 1 #8BC34A ); border-radius: 2px; } """defget_statusbar_style(self):"""获取状态栏样式"""return""" QStatusBar { background-color: #F5F5F5; padding: 3px; font-size: 9px; } """defshow_about(self):"""显示关于对话框""" about_text = """<h2 style="color: #2196F3;">📊 Excel 表格合并工具 V2.0</h2><p style="color: #FF5722; font-weight: bold;">由【昨天软件】开发</p><br/><h3 style="color: #333333;">👤 作者介绍</h3><p>专注于桌面应用开发,提供高质量的办公效率工具。</p><p>致力于打造简洁、易用、高效的软件解决方案。</p><br/><h3 style="color: #333333;">📖 功能说明</h3><p><b>1. 批量合并Excel文件</b></p><p>• 支持 .xlsx、.xls 和 .csv 格式</p><p>• 自动遍历目录下的所有文件</p><p>• 支持多Sheet表格合并</p><br/><p><b>2. 灵活的配置选项</b></p><p>• 跳过行数:可设置跳过表格开头的行数</p><p>• 共同列模式:只合并所有文件共有的列</p><p>• 全列模式:合并所有出现的列</p><p>• 包含CSV:可选择是否处理CSV文件</p><br/><p><b>3. 表头配置</b></p><p>• 手动添加:逐个添加需要合并的表头</p><p>• 导入表头:从Excel文件自动导入表头</p><p>• 清空表头:一键清除所有配置的表头</p><br/><p><b>4. 其他功能</b></p><p>• 实时日志:显示合并进度和详细信息</p><p>• 配置记忆:自动保存和恢复设置</p><p>• 美观界面:适配不同DPI缩放</p><br/><p style="color: #888888;">感谢您的使用!</p> """ msg_box = QMessageBox(self) msg_box.setWindowTitle("关于") msg_box.setText(about_text) msg_box.setIcon(QMessageBox.Information) msg_box.setStyleSheet(""" QMessageBox { background-color: white; } QLabel { font-size: 11px; } """) msg_box.exec()defcenter_window(self):"""窗口居中显示 - 适配多显示器和高DPI"""# 确保窗口在可见屏幕上 screen = QApplication.screenAt(self.mapToGlobal(self.rect().center()))if screen isNone: screen = QApplication.primaryScreen()if screen: screen_geometry = screen.geometry() window_geometry = self.geometry()# 计算居中位置 x = screen_geometry.left() + (screen_geometry.width() - window_geometry.width()) // 2 y = screen_geometry.top() + (screen_geometry.height() - window_geometry.height()) // 2# 确保窗口完全在屏幕内 x = max(screen_geometry.left(), min(x, screen_geometry.right() - window_geometry.width())) y = max(screen_geometry.top(), min(y, screen_geometry.bottom() - window_geometry.height()))self.move(x, y)defbrowse_directory(self):"""浏览目录""" folder = QFileDialog.getExistingDirectory(self, "选择包含 Excel 文件的目录")if folder:self.dir_input.setText(folder)self.log(f"已选择目录: {folder}")defadd_header(self):"""添加表头""" dialog = HeaderDialog(self)if dialog.exec() == QDialog.Accepted: header = dialog.get_header()if header:self.add_header_to_table(header)self.save_settings()defimport_headers(self):"""从Excel文件导入表头""" folder = self.dir_input.text().strip()ifnot folder ornot os.path.exists(folder): QMessageBox.warning(self, "提示", "请先选择包含Excel文件的目录")return# 查找Excel文件 excel_files = list(Path(folder).glob("*.xlsx")) + list(Path(folder).glob("*.xls"))ifnot excel_files: QMessageBox.warning(self, "提示", "目录中没有找到Excel文件")return# 获取用户设置的跳过行数 skip_rows = self.skip_spin.value()# 读取第一个文件的第一个sheet的表头,使用用户设置的跳过行数try:# 使用 header=skip_rows 来读取表头(skip_rows 指定表头所在的行) df = pd.read_excel(str(excel_files[0]), header=skip_rows, nrows=0) headers = [str(col).strip() for col in df.columns ifstr(col).strip()]if headers:# 询问是否清空现有表头ifself.header_table.rowCount() > 0: reply = QMessageBox.question(self, "确认",f"是否清空现有表头并导入 {len(headers)} 个新表头?\n(基于跳过 {skip_rows} 行)", QMessageBox.Yes | QMessageBox.No )if reply == QMessageBox.Yes:self.header_table.setRowCount(0)# 添加表头for header in headers:if header:self.add_header_to_table(header)self.log(f"✓ 已从 {excel_files[0].name} 导入 {len(headers)} 个表头(跳过 {skip_rows} 行)")self.save_settings()else: QMessageBox.warning(self, "提示", f"未找到有效表头\n\n可能原因:\n"f"• 跳过行数 ({skip_rows}) 设置不正确\n"f"• 请尝试调整跳过行数后重新导入")except Exception as e: QMessageBox.warning(self, "错误", f"读取Excel文件失败:\n{str(e)}")defadd_header_to_table(self, header: str):"""添加表头到表格"""ifnot header ornotisinstance(header, str):return# 检查是否已存在for row inrange(self.header_table.rowCount()): item) = self.header_table.item(row, 0)if item and item.text() == header:return row = self.header_table.rowCount()self.header_table.insertRow(row)# 表头名称 name_item = QTableWidgetItem(header) name_item.setToolTip(header)self.header_table.setItem(row, 0, name_item)# 删除按钮 delete_btn = QPushButton("🗑️") delete_btn.setStyleSheet(self.get_button_style('danger')) delete_btn.setProperty("row", row) delete_btn.clicked.connect(self.on_delete_header_clicked)self.header_table.setCellWidget(row, 1, delete_btn)defon_delete_header_clicked(self):"""删除表头按钮点击处理""" btn = self.sender()if btn: row = btn.property("row")if row isnotNone:self.remove_header(row)defremove_header(self, row: int):"""删除表头"""self.header_table.removeRow(row)self.save_settings()defclear_headers(self):"""清空所有表头"""self.header_table.setRowCount(0)self.save_settings()defget_headers(self) -> List[str]:"""获取所有配置的表头""" headers = []for row inrange(self.header_table.rowCount()): item = self.header_table.item(row, 0)if item: headers.append(item.text())return headersdeflog(self, message: str):"""添加日志""" timestamp = datetime.now().strftime("%H:%M:%S")self.log_text.append(f"[{timestamp}] {message}")# 滚动到底部 scrollbar = self.log_text.verticalScrollBar() scrollbar.setValue(scrollbar.maximum())defclear_log(self):"""清空日志"""self.log_text.clear()defstart_merge(self):"""开始合并""" folder_path = self.dir_input.text().strip()ifnot folder_path: QMessageBox.warning(self, "警告", "请先选择目标目录")returnifnot os.path.exists(folder_path): QMessageBox.warning(self, "警告", "所选目录不存在")return headers = self.get_headers() skip_rows = self.skip_spin.value() merge_mode = self.merge_mode_combo.currentData() include_csv = self.csv_checkbox.isChecked()self.log("=" * 60)self.log("🚀 开始合并操作")self.log(f"📂 目标目录: {folder_path}")self.log(f"⏭️ 跳过行数: {skip_rows}")self.log(f"🔀 合并模式: {'共同列'if merge_mode == 'common'else'全列'}")self.log(f"📄 包含CSV: {'是'if include_csv else'否'}")if headers:self.log(f"📋 指定表头 ({len(headers)}个): {', '.join(headers[:5])}{'...'iflen(headers) > 5else''}")else:self.log("📋 表头: 自动识别")# 更新UI状态self.merge_btn.setEnabled(False)self.cancel_btn.setEnabled(True)self.progress_bar.setValue(0)self.statusBar().showMessage("⚙️ 正在合并...")# 启动工作线程self.worker = MergeWorker(folder_path, headers, skip_rows, merge_mode, include_csv)self.worker.progress.connect(self.update_progress)self.worker.log.connect(self.log)self.worker.finished_signal.connect(self.merge_finished)self.worker.start()defcancel_merge(self):"""取消合并"""ifself.worker andself.worker.isRunning():self.worker.stop()self.log("正在取消操作...")defupdate_progress(self, value: int):"""更新进度条"""self.progress_bar.setValue(value)defmerge_finished(self, success: bool, message: str):"""合并完成回调"""self.merge_btn.setEnabled(True)self.cancel_btn.setEnabled(False)if success:self.log("=" * 60)self.log(f"✅ 合并成功完成!")self.progress_bar.setValue(100)self.statusBar().showMessage("✅ 合并完成") reply = QMessageBox.question(self, "合并完成", f"✅ 合并成功!\n\n📁 输出文件:\n{message}\n\n是否打开所在目录?", QMessageBox.Yes | QMessageBox.No )if reply == QMessageBox.Yes: os.startfile(os.path.dirname(message))else:self.log(f"❌ {message}")self.statusBar().showMessage("❌ 合并失败") QMessageBox.critical(self, "错误", message)self.progress_bar.setValue(0)self.save_settings()defsave_settings(self):"""保存设置"""self.settings.setValue("last_directory", self.dir_input.text())self.settings.setValue("skip_rows", self.skip_spin.value())self.settings.setValue("merge_mode", self.merge_mode_combo.currentIndex())self.settings.setValue("include_csv", self.csv_checkbox.isChecked())self.settings.setValue("headers", self.get_headers())defload_settings(self):"""加载设置""" last_dir = self.settings.value("last_directory", "")if last_dir:self.dir_input.setText(last_dir) skip_rows = self.settings.value("skip_rows", 1)self.skip_spin.setValue(int(skip_rows)) merge_mode = self.settings.value("merge_mode", 0)self.merge_mode_combo.setCurrentIndex(int(merge_mode)) include_csv = self.settings.value("include_csv", False)if include_csv and include_csv != "false":self.csv_checkbox.setChecked(True) headers = self.settings.value("headers", [])if headers:for header in headers:self.add_header_to_table(header)defcloseEvent(self, event):"""关闭事件"""ifself.worker andself.worker.isRunning(): reply = QMessageBox.question(self, "确认退出", "合并操作正在进行中,确定要退出吗?", QMessageBox.Yes | QMessageBox.No )if reply == QMessageBox.Yes:self.worker.stop()self.worker.wait(2000) event.accept()else: event.ignore()else:self.save_settings() event.accept()defmain(): app = QApplication(sys.argv)# 设置 Fusion 主题 app.setStyle('Fusion')# 设置全局调色板 palette = QPalette() palette.setColor(QPalette.Window, QColor(250, 250, 250)) palette.setColor(QPalette.WindowText, QColor(33, 33, 33)) palette.setColor(QPalette.Base, QColor(255, 255, 255)) palette.setColor(QPalette.AlternateBase, QColor(245, 245, 245)) palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 255)) palette.setColor(QPalette.ToolTipText, QColor(33, 33, 33)) palette.setColor(QPalette.Text, QColor(33, 33, 33)) palette.setColor(QPalette.Button, QColor(240, 240, 240)) palette.setColor(QPalette.ButtonText, QColor(33, 33, 33)) palette.setColor(QPalette.BrightText, QColor(255, 0, 0)) palette.setColor(QPalette.Highlight, QColor(33, 150, 243)) palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) app.setPalette(palette)# 设置全局字体 font = QFont("Microsoft YaHei", 10) app.setFont(font) window = ExcelMergerApp() window.show() sys.exit(app.exec())if __name__ == "__main__": main()
如果需要打包后文件的 请关注后留言
夜雨聆风