用Python+SQLite+Tkinter从零实现简易记账软件
2026/9/23 1:25:34 网站建设 项目流程

简介:账户管理、收支登记与历史查询是个人及小微企业日常财务记录中的高频场景,这套基于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.pymodels.pyui.pymain.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 type='table' AND name='bills'") 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_at=None): """ 新增一条账单记录 :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_type=None, category=None, start_date=None, end_date=None, limit=50, offset=0): """ 按条件查询账单,返回 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 "1=1" 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_month=None): """ 统计每个月的支出总额和收入总额 :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, amount=None, category=None, note=None): """部分更新账单字段,不传的字段保持不变""" 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 = f"UPDATE 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 True

update_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, padding=10) query_frame.pack(fill=tk.X) ttk.Label(query_frame, text="分类:").pack(side=tk.LEFT) self.category_filter = ttk.Combobox(query_frame, values=self._get_categories(), width=10) self.category_filter.pack(side=tk.LEFT, padx=5) ttk.Button(query_frame, text="刷新列表", command=self.refresh_list).pack(side=tk.RIGHT) # 表单区 form_frame = ttk.Frame(root, padding=10) form_frame.pack(fill=tk.X) ttk.Label(form_frame, text="金额:").grid(row=0, column=0) self.amount_entry = ttk.Entry(form_frame, width=12) self.amount_entry.grid(row=0, column=1, padx=5) ttk.Label(form_frame, text="分类:").grid(row=0, column=2) self.category_entry = ttk.Combobox(form_frame, values=["餐饮", "交通", "购物", "工资"], width=10) self.category_entry.grid(row=0, column=3, padx=5) ttk.Label(form_frame, text="类型:").grid(row=0, column=4) self.type_combo = ttk.Combobox(form_frame, values=["支出", "收入"], state="readonly", width=6) self.type_combo.current(0) self.type_combo.grid(row=0, column=5, padx=5) ttk.Button(form_frame, text="添加账单", command=self.handle_add).grid(row=0, column=6, padx=10) # 列表区 list_frame = ttk.Frame(root) list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) columns = ("created_at", "bill_type", "category", "amount", "note") self.tree = ttk.Treeview(list_frame, columns=columns, show="headings") 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", width=100) self.tree.column("amount", width=80, anchor=tk.E) self.tree.pack(fill=tk.BOTH, expand=True) # 绑定删除快捷键与选中事件 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, iid=str(bill["id"]), values=(bill["created_at"], type_text, bill["category"], amount_text, bill["note"]) )

注意self.tree.insertiid参数。把数据库主键直接用作 Treeview 的 item id,是 Tkinter 记账项目里常用的绑定手段。这样在handle_delete里通过self.tree.selection()拿到选中项的 iid,转成 int 就是数据库 id,不需要额外的映射关系。

5.2 添加与删除的事件处理:Messagebox 与状态栏反馈

事件处理函数的核心原则是:界面函数只做两件事——收集参数、展示结果;真正的业务逻辑全部交给 models 层。这样即使以后换掉 Tkinter,add_billdelete_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(amount=amount, category=category, bill_type=bill_type, note=note) 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, event=None): """删除选中的账单,按 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, event=None)或直接定义带event参数的函数,否则调用会报TypeError。第二个是Comboboxstate="readonly"参数——不设置的话用户可以直接输入自定义文本,数据校验就会多一个分支。“分类”输入框用可编辑的 Combobox 是为了允许用户录入新的分类,但“类型”输入框设成readonly是因为收支类型只有两种可能,由用户自由输入反而会引入脏数据。

6. 源码验证与打包发布:让代码在别人电脑上也能跑起来

6.1 单元验证:不启动界面的业务逻辑自检

界面部分手动点一遍就能验证,但业务层的add_billlist_billsmonthly_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(amount=45.5, category="餐饮", bill_type="expense", note="午餐") assert test_id is not None add_bill(amount=8000, category="工资", bill_type="income") add_bill(amount=15, category="交通", bill_type="expense") 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_ok=True) DB_PATH = DATA_DIR / "bookkeeping.db"

sys.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_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = backup_dir / f"bookkeeping_{timestamp}.db" with get_connection() as conn: conn.execute(f"VACUUM INTO '{backup_path}'") return backup_path

注意VACUUM INTO的目标路径需要转义,这里的f"'{backup_path}'"在路径不含单引号时可用。VACUUM INTO会同时压缩数据库文件并写入一致快照,比直接shutil.copy2更安全,因为它在 SQLite 引擎层面保证了“复制的是当前的库状态,可能正被写入的数据文件副本”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询