# 简易个人记账本GUI应用:从设计到实现


背景介绍

在数字化时代,高效管理个人收支是理财的第一步。手动记账不仅繁琐,还难以快速统计和分析数据。为此,我开发了一款简易个人记账本文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
# 完整代码已包含在上述实现部分,直接复制即可运行

发表回复

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