简介:面向前后端分离学习者的学生管理系统完整源码包,基于Vue3、Element Plus、Flask与MySQL构建,覆盖响应式前端交互、组件化开发、RESTful API设计、数据库表结构设计等核心环节,同时涉及Vue3 Composition API、Element Plus组件用法、Flask蓝图与ORM操作等具体技术点,可实现学生信息、成绩、课程等模块的管理,适合作为课程设计、毕业设计或后台管理项目参考。压缩包共2000个文件,以JavaScript、Markdown、JSON、Python为主,包含前端构建产物、项目文档、配置文件及后端源码,整体仅14.62MB,便于快速下载与部署。已有169人学习/下载。资源附有可运行代码与说明文档,能帮助读者快速理解Vue3+Element-Plus+Flask的项目结构、前后端数据交互流程及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 vite@latest 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后端环境与目录结构
后端我用虚拟环境隔离依赖,避免污染全局Python:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install flask flask-cors flask-sqlalchemy pymysql如果只是本地演示,数据库直接用SQLite,不用额外安装数据库服务,Flask-SQLAlchemy在配置里指定一个db文件路径就行;想贴近真实生产环境,可以切到MySQL,连接串改成mysql+pymysql://用户名:密码@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_key=True, autoincrement=True) student_no = db.Column(db.String(20), unique=True, nullable=False) name = db.Column(db.String(50), nullable=False) gender = db.Column(db.Integer, default=0) # 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, default=1) # 1在读 2休学 3毕业 created_at = db.Column(db.DateTime, default=datetime.now)gender和status用整数而不是字符串,是我刻意做的决定。一是数据库存储上整数更省空间,二是前端下拉框的选项值也设计成整数,前后端传值不需要再做字符串转换。显示中文的任务交给前端的作用域插槽去处理,后面4.2节会写。
学号字段必须加unique唯一约束。新增学生时后端的校验逻辑就依赖这个约束,否则数据库里可能出现两条相同学号的记录,后续查成绩、查档案都会乱套。
3.2 RESTful增删改查接口编码要点
接口列表如下:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /api/students?page=1&size=10&keyword=张 | 分页+搜索 |
| 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, type=int) size = request.args.get('size', 10, type=int) keyword = request.args.get('keyword', '', type=str) query = Student.query if keyword: query = query.filter( db.or_(Student.name.like(f'%{keyword}%'), Student.student_no.like(f'%{keyword}%')) ) pagination = query.paginate(page=page, per_page=size, error_out=False) 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_no=data['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) </script>computed在这里用来对已有的列表数据做派生计算,比如提取出所有不重复的班级,供筛选下拉框使用,不用额外请求接口,性能上也没压力。这个场景非常典型,正好是“vue3 computed”的实际应用。
4.2 表格、弹窗表单与联动
el-table绑定list数据,注意gender和status这两列不能直接prop="gender",因为它们存的是数字,显示时要转中文text。后端student_to_dict已经返回了gender_text和status_text,表格里直接用这两列即可。如果后端没返回来,也可以像下面这样用作用域插槽在前端映射:
<el-table :data="list" v-loading="loading" border stripe> <el-table-column prop="student_no" label="学号" width="120" /> <el-table-column prop="name" label="姓名" width="100" /> <el-table-column label="性别" width="80"> <template #default="{ row }">{{ genderMap[row.gender] }}</template> </el-table-column> <el-table-column prop="age" label="年龄" width="80" /> <el-table-column prop="class_name" label="班级" /> <el-table-column prop="phone" label="手机号" width="130" /> <el-table-column prop="enroll_date" label="入学时间" width="110" /> <el-table-column label="状态" width="90"> <template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : row.status === 2 ? 'warning' : 'info'"> {{ statusMap[row.status] }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="160" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table>新增和编辑我用同一个el-dialog,里面放一个el-form。打开弹窗时,如果isEdit为true就把选中行的数据回填到form里,否则清空form:
function 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的rules:
const 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-model="queryParams.keyword" placeholder="请输入姓名或学号" clearable style="width: 240px" @keyup.enter="handleSearch" /> <el-button type="primary" @click="handleSearch">搜索</el-button> <el-button @click="handleReset">重置</el-button>分页组件用v-model绑定current-page和page-size,两个事件都触发fetchList:
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.size" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="fetchList" @current-change="fetchList" />这里有个细节:搜索时后端返回的总数可能小于当前页的起始偏移,比如删到最后一条数据时,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--再fetchList |
| total返回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重新安装就好。
本文还有配套的精品资源,点击获取