一、背景介绍
在日常生活中,合理管理个人支出是理财的第一步。然而,传统的记账方式(如手写账本、简单表格)往往难以直观展示支出结构与趋势,导致用户无法快速发现消费习惯中的问题。为此,我们开发了一款个人支出统计与可视化工具,通过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设计,增加云同步功能,提升工具的实用性与易用性。