用Python+SQLite+Tkinter从零实现简易记账软件
简介账户管理、收支登记与历史查询是个人及小微企业日常财务记录中的高频场景这套基于Python开发的简易记账软件设计源码正好切中这一需求。资源面向有一定Python基础、想了解Web前后端协作与分层设计的开发者整体采用controller、dao、service分层组织核心逻辑由Python脚本实现页面用HTML与CSS搭建并引入JavaScript处理表单校验与交互响应。压缩包共37个文件其中py脚本占20个另有5个js、4个css、4个html以及map、png、txt、.gitignore等辅助文件整体大小848KB目录结构按业务模块清晰划分便于对照学习。已有717人学习下载。通过这份源码可以完整看到登录、记账、历史记录等功能的实现链路理解数据从页面到控制器、服务层、数据访问层再到数据库的流动过程同时还能借鉴其分层结构、前端资源组织及Git忽略规则用法作为课程设计、毕业设计或日常自用记账工具的起步模板。1. 为什么“简易记账软件”值得自己写一套记账软件是少数“需求永远明确、边界永远可控”的个人项目。很多人第一反应是装个 App但真正用过一段时间就会发现账单数据是私有的导出格式是封闭的分类规则是别人定死的。自己用 Python 写一套数据落在本地 SQLite 文件里想怎么查怎么查想加什么规则加什么规则这比任何现成方案都更符合“数据属于自己”的原则。从学习角度看记账软件覆盖了 Python 桌面开发最常见的几条主线SQLite 数据持久化、面向对象的业务建模、图形界面的事件绑定、统计报表的生成。它不像爬虫那样依赖外部环境变化也不像 Web 后端那样需要一堆框架配置。一个文件能跑拆成模块也能跑非常适合拿来理解“源码结构”这件事——也就是把代码按数据层、业务层、界面层拆开而不是把所有逻辑堆在一个脚本里。这篇文章按“设计 → 建表 → 业务 → 界面 → 打包”的顺序展开目标是让你看完之后能直接抄出一份可运行的源码并且知道每个参数为什么这么设。适合已经掌握 Python 基础语法、想做一个完整项目的开发者也适合需要给课程设计或简历项目找素材的人。2. 记账软件的核心设计决策存储模型与界面框架的选型2.1 为什么用 SQLite 而不是 JSON 或 CSV记账软件的数据量级通常在几千到几万条记录之间SQLite 和 JSON 在这个量级下性能差异几乎感知不到但结构约束能力差别很大。用 JSON 存账单最直观的问题是无法在写入时保证字段完整——漏一个category、把amount写成字符串程序要等到读取时才报错。CSV 更麻烦金额带不带引号、日期格式不统一、字段里出现逗号每个都是坑。SQLite 用CREATE TABLE定义字段类型写入时做类型检查查询时能用 SQL 做聚合统计。这些能力对记账软件来说不是锦上添花而是核心需求月度支出、分类汇总、关键词搜索每条都是关系型查询。Python 自带的sqlite3模块不需要装任何第三方依赖建库、建表、增删改查全都能覆盖对“简易”定位来说是最稳的选择。2.2 界面框架Tkinter 是“源码最少”的答案桌面界面框架有三个常用选项Tkinter、PyQt、web 前端套 pywebview。PyQt 功能强但打包体积大、License 有要求对简易项目来说引入成本太高。pywebview 用 HTML/CSS 写界面颜值高但多了一层前端构建的复杂度等于把一个问题变成两个问题。Tkinter 是 Python 标准库自带的代码量中等布局系统够用打包后体积小最关键的是——它没有额外的环境依赖别人拿到源码能直接跑。布局上用ttk.Treeview做账单列表ttk.Combobox选分类ttk.Entry输金额ttk.Button触发操作。这套组合是 Tkinter 记账项目的常见做法代码模板稳定网上可参考的片段也多。界面效果朴素但记账软件的核心价值在数据准确性不在视觉效果。2.3 源码目录结构从一开始就按三层拆常见做法是把项目拆成db.py、models.py、ui.py、main.py四个文件。db.py只管数据库连接和建表models.py封装记账相关的业务函数ui.py负责界面搭建和事件绑定main.py是入口。这个结构对应了数据层、业务层、展示层的经典分层思路。bookkeeping/ ├── db.py # 数据库连接、建表、基础查询 ├── models.py # 账单的增删改查业务逻辑 ├── ui.py # Tkinter 界面 └── main.py # 程序入口这样拆分有几个直接好处ui.py里不出现 SQL 语句改界面不会碰坏数据逻辑models.py里不出现 Tkinter 控件写单元测试时不需要启动窗口。更重要的是别人看你的源码时能一眼看出“这个项目是分了层的”而不是一个 500 行的 main 函数。3. 数据库层源码实现建表语句与连接参数详解3.1 建表语句字段设计要覆盖三个查询维度账单表的核心字段是金额、分类、日期、备注。这四个字段对应了记账软件最常见的三个查询维度——按时间范围统计、按分类汇总、按备注搜索。再加一个id作为主键用于后续的修改和删除。下面是db.py的完整建表实现import sqlite3 from pathlib import Path DB_PATH Path(__file__).parent / bookkeeping.db def get_connection(): 获取数据库连接开启外键约束 conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) return conn def init_db(): 初始化数据库创建账单表 with get_connection() as conn: conn.execute( CREATE TABLE IF NOT EXISTS bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL CHECK(amount 0), category TEXT NOT NULL, bill_type TEXT NOT NULL CHECK(bill_type IN (expense, income)), note TEXT DEFAULT , created_at TEXT NOT NULL ) ) conn.execute(CREATE INDEX IF NOT EXISTS idx_bills_created_at ON bills(created_at)) conn.execute(CREATE INDEX IF NOT EXISTS idx_bills_category ON bills(category))这段代码有两个容易被忽略的细节。第一conn.row_factory sqlite3.Row让查询结果支持row[amount]这种方式取字段比默认的元组下标可读性强得多。第二CHECK(bill_type IN (expense, income))把收支类型约束在数据库层面界面上写漏了也不会写入脏数据。PRAGMA foreign_keys ON在这个单表场景里不是必需的但加上之后以后如果要扩展用户表或账单分表外键约束已经处于开启状态。3.2 连接管理with上下文到底在管什么sqlite3模块的connect对象支持上下文管理器协议但它的语义和其他数据库驱动不太一样。with conn:块结束时如果块内没有抛异常会自动提交事务如果抛了异常会自动回滚。注意它不会关闭连接。因此get_connection()里每次都新建连接调用方用完要自己close()。在简易记账软件里每次操作都新建连接、用完关闭是最稳妥的做法因为 SQLite 对并发写支持有限长连接反而容易拿到database is locked错误。with get_connection() as conn:这个写法把“提交或回滚”交给了上下文管理器调用方不需要手动commit()。但要注意init_db()里执行多个建表语句时事务范围是整个with块任一条语句失败全部回滚表不会建一半。3.3 验证用 Python 脚本确认表结构正确建表之后可以跑一段验证脚本确认表结构符合预期import sqlite3 from db import DB_PATH, init_db if __name__ __main__: init_db() conn sqlite3.connect(DB_PATH) cursor conn.execute(SELECT sql FROM sqlite_master WHERE typetable AND namebills) print(cursor.fetchone()[0]) conn.close()sqlite_master是 SQLite 的系统表保存了所有表结构的原始 DDL。打印出来的 SQL 应该和你在init_db里写的完全一致。另外检查一下bookkeeping.db文件是否生成在项目目录下确认DB_PATH用的是相对于源文件的路径而不是当前工作目录。这两者区别很大如果你从其他目录启动脚本Path(bookkeeping.db)会把数据库建到启动目录而Path(__file__).parent永远指向代码所在目录。4. 业务逻辑层源码实现增删改查与月度统计的边界处理4.1 新增账单金额、分类、日期的校验顺序业务层最核心的函数是add_bill。它的职责不只是执行一条INSERT而是先做校验、再决定是否入库。校验顺序很重要先查必填字段再查金额合法性最后才拼 SQL。这样每层错误的原因都清晰不会出现“明明提示类型错误实际是没传参数”的困惑。import sqlite3 from datetime import date from db import get_connection class BillError(Exception): 自定义业务异常用于区分参数错误和数据库错误 pass def add_bill(amount, category, bill_type, note, created_atNone): 新增一条账单记录 :param amount: 金额必须为正数 :param category: 分类名称不能为空 :param bill_type: expense 或 income :param note: 备注可为空 :param created_at: 日期字符串 YYYY-MM-DD为空则取当天 :return: 新记录的自增 id if not category.strip(): raise BillError(分类不能为空) if bill_type not in (expense, income): raise BillError(收支类型必须是 expense 或 income) try: amount float(amount) except (TypeError, ValueError): raise BillError(金额必须是数字) from None if amount 0: raise BillError(金额必须大于 0) if not created_at: created_at date.today().isoformat() with get_connection() as conn: cursor conn.execute( INSERT INTO bills (amount, category, bill_type, note, created_at) VALUES (?, ?, ?, ?, ?) , (amount, category.strip(), bill_type, note.strip(), created_at) ) return cursor.lastrowid重点在参数校验顺序先校验类型再转float避免把非数字字符串直接送进数据库触发 SQLite 的类型转换异常。cursor.lastrowid是 SQLite 在AUTOINCREMENT主键下返回的新记录 id在界面层可以用来刷新列表时定位到新行。所有 SQL 都使用?占位符传参这条原则在这个项目里没有例外——字符串拼接 SQL 一旦涉及用户输入的note字段就等于把注入风险直接写进了源码。4.2 账单列表LIMIT/OFFSET 与简单分页的矛盾一个易忽略的设计点是账单查询接口返回什么格式。常见做法是返回list[dict]而不是返回sqlite3.Row对象本身的列表。原因在于sqlite3.Row依赖连接仍然存活连接一旦关闭行对象无法再被访问。如果界面层拿到的是普通 dict数据就和数据库解耦了。def list_bills(bill_typeNone, categoryNone, start_dateNone, end_dateNone, limit50, offset0): 按条件查询账单返回 dict 列表 limit 和 offset 用于分页简易版本固定每页 50 条 conditions [] params [] if bill_type: conditions.append(bill_type ?) params.append(bill_type) if category: conditions.append(category ?) params.append(category) if start_date: conditions.append(created_at ?) params.append(start_date) if end_date: conditions.append(created_at ?) params.append(end_date) where_sql AND .join(conditions) if conditions else 11 sql f SELECT * FROM bills WHERE {where_sql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ? params.extend([limit, offset]) with get_connection() as conn: rows conn.execute(sql, params).fetchall() return [dict(row) for row in rows]ORDER BY created_at DESC, id DESC排序时加id作为次级条件能解决一个实际问题同一天记了多笔账时时间字段完全相同只有id能区分先后顺序。LIMIT ? OFFSET ?是 SQLite 分页的标准做法但对简易记账软件来说深分页会越来越慢通常 5000 条以内不需要考虑优化。4.3 月度统计格式化日期和 GROUP BY 的配合月度汇总的核心是strftime(%Y-%m, created_at)它把2024-07-15截断成2024-07然后用GROUP BY按月份分组SUM聚合每月的支出和收入。注意bill_type要分开算因为支出和收入加在一起没有意义。def monthly_summary(year_monthNone): 统计每个月的支出总额和收入总额 :param year_month: 指定月份 YYYY-MM不传则统计全部月份 :return: [{month: 2024-07, total_expense: 123.0, total_income: 500.0}] where_sql params [] if year_month: where_sql WHERE strftime(%Y-%m, created_at) ? params.append(year_month) sql f SELECT strftime(%Y-%m, created_at) AS month, SUM(CASE WHEN bill_type expense THEN amount ELSE 0 END) AS total_expense, SUM(CASE WHEN bill_type income THEN amount ELSE 0 END) AS total_income, COUNT(*) AS bill_count FROM bills {where_sql} GROUP BY month ORDER BY month DESC with get_connection() as conn: rows conn.execute(sql, params).fetchall() return [dict(row) for row in rows]SUM(CASE WHEN ...)这种写法避免了先查支出再查收入两次访问数据库一条 SQL 同时算两类汇总。返回值里的bill_count是额外的诊断信息——如果月度总笔数和界面列表不一致可以快速定位是查询条件写错还是统计函数写错。4.4 删除与修改先查后改还是直接执行删除和修改的源码实现相对简单但有一个决策点是否要先查询记录是否存在。常见做法是直接用cursor.rowcount判断是否作用到了行。rowcount返回受影响行数为 0 说明 id 不存在此时抛出BillError(账单不存在)避免界面层显示“删除成功”但实际什么都没删。def delete_bill(bill_id): 按 id 删除账单返回是否删除成功 with get_connection() as conn: cursor conn.execute(DELETE FROM bills WHERE id ?, (bill_id,)) if cursor.rowcount 0: raise BillError(f账单 {bill_id} 不存在) return True def update_bill(bill_id, amountNone, categoryNone, noteNone): 部分更新账单字段不传的字段保持不变 updates [] params [] if amount is not None: updates.append(amount ?) params.append(float(amount)) if category is not None: updates.append(category ?) params.append(category.strip()) if note is not None: updates.append(note ?) params.append(note.strip()) if not updates: return False params.append(bill_id) sql fUPDATE bills SET {, .join(updates)} WHERE id ? with get_connection() as conn: cursor conn.execute(sql, params) if cursor.rowcount 0: raise BillError(f账单 {bill_id} 不存在) return Trueupdate_bill用动态拼接SET子句来实现“部分更新”语义但注意这里拼接的是字段名而不是用户输入值字段名是源码写死的安全上没有问题。金额在业务层先转float再传给 SQLite这样即使传入123abc也会在业务层就被拦截不会污染数据库里的数值类型。5. Python 界面层源码实现Tkinter 数据绑定与参数刷新机制5.1 主窗口骨架Treeview 列表、表单区、查询区的布局界面层是用户直接接触的部分布局设计要符合操作习惯。常见做法是把窗口分成上下两个区域顶部是查询条件和操作按钮底部是账单列表。底部再放一个状态栏显示当前总数。Treeview 的列定义要和数据库字段一一对应但不需要显示id列id通过tree.insert传给iid参数这样选中一行时就能拿到数据库主键。import tkinter as tk from tkinter import ttk, messagebox from models import add_bill, delete_bill, list_bills, monthly_summary, BillError class BookkeepingApp: def __init__(self, root): self.root root root.title(简易记账软件) root.geometry(900x600) # 查询区 query_frame ttk.Frame(root, padding10) query_frame.pack(filltk.X) ttk.Label(query_frame, text分类:).pack(sidetk.LEFT) self.category_filter ttk.Combobox(query_frame, valuesself._get_categories(), width10) self.category_filter.pack(sidetk.LEFT, padx5) ttk.Button(query_frame, text刷新列表, commandself.refresh_list).pack(sidetk.RIGHT) # 表单区 form_frame ttk.Frame(root, padding10) form_frame.pack(filltk.X) ttk.Label(form_frame, text金额:).grid(row0, column0) self.amount_entry ttk.Entry(form_frame, width12) self.amount_entry.grid(row0, column1, padx5) ttk.Label(form_frame, text分类:).grid(row0, column2) self.category_entry ttk.Combobox(form_frame, values[餐饮, 交通, 购物, 工资], width10) self.category_entry.grid(row0, column3, padx5) ttk.Label(form_frame, text类型:).grid(row0, column4) self.type_combo ttk.Combobox(form_frame, values[支出, 收入], statereadonly, width6) self.type_combo.current(0) self.type_combo.grid(row0, column5, padx5) ttk.Button(form_frame, text添加账单, commandself.handle_add).grid(row0, column6, padx10) # 列表区 list_frame ttk.Frame(root) list_frame.pack(filltk.BOTH, expandTrue, padx10, pady5) columns (created_at, bill_type, category, amount, note) self.tree ttk.Treeview(list_frame, columnscolumns, showheadings) self.tree.heading(created_at, text日期) self.tree.heading(bill_type, text类型) self.tree.heading(category, text分类) self.tree.heading(amount, text金额) self.tree.heading(note, text备注) self.tree.column(created_at, width100) self.tree.column(amount, width80, anchortk.E) self.tree.pack(filltk.BOTH, expandTrue) # 绑定删除快捷键与选中事件 self.tree.bind(Delete, self.handle_delete) self.refresh_list() def refresh_list(self): 从数据库读取最新账单并刷新表格 for item in self.tree.get_children(): self.tree.delete(item) bills list_bills() for bill in bills: type_text 支出 if bill[bill_type] expense else 收入 amount_text f{bill[amount]:.2f} self.tree.insert( , tk.END, iidstr(bill[id]), values(bill[created_at], type_text, bill[category], amount_text, bill[note]) )注意self.tree.insert的iid参数。把数据库主键直接用作 Treeview 的 item id是 Tkinter 记账项目里常用的绑定手段。这样在handle_delete里通过self.tree.selection()拿到选中项的 iid转成 int 就是数据库 id不需要额外的映射关系。5.2 添加与删除的事件处理Messagebox 与状态栏反馈事件处理函数的核心原则是界面函数只做两件事——收集参数、展示结果真正的业务逻辑全部交给 models 层。这样即使以后换掉 Tkinteradd_bill和delete_bill的源码一行都不用改。def handle_add(self): 添加账单收集表单参数调用业务层写入数据库 amount self.amount_entry.get().strip() category self.category_entry.get().strip() type_text self.type_combo.get() note if type_text 支出: bill_type expense elif type_text 收入: bill_type income else: messagebox.showwarning(提示, 请选择收支类型) return if not amount or not category: messagebox.showwarning(提示, 金额和分类不能为空) return try: add_bill(amountamount, categorycategory, bill_typebill_type, notenote) except BillError as e: messagebox.showerror(错误, str(e)) return self.amount_entry.delete(0, tk.END) self.refresh_list() self.status_var.set(f已添加 {category} {bill_type} 金额 {amount}) def handle_delete(self, eventNone): 删除选中的账单按 Delete 键或调用此方法 selected self.tree.selection() if not selected: messagebox.showinfo(提示, 请先选中要删除的记录) return bill_id int(selected[0]) if messagebox.askyesno(确认, 确定删除这笔账单吗): try: delete_bill(bill_id) except BillError as e: messagebox.showerror(错误, str(e)) return self.refresh_list() self.status_var.set(f已删除账单 #{bill_id})self.tree.selection()返回的是元组多选模式下会包含多个 item id。简易版只取第一个因为删除多个需要二次确认逻辑复杂度会上升一截。handle_add里删除输入框内容用的是self.amount_entry.delete(0, tk.END)这里0是起始索引tk.END是终止索引Tkinter 的 Entry 组件删除区间是前闭后开delete(0, END)表示清空全部内容。5.3 月度统计面板从 SQL 结果到 Label 的映射统计展示是记账软件区别于“花名单”的关键。月度统计面板不直接暴露 SQL 给界面而是调用monthly_summary拿到数据后格式化展示。这里有一个隐性需求用户期望看到“本月支出多少”而不是“所有月份的总支出”。通过在__init__里计算当前月份字符串只查当前月。def show_monthly_stats(self): 显示当前月份的收支汇总 from datetime import date current_month date.today().strftime(%Y-%m) results monthly_summary(current_month) if not results: self.status_var.set(本月暂无账单) return row results[0] self.status_var.set( f本月支出: ¥{row[total_expense]:.2f} f本月收入: ¥{row[total_income]:.2f} f共 {row[bill_count]} 笔 )在这个面板里date.today().strftime(%Y-%m)生成2024-07格式的字符串与数据库里strftime(%Y-%m, created_at)的输出格式完全一致。两侧格式必须手动对齐因为 SQLite 的月份补零和 Python 的补零规则不同——3月的字符串是03而不是3两边格式不一致会导致查询结果为空。5.4 Tkinter 事件绑定的两个必调参数Tkinter 的事件绑定有两个容易踩的参数问题。第一个是bind回调函数会自动接收一个event参数如果你的处理函数不需要它要写成def handle_delete(self, eventNone)或直接定义带event参数的函数否则调用会报TypeError。第二个是Combobox的statereadonly参数——不设置的话用户可以直接输入自定义文本数据校验就会多一个分支。“分类”输入框用可编辑的 Combobox 是为了允许用户录入新的分类但“类型”输入框设成readonly是因为收支类型只有两种可能由用户自由输入反而会引入脏数据。6. 源码验证与打包发布让代码在别人电脑上也能跑起来6.1 单元验证不启动界面的业务逻辑自检界面部分手动点一遍就能验证但业务层的add_bill、list_bills、monthly_summary适合写一段简单的自检脚本在开发阶段每次改动后执行。这个脚本不依赖 Tkinter因此可以在没有任何显示环境的机器上跑。import tempfile import os from db import DB_PATH, init_db, get_connection from models import add_bill, list_bills, monthly_summary, delete_bill, BillError def run_self_test(): # 使用临时目录隔离测试数据 global DB_PATH tmp_dir tempfile.mkdtemp() os.environ[BOOKKEEPING_DB] os.path.join(tmp_dir, test.db) init_db() test_id add_bill(amount45.5, category餐饮, bill_typeexpense, note午餐) assert test_id is not None add_bill(amount8000, category工资, bill_typeincome) add_bill(amount15, category交通, bill_typeexpense) bills list_bills() assert len(bills) 3 stats monthly_summary(2024-07) assert len(stats) 1 assert stats[0][total_expense] 60.5 delete_bill(test_id) bills_after_delete list_bills() assert len(bills_after_delete) 2 try: delete_bill(9999) assert False, 应该抛出 BillError except BillError: pass print(全部自检通过) if __name__ __main__: run_self_test()这段自检脚本模拟了一个“会话”的完整链路新增两笔支出、一笔收入验证列表数量、月度汇总金额、按 id 删除以及删除不存在 id 时异常被正常抛出。自检的价值在于界面代码重构后业务逻辑有没有被改坏跑一遍就知道。6.2 打包成可执行文件PyInstaller 的参数建议自己电脑上跑通源码只是第一步要让没有 Python 环境的人也能用需要打包成 exe。PyInstaller 是常见方案--onefile参数把 Python 解释器、依赖库、源码全部打进单个可执行文件里缺点是启动稍慢但分发方便。--windowed参数在 Windows 下运行时不显示黑色控制台窗口对桌面应用是必选项。pip install pyinstaller pyinstaller --onefile --windowed --name bookkeeping main.py打包后需要验证两件事。第一bookkeeping.db文件的位置打包成--onefile后__file__指向临时解压目录Path(__file__).parent / bookkeeping.db会把数据库写到临时目录程序退出后数据就丢了。常见做法是改成写到用户目录from pathlib import Path import os if getattr(sys, frozen, False): # PyInstaller 打包后的运行环境 DATA_DIR Path.home() / .bookkeeping else: # 源码直接运行 DATA_DIR Path(__file__).parent DATA_DIR.mkdir(exist_okTrue) DB_PATH DATA_DIR / bookkeeping.dbsys.frozen是 PyInstaller 打包后在运行时注入的属性源码直接运行时不存在。用这个判断区分“开发模式”和“发布模式”数据库文件在两种模式下都放在固定位置不会因为打包方式变化而丢失。第二main.py的入口代码必须放在if __name__ __main__:块里PyInstaller 才能正确识别执行起点。入口代码负责创建tk.Tk()实例、实例化BookkeepingApp、调用mainloop()进入事件循环。6.3 数据迁移与备份一个随手加但很实用的函数记账数据是长期积累的备份是刚需。在db.py里加一个备份函数把当前数据库文件复制到带时间戳的备份文件。SQLite 在写入过程中直接复制.db文件可能拿到不一致的快照用 SQLite 自带的VACUUM INTO语句能获得一致性备份def backup_database(): 用 VACUUM INTO 生成当前数据库的一致性备份 from datetime import datetime backup_dir DB_PATH.parent / backups backup_dir.mkdir(exist_okTrue) timestamp datetime.now().strftime(%Y%m%d_%H%M%S) backup_path backup_dir / fbookkeeping_{timestamp}.db with get_connection() as conn: conn.execute(fVACUUM INTO {backup_path}) return backup_path注意VACUUM INTO的目标路径需要转义这里的f{backup_path}在路径不含单引号时可用。VACUUM INTO会同时压缩数据库文件并写入一致快照比直接shutil.copy2更安全因为它在 SQLite 引擎层面保证了“复制的是当前的库状态可能正被写入的数据文件副本”。本文还有配套的精品资源点击获取