背景介绍
在数字化时代,高效管理个人收支是理财的第一步。手动记账不仅繁琐,还难以快速统计和分析数据。为此,我开发了一款简易个人记账本文GUI应用,帮助用户轻松记录日常收支、自动统计分析并可视化展示。该应用基于Python技术栈实现:
– GUI框架:Tkinter(Python内置,轻量易用)
– 数据持久化:JSON(无需数据库,文件存储简单高效)
– 可视化:Matplotlib(生成饼图展示收支占比)
– 核心逻辑:日期处理、输入验证、事件驱动编程
思路分析
我将应用拆解为6个核心模块,逐一实现:
1. 数据模型与持久化
- 数据结构:每条记录用字典存储(
date/amount/category/note) - 持久化:通过JSON文件读写记录列表,启动时加载、操作后自动保存
2. GUI界面设计
- 主窗口:分为记录列表区、操作按钮区、统计区、可视化区
- 弹窗:添加/编辑记录时使用Toplevel窗口,简化交互流程
3. 输入验证
- 日期格式检查(YYYY-MM-DD)
- 金额合法性验证(必须为数字)
- 类别非空校验
4. 记录管理
- 增删改查功能:通过按钮触发对应操作
- 记录排序:按日期倒序显示
5. 统计分析
- 按月筛选记录,计算总收入/总支出/净收入
- 按类别分组统计收支金额
6. 可视化嵌入
- 使用Matplotlib生成饼图(支出/收入占比切换)
- 将饼图嵌入Tkinter Canvas组件
代码实现
以下是完整可运行的代码,包含详细注释:
import tkinter as tk
from tkinter import messagebox, ttk
import datetime
import json
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
class PersonalAccountBookApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("简易个人记账本")
self.geometry("1000x700")
# 初始化数据(加载JSON文件)
self.records = self.load_data()
self.selected_index = -1 # 当前选中的记录索引
# ---------------------- 界面组件初始化 ----------------------
# 1. 操作按钮区
btn_frame = tk.Frame(self)
btn_frame.pack(pady=10)
self.add_btn = tk.Button(btn_frame, text="添加记录", command=self.open_add_window)
self.add_btn.grid(row=0, column=0, padx=5)
self.edit_btn = tk.Button(btn_frame, text="编辑记录", command=self.open_edit_window)
self.edit_btn.grid(row=0, column=1, padx=5)
self.delete_btn = tk.Button(btn_frame, text="删除记录", command=self.delete_record)
self.delete_btn.grid(row=0, column=2, padx=5)
# 2. 记录列表区
list_frame = tk.Frame(self)
list_frame.pack(pady=5, fill=tk.BOTH, expand=True)
self.record_listbox = tk.Listbox(list_frame, width=100, height=15)
self.record_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar = tk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.record_listbox.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.record_listbox.config(yscrollcommand=scrollbar.set)
# 绑定列表选中事件
self.record_listbox.bind('<<ListboxSelect>>', self.on_list_select)
# 3. 统计区
stat_frame = tk.Frame(self)
stat_frame.pack(pady=5, fill=tk.X)
tk.Label(stat_frame, text="月份(YYYY-MM): ").grid(row=0, column=0)
self.month_entry = tk.Entry(stat_frame, width=10)
self.month_entry.grid(row=0, column=1)
self.month_entry.insert(0, datetime.date.today().strftime("%Y-%m"))
self.stat_btn = tk.Button(stat_frame, text="统计", command=self.calculate_statistics)
self.stat_btn.grid(row=0, column=2, padx=5)
self.stat_result = tk.Text(stat_frame, height=5, width=80)
self.stat_result.grid(row=1, column=0, columnspan=3, pady=5)
# 4. 可视化区
vis_frame = tk.Frame(self)
vis_frame.pack(pady=5, fill=tk.BOTH, expand=True)
self.vis_type = tk.StringVar(value="支出占比")
tk.Radiobutton(vis_frame, text="支出占比", variable=self.vis_type, value="支出占比", command=self.update_visualization).grid(row=0, column=0)
tk.Radiobutton(vis_frame, text="收入占比", variable=self.vis_type, value="收入占比", command=self.update_visualization).grid(row=0, column=1)
self.vis_canvas = tk.Canvas(vis_frame, width=600, height=400)
self.vis_canvas.grid(row=1, column=0, columnspan=2, pady=5)
# 初始化记录列表
self.update_record_list()
# ---------------------- 数据持久化 ----------------------
def load_data(self):
"""加载历史记录(JSON文件)"""
try:
with open("records.json", "r", encoding="utf-8") as f:
return json.load(f).get("records", [])
except FileNotFoundError:
return []
except json.JSONDecodeError:
messagebox.showerror("错误", "记录文件损坏,请删除records.json重试")
return []
def save_data(self):
"""保存记录到JSON文件"""
try:
with open("records.json", "w", encoding="utf-8") as f:
json.dump({"records": self.records}, f, ensure_ascii=False, indent=4)
except Exception as e:
messagebox.showerror("错误", f"保存失败:{str(e)}")
# ---------------------- 记录管理 ----------------------
def update_record_list(self):
"""更新记录列表显示(按日期倒序)"""
self.record_listbox.delete(0, tk.END)
sorted_records = sorted(self.records, key=lambda x: x["date"], reverse=True)
for record in sorted_records:
display_str = f"{record['date']} | {record['amount']:.2f} | {record['category']} | {record['note']}"
self.record_listbox.insert(tk.END, display_str)
def on_list_select(self, event):
"""处理列表选中事件"""
selected = self.record_listbox.curselection()
if selected:
self.selected_index = selected[0]
def open_add_window(self):
"""打开添加记录弹窗"""
self._open_record_window(title="添加记录", record=None)
def open_edit_window(self):
"""打开编辑记录弹窗"""
if self.selected_index == -1:
messagebox.showwarning("警告", "请先选中记录")
return
self._open_record_window(title="编辑记录", record=self.records[self.selected_index])
def _open_record_window(self, title, record):
"""通用记录编辑弹窗(复用添加/编辑逻辑)"""
window = tk.Toplevel(self)
window.title(title)
window.geometry("400x300")
# 默认值设置
default_date = datetime.date.today().strftime("%Y-%m-%d") if not record else record["date"]
default_amount = "" if not record else str(record["amount"])
default_category = "" if not record else record["category"]
default_note = "" if not record else record["note"]
# 表单组件
tk.Label(window, text="日期(YYYY-MM-DD): ").grid(row=0, column=0, padx=5, pady=5, sticky=tk.W)
date_entry = tk.Entry(window)
date_entry.grid(row=0, column=1, padx=5, pady=5)
date_entry.insert(0, default_date)
tk.Label(window, text="金额(收入+支出-): ").grid(row=1, column=0, padx=5, pady=5, sticky=tk.W)
amount_entry = tk.Entry(window)
amount_entry.grid(row=1, column=1, padx=5, pady=5)
amount_entry.insert(0, default_amount)
tk.Label(window, text="类别: ").grid(row=2, column=0, padx=5, pady=5, sticky=tk.W)
category_entry = tk.Entry(window)
category_entry.grid(row=2, column=1, padx=5, pady=5)
category_entry.insert(0, default_category)
tk.Label(window, text="备注: ").grid(row=3, column=0, padx=5, pady=5, sticky=tk.W)
note_entry = tk.Entry(window)
note_entry.grid(row=3, column=1, padx=5, pady=5)
note_entry.insert(0, default_note)
# 提交逻辑
def submit():
date = date_entry.get().strip()
amount_str = amount_entry.get().strip()
category = category_entry.get().strip()
note = note_entry.get().strip()
# 输入验证
if not self._validate_date(date):
messagebox.showwarning("警告", "日期格式错误(YYYY-MM-DD)")
return
if not self._validate_amount(amount_str):
messagebox.showwarning("警告", "金额必须为数字")
return
if not category:
messagebox.showwarning("警告", "类别不能为空")
return
# 构造记录
new_record = {
"date": date,
"amount": float(amount_str),
"category": category,
"note": note
}
# 更新记录列表
if not record: # 添加模式
self.records.append(new_record)
else: # 编辑模式
self.records[self.selected_index] = new_record
self.save_data()
self.update_record_list()
window.destroy()
tk.Button(window, text="提交", command=submit).grid(row=4, column=0, columnspan=2, pady=10)
def delete_record(self):
"""删除选中记录"""
if self.selected_index == -1:
messagebox.showwarning("警告", "请先选中记录")
return
if messagebox.askyesno("确认删除", "是否删除该记录?"):
del self.records[self.selected_index]
self.save_data()
self.update_record_list()
self.selected_index = -1
# ---------------------- 输入验证 ----------------------
def _validate_date(self, date_str):
"""验证日期格式"""
try:
datetime.datetime.strptime(date_str, "%Y-%m-%d")
return True
except ValueError:
return False
def _validate_amount(self, amount_str):
"""验证金额格式"""
try:
float(amount_str)
return True
except ValueError:
return False
# ---------------------- 统计分析 ----------------------
def calculate_statistics(self):
"""计算指定月份的统计数据"""
month = self.month_entry.get().strip()
if not month or len(month) !=7 or month[4] != '-':
messagebox.showwarning("警告", "月份格式错误(YYYY-MM)")
return
# 筛选月份记录
month_records = [r for r in self.records if r["date"].startswith(month)]
if not month_records:
self.stat_result.delete(1.0, tk.END)
self.stat_result.insert(tk.END, "该月份无记录")
return
# 计算核心指标
total_income = sum(r["amount"] for r in month_records if r["amount"]>0)
total_expense = sum(abs(r["amount"]) for r in month_records if r["amount"]<0)
net_income = total_income - total_expense
# 按类别统计
category_stats = {}
for r in month_records:
category_stats[r["category"]] = category_stats.get(r["category"],0) + r["amount"]
# 显示结果
result = f"总收入: {total_income:.2f} | 总支出: {total_expense:.2f} | 净收入: {net_income:.2f}\n"
result += "\n类别统计:\n"
for cat, amt in category_stats.items():
result += f"{cat}: {amt:.2f}\n"
self.stat_result.delete(1.0, tk.END)
self.stat_result.insert(tk.END, result)
# 更新可视化
self.update_visualization()
# ---------------------- 可视化 ----------------------
def update_visualization(self):
"""生成收支占比饼图"""
month = self.month_entry.get().strip()
if not month or len(month)!=7:
return
# 筛选数据
month_records = [r for r in self.records if r["date"].startswith(month)]
if not month_records:
return
# 选择可视化类型
if self.vis_type.get() == "支出占比":
data = [abs(r["amount"]) for r in month_records if r["amount"]<0]
labels = [r["category"] for r in month_records if r["amount"]<0]
title = f"{month}支出占比"
else:
data = [r["amount"] for r in month_records if r["amount"]>0]
labels = [r["category"] for r in month_records if r["amount"]>0]
title = f"{month}收入占比"
if not data:
return
# 生成饼图
plt.close() # 关闭旧图释放资源
fig, ax = plt.subplots(figsize=(6,4), dpi=100)
ax.pie(data, labels=labels, autopct='%1.1f%%', startangle=90)
ax.set_title(title)
# 嵌入Tkinter Canvas
canvas = FigureCanvasTkAgg(fig, master=self.vis_canvas)
canvas.draw()
canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
## 总结
该应用完整实现了所有需求功能,具有以下特点:
1. **轻量高效**:无额外依赖(仅需安装Matplotlib),启动速度快
2. **易用性**:直观的GUI界面,操作流程简洁
3. **可扩展性**:支持后续添加导出Excel、多用户、密码保护等功能
通过开发这款应用,我加深了对事件驱动编程、GUI组件交互、数据可视化的理解。如果你需要进一步优化,可以考虑:
- 使用ttk美化界面
- 添加数据导出功能(如CSV/Excel)
- 引入数据库(如SQLite)存储大量记录
**运行方式**:
1. 安装依赖:`pip install matplotlib`
2. 运行代码:`python account_book.py`
希望这款应用能帮助你更好地管理个人财务!如果有任何问题或建议,欢迎留言交流。
```python
# 完整代码已包含在上述实现部分,直接复制即可运行