ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Django与Vue前后端分离搭建家教信息管理系统实践解析

2026/9/12 12:22:42 拓冰建站 浏览量
Django与Vue前后端分离搭建家教信息管理系统实践解析 简介这是一份基于Python Django与Vue.js开发的家教信息管理系统完整源码包适用于毕业设计、课程设计及前后端分离项目实战学习。平台定位为家长与学生请家教的在线平台包含前台与后台两大核心部分前台实现首页、家教详情页、用户中心、家教入驻模块后台提供总览、家教管理、分类管理、标签管理、评论管理、用户管理、运营管理、日志管理和系统信息等模块功能划分清晰。压缩包内共434个文件大小约22.85MB前端以Vue组件、TypeScript/JavaScript脚本为主后端为Python/Django业务代码同时包含大量JPEG/PNG图片素材便于直接查看页面效果、对照学习前后端交互逻辑并理解项目目录结构。目前已有372人学习开发者可将其作为毕业设计或课程设计的选题参考也可基于注释清晰的代码框架进行二次开发和功能扩展。## 1. 家教信息管理系统先把五张业务表和角色定下来 一份基于 pythondjangovue 的家教信息管理系统源码打开后第一件事不该是急着跑起来而是先把 ER 图想清楚。家教业务的链条不复杂却同时跨了用户、商品、交易、评价四类交互教员注册后发布授课课程学生浏览课程、下单购买课时授课完成后双方互评。落到数据库层面就是用户表区分教员和学生、课程表、订单表、评价表再加一张课时记录表。用 Django 做这套系统的价值在于 ORM 把多表关联和后台管理省掉大半Vue 则负责把展示和交互做得更贴近真实使用场景。下面按我搭建这类系统的常走路径把模型、接口、页面和部署依次过一遍。 ## 2. 数据模型设计与Django后端API ### 2.1 从创建项目到定义用户模型 先搭一个干净的 Django 环境。用 django 自带的命令也好手写也好关键是把虚拟环境和依赖一次性装对避免后面 mysqlclient 编译报错再返工。 bash python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate.bat pip install django djangorestframework django-cors-headers mysqlclient django-admin startproject tutor_system python manage.py startapp tutor python manage.py startapp course python manage.py startapp order这套依赖里有几个点要留意djangorestframework 提供 API 视图和序列化器django-cors-headers 解决 Vue 开发服务器向 Django 发跨域请求的问题mysqlclient 在 Linux 上装之前要先装系统级依赖Ubuntu 下是libmysqlclient-dev不然后轮编译多半会报找不到头文件。建好项目文件后去settings.py里做两项必要修改把tutor、course、order三个子应用加进INSTALLED_APPS再注册 cors。# tutor_system/settings.py INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, # ... rest_framework, corsheaders, tutor, course, order, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 其他中间件 ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, # Vite 默认端口 ] AUTH_USER_MODEL tutor.UserAUTH_USER_MODEL必须在第一次数据库迁移前设置这是 django 的一个硬约束如果在跑完 migrate 之后再改要么删库重来要么得用很繁琐的数据迁移去绕。接着定义用户模型这里直接在 AbstractUser 上扩展理由很直接毕业设计阶段的权限粒度不需要太细一个role字段就能区分教员、学生和管理员省掉与 User 表的一对一关联。# tutor/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES ( (teacher, 教员), (student, 学生), (admin, 管理员), ) phone models.CharField(手机号, max_length11, blankTrue) role models.CharField(角色, max_length10, choicesROLE_CHOICES, defaultstudent) avatar models.ImageField(upload_toavatars/, blankTrue) class Meta: db_table sys_user字段说明role是系统里所有权限判断的入口avatar用 ImageField 需要安装 Pillow建议一开始就pip install Pillow。同步迁移之后Django Admin 里能直接看到这个模型实现一个基础的管理后台。对毕业设计来说这是最快拿到可演示界面的路径。2.2 三张核心业务表课程、订单、评价用户模型定好后课程、订单、评价三张表可以一次性写出来。课程表在业务上必须冗余一个发布时间方便前端做排序订单表需要同时维护课程快照和金额避免课程改价之后历史订单跟着变评价表要绑在订单和课程上不能只绑用户否则无法区分一堂课到底是哪个订单产生的评价。# course/models.py class Course(models.Model): STATUS_CHOICES ((open, 开放), (closed, 关闭)) title models.CharField(课程标题, max_length100) subject models.CharField(科目, max_length30) grade models.CharField(年级段, max_length20, blankTrue) teacher models.ForeignKey( tutor.User, on_deletemodels.CASCADE, related_namecourses, limit_choices_to{role: teacher}, ) price models.DecimalField(单课时价格, max_digits8, decimal_places2) duration models.PositiveIntegerField(课时时长(分钟), default90) description models.TextField(课程描述, blankTrue) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultopen) created_at models.DateTimeField(发布时间, auto_now_addTrue) def __str__(self): return f{self.subject}-{self.title}limit_choices_to{role: teacher}的作用是在 Admin 选择器里直接过滤掉学生用户注意这个约束只对 Admin 和表单生效写 DRF 序列化器时要自己再校验一次否则外部请求仍可以伪造 teacher 外键。订单表需要存course外键、购买课时数、订单金额和状态四个核心字段。# order/models.py class Order(models.Model): STATUS_CHOICES ( (pending, 待确认), (confirmed, 已确认), (completed, 已完成), (cancelled, 已取消), ) course models.ForeignKey(course.Course, on_deletemodels.CASCADE, related_nameorders) student models.ForeignKey(tutor.User, on_deletemodels.CASCADE, related_nameorders) hours models.PositiveIntegerField(购买课时数, default10) amount models.DecimalField(订单金额, max_digits10, decimal_places2) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultpending) created_at models.DateTimeField(auto_now_addTrue) class Meta: ordering [-created_at]评价模型放在 course app 下面更合理因为评价聚合时以课程为维度# course/models.py class Review(models.Model): course models.ForeignKey(Course, on_deletemodels.CASCADE, related_namereviews) order models.OneToOneField(order.Order, on_deletemodels.CASCADE) student models.ForeignKey(tutor.User, on_deletemodels.CASCADE) rating models.PositiveSmallIntegerField(评分, default5) comment models.TextField(评价内容, blankTrue) created_at models.DateTimeField(auto_now_addTrue)OneToOneField保证了同一订单不会产生两条评价。这个约束在数据库层顺手兜住比只在前端做限制要可靠。2.3 DRF序列化器与视图集模型定义完毕后用 DRF 序列化器把它们暴露成 API。序列化器要解决两个问题一对嵌套字段如课程的教员姓名输出二对敏感字段的写入校验比如学生端提交订单时不能自己填金额。# course/serializers.py from rest_framework import serializers from .models import Course, Review class ReviewSerializer(serializers.ModelSerializer): student_name serializers.CharField(sourcestudent.username, read_onlyTrue) class Meta: model Review fields [id, course, order, student, student_name, rating, comment] extra_kwargs {student: {read_only: True}} class CourseSerializer(serializers.ModelSerializer): teacher_name serializers.CharField(sourceteacher.username, read_onlyTrue) review_count serializers.SerializerMethodField() avg_rating serializers.SerializerMethodField() class Meta: model Course fields [id, title, subject, grade, teacher, teacher_name, price, duration, description, status, review_count, avg_rating] def get_review_count(self, obj): return obj.reviews.count() def get_avg_rating(self, obj): reviews obj.reviews.all() if not reviews: return 0 total sum(r.rating for r in reviews) return round(total / len(reviews), 1)SerializerMethodField适合做聚合计算不过要注意每次查询都会额外发起 count 查询数据量大了之后建议改用annotate在 queryset 层一次性算好。然后写视图集并配置 django-filter 和 search 作为查询参数。# course/views.py from rest_framework import viewsets from rest_framework.permissions import IsAuthenticatedOrReadOnly from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters from .models import Course, Review from .serializers import CourseSerializer, ReviewSerializer class CourseViewSet(viewsets.ModelViewSet): queryset Course.objects.select_related(teacher).prefetch_related(reviews) serializer_class CourseSerializer permission_classes [IsAuthenticatedOrReadOnly] filter_backends [DjangoFilterBackend, filters.SearchFilter] filterset_fields [subject, grade, status] search_fields [title, teacher__username]配套的 URL 路由用 DefaultRouter 注册最省事# tutor_system/urls.py from django.urls import path, include from rest_framework.routers import DefaultRouter from course.views import CourseViewSet router DefaultRouter() router.register(courses, CourseViewSet) urlpatterns [ path(api/, include(router.urls)), ]默认的 DRF 路由会把GET /api/courses/、POST /api/courses/等 RESTful 接口全部映射好返回 JSON 并自动处理 405、404 错误体。2.4 订单状态机的权限控制订单是状态流转最复杂的对象。让学生直接改status字段会带来大量脏数据所以要按角色放开有限的写权限。当前状态可执行操作角色目标状态pending确认订单教员confirmedpending取消订单学生cancelledconfirmed完成订单教员completedconfirmed取消订单双方cancelledcompleted无无—在视图集里重写perform_create和partial_update把状态流转收敛到一个方法里# order/views.py from rest_framework import viewsets from rest_framework.exceptions import PermissionDenied from .models import Order from .serializers import OrderSerializer class OrderViewSet(viewsets.ModelViewSet): queryset Order.objects.select_related(course, student) serializer_class OrderSerializer http_method_names [get, post, patch, delete] def perform_create(self, serializer): course serializer.validated_data[course] if course.status ! open: raise PermissionDenied(该课程未开放预约无法下单) serializer.save( studentself.request.user, amountcourse.price * serializer.validated_data[hours], ) def perform_update(self, serializer): order self.get_object() old_status order.status new_status serializer.validated_data.get(status, old_status) self._validate_transition(order, old_status, new_status) serializer.save() def _validate_transition(self, order, old_status, new_status): user self.request.user if old_status new_status: return if old_status pending and new_status confirmed: if user ! order.course.teacher: raise PermissionDenied(只有课程所属教员能确认订单) elif old_status confirmed and new_status completed: if user ! order.course.teacher: raise PermissionDenied(只有课程所属教员能完成订单) elif old_status in (pending, confirmed) and new_status cancelled: if user not in (order.student, order.course.teacher): raise PermissionDenied(学生和教員均可以取消订单) else: raise PermissionDenied(非法的状态跳转)这套状态校验的作用在于把订单的流转规则收在服务端代码里前端不管怎么改下拉框或伪造请求最终都过不了这层校验。要注意http_method_names限制了 PUT只允许 PATCH这可以避免整条记录被误覆盖。3. Vue前端从零搭建界面3.1 初始化Vue项目并配置路由回到前端需要先确保本机环境可用。vue 安装及环境配置的核心其实是 node 版本管理建议 Node 20 以上Vite 项目会更顺畅。创建项目时直接使用 create-vue 脚手架。npm create vuelatest tutor-web cd tutor-web npm install npm install axios vue-router pinia element-plus npm run dev打开src/router/index.js配置基础路由和登录跳转逻辑。vue 路由参数在课程详情页里会高频使用所以路由至少设计成这样一个层级首页、课程列表、课程详情带参数、个人中心、登录页、后台管理页。// src/router/index.js import { createRouter, createWebHistory } from vue-router import HomeView from ../views/HomeView.vue const router createRouter({ history: createWebHistory(), routes: [ { path: /, name: home, component: HomeView }, { path: /courses, name: courses, component: () import(../views/CourseListView.vue) }, { path: /courses/:id, name: course-detail, component: () import(../views/CourseDetailView.vue), props: true, }, { path: /login, name: login, component: () import(../views/LoginView.vue) }, { path: /user, name: user, component: () import(../views/UserHomeView.vue) }, { path: /admin, name: admin, component: () import(../views/AdminView.vue) }, ], }) export default routerprops: true的意思是把路由参数:id直接透传成组件 prop这样在 CourseDetailView 里直接通过defineProps([id])拿到课程 ID省去了在组件内部再去读 route 对象的步骤。路径懒加载() import(...)是 Vue Router 4 原生支持的每个路由拆成一个 chunk首次加载时体积更小。3.2 axios拦截器与token续期前后端分离项目的核心工作是 token 管理。Vue 前端登录后拿到 Django 返回的 access/refresh把两个 token 分别存入 localStorage之后的请求统一在 header 里携带 access token刷新则考虑是否要做静默续期。// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: http://localhost:8000/api/, timeout: 10000, }) request.interceptors.request.use( (config) { const access localStorage.getItem(access) if (access) { config.headers.Authorization Bearer ${access} } return config }, (error) Promise.reject(error) ) request.interceptors.response.use( (response) response.data, async (error) { const originalRequest error.config if (error.response error.response.status 401 !originalRequest._retry) { originalRequest._retry true const refresh localStorage.getItem(refresh) if (refresh) { try { const { data } await axios.post(http://localhost:8000/api/token/refresh/, { refresh }) localStorage.setItem(access, data.access) originalRequest.headers.Authorization Bearer ${data.access} return request(originalRequest) } catch (e) { localStorage.clear() router.push(/login) ElMessage.error(登录已过期请重新登录) } } } if (error.response) { ElMessage.error(error.response.data.detail || 网络请求失败) } return Promise.reject(error) } ) export default request这里_retry标志是防递归的核心如果刷新 token 后请求仍返回 401就不会再次进入 refresh 逻辑避免无限循环。注意baseURL中硬编码了本地地址进入生产环境时要改成环境变量常见做法是在.env.production中写VITE_API_BASE然后baseURL: import.meta.env.VITE_API_BASE。3.3 Element Plus搭建课程管理页后台管理页面里课程列表是最常写的类型。Element Plus 的 el-table 和 el-form 搭配起来速度很快但要注意 el-table 的data和分页组件current-page需要双向绑定否则切页时表格内容不同步。template div classcourse-list el-row :gutter16 el-col :span8 el-input v-modelsearchKeyword placeholder搜索课程标题或教员 clearable inputonSearch / /el-col el-col :span6 el-select v-modelselectedSubject placeholder选择科目 clearable changeonSearch el-option label数学 value数学 / el-option label物理 value物理 / el-option label英语 value英语 / /el-select /el-col el-col :span10 styletext-align: right el-button typeprimary clickopenCreateDialog新增课程/el-button /el-col /el-row el-table :datacourses v-loadingloading stripe el-table-column propid labelID width60 / el-table-column proptitle label课程名称 min-width160 / el-table-column propteacher_name label教员 width120 / el-table-column propsubject label科目 width90 / el-table-column label价格 width110 template #defaultscope¥{{ scope.row.price }}/课时/template /el-table-column el-table-column label平均评分 width110 template #defaultscope el-rate :model-valuescope.row.avg_rating disabled / /template /el-table-column el-table-column propstatus label状态 width90 / el-table-column label操作 width160 template #defaultscope el-button sizesmall clickopenEditDialog(scope.row)编辑/el-button el-button sizesmall typedanger clickonDisable(scope.row)下架/el-button /template /el-table-column /el-table el-pagination v-model:current-pagepage v-model:page-sizepageSize :totaltotal layouttotal, prev, pager, next current-changefetchCourses / /div /template script setup import { ref, onMounted } from vue import request from /utils/request const courses ref([]) const loading ref(false) const searchKeyword ref() const selectedSubject ref() const page ref(1) const pageSize ref(10) const total ref(0) const fetchCourses async () { loading.value true try { const res await request.get(/courses/, { params: { page: page.value, page_size: pageSize.value, search: searchKeyword.value || undefined, subject: selectedSubject.value || undefined, }, }) courses.value res.results total.value res.count } finally { loading.value false } } onMounted(fetchCourses) /script后端需要配合修改分页配置DRF 默认的分页参数是page和page_size返回结构是{count, next, previous, results}。在 Django settings 里加一行REST_FRAMEWORK[PAGE_SIZE]即可也可以自定义PageNumberPagination支持page_size的查询参数。4. 打通前后端业务闭环4.1 搜索教员的接口与页面联动课程列表页的搜索框要求按输入实时返回课程。Django 端已经用 SearchFilter 配置了title和teacher__username两个字段前端只要把关键词作为search参数传过去。若要做科目加教员的组合筛选可以把两者拆成单独参数并利用 DjangoFilterBackend 的filterset_fields处理。一个容易踩的坑登录用户的角色不同课程列表呈现的逻辑也不同。学生端只显示statusopen的课程教员端却要看到自己所有课程的订单量和评价数。所以列表接口不要直接暴露给所有人而是在 queryset 里根据当前请求用户做过滤。# course/views.py class CourseViewSet(viewsets.ModelViewSet): def get_queryset(self): qs Course.objects.select_related(teacher).prefetch_related(reviews) user self.request.user if user.is_authenticated and user.role teacher: return qs return qs.filter(statusopen)这种按角色过滤的写法要注意 queryset 求值时机DRF 的get_queryset每次请求都会调用所以必须写在方法内部而不是直接给queryset赋值否则 request.user 无法在实例化时判断。4.2 下单与支付状态联动学生选好课程后点击“立即预约”前端把课程 ID 和课时数提交到/api/orders/。后端负责校验课程状态、计算金额、写入当前用户。这里有个细节金额的精度。价格用 DecimalField前端在做合计显示时会出现0.1 0.2 0.30000000000000004的浮点问题所以前端合计必须由后端返回前端只负责展示。// CourseDetailView.vue 中提交订单 const submitOrder async (courseId) { if (!courseId) return const res await request.post(/orders/, { course: courseId, hours: selectedHours.value, }) ElMessage.success(下单成功等待教员确认) // 跳转到订单列表或个人中心 router.push(/user) }后端 OrderViewSet 里perform_create已经做了相应处理。但用户会好奇“如果订单提交后一直是无支付状态怎么办”所以真实系统通常还要加支付方式字段。在不接第三方支付网关的前提下一个可行方案是把支付状态收敛成订单状态的一部分启用paid_time和pay_type字段对于课程设计场景在数据库层面存模拟支付的标记即可。# order/models.py 增加字段 class Order(models.Model): PAY_TYPE_CHOICES ((wallet, 余额支付), (mock, 模拟支付)) pay_type models.CharField(支付方式, max_length10, choicesPAY_TYPE_CHOICES, defaultmock) paid_time models.DateTimeField(支付时间, nullTrue, blankTrue)4.3 评价提交与数据聚合学生端在“已完成”的订单后可以看到“评价”按钮点击后弹出一个评分组件。评价提交的接口就是POST /api/courses/{id}/reviews/但需要把 order 带上。const submitReview async (courseId, orderId) { await request.post(/courses/${courseId}/reviews/, { order: orderId, rating: score.value, comment: comment.value, }) }后端 ReviewSerializer 里student字段是只读的创建时自动赋值为当前请求用户。要注意的是 order 与 student 的一致性校验# course/views.py class ReviewViewSet(viewsets.ModelViewSet): serializer_class ReviewSerializer permission_classes [IsAuthenticated] def perform_create(self, serializer): order serializer.validated_data[order] if order.student ! self.request.user: raise PermissionDenied(只能评价自己名下的订单) if order.status ! completed: raise PermissionDenied(订单未完成不能评价) serializer.save(studentself.request.user)这里让Review.order担任“唯一性校验”的责任因为一对多关系天然能一次判断一个订单是否已有对应评价不用额外查表。5. 部署上线与常见坑位5.1 Django后端部署与静态资源收集本地跑通之后部署是另一个世界。最重要的一步是 settings 拆分生产环境的 DEBUG 关闭否则浏览器会直接显示 Django 报错页面同时将敏感信息泄露。常见做法是把settings.py改成本地开发和生产两套默认settings/development.py生产settings/production.py用DJANGO_SETTINGS_MODULE环境变量切换。# settings/production.py DEBUG False ALLOWED_HOSTS [your-domain.com] STATIC_ROOT BASE_DIR / static_assets生产环境用 gunicorn 启动 Django 服务pip install gunicorn gunicorn tutor_system.wsgi:application --bind 127.0.0.1:8000 --workers 4--workers 4适合 CPU 核数较少的服务器经验值是 2 * CPU 核数 1但提供内存小于 2GB 的机器适当降到 2。5.2 Vue打包与Nginx反向代理Vue 前端打包时最容易踩的坑是baseURL没有用环境变量导致打包后所有请求打到 localhost。先.env.production中配置另一个VITE_API_BASE再执行npm run build生成dist目录。Nginx 配置需要同时处理前端转发和后端 API 反向代理server { listen 80; server_name your-domain.com; root /var/www/html/tutor-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /static/ { alias /var/www/tutor_api/static_assets/; } location /media/ { alias /var/www/tutor_api/media/; } }try_files $uri $uri/ /index.html这一行必须写全这是单页应用的售后用户直接访问/courses/12时不会经路由哈希Nginx 需要把请求回落到 index.html 再由 vue-router 接管。如果漏了刷新详情页就会 404。上线后验证时先用curl -I https://your-domain.com/api/courses/检查 API 是否通再用浏览器无痕模式访问前端页面确认 token 存储和路由守卫正常工作如果页面白屏打开 DevTools 查看 JS 文件是否加载失败多数原因是base路径配置不对。本文还有配套的精品资源点击获取