# 开发个人收支统计与可视化工具:从数据管理到可视化分析的Python实践


在个人理财场景中,清晰的收支统计与可视化分析能帮助我们更好地掌握消费习惯、优化支出结构。本文将介绍如何使用Python开发一款个人收支统计与可视化工具,整合CSV文件操作、数据统计、GUI交互与Matplotlib可视化,帮助中级开发者系统学习多领域技术。

功能拆解与技术思路

核心功能模块

  1. 数据管理:支持从CSV导入历史记录、手动录入单条收支(日期、类型、分类、金额)。
  2. 统计分析:按时间范围(周/月)筛选数据,统计总收入/支出、分类金额及占比。
  3. 可视化展示:用柱状图展示各分类收支金额,用饼图展示收入/支出的类别占比。
  4. 数据导出:将统计结果导出为CSV,将可视化图表导出为图片。
  5. GUI交互:通过Tkinter构建界面,实现按钮点击、文件选择等交互逻辑。

技术选型与学习点

  • 文件操作csv模块处理CSV的导入/导出,需注意编码、数据格式转换。
  • 数据处理:用字典/列表存储交易记录,按时间/类型/分类筛选、聚合,涉及日期解析(datetime)、占比计算。
  • 可视化Matplotlib绘制图表,并通过FigureCanvasTkAgg嵌入Tkinter界面。
  • GUI设计Tkinter的布局管理(pack/grid/place)、组件事件绑定(按钮点击、输入验证)。

代码实现:从模块到完整应用

下面通过面向对象的方式实现工具,将逻辑封装在PersonalFinanceApp类中(继承自tk.Tk)。

1. 初始化与界面搭建

import csv
import tkinter as tk
from tkinter import filedialog, messagebox
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from datetime import datetime, timedelta

class PersonalFinanceApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("个人收支统计与可视化工具")
        self.geometry("1000x800")
        self.transactions = []  # 存储所有收支记录(字典列表)
        self.current_stats = None  # 存储统计结果
        self.current_fig = None   # 存储当前可视化图表
        self.create_widgets()  # 初始化界面组件

    def create_widgets(self):
        # 数据操作区:导入CSV + 手动录入
        data_frame = tk.Frame(self)
        data_frame.pack(fill='x', padx=10, pady=5)
        tk.Button(data_frame, text="导入CSV文件", command=self.import_csv).pack(side='left', padx=5)

        # 手动录入输入框
        tk.Label(data_frame, text="日期:").pack(side='left', padx=5)
        self.date_entry = tk.Entry(data_frame, width=12)
        self.date_entry.pack(side='left', padx=5)

        tk.Label(data_frame, text="类型:").pack(side='left', padx=5)
        self.type_var = tk.StringVar(value='支出')
        tk.OptionMenu(data_frame, self.type_var, '支出', '收入').pack(side='left', padx=5)

        tk.Label(data_frame, text="分类:").pack(side='left', padx=5)
        self.category_entry = tk.Entry(data_frame, width=10)
        self.category_entry.pack(side='left', padx=5)

        tk.Label(data_frame, text="金额:").pack(side='left', padx=5)
        self.amount_entry = tk.Entry(data_frame, width=10)
        self.amount_entry.pack(side='left', padx=5)

        tk.Button(data_frame, text="手动录入", command=self.add_manual).pack(side='left', padx=5)

        # 统计设置区:时间范围 + 统计按钮
        stats_frame = tk.Frame(self)
        stats_frame.pack(fill='x', padx=10, pady=5)
        tk.Label(stats_frame, text="统计范围:").pack(side='left', padx=5)
        self.time_var = tk.StringVar(value='周')
        tk.OptionMenu(stats_frame, self.time_var, '周', '月').pack(side='left', padx=5)
        tk.Button(stats_frame, text="统计分析", command=self.statistics).pack(side='left', padx=5)

        # 统计结果显示区:文本 + 图表
        result_frame = tk.Frame(self)
        result_frame.pack(fill='both', expand=True, padx=10, pady=5)
        self.text_area = tk.Text(result_frame, height=10, width=80)
        self.text_area.pack(side='left', fill='both', expand=True, padx=5)
        self.chart_frame = tk.Frame(result_frame)
        self.chart_frame.pack(side='right', fill='both', expand=True, padx=5)

        # 导出区:CSV + 图片
        export_frame = tk.Frame(self)
        export_frame.pack(fill='x', padx=10, pady=5)
        tk.Button(export_frame, text="导出统计CSV", command=self.export_csv).pack(side='left', padx=5)
        tk.Button(export_frame, text="导出图表图片", command=self.export_image).pack(side='left', padx=5)

2. 数据操作:CSV导入与手动录入

    def import_csv(self):
        """从CSV导入收支记录,自动转换金额为float"""
        file_path = filedialog.askopenfilename(filetypes=[("CSV Files", "*.csv")])
        if not file_path:
            return
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                reader = csv.DictReader(f)
                for row in reader:
                    row['金额'] = float(row['金额'])  # 金额转浮点
                    self.transactions.append(row)
            messagebox.showinfo("成功", f"导入{len(self.transactions)}条记录")
        except Exception as e:
            messagebox.showerror("错误", f"导入失败:{str(e)}")

    def add_manual(self):
        """手动录入单条收支记录,验证输入格式"""
        date = self.date_entry.get().strip()
        typ = self.type_var.get()
        category = self.category_entry.get().strip()
        amount = self.amount_entry.get().strip()
        try:
            datetime.strptime(date, '%Y-%m-%d')  # 验证日期格式
            amount = float(amount)               # 验证金额为数字
            if not category:
                raise ValueError("分类不能为空")
            # 添加记录
            self.transactions.append({
                '日期': date, '类型': typ, '分类': category, '金额': amount
            })
            self.date_entry.delete(0, 'end')
            self.category_entry.delete(0, 'end')
            self.amount_entry.delete(0, 'end')
            messagebox.showinfo("成功", "记录添加成功")
        except ValueError as e:
            messagebox.showerror("输入错误", f"请检查输入:{str(e)}")
        except Exception as e:
            messagebox.showerror("错误", f"添加失败:{str(e)}")

3. 统计分析:时间筛选与分类聚合

    def statistics(self):
        """按时间范围统计收支,计算分类金额与占比"""
        time_range = self.time_var.get()
        end_date = datetime.now()
        # 计算时间范围(周:近7天,月:近30天)
        start_date = end_date - timedelta(days=7) if time_range == '周' else end_date - timedelta(days=30)
        start_str, end_str = start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')

        # 筛选时间范围内的记录
        filtered = [t for t in self.transactions 
                   if start_str <= t['日期'] <= end_str]
        if not filtered:
            messagebox.showinfo("提示", "该时间范围内无记录")
            return

        # 分类统计收入/支出
        income, expense = {}, {}
        total_income, total_expense = 0, 0
        for t in filtered:
            typ, cat, amt = t['类型'], t['分类'], t['金额']
            if typ == '收入':
                income[cat] = income.get(cat, 0) + amt
                total_income += amt
            else:
                expense[cat] = expense.get(cat, 0) + amt
                total_expense += amt

        # 计算占比(避免除以0)
        income_ratio = {k: (v/total_income*100) for k, v in income.items()} if total_income else {}
        expense_ratio = {k: (v/total_expense*100) for k, v in expense.items()} if total_expense else {}

        # 保存统计结果
        self.current_stats = {
            'time_range': f"{start_str}至{end_str}",
            'total_income': total_income,
            'income_categories': income,
            'income_ratios': income_ratio,
            'total_expense': total_expense,
            'expense_categories': expense,
            'expense_ratios': expense_ratio
        }

        # 显示统计文本
        self._show_stats_text()
        # 生成可视化图表
        self.visualize()

    def _show_stats_text(self):
        """在Text组件中显示统计结果文本"""
        stats = self.current_stats
        self.text_area.delete(1.0, 'end')
        text = f"时间范围:{stats['time_range']}\n"
        text += f"总收入:{stats['total_income']}(" + " + ".join([f"{k}{v}" for k, v in stats['income_categories'].items()]) + ")\n"
        text += f"总支出:{stats['total_expense']}(" + " + ".join([f"{k}{v}" for k, v in stats['expense_categories'].items()]) + ")\n"
        text += "支出分类占比:" + "、".join([f"{k}{v:.1f}%({stats['expense_categories'][k]}/{stats['total_expense']})" for k, v in stats['expense_ratios'].items()]) + "\n"
        text += "收入分类占比:" + "、".join([f"{k}{v:.1f}%({stats['income_categories'][k]}/{stats['total_income']})" for k, v in stats['income_ratios'].items()])
        self.text_area.insert('end', text)

4. 可视化:Matplotlib与Tkinter的集成

    def visualize(self):
        """生成柱状图(分类金额)+ 饼图(收入/支出占比),嵌入Tkinter"""
        stats = self.current_stats
        # 清空图表区域
        for widget in self.chart_frame.winfo_children():
            widget.destroy()

        fig = Figure(figsize=(8, 6), dpi=100)
        # 子图1:柱状图(所有分类)
        ax1 = fig.add_subplot(2, 2, (1, 2))
        income_cats = list(stats['income_categories'].keys())
        expense_cats = list(stats['expense_categories'].keys())
        all_cats = income_cats + expense_cats
        all_amounts = list(stats['income_categories'].values()) + list(stats['expense_categories'].values())
        colors = ['green']*len(income_cats) + ['red']*len(expense_cats)  # 收入绿,支出红
        ax1.bar(all_cats, all_amounts, color=colors)
        ax1.set_title('各分类收支金额')
        ax1.set_ylabel('金额(元)')
        ax1.tick_params(axis='x', rotation=45)  # X轴标签旋转,避免重叠

        # 子图2:收入饼图
        ax2 = fig.add_subplot(2, 2, 3)
        if stats['total_income'] > 0:
            labels = income_cats
            sizes = list(stats['income_categories'].values())
            ax2.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=90)
            ax2.set_title('收入分类占比')

        # 子图3:支出饼图
        ax3 = fig.add_subplot(2, 2, 4)
        if stats['total_expense'] > 0:
            labels = expense_cats
            sizes = list(stats['expense_categories'].values())
            ax3.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=90)
            ax3.set_title('支出分类占比')

        fig.tight_layout()  # 自动调整子图间距
        # 嵌入Tkinter
        canvas = FigureCanvasTkAgg(fig, master=self.chart_frame)
        canvas.draw()
        canvas.get_tk_widget().pack(fill='both', expand=True)
        self.current_fig = fig  # 保存图表,用于导出

5. 数据导出:CSV与图片

    def export_csv(self):
        """将统计结果导出为CSV,格式与示例一致"""
        if not hasattr(self, 'current_stats'):
            messagebox.showinfo("提示", "请先进行统计分析")
            return
        stats = self.current_stats
        file_path = filedialog.asksaveasfilename(defaultextension='.csv', filetypes=[("CSV Files", "*.csv")])
        if not file_path:
            return
        try:
            with open(file_path, 'w', newline='', encoding='utf-8') as f:
                writer = csv.writer(f)
                writer.writerow(['统计类型', '时间范围', '金额', '分类', '占比'])
                # 总收入(总计 + 分类)
                writer.writerow(['总收入', stats['time_range'], stats['total_income'], '总计', '100.0%'])
                for cat, amt in stats['income_categories'].items():
                    ratio = stats['income_ratios'][cat]
                    writer.writerow(['总收入', stats['time_range'], amt, cat, f"{ratio:.1f}%"])
                # 总支出(总计 + 分类)
                writer.writerow(['总支出', stats['time_range'], stats['total_expense'], '总计', '100.0%'])
                for cat, amt in stats['expense_categories'].items():
                    ratio = stats['expense_ratios'][cat]
                    writer.writerow(['总支出', stats['time_range'], amt, cat, f"{ratio:.1f}%"])
            messagebox.showinfo("成功", "CSV导出成功")
        except Exception as e:
            messagebox.showerror("错误", f"导出失败:{str(e)}")

    def export_image(self):
        """将可视化图表导出为PNG图片"""
        if not hasattr(self, 'current_fig'):
            messagebox.showinfo("提示", "请先生成图表")
            return
        file_path = filedialog.asksaveasfilename(defaultextension='.png', filetypes=[("PNG Files", "*.png")])
        if not file_path:
            return
        try:
            self.current_fig.savefig(file_path)
            messagebox.showinfo("成功", "图片导出成功")
        except Exception as e:
            messagebox.showerror("错误", f"导出失败:{str(e)}")

完整应用与运行

将上述代码整合后,运行以下入口代码:

if __name__ == "__main__":
    app = PersonalFinanceApp()
    app.mainloop()

启动程序后,可通过“导入CSV”或“手动录入”添加数据,选择时间范围后点击“统计分析”,即可在界面左侧查看文本统计结果,右侧查看可视化图表。最后通过“导出统计CSV”或“导出图表图片”保存结果。

总结与扩展

学习收获

  • 文件操作:掌握csv模块的字典读写、编码处理与异常捕获。
  • 数据统计:学会按多维度(时间、类型、分类)筛选、聚合数据,计算占比。
  • GUI交互:Tkinter的布局管理、组件事件绑定、输入验证逻辑。
  • 可视化:Matplotlib图表的绘制、子图布局,以及与Tkinter的嵌入集成。

扩展方向

  • 支持多用户数据隔离(按用户ID存储)。
  • 增加收支预算功能,对比实际支出与预算。
  • 优化可视化:添加交互(如悬停显示金额)、支持更多图表类型(如折线图展示趋势)。

通过本项目,开发者可系统提升文件操作、数据处理、GUI设计、可视化的综合能力,为个人或小型团队的财务管理提供实用工具。


发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注