200字
PyQt5 + Pandas 打造全能的表格(Excel/CSV)读取与处理工具
2026-03-17
2026-03-17

使用 PyQt5 + Pandas 打造全能的表格(Excel/CSV)读取与处理工具

在日常办公和数据分析中,我们经常需要处理各种 Excel 和 CSV 文件。无论是进行简单的数据累加、去重,还是复杂的数据透视、批量拆分,传统的操作往往需要使用 Excel 的复杂函数,或者编写重复的 Python 脚本。

为了提高工作效率,今天我们将手把手教大家使用 PyQt5Pandas 开发一款图形化的表格读取与处理工具。这款工具不仅支持直接拖拽文件加载,还能实时预览,并内置了多种常用的数据处理功能,非常适合零基础办公人员和数据分析师使用!

完整代码()

  • 必须关注公众号才可访问以下链接:
  • 完整代码存放1:点我进行访问
  • 完整代码存放2:下方复制
import sys
import os
import pandas as pd
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                             QPushButton, QLabel, QLineEdit, QFileDialog, QTableView, 
                             QTabWidget, QComboBox, QMessageBox, QGroupBox, QSpinBox,
                             QHeaderView)
from PyQt5.QtCore import Qt, QAbstractTableModel
from PyQt5.QtGui import QDragEnterEvent, QDropEvent

class PandasModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data

    def rowCount(self, parent=None):
        return self._data.shape[0]

    def columnCount(self, parent=None):
        return self._data.shape[1]

    def data(self, index, role=Qt.DisplayRole):
        if index.isValid():
            if role == Qt.DisplayRole:
                val = self._data.iloc[index.row(), index.column()]
                return str(val) if not pd.isna(val) else ""
        return None

    def headerData(self, col, orientation, role):
        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
            return str(self._data.columns[col])
        return None

class DragDropWidget(QLabel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setText("拖拽单个/多个文件或目录到此处\n(支持 Excel/CSV)")
        self.setAlignment(Qt.AlignCenter)
        self.setStyleSheet("QLabel { border: 2px dashed #aaa; border-radius: 5px; background: #f9f9f9; font-size: 16px; color: #666; padding: 20px; }")
        self.setAcceptDrops(True)
        self.main_window = parent

    def dragEnterEvent(self, event: QDragEnterEvent):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event: QDropEvent):
        urls = event.mimeData().urls()
        paths = [url.toLocalFile() for url in urls]
        self.main_window.handle_dropped_files(paths)

class TableToolApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.df = None
        self.current_files = []
        self.initUI()
      
    def initUI(self):
        self.setWindowTitle("表格(Excel/CSV)读取与处理工具 作者:小庄-Python学习")
        self.resize(1000, 800)
      
        # Central Widget
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QVBoxLayout(central_widget)
      
        # 1. Top Section: Drag & Drop + Browse
        top_layout = QHBoxLayout()
        self.drop_area = DragDropWidget(self)
        self.btn_browse = QPushButton("浏览文件/目录")
        self.btn_browse.setMinimumHeight(80)
        self.btn_browse.clicked.connect(self.browse_files)
      
        top_layout.addWidget(self.drop_area, stretch=3)
        top_layout.addWidget(self.btn_browse, stretch=1)
        main_layout.addLayout(top_layout)
      
        # 2. Header Row Settings
        header_layout = QHBoxLayout()
        header_layout.addWidget(QLabel("表头所在行 (默认第1行):"))
        self.spin_header = QSpinBox()
        self.spin_header.setMinimum(1)
        self.spin_header.setValue(1)
        self.spin_header.valueChanged.connect(self.reload_data)
        header_layout.addWidget(self.spin_header)
        header_layout.addStretch()
        main_layout.addLayout(header_layout)
      
        # 3. Preview Area (Top 20 rows)
        preview_group = QGroupBox("数据预览 (前 20 行)")
        preview_layout = QVBoxLayout()
        self.table_view = QTableView()
        preview_layout.addWidget(self.table_view)
        preview_group.setLayout(preview_layout)
        main_layout.addWidget(preview_group)
      
        # 4. Operations Area (Tabs)
        self.tabs = QTabWidget()
        self.init_tabs()
        main_layout.addWidget(self.tabs)
      
    def init_tabs(self):
        # Tab 1: Column Operations
        tab_col_op = QWidget()
        lay_col = QVBoxLayout(tab_col_op)
      
        h1 = QHBoxLayout()
        h1.addWidget(QLabel("选择操作列:"))
        self.combo_col_op = QComboBox()
        h1.addWidget(self.combo_col_op)
      
        btn_sum = QPushButton("累加")
        btn_count = QPushButton("计数")
        btn_dedup = QPushButton("去重")
        btn_split_col = QPushButton("按分隔符拆分列")
        self.split_sep_input = QLineEdit(",")
        self.split_sep_input.setPlaceholderText("分隔符")
        self.split_sep_input.setFixedWidth(50)
      
        btn_sum.clicked.connect(self.op_sum)
        btn_count.clicked.connect(self.op_count)
        btn_dedup.clicked.connect(self.op_dedup)
        btn_split_col.clicked.connect(self.op_split_col)
      
        h1.addWidget(btn_sum)
        h1.addWidget(btn_count)
        h1.addWidget(btn_dedup)
        h1.addWidget(self.split_sep_input)
        h1.addWidget(btn_split_col)
        h1.addStretch()
        lay_col.addLayout(h1)
        lay_col.addStretch()
        self.tabs.addTab(tab_col_op, "列基础操作")
      
        # Tab 2: Pivot
        tab_pivot = QWidget()
        lay_pivot = QVBoxLayout(tab_pivot)
        h2 = QHBoxLayout()
        h2.addWidget(QLabel("透视索引(Index):"))
        self.combo_pivot_idx = QComboBox()
        h2.addWidget(self.combo_pivot_idx)
      
        h2.addWidget(QLabel("透视列(Columns):"))
        self.combo_pivot_col = QComboBox()
        h2.addWidget(self.combo_pivot_col)
      
        h2.addWidget(QLabel("透视值(Values):"))
        self.combo_pivot_val = QComboBox()
        h2.addWidget(self.combo_pivot_val)
      
        btn_pivot = QPushButton("执行透视并导出")
        btn_pivot.clicked.connect(self.op_pivot)
        h2.addWidget(btn_pivot)
        h2.addStretch()
        lay_pivot.addLayout(h2)
        lay_pivot.addStretch()
        self.tabs.addTab(tab_pivot, "数据透视")
      
        # Tab 3: Split Files
        tab_split = QWidget()
        lay_split = QVBoxLayout(tab_split)
        h3 = QHBoxLayout()
        h3.addWidget(QLabel("按行拆分小文件, 每文件行数:"))
        self.spin_split_rows = QSpinBox()
        self.spin_split_rows.setRange(1, 1000000)
        self.spin_split_rows.setValue(100)
        h3.addWidget(self.spin_split_rows)
      
        btn_split_file = QPushButton("拆分并导出")
        btn_split_file.clicked.connect(self.op_split_file)
        h3.addWidget(btn_split_file)
        h3.addStretch()
        lay_split.addLayout(h3)
        lay_split.addStretch()
        self.tabs.addTab(tab_split, "拆分文件")
      
        # Tab 4: Batch Modify
        tab_mod = QWidget()
        lay_mod = QVBoxLayout(tab_mod)
        h4 = QHBoxLayout()
        h4.addWidget(QLabel("选择修改列:"))
        self.combo_mod_col = QComboBox()
        h4.addWidget(self.combo_mod_col)
      
        self.combo_mod_type = QComboBox()
        self.combo_mod_type.addItems(["全部修改为", "头部添加", "尾部添加", "第X个字符后添加"])
        h4.addWidget(self.combo_mod_type)
      
        self.mod_pos_input = QSpinBox()
        self.mod_pos_input.setPrefix("位置: ")
        self.mod_pos_input.setMinimum(1)
        self.mod_pos_input.hide()
        self.combo_mod_type.currentIndexChanged.connect(
            lambda idx: self.mod_pos_input.show() if idx == 3 else self.mod_pos_input.hide()
        )
        h4.addWidget(self.mod_pos_input)
      
        self.mod_val_input = QLineEdit()
        self.mod_val_input.setPlaceholderText("输入修改/添加的内容")
        h4.addWidget(self.mod_val_input)
      
        btn_mod = QPushButton("应用修改")
        btn_mod.clicked.connect(self.op_modify)
        h4.addWidget(btn_mod)
      
        btn_export = QPushButton("导出修改后文件")
        btn_export.clicked.connect(self.export_file)
        h4.addWidget(btn_export)
      
        h4.addStretch()
        lay_mod.addLayout(h4)
        lay_mod.addStretch()
        self.tabs.addTab(tab_mod, "批量修改列")
      
        # Tab 5: Extras
        tab_extra = QWidget()
        lay_extra = QVBoxLayout(tab_extra)
        h5 = QHBoxLayout()
        h5.addWidget(QLabel("缺失值处理:"))
        self.combo_na_type = QComboBox()
        self.combo_na_type.addItems(["数值型填充为0", "全部填充为空字符串", "删除含缺失值的行"])
        h5.addWidget(self.combo_na_type)
        btn_na = QPushButton("执行处理")
        btn_na.clicked.connect(self.op_handle_na)
        h5.addWidget(btn_na)
      
        btn_export_all = QPushButton("直接导出当前所有数据")
        btn_export_all.clicked.connect(self.export_file)
        h5.addWidget(btn_export_all)
      
        h5.addStretch()
        lay_extra.addLayout(h5)
        lay_extra.addStretch()
        self.tabs.addTab(tab_extra, "扩展功能")

    def handle_dropped_files(self, paths):
        valid_files = []
        for p in paths:
            if os.path.isfile(p) and p.lower().endswith(('.csv', '.xls', '.xlsx')):
                valid_files.append(p)
            elif os.path.isdir(p):
                for root, dirs, files in os.walk(p):
                    for f in files:
                        if f.lower().endswith(('.csv', '.xls', '.xlsx')):
                            valid_files.append(os.path.join(root, f))
      
        if not valid_files:
            QMessageBox.warning(self, "提示", "未找到有效的 Excel 或 CSV 文件!")
            return
          
        self.current_files = valid_files
        self.drop_area.setText(f"已加载 {len(self.current_files)} 个文件\n(最新: {os.path.basename(self.current_files[0])})")
        self.load_data()

    def browse_files(self):
        files, _ = QFileDialog.getOpenFileNames(self, "选择文件", "", "表格文件 (*.csv *.xls *.xlsx);;所有文件 (*.*)")
        if files:
            self.handle_dropped_files(files)

    def load_data(self):
        if not self.current_files:
            return
          
        header_row = self.spin_header.value() - 1
        dfs = []
        try:
            for f in self.current_files:
                if f.lower().endswith('.csv'):
                    # 尝试不同编码
                    try:
                        df = pd.read_csv(f, header=header_row, encoding='utf-8')
                    except UnicodeDecodeError:
                        df = pd.read_csv(f, header=header_row, encoding='gbk')
                else:
                    df = pd.read_excel(f, header=header_row)
                dfs.append(df)
              
            self.df = pd.concat(dfs, ignore_index=True)
            self.update_preview()
            self.update_comboboxes()
        except Exception as e:
            QMessageBox.critical(self, "错误", f"读取文件时出错:\n{str(e)}")
          
    def reload_data(self):
        self.load_data()

    def update_preview(self):
        if self.df is not None:
            preview_df = self.df.head(20)
            model = PandasModel(preview_df)
            self.table_view.setModel(model)
          
    def update_comboboxes(self):
        if self.df is not None:
            cols = [str(c) for c in self.df.columns]
          
            self.combo_col_op.clear()
            self.combo_col_op.addItems(cols)
          
            self.combo_pivot_idx.clear()
            self.combo_pivot_idx.addItems(cols)
            self.combo_pivot_col.clear()
            self.combo_pivot_col.addItems(cols)
            self.combo_pivot_val.clear()
            self.combo_pivot_val.addItems(cols)
          
            self.combo_mod_col.clear()
            self.combo_mod_col.addItems(cols)

    def op_sum(self):
        col = self.combo_col_op.currentText()
        if not col or self.df is None: return
        try:
            total = pd.to_numeric(self.df[col], errors='coerce').sum()
            QMessageBox.information(self, "结果", f"列 '{col}' 的累加结果为: {total}")
        except Exception as e:
            QMessageBox.warning(self, "错误", str(e))

    def op_count(self):
        col = self.combo_col_op.currentText()
        if not col or self.df is None: return
        count = self.df[col].count()
        QMessageBox.information(self, "结果", f"列 '{col}' 的非空计数为: {count}")

    def op_dedup(self):
        col = self.combo_col_op.currentText()
        if not col or self.df is None: return
        self.df = self.df.drop_duplicates(subset=[col])
        self.update_preview()
        QMessageBox.information(self, "结果", f"已按列 '{col}' 去重。")

    def op_split_col(self):
        col = self.combo_col_op.currentText()
        sep = self.split_sep_input.text()
        if not col or self.df is None or not sep: return
        try:
            new_cols = self.df[col].astype(str).str.split(sep, expand=True)
            new_cols.columns = [f"{col}_split_{i+1}" for i in range(new_cols.shape[1])]
            self.df = pd.concat([self.df, new_cols], axis=1)
            self.update_preview()
            self.update_comboboxes()
            QMessageBox.information(self, "结果", "列拆分成功。")
        except Exception as e:
            QMessageBox.warning(self, "错误", str(e))

    def op_pivot(self):
        if self.df is None: return
        idx = self.combo_pivot_idx.currentText()
        col = self.combo_pivot_col.currentText()
        val = self.combo_pivot_val.currentText()
      
        try:
            pivot_df = pd.pivot_table(self.df, values=val, index=idx, columns=col, aggfunc='sum', fill_value=0)
          
            save_path, _ = QFileDialog.getSaveFileName(self, "保存透视表", "pivot_result.xlsx", "Excel (*.xlsx);;CSV (*.csv)")
            if save_path:
                if save_path.endswith('.csv'):
                    pivot_df.to_csv(save_path)
                else:
                    pivot_df.to_excel(save_path)
                QMessageBox.information(self, "成功", "透视表导出成功!")
        except Exception as e:
            QMessageBox.warning(self, "错误", f"透视失败: {str(e)}")

    def op_split_file(self):
        if self.df is None: return
        rows = self.spin_split_rows.value()
        save_dir = QFileDialog.getExistingDirectory(self, "选择保存目录")
        if not save_dir: return
      
        try:
            total_rows = len(self.df)
            chunks = (total_rows // rows) + (1 if total_rows % rows != 0 else 0)
          
            for i in range(chunks):
                chunk_df = self.df.iloc[i*rows : (i+1)*rows]
                save_path = os.path.join(save_dir, f"split_part_{i+1}.xlsx")
                chunk_df.to_excel(save_path, index=False)
              
            QMessageBox.information(self, "成功", f"成功拆分为 {chunks} 个文件!")
        except Exception as e:
            QMessageBox.warning(self, "错误", str(e))

    def op_modify(self):
        if self.df is None: return
        col = self.combo_mod_col.currentText()
        mod_type = self.combo_mod_type.currentIndex()
        val = self.mod_val_input.text()
      
        try:
            if mod_type == 0: # 全部修改为
                self.df[col] = val
            elif mod_type == 1: # 头部添加
                self.df[col] = val + self.df[col].astype(str)
            elif mod_type == 2: # 尾部添加
                self.df[col] = self.df[col].astype(str) + val
            elif mod_type == 3: # 第X个字符后添加
                pos = self.mod_pos_input.value()
                def insert_str(s):
                    s_str = str(s)
                    if len(s_str) >= pos:
                        return s_str[:pos] + val + s_str[pos:]
                    return s_str + val
                self.df[col] = self.df[col].apply(insert_str)
              
            self.update_preview()
            QMessageBox.information(self, "成功", "批量修改应用成功!")
        except Exception as e:
            QMessageBox.warning(self, "错误", str(e))

    def export_file(self):
        if self.df is None: return
        save_path, _ = QFileDialog.getSaveFileName(self, "导出文件", "export.xlsx", "Excel (*.xlsx);;CSV (*.csv)")
        if save_path:
            try:
                if save_path.endswith('.csv'):
                    self.df.to_csv(save_path, index=False, encoding='utf-8-sig')
                else:
                    self.df.to_excel(save_path, index=False)
                QMessageBox.information(self, "成功", "文件导出成功!")
            except Exception as e:
                QMessageBox.warning(self, "错误", f"导出失败: {str(e)}")

    def op_handle_na(self):
        if self.df is None: return
        na_type = self.combo_na_type.currentIndex()
        try:
            if na_type == 0:
                for c in self.df.select_dtypes(include='number').columns:
                    self.df[c].fillna(0, inplace=True)
            elif na_type == 1:
                self.df.fillna("", inplace=True)
            elif na_type == 2:
                self.df.dropna(inplace=True)
          
            self.update_preview()
            QMessageBox.information(self, "成功", "缺失值处理成功!")
        except Exception as e:
            QMessageBox.warning(self, "错误", str(e))

if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    window = TableToolApp()
    window.show()
    sys.exit(app.exec_())

🎯 核心功能一览

我们这款工具内置了以下实用功能:

  1. 智能文件加载:支持点击按钮选择,或直接拖拽单个/多个文件、甚至整个目录到软件中,自动识别并合并 Excel (.xls, .xlsx) 和 .csv 文件。
  2. 实时数据预览:加载文件后,主界面会实时显示数据的前 20 行,方便确认数据是否正确。
  3. 自定义表头:遇到表头不在第一行的数据怎么办?用户可以自定义表头所在的行数,工具会自动重新解析数据。
  4. 丰富的列操作
    • 快速对指定列进行 累加计数
    • 对指定列进行数据 去重
    • 按照自定义分隔符将一列 拆分 为多列
  5. 一键数据透视:图形化选择透视的索引(Index)、列(Columns)和值(Values),自动执行求和透视并支持一键导出结果。
  6. 大文件按行拆分:遇到几十万行的大文件处理卡顿?设置“每文件行数”,一键将大文件拆分为多个小文件保存。
  7. 强大的批量修改列功能
    • 全部修改为固定值
    • 在数据头部/尾部添加特定字符
    • 在第 X 个字符后插入特定字符
  8. 扩展功能:一键处理缺失值(如填充0、填充空字符串或直接删除缺失行),以及一键导出当前处理完毕的所有数据。

🛠️ 技术栈与依赖

本项目基于 Python 开发,主要用到了以下三个强大的库:

  • PyQt5:负责构建整个图形化用户界面 (GUI),提供了丰富的控件和交互能力。
  • Pandas:负责底层的数据读取、处理和分析,是数据处理的“核武器”。
  • openpyxl:Pandas 读写新版 Excel (.xlsx) 文件的必备引擎。

安装依赖

可以通过以下命令一键安装所有所需库:

pip install PyQt5 pandas openpyxl

💻 核心代码解析

下面我们来拆解一下这个工具的几个核心实现点。

1. 拖拽文件与目录加载功能

为了让用户体验更好,我们继承 QLabel 自定义了一个 DragDropWidget,用于接收拖拽事件:

class DragDropWidget(QLabel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setText("拖拽单个/多个文件或目录到此处\n(支持 Excel/CSV)")
        self.setAcceptDrops(True)
        self.main_window = parent

    def dragEnterEvent(self, event: QDragEnterEvent):
        # 允许接收包含路径的文件拖拽
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event: QDropEvent):
        urls = event.mimeData().urls()
        paths = [url.toLocalFile() for url in urls]
        # 将获取到的路径交给主窗口处理
        self.main_window.handle_dropped_files(paths)

2. Pandas 与 QTableView 联动 (数据预览)

要在 PyQt 的 QTableView 中展示 Pandas 数据框,我们需要自定义一个 QAbstractTableModel,重写其行数、列数和数据返回方法:

class PandasModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data

    def rowCount(self, parent=None):
        return self._data.shape[0]

    def columnCount(self, parent=None):
        return self._data.shape[1]

    def data(self, index, role=Qt.DisplayRole):
        if index.isValid():
            if role == Qt.DisplayRole:
                val = self._data.iloc[index.row(), index.column()]
                return str(val) if not pd.isna(val) else ""
        return None

    def headerData(self, col, orientation, role):
        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
            return str(self._data.columns[col])
        return None

在工具中,我们通过 self.df.head(20) 获取前 20 行数据并传入该 Model,实现了轻量级的数据预览。

3. 数据处理:拆分、去重与透视

基于 Pandas 的强大功能,我们可以仅用几行代码实现复杂的逻辑:

  • 去重self.df = self.df.drop_duplicates(subset=[col])
  • 拆分列new_cols = self.df[col].astype(str).str.split(sep, expand=True)
  • 数据透视
pivot_df = pd.pivot_table(
    self.df, 
    values=val, 
    index=idx, 
    columns=col, 
    aggfunc='sum', 
    fill_value=0
)

4. 字符串的精细批量修改

对于“在第 X 个字符后插入内容”这种需求,我们可以利用 Pandas 的 apply 方法搭配自定义函数实现:

def insert_str(s):
    s_str = str(s)
    if len(s_str) >= pos:
        return s_str[:pos] + val + s_str[pos:]
    return s_str + val

self.df[col] = self.df[col].apply(insert_str)

🚀 总结

通过将 PyQt5 和 Pandas 结合,我们打造出了一个功能完备、界面友好的本地数据处理利器。这个工具不仅能极大提升我们处理零碎 Excel 文件的效率,更提供了一个很好的学习范例——你可以根据自己的实际业务需求,在 main.py 的选项卡中继续添加更多自定义的数据清洗和分析功能!

快把这份代码运行起来,让你的数据处理工作从此告别加班吧!

PyQt5 + Pandas 打造全能的表格(Excel/CSV)读取与处理工具
作者
一晌小贪欢
发表于
2026-03-17
License
CC BY-NC-SA 4.0

评论