Vue3+Element Plus+Flask:从零搭建前后端分离的学生管理系统

发布时间:2026/9/20 11:12:46
Vue3+Element Plus+Flask:从零搭建前后端分离的学生管理系统 简介面向前后端分离学习者的学生管理系统完整源码包基于Vue3、Element Plus、Flask与MySQL构建覆盖响应式前端交互、组件化开发、RESTful API设计、数据库表结构设计等核心环节同时涉及Vue3 Composition API、Element Plus组件用法、Flask蓝图与ORM操作等具体技术点可实现学生信息、成绩、课程等模块的管理适合作为课程设计、毕业设计或后台管理项目参考。压缩包共2000个文件以JavaScript、Markdown、JSON、Python为主包含前端构建产物、项目文档、配置文件及后端源码整体仅14.62MB便于快速下载与部署。已有169人学习/下载。资源附有可运行代码与说明文档能帮助读者快速理解Vue3Element-PlusFlask的项目结构、前后端数据交互流程及MySQL数据存储方案并在此基础上进行功能扩展与二次开发。 前阵子帮一位师弟做课程设计需求很直白一个学生管理系统。技术栈我几乎没有犹豫就定了Vue3 Element Plus Flask。前端用Vue3的组合式API组织业务逻辑Element Plus提供表格、表单、弹窗这些现成组件后端用Flask提供轻量RESTful接口前后端分离整套代码结构清晰非常适合课程设计、毕业设计也适合想系统性入门全栈开发的朋友照着撸一遍。这篇文章我会从技术选型、环境搭建、后端接口、前端页面再到联调部署把整个实现过程完整讲一遍重点是每个环节“为什么这么选”以及我实际踩过的坑。不管你是正在做毕设的学生还是刚接触前后端分离开发想练手的开发者看完这篇应该都能直接照着搭起来。1. 项目整体设计与技术选型1.1 为什么选择Vue3 Element Plus Flask正式动手之前我对比过两套方案Vue2 Django以及Vue3 Element Plus Flask。最终选后者不是因为冷门技术更高级而是这个组合对“学生管理系统”这种体量刚刚好。Vue3相比Vue2最大的变化是Composition API。写管理系统时最头疼的就是状态管理搜索条件、分页参数、列表数据、弹窗开关、表单数据这些状态经常互相联动。Vue2的Options API把data、computed、methods、watch拆得比较散一个功能相关的代码要横跨好几个配置项读起来费劲Vue3的setup函数则可以把一个功能的所有状态和逻辑集中在一起比如列表页的加载、刷新、搜索都归拢到一个函数里维护成本明显更低。这也是面试题里反复提到的vue2和vue3区别对新手来说直接从Vue3入手不用学一套旧习惯再改。Flask这边轻便是它最大的优点。学生管理系统的后端本质就几个RESTful接口查列表、加一条、改一条、删一条最多加一个登录。Django自带admin后台、ORM、模板引擎功能很全但课程设计用这些其实是杀鸡用牛刀。Flask把路由、请求参数处理、JSON响应写清楚再配一个SQLAlchemy操作数据库足够覆盖所有需求而且学习曲线平缓得多。网上说“flask不适合大型项目”但做中小型系统、做毕设、做内部工具它反而是最能让你把注意力放在业务逻辑上的框架。python flask这套组合在中小型项目里非常常见网上的教程和开源案例也多遇到问题基本都能搜到答案。1.2 项目功能范围与前后端分工我给自己划定的功能范围是学生信息的增删改查、分页展示、关键词搜索、性别和状态筛选、新增编辑共用同一个弹窗表单、删除带二次确认。如果要做登录鉴权就在后端加一个/login接口和token校验前端在axios拦截器里统一携带token工作量不大答辩时还能多一个亮点。前后端的分工很简单前端负责页面渲染和用户交互通过axios调用后端接口后端只负责提供数据和校验规则不关心页面长什么样。两边用一份字段约定做对接比如学生对象的字段名是student_no、name、gender、class_name前端表单提交的JSON和后端返回的JSON完全一致能省去很多字段映射的心力。这里的字段约定我建议一开始就用文档写清楚哪怕只是写在代码注释里。实际开发中不少同学栽在“后端返回的是gender前端写的是sex”这种低级错误上排查半天才发现字段大小写不一致。约定一旦建立后面所有接口都遵循同一套命名问题会少很多。2. 环境准备与项目初始化2.1 使用Vite创建Vue3项目并引入Element Plus在Vite出现之前创建Vue项目通常用vue-cli现在官方推荐直接用Vite速度快、配置简单、热更新也快。初始化命令是npm create vitelatest student-manage -- --template vue cd student-manage npm installVite默认创建的模板不包含路由和状态管理如果项目里要用vue-router和pinia再单独安装npm install element-plus axios vue-router piniaElement Plus的引入方式有全量引入和按需引入两种。课程设计这种规模的项目我推荐全量引入代码最省事import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus) app.mount(#app)按需引入需要额外安装unplugin-auto-import和unplugin-vue-components并在vite.config.js里配置插件能减小打包体积但对学生管理系统来说收益不大全量引入多出来的体积也就几百KB换来的却是“不用关心组件没注册”的省心。图标组件建议做全局自动注册。Element Plus的图标是单独的一个包安装后在main.js里统一注册一次模板里就能直接用组件名调用npm install element-plus/icons-vueimport * as ElementPlusIconsVue from element-plus/icons-vue for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) }这一点和热词里的“element-plus icons vite 自动注册”是同一个思路不用每个文件单独import图标写模板时直接用 就行。顺便说一句Element Plus官方网站在某些网络环境下打开确实很慢文档加载半天这时候不用死等直接打开项目里的node_modules/element-plus目录看类型定义和源码或者上npm看README反而更快。Vue3的官方文档同理英文版更新最快中文版偶尔会滞后遇到概念不清的场景两边对照着看。2.2 搭建Flask后端环境与目录结构后端我用虚拟环境隔离依赖避免污染全局Pythonpython -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install flask flask-cors flask-sqlalchemy pymysql如果只是本地演示数据库直接用SQLite不用额外安装数据库服务Flask-SQLAlchemy在配置里指定一个db文件路径就行想贴近真实生产环境可以切到MySQL连接串改成mysqlpymysql://用户名:密码localhost/库名即可。对学生管理系统这种数据量SQLite完全够用。目录结构我习惯这样组织server/ app.py # Flask应用入口注册路由 models.py # SQLAlchemy数据模型 config.py # 数据库连接等配置初学者最容易犯的错误是把所有代码塞进一个文件里。Flask允许这样做但项目一复杂就难维护。拆成模块之后数据库模型放models.py配置放config.py入口只负责启动和路由注册后续加功能时思路清晰很多。如果之前没用过flask框架可以先跑通官方文档里的最小示例再回来接着看。2.3 配置前端开发代理前后端分离开发时前端跑在5173端口后端Flask跑在5000端口直接请求会产生跨域问题。标准的解决方式是前端用vite的proxy做代理把/api开头的请求转发到后端// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } })配置完成后前端代码里的axios请求一律写成相对路径比如axios.get(/api/students)开发时Vite会把请求转发到5000端口浏览器里看不到跨域报错。后端那边同时用flask-cors开启跨域支持两套机制不冲突可以同时存在。3. 后端Flask核心接口实现3.1 学生表模型设计先看数据库模型。学生表字段不多但每个字段的选择都有讲究# models.py from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class Student(db.Model): __tablename__ student id db.Column(db.Integer, primary_keyTrue, autoincrementTrue) student_no db.Column(db.String(20), uniqueTrue, nullableFalse) name db.Column(db.String(50), nullableFalse) gender db.Column(db.Integer, default0) # 0男 1女 age db.Column(db.Integer) class_name db.Column(db.String(50)) phone db.Column(db.String(20)) enroll_date db.Column(db.Date) status db.Column(db.Integer, default1) # 1在读 2休学 3毕业 created_at db.Column(db.DateTime, defaultdatetime.now)gender和status用整数而不是字符串是我刻意做的决定。一是数据库存储上整数更省空间二是前端下拉框的选项值也设计成整数前后端传值不需要再做字符串转换。显示中文的任务交给前端的作用域插槽去处理后面4.2节会写。学号字段必须加unique唯一约束。新增学生时后端的校验逻辑就依赖这个约束否则数据库里可能出现两条相同学号的记录后续查成绩、查档案都会乱套。3.2 RESTful增删改查接口编码要点接口列表如下方法路径功能GET/api/students?page1size10keyword张分页搜索POST/api/students新增学生PUT/api/students/修改学生DELETE/api/students/删除学生列表接口的分页和搜索是核心代码。Flask用request.args取query参数SQLAlchemy的paginate方法能同时完成分页和总数统计app.route(/api/students, methods[GET]) def get_students(): page request.args.get(page, 1, typeint) size request.args.get(size, 10, typeint) keyword request.args.get(keyword, , typestr) query Student.query if keyword: query query.filter( db.or_(Student.name.like(f%{keyword}%), Student.student_no.like(f%{keyword}%)) ) pagination query.paginate(pagepage, per_pagesize, error_outFalse) items [student_to_dict(s) for s in pagination.items] return jsonify({ code: 0, message: ok, data: { list: items, total: pagination.total, page: page, size: size } })student_to_dict这个辅助函数负责把SQLAlchemy模型对象转成普通字典同时把gender、status映射成中文文本把日期格式化成字符串def student_to_dict(s): return { id: s.id, student_no: s.student_no, name: s.name, gender: s.gender, gender_text: 男 if s.gender 0 else 女, age: s.age, class_name: s.class_name, phone: s.phone, enroll_date: s.enroll_date.strftime(%Y-%m-%d) if s.enroll_date else , status: s.status, status_text: {1: 在读, 2: 休学, 3: 毕业}.get(s.status, 未知), created_at: s.created_at.strftime(%Y-%m-%d %H:%M:%S) if s.created_at else }新增和修改接口的校验逻辑要有至少保证学号必填、姓名必填。手机号格式也建议校验一下用一个简单的正则就够了app.route(/api/students, methods[POST]) def add_student(): data request.get_json() if not data.get(student_no) or not data.get(name): return jsonify({code: 1, message: 学号和姓名必填}), 400 if Student.query.filter_by(student_nodata[student_no]).first(): return jsonify({code: 1, message: 学号已存在}), 400 student Student(**data) db.session.add(student) db.session.commit() return jsonify({code: 0, message: 新增成功, data: student_to_dict(student)})修改接口逻辑类似先按id查记录不存在就返回404存在则更新字段再commit。删除接口更简单找到记录后db.session.delete再commit。注意这些操作都要包在try/except里数据库操作异常时回滚session不然数据会处于半写入状态。3.3 统一响应格式与CORS所有接口的响应我都统一成{code, message, data}三层结构。code为0表示成功非0表示业务错误前端axios拦截器只看一层就能判断请求是否成功不需要每个接口单独写错误处理。CORS配置用flask-cors最省事from flask_cors import CORS CORS(app)如果前端走了vite代理实际浏览器请求是同源的不触发CORS但一旦你绕过代理直接请求5000端口CORS就起作用了。两个配置同时开着最稳妥。4. 前端Vue3页面开发实战4.1 用Composition API管理列表页状态列表页我把它拆成一个student/index.vue组件核心状态用ref和reactive管理。Composition API在这里的优势很明显加载状态、列表数据、总数、查询参数、弹窗开关、表单数据这些相互关联的状态在setup函数里一目了然script setup import { ref, reactive, computed, onMounted } from vue import { getStudents, addStudent, updateStudent, deleteStudent } from /api/student import { ElMessage, ElMessageBox } from element-plus const loading ref(false) const list ref([]) const total ref(0) const queryParams reactive({ page: 1, size: 10, keyword: }) const dialogVisible ref(false) const isEdit ref(false) const formRef ref() const form reactive({ id: null, student_no: , name: , gender: 0, age: null, class_name: , phone: , enroll_date: , status: 1 }) const genderMap { 0: 男, 1: 女 } const statusMap { 1: 在读, 2: 休学, 3: 毕业 } const classOptions computed(() { return [...new Set(list.value.map(item item.class_name).filter(Boolean))] }) async function fetchList() { loading.value true try { const res await getStudents(queryParams) list.value res.data.list total.value res.data.total } finally { loading.value false } } function handleSearch() { queryParams.page 1 fetchList() } function handleReset() { queryParams.keyword queryParams.page 1 fetchList() } onMounted(fetchList) /scriptcomputed在这里用来对已有的列表数据做派生计算比如提取出所有不重复的班级供筛选下拉框使用不用额外请求接口性能上也没压力。这个场景非常典型正好是“vue3 computed”的实际应用。4.2 表格、弹窗表单与联动el-table绑定list数据注意gender和status这两列不能直接propgender因为它们存的是数字显示时要转中文text。后端student_to_dict已经返回了gender_text和status_text表格里直接用这两列即可。如果后端没返回来也可以像下面这样用作用域插槽在前端映射el-table :datalist v-loadingloading border stripe el-table-column propstudent_no label学号 width120 / el-table-column propname label姓名 width100 / el-table-column label性别 width80 template #default{ row }{{ genderMap[row.gender] }}/template /el-table-column el-table-column propage label年龄 width80 / el-table-column propclass_name label班级 / el-table-column propphone label手机号 width130 / el-table-column propenroll_date label入学时间 width110 / el-table-column label状态 width90 template #default{ row } el-tag :typerow.status 1 ? success : row.status 2 ? warning : info {{ statusMap[row.status] }} /el-tag /template /el-table-column el-table-column label操作 width160 fixedright template #default{ row } el-button link typeprimary clickhandleEdit(row)编辑/el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table新增和编辑我用同一个el-dialog里面放一个el-form。打开弹窗时如果isEdit为true就把选中行的数据回填到form里否则清空formfunction handleEdit(row) { isEdit.value true Object.assign(form, row) dialogVisible.value true } function handleAdd() { isEdit.value false Object.assign(form, { id: null, student_no: , name: , gender: 0, age: null, class_name: , phone: , enroll_date: , status: 1 }) dialogVisible.value true }表单校验用el-form的rulesconst rules { student_no: [{ required: true, message: 请输入学号, trigger: blur }], name: [{ required: true, message: 请输入姓名, trigger: blur }], phone: [{ pattern: /^1[3-9]\d{9}$/, message: 手机号格式不正确, trigger: blur }] }提交时注意先调用formRef.value.validate()校验通过后再根据isEdit决定调新增还是修改接口async function handleSubmit() { await formRef.value.validate() if (isEdit.value) { await updateStudent(form.id, form) ElMessage.success(修改成功) } else { await addStudent(form) ElMessage.success(新增成功) } dialogVisible.value false fetchList() }成功之后统一fetchList刷新列表这是保证页面数据不“隐身”的关键。4.3 搜索分页与状态联动搜索框绑定queryParams.keyword搜索按钮把页码重置为1再请求el-input v-modelqueryParams.keyword placeholder请输入姓名或学号 clearable stylewidth: 240px keyup.enterhandleSearch / el-button typeprimary clickhandleSearch搜索/el-button el-button clickhandleReset重置/el-button分页组件用v-model绑定current-page和page-size两个事件都触发fetchListel-pagination v-model:current-pagequeryParams.page v-model:page-sizequeryParams.size :totaltotal :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next size-changefetchList current-changefetchList /这里有个细节搜索时后端返回的总数可能小于当前页的起始偏移比如删到最后一条数据时page停留在5但只剩4页数据此时应该把page减1再请求。这个场景我放在第5章的问题排查里详细说。5. 前后端联调与问题排查实录5.1 axios请求封装与接口对接前端把开发代理配好之后axios的baseURL设为/api这样代码里写路径时不用重复加前缀。我习惯单独建一个http.js统一创建axios实例和响应拦截器import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.response.use( res { const data res.data if (data.code ! 0) { ElMessage.error(data.message || 请求失败) return Promise.reject(new Error(data.message)) } return data }, err { ElMessage.error(err.message || 网络错误) return Promise.reject(err) } )接口函数再单独放到api目录里import request from /utils/http export function getStudents(params) { return request.get(/students, { params }) } export function addStudent(data) { return request.post(/students, data) } export function updateStudent(id, data) { return request.put(/students/${id}, data) } export function deleteStudent(id) { return request.delete(/students/${id}) }这样业务组件里只需要调用这些函数解耦得很干净。5.2 联调中遇到的典型问题实际开发过程中我踩过不少坑整理成一份问题排查表问题现象原因解决方案前端请求报跨域没配vite代理或代理配置未生效在vite.config.js配置server.proxy后重启dev server修改数据后列表不刷新提交成功后没有调用fetchList在成功回调里fetchList性别列显示0/1数字后端返回的是数字前端没做映射使用后端返回的gender_text或用作用域插槽映射日期显示成一长串Date对象没格式化后端在student_to_dict里用strftime格式化删除后当前页数据为空删除后page停留在越界页码删除成功后判断list.length 1且page 1时page--再fetchListtotal返回undefined后端返回了total但前端取错层级确认axios响应拦截器的返回结构其中有两个问题最容易忽略。第一个是“删除最后一页的最后一条数据”删除请求成功列表也刷新了但页面是空的因为当前page号已经超出总页数。解决方法是删除后拿到最新的total或者逻辑判断后端返回的list为空且page 1时自动把page减1再请求一次。第二个是Element Plus表单校验通过之后直接拿form对象提交里面可能带有id字段。修改接口可以把id放在URL里form里的id会被SQLAlchemy当作普通字段更新不影响结果但规范一点的做法是提交前从form里把id解构出来URL单独用const { id, ...rest } form await updateStudent(id, rest)5.3 前端打包后交给Flask托管课程设计或演示通常只需要一台服务器前端打包后可以交给Flask托管省去配置Nginx的步骤。前端先执行npm run build生成dist目录然后在Flask里加一段静态文件路由from pathlib import Path app.route(/, defaults{path: }) app.route(/path:path) def serve(path): if path and (Path(app.static_folder) / path).exists(): return send_from_directory(app.static_folder, path) return send_from_directory(app.static_folder, index.html)app.static_folder设置为前端的dist目录。注意前端打包前要把axios的baseURL改成相对路径或者配合Flask的静态托管路径进行调整否则打包后的页面请求的是不存在的绝对路径。这样操作之后一个简单的nginx都不需要装Flask自带的开发服务器就能扛住演示场景。如果要正式上线再考虑用gunicorn nginx但那又是另外一个话题了。6. 项目扩展方向与个人实操体会学生管理系统看起来简单但麻雀虽小五脏俱全列表、表单、弹窗、分页、搜索、增删改查、前后端联调、部署每一环都是全栈开发者的基本功。Vue3 Element Plus Flask这套组合的优势在于前端有现成的组件库兜底后端有Flask的轻量加持你不必花大量时间在基建上可以把精力集中在理解数据流和交互逻辑上。如果后续想继续扩展可以往这几个方向加功能登录鉴权用Flask-JWT-Extended导出Excel用前端xlsx或者后端openpyxl数据可视化用ECharts比如按班级统计人数分布、绘制成绩箱线图。社区里“vue element-plus 实现 echarts 箱线图”这类需求也不少等学生管理系统跑通以后在此基础上加图表页比从零开始画图要省力得多。最后分享一个实操经验开发过程中最舒服的一刻是前后端联调全部打通的那个瞬间最痛苦的一刻往往是一个字段大小写不一致导致返回undefined。写代码时把字段约定当成接口文档的一部分能帮你避开大量无意义的调试时间。这套流程我已经跑过好几遍按上面步骤操作基本不会卡住真卡住也多半是依赖版本问题删掉node_modules和venv重新安装就好。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询