# 个人支出统计与可视化工具:从记账到直观分析的完整实现


一、背景介绍

在日常生活中,合理管理个人支出是理财的第一步。然而,传统的记账方式(如手写账本、简单表格)往往难以直观展示支出结构与趋势,导致用户无法快速发现消费习惯中的问题。为此,我们开发了一款个人支出统计与可视化工具,通过GUI交互界面简化记录流程,结合数据可视化技术帮助用户清晰掌握支出分布,实现高效理财。

二、思路分析

工具的核心目标是「记录-存储-统计-可视化」的闭环,我们将功能拆解为四大模块:

1. GUI交互层

使用Python内置的Tkinter库构建界面,分为三大区域:
表单输入区:用于填写支出信息(日期、类别、金额、备注);
记录列表区:展示所有记录,支持删除/编辑操作;
可视化分析区:提供月份筛选、图表生成与导出功能。

2. 数据存储层

采用JSON格式持久化数据,存储路径为expenses.json。每条记录以字典形式保存,包含date(日期)、category(类别)、amount(金额)、note(备注)四个字段。

3. 统计分析层

  • 月度统计:按月份过滤记录,按日期分组求和每日支出;
  • 类别统计:按类别分组求和,计算各类别占总支出的比例。

4. 可视化层

使用Matplotlib库生成柱状图(月度每日支出)与饼图(类别占比),并通过FigureCanvasTkAgg嵌入Tkinter窗口,支持图表导出为PNG文件。

三、代码实现

以下是完整的Python代码,包含详细注释:

“`python
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import json
from datetime import datetime
import os

class ExpenseTracker:
def init(self, root):
self.root = root
self.root.title(“个人支出统计与可视化工具”)
self.root.geometry(“1200×800”)

    # 初始化数据(从JSON加载或为空)
    self.expenses = []
    self.data_file = "expenses.json"
    self.load_data()

    # 当前编辑的记录索引(None表示添加模式)
    self.editing_index = None

    # 初始化GUI布局
    self.setup_gui()

def setup_gui(self):
    """构建GUI界面布局"""
    # ---------------------- 左侧表单区 ----------------------
    form_frame = ttk.Frame(self.root, padding="10")
    form_frame.grid(row=0, column=0, sticky="nsew")

    # 日期输入
    ttk.Label(form_frame, text="日期(YYYY-MM-DD):").grid(row=0, column=0, sticky="w")
    self.date_entry = ttk.Entry(form_frame, width=20)
    self.date_entry.grid(row=0, column=1, padx=5, pady=5)
    self.date_entry.insert(0, datetime.today().strftime("%Y-%m-%d"))  # 默认今天

    # 类别选择
    ttk.Label(form_frame, text="类别:").grid(row=1, column=0, sticky="w")
    self.category_combo = ttk.Combobox(form_frame, values=["餐饮", "交通", "购物", "娱乐", "住房", "其他"], width=18)
    self.category_combo.grid(row=1, column=1, padx=5, pady=5)
    self.category_combo.current(0)

    # 金额输入
    ttk.Label(form_frame, text="金额(元):").grid(row=2, column=0, sticky="w")
    self.amount_entry = ttk.Entry(form_frame, width=20)
    self.amount_entry.grid(row=2, column=1, padx=5, pady=5)

    # 备注输入
    ttk.Label(form_frame, text="备注:").grid(row=3, column=0, sticky="w")
    self.note_entry = ttk.Entry(form_frame, width=20)
    self.note_entry.grid(row=3, column=1, padx=5, pady=5)

    # 添加/更新按钮
    self.add_btn = ttk.Button(form_frame, text="添加记录", command=self.add_or_update_record)
    self.add_btn.grid(row=4, column=0, columnspan=2, pady=10)

    # ---------------------- 中间记录列表区 ----------------------
    list_frame = ttk.Frame(self.root, padding="10")
    list_frame.grid(row=0, column=1, sticky="nsew")

    # Treeview表格展示记录
    self.tree = ttk.Treeview(list_frame, columns=("日期", "类别", "金额", "备注"), show="headings")
    self.tree.heading("日期", text="日期")
    self.tree.heading("类别", text="类别")
    self.tree.heading("金额", text="金额")
    self.tree.heading("备注", text="备注")
    self.tree.column("日期", width=100)
    self.tree.column("类别", width=80)
    self.tree.column("金额", width=80)
    self.tree.column("备注", width=150)
    self.tree.grid(row=0, column=0, columnspan=2, sticky="nsew")

    # 滚动条
    scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
    scrollbar.grid(row=0, column=2, sticky="ns")
    self.tree.configure(yscrollcommand=scrollbar.set)

    # 操作按钮
    self.delete_btn = ttk.Button(list_frame, text="删除选中", command=self.delete_record)
    self.delete_btn.grid(row=1, column=0, pady=5)

    self.edit_btn = ttk.Button(list_frame, text="编辑选中", command=self.prepare_edit)
    self.edit_btn.grid(row=1, column=1, pady=5)

    self.save_btn = ttk.Button(list_frame, text="保存数据", command=self.save_data)
    self.save_btn.grid(row=2, column=0, columnspan=2, pady=5)

    # ---------------------- 右侧可视化区 ----------------------
    visual_frame = ttk.Frame(self.root, padding="10")
    visual_frame.grid(row=0, column=2, sticky="nsew")

    # 月度统计
    ttk.Label(visual_frame, text="月度统计:").grid(row=0, column=0, sticky="w")
    self.month_combo = ttk.Combobox(visual_frame, width=15)
    self.update_month_options()  # 动态加载月份选项
    self.month_combo.grid(row=0, column=1, padx=5, pady=5)

    self.bar_btn = ttk.Button(visual_frame, text="生成柱状图", command=self.generate_bar_chart)
    self.bar_btn.grid(row=1, column=0, columnspan=2, pady=5)

    # 类别占比
    ttk.Label(visual_frame, text="类别占比:").grid(row=2, column=0, sticky="w")
    self.pie_btn = ttk.Button(visual_frame, text="生成饼图", command=self.generate_pie_chart)
    self.pie_btn.grid(row=3, column=0, columnspan=2, pady=5)

    # 导出图表
    self.export_btn = ttk.Button(visual_frame, text="导出图表", command=self.export_chart)
    self.export_btn.grid(row=4, column=0, columnspan=2, pady=5)

    # 图表展示区域
    self.chart_frame = ttk.Frame(visual_frame)
    self.chart_frame.grid(row=5, column=0, columnspan=2, sticky="nsew")

    # 初始化Matplotlib图表
    self.fig, self.ax = plt.subplots(figsize=(5,4))
    self.canvas = FigureCanvasTkAgg(self.fig, master=self.chart_frame)
    self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)

    # ---------------------- 窗口自适应配置 ----------------------
    self.root.grid_rowconfigure(0, weight=1)
    self.root.grid_columnconfigure(1, weight=1)
    list_frame.grid_rowconfigure(0, weight=1)
    list_frame.grid_columnconfigure(0, weight=1)
    visual_frame.grid_rowconfigure(5, weight=1)
    visual_frame.grid_columnconfigure(0, weight=1)

# ---------------------- 数据管理功能 ----------------------
def load_data(self):
    """从JSON文件加载数据"""
    if os.path.exists(self.data_file):
        try:
            with open(self.data_file, "r", encoding="utf-8") as f:
                self.expenses = json.load(f)
            self.refresh_treeview()
        except json.JSONDecodeError:
            messagebox.showerror("错误", "JSON文件格式异常,无法加载!")
    else:
        self.expenses = []

def save_data(self):
    """将数据保存到JSON文件"""
    try:
        with open(self.data_file, "w", encoding="utf-8") as f:
            json.dump(self.expenses, f, ensure_ascii=False, indent=4)
        messagebox.showinfo("成功", "数据已保存至expenses.json!")
    except Exception as e:
        messagebox.showerror("错误", f"保存失败:{str(e)}")

# ---------------------- 记录操作功能 ----------------------
def add_or_update_record(self):
    """添加新记录或更新编辑中的记录"""
    # 获取表单数据
    date = self.date_entry.get().strip()
    category = self.category_combo.get().strip()
    amount_text = self.amount_entry.get().strip()
    note = self.note_entry.get().strip()

    # 输入验证
    if not self.validate_input(date, category, amount_text):
        return

    amount = float(amount_text)
    record = {"date": date, "category": category, "amount": amount, "note": note}

    # 更新模式
    if self.editing_index is not None:
        self.expenses[self.editing_index] = record
        self.add_btn.config(text="添加记录")
        self.editing_index = None
    # 添加模式
    else:
        self.expenses.append(record)

    self.clear_form()
    self.refresh_treeview()

def validate_input(self, date, category, amount_text):
    """验证表单输入合法性"""
    if not date:
        messagebox.showwarning("警告", "日期不能为空!")
        return False
    try:
        datetime.strptime(date, "%Y-%m-%d")
    except ValueError:
        messagebox.showwarning("警告", "日期格式应为YYYY-MM-DD!")
        return False

    if not category:
        messagebox.showwarning("警告", "类别不能为空!")
        return False

    if not amount_text:
        messagebox.showwarning("警告", "金额不能为空!")
        return False
    try:
        amount = float(amount_text)
        if amount <=0:
            raise ValueError
    except ValueError:
        messagebox.showwarning("警告", "金额应为正数!")
        return False

    return True

def clear_form(self):
    """清空表单输入"""
    self.date_entry.delete(0, tk.END)
    self.date_entry.insert(0, datetime.today().strftime("%Y-%m-%d"))
    self.category_combo.current(0)
    self.amount_entry.delete(0, tk.END)
    self.note_entry.delete(0, tk.END)

def refresh_treeview(self):
    """刷新记录表格"""
    for item in self.tree.get_children():
        self.tree.delete(item)
    for idx, record in enumerate(self.expenses):
        self.tree.insert("", "end", iid=str(idx), values=(
            record["date"], record["category"], f"{record['amount']:.2f}", record["note"]
        ))
    self.update_month_options()

def delete_record(self):
    """删除选中记录"""
    selected = self.tree.selection()
    if not selected:
        messagebox.showwarning("警告", "请选中要删除的记录!")
        return
    idx = int(selected[0])
    del self.expenses[idx]
    self.refresh_treeview()

def prepare_edit(self):
    """准备编辑选中记录"""
    selected = self.tree.selection()
    if not selected:
        messagebox.showwarning("警告", "请选中要编辑的记录!")
        return
    idx = int(selected[0])
    record = self.expenses[idx]

    # 填充表单
    self.date_entry.delete(0, tk.END)
    self.date_entry.insert(0, record["date"])
    self.category_combo.set(record["category"])
    self.amount_entry.delete(0, tk.END)
    self.amount_entry.insert(0, str(record["amount"]))
    self.note_entry.delete(0, tk.END)
    self.note_entry.insert(0, record["note"])

    # 切换到更新模式
    self.add_btn.config(text="更新记录")
    self.editing_index = idx

# ---------------------- 统计与可视化功能 ----------------------
def update_month_options(self):
    """更新月份选择下拉框"""
    months = set()
    # 从现有记录提取月份
    for record in self.expenses:
        months.add(record["date"][:7])
    # 添加当前月份
    months.add(datetime.today().strftime("%Y-%m"))
    self.month_combo["values"] = sorted(months)
    if months:
        self.month_combo.current(0)

def generate_bar_chart(self):
    """生成月度每日支出柱状图"""
    selected_month = self.month_combo.get()
    if not selected_month:
        messagebox.showwarning("警告", "请选择月份!")
        return

    # 过滤当月记录
    month_records = [r for r in self.expenses if r["date"].startswith(selected_month)]
    if not month_records:
        messagebox.showinfo("提示", "该月份无记录!")
        return

    # 按日期分组求和
    date_expense = {}
    for r in month_records:
        date = r["date"]
        date_expense[date] = date_expense.get(date, 0) + r["amount"]

    # 排序并绘图
    sorted_dates = sorted(date_expense.keys())
    amounts = [date_expense[d] for d in sorted_dates]

    self.ax.clear()
    self.ax.bar(sorted_dates, amounts, color="#3498db")
    self.ax.set_title(f"{selected_month} 每日支出")
    self.ax.set_xlabel("日期")
    self.ax.set_ylabel("金额(元)")
    self.ax.tick_params(axis="x", rotation=45)
    self.fig.tight_layout()
    self.canvas.draw()

def generate_pie_chart(self):
    """生成类别支出占比饼图"""
    if not self.expenses:
        messagebox.showwarning("警告", "无记录可生成饼图!")
        return

    # 按类别分组求和
    category_expense = {}
    total = 0.0
    for r in self.expenses:
        cat = r["category"]
        category_expense[cat] = category_expense.get(cat,0) + r["amount"]
        total += r["amount"]

    # 计算占比并绘图
    labels = list(category_expense.keys())
    sizes = list(category_expense.values())
    percentages = [f"{(s/total)*100:.1f}%" for s in sizes]

    self.ax.clear()
    self.ax.pie(sizes, labels=labels, autopct=lambda p: f"{p:.1f}%", startangle=90)
    self.ax.set_title("支出类别占比")
    self.fig.tight_layout()
    self.canvas.draw()

def export_chart(self):
    """导出当前图表为PNG"""
    if not self.ax.get_title():
        messagebox.showwarning("警告", "请先生成图表!")
        return

    # 生成文件名
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    filename = f"expense_chart_{timestamp}.png"

    try:
        self.fig.savefig(filename, dpi=100, bbox_inches="tight")
        messagebox.showinfo("成功", f"图表已导出为{filename}!")
    except Exception as e:
        messagebox.showerror("错误", f"导出失败:{str(e)}")

四、总结

本工具通过Tkinter实现友好的GUI交互,结合JSON数据持久化与Matplotlib可视化,完整覆盖了支出记录、统计分析与图表展示的核心需求。代码采用模块化设计,易于扩展(如添加预算提醒、多用户支持等功能)。

运行步骤
1. 安装依赖:pip install matplotlib
2. 运行代码:python expense_tracker.py

通过这款工具,用户可以轻松管理个人支出,直观发现消费趋势,为理性理财提供数据支撑。未来可进一步优化UI设计,增加云同步功能,提升工具的实用性与易用性。


发表回复

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