Python Flask+SQLite实现物品管理系统CRUD实战

发布时间:2026/10/21 17:24:41
Python Flask+SQLite实现物品管理系统CRUD实战 开头这次说说我们某门课程里那份“第二次作业”。单看名字平平无奇但就是这种“课程中间节点”的作业往往最能检验你前几周到底有没有把基础打牢。我们的任务是做一个简单的小型管理系统方向自选我选的是“个人物品管理Demo”起因是宿舍里东西实在太多想着顺便做个能记录物品位置和借用状态的小工具一举两得。这个作业的实际技术含量不算高主体是一个基于Python Flask后端加一个轻量SQLite数据库的小型Web应用再配上原生HTML/CSS/JavaScript做个操作页面。它解决的核心问题其实就是“数据怎么存、怎么查、怎么改”而且要把增删改查完整跑通再把页面和后端对接起来。别看它名字简单做完之后你对路由、数据库、请求处理这几块的理解会明显上一个档次。适合刚学完Python基础、想开始接触Web开发但没有头绪的同学参考。我把整个踩坑过程、设计思路和能直接抄的代码都整理了出来希望能帮到正在做类似课程作业的朋友。1. 内容整体设计与思路拆解1.1 为什么选“物品管理系统”而不是其他方向当时摆在我面前的选择其实不少比如记账本、学生信息管理、图书借阅系统。这些题目本身都不难但我最终选了物品管理一个重要原因是它的实体关系非常自然不会绕弯子。你想想看物品这个实体天然就自带几个关键属性名称、分类、存放位置、借出状态、借给了谁、备注信息。这些字段本身就是一张非常好的数据表结构直接映射到数据库里的多个字段完全不需要纠结复杂的关联关系。相比之下记账本需要考虑收入和支出两类数据的区分图书借阅系统还要考虑多表关联、借阅期限等逻辑用来做第二次作业反而容易把自己绕晕。另外一点很现实物品管理Demo的展示效果好。页面做出来之后你有天然的“搜索、筛选、状态切换”这些交互点能很直观地展示你对前后端联调的理解答辩和汇报的时候也有东西可讲。给同样在选型的朋友一个建议第二次作业的重点一定不是“功能多炫”而是“思路清晰、CRUD完整、代码能讲清楚”。难度适中的方向反而能让你把更多精力花在打磨细节上。1.2 技术栈选型的底层逻辑这次作业我不能挑太重型的框架也不能全部靠前端造假数据糊弄。选型的时候我给自己定了三条硬标准环境搭建简单最好一条命令能跑起来。数据库操作直观最好用文件型数据库免安装。渲染方案要省事不用前后端分离模板直出即可。基于这三条Python Flask加SQLite就成了非常自然的答案。Flask本身极轻量一个文件就能写一个完整应用SQLite是文件型数据库不用装MySQL或者配置数据库服务保存后就是一个.db文件交作业的时候直接带走。模板方面我直接用Jinja2这也是Flask自带支持的把后端查出来的数据直接渲染到HTML里不用额外管理跨域、API鉴权这些东西对第二次作业来说足够。有人可能会问为什么不用DjangoDjango功能全但它的ORM、Admin后台、应用目录结构这些对第二次作业来说反而成了负担理解成本高也有人可能想直接用原生PHP加MySQL行不行行但环境配置就会劝退一批人。Flask恰好卡在“有足够工程感但又不过度设计”这个最佳区间。技术栈定下来之后就三样东西组件选择理由后端框架Flask轻量路由清晰适合小型应用数据库SQLite零配置单文件便于打包和提交前端HTML CSS 原生JS不引入构建工具直接渲染模板1.3 核心需求拆解和功能边界控制做这个Demo之前我先把功能边界画死了。整个系统就要四个核心能力物品列表展示默认按创建时间倒序展示所有物品。新增物品填写名称、分类、位置、状态和备注写入数据库。编辑物品点击编辑后进入修改页更新任意字段。删除物品直接删除一条记录删除前给一次确认。另外我加了一个非常轻量的“搜索”功能只按物品名称模糊搜索。搜索按理说不是CRUD的必选项但它能很自然地引入SQL的WHERE子句和模糊匹配在作业里体现你对查询条件的掌控能力性价比很高。至于别的比如用户登录、分页、批量导入导出我一概没有做因为那是后续迭代才需要考虑的强行加到第二次作业里只会让代码变乱。功能边界一旦定清楚后面写代码就不会东一榔头西一棒子。2. 核心细节解析与实操要点2.1 数据库表结构设计的学问数据库设计是整个Demo的地基。我建了一张表命名为items一开始就确定了字段类型和约束条件。下面是当时实际执行的建表SQLCREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 未分类, location TEXT NOT NULL DEFAULT 未指定位置, status TEXT NOT NULL DEFAULT 在库, borrower TEXT DEFAULT , note TEXT DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有几个细节值得展开讲。第一主键选INTEGER PRIMARY KEY AUTOINCREMENT这是SQLite里最标准的自增主键写法能保证每次插入的数据都有一个不重复的整数ID后面编辑、删除都靠这个ID定位记录。第二种写法INTEGER PRIMARY KEY其实在SQLite里也有自增效果但加上AUTOINCREMENT能避免ID被重用对于删除操作更安全。第二name TEXT NOT NULL表示名称不能为空这是在数据层做的第一道校验。你在页面上也可以做校验但数据库层面必须有约束否则绕过页面接口直插数据就会出现坏数据。第三borrower和note设置了默认空字符串。这个就是纯经验之谈了有些数据确实没有备注也没人借走如果不给它默认值插入的时候要么得显式写上空字符串要么就得处理NULL的情况徒增麻烦。第四created_at DATETIME DEFAULT CURRENT_TIMESTAMP这个字段完全不需要代码参与赋值数据库会自动填当前时间。这让列表默认排序变得非常舒服直接按时间倒序就行。建表看似简单但你需要注意的是SQLite本身不强制你写得很严谨如果偷懒不写NOT NULL后期前端报错时你根本分不清是页面传值问题还是数据本身有问题。数据约束严格一分排查问题的时间就能省下十分。2.2 Flask路由规划与请求方式Flask里每个页面都对应一个路由函数我规划了五条路由对应前文的四个功能再加一个列表页GET / - 首页物品列表 GET /add - 新增页面 POST /add - 处理新增 GET /edit/int:id - 编辑页面 POST /update/int:id - 处理更新 POST /delete/int:id - 处理删除这里有个很关键的设计习惯就是“页面请求”和“数据操作请求”分开。打开新增页面用GET提交表单用POST打开编辑页面用GET提交修改用POST。这样做的根源是HTTP协议本身的语义GET应该只获取数据不改变服务器状态POST才允许修改数据。实际写代码的时候我见过不少同学图省事把删除也写成GET请求直接在链接上带着/delete/1这样访问。这样确实能跑通但非常危险——只要有人不小心点了这个链接数据就被无声无息地删了。如果当时写成POST再配合一个确认弹窗至少能挡住误操作。作业虽然只是练习但一开始养成正确的HTTP方法习惯非常有价值。路由函数里的/int:id是动态路由尖括号里的部分会从URL中提取出来作为参数传入函数int则限定这个参数必须是整数。如果你访问/edit/abcFlask会直接返回404不会进入视图函数这等于免费帮你做了一次数据校验。2.3 为什么把数据库操作封装成独立模块这个决定是我后来觉得整个作业里最值的一步。我没有把SQL语句写在路由函数里而是单独建了一个db.py文件把所有数据库操作封装成了普通函数import sqlite3 DB_PATH items.db def get_db(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return conn def query_all_items(): conn get_db() rows conn.execute(SELECT * FROM items ORDER BY id DESC).fetchall() conn.close() return rows def query_item_by_id(item_id): conn get_db() row conn.execute(SELECT * FROM items WHERE id ?, (item_id,)).fetchone() conn.close() return row def insert_item(data): conn get_db() conn.execute( INSERT INTO items (name, category, location, status, borrower, note) VALUES (?, ?, ?, ?, ?, ?), (data[name], data[category], data[location], data[status], data[borrower], data[note]) ) conn.commit() conn.close() def update_item(item_id, data): conn get_db() conn.execute( UPDATE items SET name ?, category ?, location ?, status ?, borrower ?, note ? WHERE id ?, (data[name], data[category], data[location], data[status], data[borrower], data[note], item_id) ) conn.commit() conn.close() def delete_item(item_id): conn get_db() conn.execute(DELETE FROM items WHERE id ?, (item_id,)) conn.commit() conn.close()为什么要把查询语句从路由里拎出来最直接的好处是路由函数变得非常干净只负责“接收请求、调用函数、渲染页面”三件事。比如列表页的路由就只有这几行app.route(/) def index(): items query_all_items() return render_template(index.html, itemsitems)整个函数一眼能看完。另一个好处是以后如果你想换成MySQL只需要改db.py内部的连接和SQL方言路由层完全不用动。这种分层意识写进作业里答辩老师一眼就能看出你不是在拼凑代码而是真的理解结构。另外你可能会注意到连接写法里那句conn.row_factory sqlite3.Row。默认行为下SQLite查询返回的是元组取数据要靠下标row[0]、row[1]完全靠记忆改字段顺序代码就崩。设置了row_factory sqlite3.Row之后查询结果就可以用row[name]这种方式访问代码可读性提高了不止一个级别。这个小细节强烈建议写进去。2.4 模板渲染与表单回显前端部分我用的是Jinja2模板页面文件放在templates目录静态文件放在static目录。列表页的核心就是一个表格用Jinja2的for循环把后台传过来的items逐行渲染table thead tr thID/th th名称/th th分类/th th位置/th th状态/th th借用人/th th备注/th th操作/th /tr /thead tbody {% for item in items %} tr td{{ item[id] }}/td td{{ item[name] }}/td td{{ item[category] }}/td td{{ item[location] }}/td td{{ item[status] }}/td td{{ item[borrower] or - }}/td td{{ item[note] or - }}/td td a href/edit/{{ item[id] }}编辑/a button onclickconfirmDelete({{ item[id] }})删除/button /td /tr {% endfor %} /tbody /table这里有一个非常容易踩的坑如果数据库里borrower或note是空字符串页面会渲染出一个空格看起来很糟。用{{ item[borrower] or - }}就能在值为空时显示一个破折号让表格干净很多。这个技巧在真实项目中非常常用写进作业里也是加分项。编辑页的表单需要“回显”数据也就是打开页面时输入框里要显示数据库里当前的值。做法很简单在input标签的value属性里直接接Jinja2变量input typetext namename value{{ item[name] }} required input typetext namecategory value{{ item[category] }}需要注意的是“编辑物品”这个场景跟“新增物品”不一样它的value必须放在input标签内部和input之间要有一个空格。如果你写成value{{ item[name] }}忘掉引号模板渲染就会出错。状态字段我用的是下拉框编辑时要让当前状态正确选中。因为在Jinja2里不好直接写“selected”所以我在路由函数里预先做了一次判断把带有selected属性的三个option字符串直接准备好status_options for s in [在库, 已借出, 维修中]: selected selected if s item[status] else status_options foption value{s} {selected}{s}/option然后把status_options传给模板渲染。这样模板里就不用写一堆条件判断逻辑清晰很多。2.5 表单数据校验的三层防线数据校验不是一个地方做完就万事大吉的。我在这个Demo里实际做了三层每一层各有侧重。第一层是HTML前端校验required属性在提交前就会拦下空名称用户不需要等页面刷新就能看到提示。第二层是Flask视图函数里的校验用request.form.get(name, ).strip()取出值并去掉首尾空格然后判断是否为空为空就返回错误提示。第三层是数据库约束前面建表时已经设置了NOT NULL即使前两层都被绕过数据库也会拒绝坏数据。三层校验的代价都不高但它们分别覆盖了“用户体验”“接口防御”“数据完整性”三个维度。写作业的时候能够主动说出这三层设计的理由比单纯说“我做了校验”要有说服力得多。3. 实操过程与核心环节实现3.1 环境准备与项目初始化本地环境我先确认了Python版本在3.8以上然后直接安装Flaskpip install flask安装完成后项目目录结构如下second_assignment/ ├── app.py ├── db.py ├── init_db.py ├── templates/ │ ├── index.html │ ├── add.html │ └── edit.html └── static/ └── style.cssinit_db.py是一个单独用来建库建表的脚本import sqlite3 conn sqlite3.connect(items.db) conn.execute( CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 未分类, location TEXT NOT NULL DEFAULT 未指定位置, status TEXT NOT NULL DEFAULT 在库, borrower TEXT DEFAULT , note TEXT DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close() print(数据库初始化完成)运行python init_db.py就能生成items.db之后再启动app.py。这里建议把建表和跑服务分成两个文件因为后续你可能会反复重置数据库单独一个脚本删掉重跑更灵活。3.2 后端核心代码实现整个应用的入口是app.py核心代码不长但每部分都值得逐行看from flask import Flask, render_template, request, redirect, url_for import db app Flask(__name__) app.route(/) def index(): items db.query_all_items() return render_template(index.html, itemsitems) app.route(/add, methods[GET, POST]) def add(): if request.method POST: data { name: request.form.get(name, ).strip(), category: request.form.get(category, ).strip() or 未分类, location: request.form.get(location, ).strip() or 未指定位置, status: request.form.get(status, 在库), borrower: request.form.get(borrower, ).strip(), note: request.form.get(note, ).strip(), } if not data[name]: return 物品名称不能为空, 400 db.insert_item(data) return redirect(url_for(index)) return render_template(add.html) app.route(/edit/int:item_id, methods[GET, POST]) def edit(item_id): item db.query_item_by_id(item_id) if item is None: return 记录不存在, 404 if request.method POST: data { name: request.form.get(name, ).strip(), category: request.form.get(category, ).strip() or 未分类, location: request.form.get(location, ).strip() or 未指定位置, status: request.form.get(status, 在库), borrower: request.form.get(borrower, ).strip(), note: request.form.get(note, ).strip(), } if not data[name]: return 物品名称不能为空, 400 db.update_item(item_id, data) return redirect(url_for(index)) status_options for s in [在库, 已借出, 维修中]: selected selected if s item[status] else status_options foption value{s} {selected}{s}/option return render_template(edit.html, itemitem, status_optionsstatus_options) app.route(/delete/int:item_id, methods[POST]) def delete(item_id): item db.query_item_by_id(item_id) if item is None: return 记录不存在, 404 db.delete_item(item_id) return redirect(url_for(index)) if __name__ __main__: app.run(debugTrue)这段代码里有几个处理细节值得单独拿出来讲。首先是request.form.get(name, ).strip()。为什么不在request.form[name]这样直接取因为如果表单里没有这个字段request.form[name]会直接抛出异常而get方法会返回默认值更安全。.strip()的作用是去掉用户输入的首尾空格像“ 台灯 ”这种输入会被自动规范成“台灯”省得以后搜索时匹配不上。其次是or 未分类这种写法。它的作用是如果request.form.get(category, ).strip()得到的是空字符串就把默认值‘未分类’赋给category。Python里空字符串是False所以 or 未分类的结果是‘未分类’。这个做法的好处是不会往数据库里写入空值页面展示更干净。然后是return redirect(url_for(index))。提交完成后不应该直接渲染模板而应该重定向到列表页否则用户刷新页面时浏览器会再次提交表单导致重复插入。这个模式叫 Post/Redirect/Get在Web开发里是标准实践。写进作业代码里会让整个流程的专业度提升明显。最后是删除接口的POST限制写成app.route(/delete/int:item_id, methods[POST])并且前端删除按钮用JavaScript发POST请求这样即使有人拿到链接也删除不了数据。下面是列表页脚部放的确认删除脚本script function confirmDelete(itemId) { if (confirm(确定删除这条记录吗)) { fetch(/delete/ itemId, { method: POST }).then(() { window.location.href /; }); } } /script原生JavaScript配合fetch实现一个简单的POST请求不需要引入任何库。3.3 页面设计与交互细节样式方面我没有做很复杂的设计就是一份干净的CSS让表格居中、给一些内边距、按钮区分颜色。但有一个交互细节我给足了耐心状态字段用不同的背景色展示。在Jinja2模板里用条件判断{% if item[status] 已借出 %} span stylecolor: #d9534f;已借出/span {% elif item[status] 维修中 %} span stylecolor: #f0ad4e;维修中/span {% else %} span stylecolor: #5cb85c;在库/span {% endif %}这样页面从视觉上就能快速区分物品状态表格看起来不单调。这类小细节放在作业里能有效提升整体完成度代码量又不会增加多少。另外我在新增和编辑页面都加了同一个表单结构。用required限制名称必填分类和位置给了默认值占位状态用下拉框借用人和备注留空也可以。提交按钮放在表单底部旁边再加一个“返回列表”的链接避免用户找不到回去的路。3.4 联调流程与数据验证联调阶段我的习惯是“每完成一个功能就立刻验证一个功能”。先启动服务然后用一组测试数据从头滚一遍新增“台灯”分类“电器”位置“书桌”状态“在库”。新增“教材”分类“书籍”位置“书架”没有借用人不填备注。修改“台灯”的状态为“已借出”借用人填“室友小李”。删除“教材”。在首页搜索“台灯”确认只剩一条结果。每个步骤做完打开数据库看一遍数据状态。用命令行查看SQLite数据sqlite3 items.db SELECT * FROM items;这一步非常有用因为页面显示正常和数据库实际存储正常是两码事。我在联调时就发现过一次问题页面显示的备注是空的但数据库里实际存的是‘无’这个字符。原因是我一开始在表单里给备注填了默认值‘无’导致空备注和明确填‘无’在数据上无法区分。后来把默认值去掉让空字符串保持空页面用or -显示语义就清楚了。3.5 作业报告里的加分细节除了能跑的代码我还在作业报告里专门写了一段“设计说明”主要讲三件事为什么用SQLite而不是MySQL重点说零配置、单文件、适合Demo。为什么把数据库操作单独封装重点说路由层只关注业务逻辑数据访问层只关注SQL。为什么删除用POST重点说HTTP方法语义和误操作风险。这样一来答辩的时候就有话可说不至于被问到“你这个项目做了什么”就愣住。老师在意的往往不是代码有多高级而是你有没有想清楚每一步选择背后的原因。4. 常见问题与排查技巧实录4.1 表单提交后页面报错 400这种情况大概率是名称字段提交为空了。我的排查步骤是先在浏览器按F12打开开发者工具看请求的Form Data里有没有name字段。如果没有说明是页面端的required校验被绕过或字段名不一致。如果字段有值但后端还是400那就在视图函数第一行加一个打印print(request.form)直接在终端看提交的数据结构。我在这个作业里就遇到过名字写错的情况模板里input的name写的是names后端取的却是name表单提交过来names有值、name为空直接就400了。这类问题靠看代码很容易忽略靠打印请求数据一眼就能发现。4.2 sqlite3.OperationalError: table items has no column named xxx这个错出现之后先别急着改代码很可能只是数据库文件不是最新结构。比如你最开始建表时没有note字段后来在代码里加了note但items.db还是旧结构。解决办法分两种。开发阶段最省事是直接删除items.db重新运行python init_db.py。但如果你已经录入了不少测试数据不想丢掉就得用ALTER TABLE加列ALTER TABLE items ADD COLUMN note TEXT DEFAULT ;两种方式我都用过个人建议作业阶段直接重建库省时间也避免脏数据。反正测试数据没了可以重新录。4.3 模板渲染显示“None”而不是空内容这个问题很典型。当某条记录的备注字段为NULL而不是空字符串时Jinja2里直接{{ item[note] }}会被渲染成None。我前面已经用了or -来规避但如果你还没加这层处理可以在模板里把输出改成{{ item[note] if item[note] else - }}或者更干脆的做法是查询时就用SQL处理SELECT id, name, category, location, status, borrower, COALESCE(note, ) AS note FROM itemsCOALESCE函数把NULL转换成空字符串从源头消灭掉None显示问题。4.4 程序能启动但页面样式完全没生效这个通常是浏览器没能加载CSS文件。我的排查顺序是先看浏览器开发者工具Netwoork面板确认CSS请求状态。如果是404再去检查static目录名有没有拼对。Flask默认静态目录就是static如果你建的是statics就会404。还有一个常见坑是HTML里引用CSS时写成了绝对路径/static/style.css如果应用是部署在子路径下这个绝对路径就会失效。不过作业阶段直接跑本地的话用相对路径或者Flask的url_for(static, filenamestyle.css)都行。个人推荐后者因为这是官方推荐写法还能自动处理路径问题。4.5 刷新页面时数据重复提交这个坑我今天必须单独拎出来说。一开始我在新增成功之后直接return render_template(index.html, items...)表面看没问题但用户按F5刷新的时候浏览器会重放最后一次POST请求于是同一件物品被插入两次。后来改成redirect(url_for(index))也就是Post/Redirect/Get模式问题立刻消失。这个模式应付作业绰绰有余它是Web开发里最基本也最有效的防重复提交办法。4.6 不借助图形工具快速验证数据Windows下如果没装sqlite3命令可以在Python里直接查询import sqlite3 conn sqlite3.connect(items.db) for row in conn.execute(SELECT * FROM items): print(row) conn.close()这个小脚本不需要任何额外依赖调试时非常实用。我在整个开发过程中就反复用这个方式确认数据状态比打开图形化管理工具快得多。下面总结一下整个开发过程中遇到的高频问题现象可能原因解决办法提交后400字段名不一致或为空打印 request.form 对照检查表缺列报错数据库文件是旧结构删除重建或ALTER TABLE页面显示None数据库NULL值未处理使用or -或COALESCECSS无法加载static目录名或路径错误使用url_for生成静态资源路径刷新重复插入提交后没有重定向改用redirect GET删除无反应前端发的是GET而非POST用fetch构造POST请求5. 实操心得与收尾整个“第二次作业”做下来我个人最大的体会是一个作业项目的价值不在于功能堆了多少而在于你是否把一个完整链路上的每个环节都想明白了。从建表、写路由、渲染模板、处理表单到最后的联调每一环单独看都不难但串起来之后你对“一个Web应用是怎么跑起来的”会有非常具体的感知这是只看教程完全得不到的。另外我踩过几次坑之后特别想提醒大家一件事遇到报错先不要急着改代码先看数据、看请求、看控制台输出。90%的报错信息其实已经把问题定位到了具体的文件和行号顺着线索排查通常几分钟就能找到问题所在。作业过程中把这些问题和解决方案记录下来最后写成“常见问题记录”附在报告里会是很实在的加分项。这个Demo后续还能怎么扩展最自然的路径有两个一是增加一个“借出记录”表把谁借走、借了多久、什么时候还存下来这就引入了一对多关联项目复杂度和可讲的内容都会明显提升二是把前端从Jinja2模板换成Vue或者React通过API接口读取数据这样就从服务端渲染切到了前后端分离模式是一次很好的技术升级练习。不过这些都是后话第二次作业阶段能把一个清晰的CRUD闭环完整做扎实就已经比很多只停留在线下练习的进度超前了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询