刚接手“Python+Vue的大学生去哪旅游管理系统”这个题目的时候,估计很多人跟我当时的反应一样:这不就是一个典型的课程设计吗?用Django或者Flask写个后端,Vue搭个前端,然后旅游景点增删改查、用户登录注册、路线推荐,最后打包成能演示的系统,交上去完事。但真做起来你会发现,小项目五脏俱全,前后端分离的思路、ORM查询、跨域、打包部署,哪一环都能把人卡住半天。这个项目我前后带过几个学弟学妹做过,也帮人改过不少半成品代码,今天就把整个从需求拆解到落地运行的思路、细节和踩坑经验完整写一遍。不管你是准备交课程设计、毕业设计,还是单纯想练手前后端分离开发,这篇应该都能帮你在开始动手之前,先把地图看明白。
1. 先把系统拆开看:大学生旅游管理系统到底做哪些事
很多同学拿到题目第一件事就是打开Pycharm,想着赶紧敲代码。我建议先花半小时把需求拆干净,搞清楚哪些功能是必须的,哪些是凑数的。
“大学生去哪旅游”这个题目,关键词在“大学生”和“旅游管理”。大学生群体有什么特点?预算有限、时间集中(周末和寒暑假)、喜欢结伴出行、偏向热门打卡地加一些小众平价景点。所以你做的系统,不能是那种商务旅游管理后台,得有学生味儿。
1.1 功能模块的底线划分
一个能过关、能演示、能写进论文的基本功能集合,我一般建议拆成三个端:
用户前端
- 注册/登录:学生用学号或者手机号注册登录
- 景点展示:列表页、详情页,带图片、简介、票价、开放时间
- 路线推荐:按目的地展示旅游路线,包含行程天数、预算区间
- 收藏/评论:学生可以收藏景点、在景点下发表评论或攻略
- 个人中心:查看自己收藏过的东西、浏览历史
管理后台
- 景点管理:增删改查景点信息、上下架
- 路线管理:维护路线数据
- 用户管理:查看注册用户、禁用异常账号
- 评论管理:删除违规评论
数据层
- 数据库设计:用户表、景点表、路线表、评论表、收藏表(如果做了浏览记录就是六张表)
这套功能做完,演示的时候逻辑是很完整的:从前端注册登录,到浏览景点、收藏、评论,再到后台管理维护数据,一条线走通,老师问什么你都有东西可讲。
1.2 前后端分离还是混合开发
这个题目在知乎和CSDN上搜,能看到两种路线:
一种是不分离,用Django的模板系统加Bootstrap/JQuery,把页面直接渲染出来。好处是简单,不涉及跨域,开发效率高,适合只要求“能跑”的学生。但坏处是,如果老师提前说明了要用Vue,那这条路就走不通。
另一种就是标题里写的,Python + Vue 前后端分离。Django/Flask只提供JSON接口,前端Vue负责页面展示和交互。这套方案的好处是结构清晰,符合企业里真实开发流程,毕设答辩时有得聊。坏处是中间多了联调和跨域两个坎,很多新手就是栽在这儿的。
我个人建议,只要标题明确出现了“Vue”,就老老实实做前后端分离。一来这是硬性要求,二来前后端分离项目写进报告和开题里,看起来工作量是实打实的。
1.3 Django 还是 Flask:我的选择逻辑
标题里同时出现了“django”和“flask”,如果需求没有特别指定,我强烈建议主用Django。理由很直接:
| 对比项 | Django | Flask |
|---|---|---|
| 自带ORM | 有,且功能完善 | 没有,需自己装SQLAlchemy |
| Admin后台 | 自带,改配置就能管理数据 | 需要扩展包,配置麻烦 |
| 用户认证 | 自带User模型和登录逻辑 | 需要Flask-Login |
| 路由和视图 | 结构固定,适合新手 | 灵活,但自由度过高容易写乱 |
| 课程设计典型场景 | 查资料、问AI、找代码都方便 | 资料同样多,但零散 |
Flask轻、灵活,适合一个人从零手搓一切,但是很多东西需要自己做决策。Django则是“规定好了的路”,你沿着走就行。写旅游管理系统这种典型CRUD项目,Django能省下大量重复劳动,Admin后台甚至可以当简易的管理端用,演示的时候能省不少事。
如果你确实被要求必须用Flask,那也别慌,下面的设计思路完全一样,只是把Django的Model和View换成Flask的SQLAlchemy和路由函数而已,我最后会单独说迁移思路。
2. 开工前哨战:Pycharm、Python和Vue环境配置的一堆坑
环境配置看着简单,但是每年都有一大批人卡在这一步。把热词里的“pycharm安装”“python安装教程”“vue安装及环境配置”挨个过一遍,我把最容易翻车的细节集中说。
2.1 Python版本和Pycharm版本怎么选
Python版本我建议用3.8到3.10之间的稳定版本。像3.12这类太新的版本,有些第三方库还没跟上,pip安装时容易报编译错误。去官网下载安装包时,一定记得勾选“Add Python to PATH”,这是新手最容易忽略的,没勾的话命令行里打python提示不是内部或外部命令,后面全都不顺。
Pycharm版本上,社区版(Community)是免费的,专业版(Professional)功能全,但需要激活。学生的话可以拿学校邮箱去申请免费的教育许可,完全合法且省心。这个项目用社区版其实就足够了,Vue的前端代码我推荐用Pycharm打开也行,或者直接用VSCode,看个人习惯。不过既然标题指定了Pycharm,你至少要把后端项目放在Pycharm里写。
2.2 创建虚拟环境是必须养成的习惯
打开Pycharm新建Django项目时,解释器选择界面一定要选“New environment using Virtualenv”。有些同学图快,直接选了系统自带的Python解释器,后面Django装到全局环境里,不同项目之间的包互相污染,迟早出问题。
虚拟环境的好处是:每个项目一个独立空间,装什么包都不影响其他项目,requirements.txt还能源源不断地导出来。以后换电脑、交作业,别人拿到你的项目,只要pip install -r requirements.txt就能把环境复原。
2.3 Node.js和Vue CLI/Vite的版本匹配
前端环境难点不在安装,在版本。Vue框架从2到3是个坎,Vue CLI和Vite的构建方式又不一样。我给学生的统一建议是:
- 装Node.js 16以上版本(建议直接上18 LTS)
- 用npm install -g @vue/cli 或者 npm create vue@latest 创建项目
- 新手我建议用Vue CLI(webpack那套)而不是Vite,虽然启动慢一点,但是文档多、资料全,报错搜得到答案
npm下载慢怎么办?别傻傻等。先执行:
npm config set registry https://registry.npmmirror.com换成国内镜像以后,速度立竿见影。这一步不做,一个node_modules等半小时很正常。
2.4 前端项目放到哪里、怎么打开
前后端分离的项目,我习惯建一个总的文件夹,里面放backend和frontend两个子目录。比如:
Travel_System/ ├── backend/ # Django项目 └── frontend/ # Vue项目这样整体结构清楚,打压缩包交作业也方便。用Pycharm打开backend目录,用VSCode或者Pycharm打开frontend目录,两边各开一个终端。千万别用Pycharm打开整个总目录然后只写后端,这样前端node_modules会被Pycharm扫描半天,卡得不行。
3. Django后端落地:从建项目到数据能查、能删、能返回JSON
环境弄好,后端这块就开始动手了。这一节我会把从命令行建项目到写模型、视图、接口、解决跨域的完整链路走一遍,重点结合“django创建app、django执行查询-删除对象、django cookie设置token、django项目实战新手”这些高频搜索词。
3.1 从零创建一个Django项目
打开终端,激活虚拟环境,执行下面这套命令:
django-admin startproject travel_backend cd travel_backend python manage.py startapp travel这里的travel_backend是项目配置目录,travel是我们自己创建的应用,所有业务逻辑代码都放在travel目录里。创建完以后,一定记得在settings.py的INSTALLED_APPS里加上travel,不然后面写的模型、视图不被识别。
INSTALLED_APPS = [ 'django.contrib.admin', ... 'travel', # 加这一行 ]3.2 重点:模型设计直接影响后期所有接口写法和数据库查询方式
我这人写项目有个习惯,模型字段宁可多花半小时想清楚,绝不写到一半再加字段。因为Django的ORM在表已经生成之后改字段,特别是加了非空约束的字段,迁移时经常要处理默认值、数据迁移这些麻烦事。
旅游系统的核心模型我这样设计:
from django.db import models from django.contrib.auth.models import User class ScenicSpot(models.Model): # 景点表 name = models.CharField(max_length=100, verbose_name='景点名称') city = models.CharField(max_length=50, verbose_name='所在城市') price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='门票价格') description = models.TextField(verbose_name='景点介绍') image = models.ImageField(upload_to='scenic/', blank=True, null=True, verbose_name='景点图片') open_time = models.CharField(max_length=100, blank=True, verbose_name='开放时间') category = models.CharField(max_length=20, blank=True, verbose_name='景点分类') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'scenic_spot' def __str__(self): return self.name class TravelRoute(models.Model): # 路线表 title = models.CharField(max_length=100, verbose_name='路线标题') days = models.IntegerField(verbose_name='行程天数') budget = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='预算金额') spots = models.ManyToManyField(ScenicSpot, verbose_name='包含景点') note = models.TextField(blank=True, verbose_name='路线说明') class Meta: db_table = 'travel_route' class Favorite(models.Model): # 收藏表 user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户') spot = models.ForeignKey(ScenicSpot, on_delete=models.CASCADE, verbose_name='景点') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'favorite' unique_together = ('user', 'spot') class Comment(models.Model): # 评论表 user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户') spot = models.ForeignKey(ScenicSpot, on_delete=models.CASCADE, verbose_name='景点') content = models.TextField(verbose_name='评论内容') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'travel_comment'说几个设计时的关键点:
- name字段的max_length,景点名称一般50到100够用,但是如果你要放超长中文名或带英文,最好给足长度
- price用DecimalField而不是FloatField,浮点精度问题在涉及金额时绝对不能留隐患
- ImageField需要额外配置MEDIA_ROOT和MEDIA_URL,否则图片存不进去,展示也读不出来。这个热词里有“vue image能显示pdf吗”,说明很多人卡在前端显示图片这块,其实后端先要把图片路径正确返回才行
- 外键的on_delete怎么选?用户删了,他的收藏和评论是否保留?我一般选CASCADE——删除用户时把关联数据一并清掉,这样不留孤儿数据。但如果你的业务要求保留用户评论,就得改成SET_NULL且字段允许为空
写完后执行:
python manage.py makemigrations travel python manage.py migrate python manage.py createsuperuser数据表就建好了,后台管理员账号也建好了。
3.3 查询与删除:Django ORM里高频操作的正确姿势
热词里有一条“django执行查询-删除对象”,这几乎是所有Django项目的日常操作,也是面试官和答辩老师爱问的点。我拆开来讲。
查询
最常用的几种方式:
# 获取所有景点 spots = ScenicSpot.objects.all() # 筛选某个城市的景点,filter返回QuerySet,可以一直链式调用 spots = ScenicSpot.objects.filter(city='北京').filter(price__lte=100) # 精确获取单条记录,不存在会报DoesNotExist异常,要注意捕获 spot = ScenicSpot.objects.get(id=1) # 模糊查询,比如搜索景点名称中包含'湖'的记录 spots = ScenicSpot.objects.filter(name__contains='湖') # 排序查询,价格从低到高 spots = ScenicSpot.objects.order_by('price')filter和get的区别要牢记:filter返回的是一个QuerySet对象,就算结果只有一条,它也是一个集合;get返回的是单个模型实例,查不到或者查到多条都会抛异常。日常接口里我很少直接用get,因为异常处理起来麻烦,更多是filter(...).first(),取第一条记录,查不到就返回None,省心得多。
删除对象
删除分两种场景,细节不一样:
# 场景一:删除单个对象实例 spot = ScenicSpot.objects.filter(id=5).first() if spot: spot.delete() # 场景二:按条件批量删除,直接对QuerySet调用delete Comment.objects.filter(user_id=3).delete() # 场景三:关联删除,因为外键设置了CASCADE,删除景点后,收藏和评论也会自动删除 spot = ScenicSpot.objects.filter(id=5).first() if spot: spot.delete()Django ORM的delete方法会返回一个元组,格式是(删除的总条数, {'应用名.模型名': 删除条数})。你可以打印出来看看,会清楚地看到级联删除了哪些关联数据。这是个非常实用的调试信息。
有个坑必须提醒:如果你用User模型自带的delete(),Django不会走数据库的级联删除,而是靠ORM自己模拟。所以在模型里设置了on_delete=models.CASCADE,删除父表对象时子表数据会被一并处理,但是你自己写裸SQL或者在Admin里直接删操作就没有这保证了。总之,业务代码里的删除操作放心交给ORM就好。
3.4 视图层:返回JSON接口的正确写法
前后端分离项目里,Django视图只做一件事:接收前端请求,查数据库,拼成JSON返回。我推荐新手先用函数视图(FBV),简单直接,好理解。下面是一个标准的景点列表接口:
import json from django.http import JsonResponse from django.views.decorators.http import require_GET, require_POST from django.contrib.auth.decorators import login_required from .models import ScenicSpot @require_GET def scenic_list(request): city = request.GET.get('city', '') keyword = request.GET.get('keyword', '') spots = ScenicSpot.objects.all() if city: spots = spots.filter(city=city) if keyword: spots = spots.filter(name__contains=keyword) data = [] for spot in spots: data.append({ 'id': spot.id, 'name': spot.name, 'city': spot.city, 'price': str(spot.price), 'description': spot.description, 'image': request.build_absolute_uri(spot.image.url) if spot.image else '', 'open_time': spot.open_time, 'category': spot.category, }) return JsonResponse({'code': 0, 'data': data, 'message': 'success'})这里有个小细节值得说:DecimalField类型的price直接放进JSON里会报错,所以用str()转成字符串。另外图片路径一定要用request.build_absolute_uri拼成绝对路径,否则前端拿到的只是“/media/scenic/xx.jpg”,没法直接访问。
如果你学习能力强,也可以尝试用Django REST Framework写CBV,这个框架做序列化会省很多代码。但对于旅游管理系统这个体量,手写序列化完全够用,而且不引入额外框架,答辩时对代码的讲解也更可控。
3.5 URL路由配置和登录Token方案
路由配置不难,但是要注意include的用法。在项目根路由里注册子路由:
from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('travel.urls')), # 所有接口都挂上/api前缀 ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)然后在travel应用里新建urls.py:
from django.urls import path from . import views urlpatterns = [ path('scenic/list/', views.scenic_list, name='scenic_list'), # 其他接口按需添加 ]登录和Token这块,热词里有“django cookie 设置 token”,我建议最直接的做法是用Django自带的session认证加Token校验双轨制:
- 登录成功后,后端调用request.session保存用户信息
- 同时给前端返回一个token,前端存到localStorage
- 后续需要登录状态的接口,前端在Header里带上token,后端解析
一种简单的token生成方式:
import hashlib import time def generate_token(user): # 生成一个简单的token,实际项目可以用itsdangerous或PyJWT raw = f'{user.id}-{user.username}-{time.time()}' return hashlib.sha256(raw.encode('utf-8')).hexdigest()不过说实话,课程设计级别的项目,我最常用的还是用Django自带的session直接做登录态,前端用axios带cookies就行。不需要搞复杂JWT体系。如果一定要token,那建议直接装PyJWT库,按JWT标准来写,不要自己发明加密方案。
3.6 跨域问题:前后端分离第一道鬼门关
自己在浏览器里打开前端页面试接口时,十有八九会遇到CORS报错。因为在Django后端跑在8000端口,Vue开发服务器跑在8080端口,两个端口不同,跨域了。
解决方法有两个方案:
方案一(开发环境推荐):Vue的vue.config.js里配置代理,让前端请求都走8080端口转发到8000,浏览器感知不到跨域。配置如下:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ devServer: { port: 8080, proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } })这样前端请求“/api/scenic/list/”,代理服务器自动转发到Django那边,后端不用做任何跨域配置。这个方案在开发和打包后部署两种场景下都比较省心。
方案二:后端装上django-cors-headers,在settings.py里配置允许的域名。适合后端接口直接被其他域名的前端调用时使用。
如果你做的是演示项目,建议方案一为主,省得Django那边配CORS时出现options预检请求一堆幺蛾子。
4. Vue前端:页面搭建、路由跳转和数据联调一条龙
后端接口能返回JSON了,现在轮到前端上场。这块结合热搜词里“vue路由”、“slot vue”、“vue入门”、“vue样式”这些点,我把前端的骨架搭法讲清楚。
4.1 创建Vue项目并配置基础结构
我用Vue CLI创建项目:
vue create travel_frontend选择Vue 3版本,进入项目后先装好需要的依赖:
npm install axios vue-router@4 element-plusaxios负责发HTTP请求,vue-router是路由管理器,element-plus是一套组件库,用起来省得自己写大量样式。
项目的关键目录结构:
travel_frontend/ ├── src/ │ ├── router/index.js # 路由配置 │ ├── views/ # 页面组件 │ │ ├── HomeView.vue │ │ ├── ScenicList.vue │ │ ├── ScenicDetail.vue │ │ ├── LoginView.vue │ │ ├── RegisterView.vue │ │ └── AdminView.vue │ ├── components/ # 公共组件 │ │ └── ScenicCard.vue │ ├── api/index.js # 接口请求统一封装 │ └── App.vue4.2 路由配置:页面跳转和守卫
Vue Router是单页应用的核心,页面切换不刷新、不重新加载全部资源。路由配置里最容易忽略的是“动态路由”和“路由守卫”。
先看基础路由配置:
import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import ScenicList from '../views/ScenicList.vue' import ScenicDetail from '../views/ScenicDetail.vue' import LoginView from '../views/LoginView.vue' import RegisterView from '../views/RegisterView.vue' const routes = [ { path: '/', name: 'home', component: HomeView }, { path: '/scenic', name: 'scenic_list', component: ScenicList }, { path: '/scenic/:id', name: 'scenic_detail', component: ScenicDetail, props: true }, { path: '/login', name: 'login', component: LoginView }, { path: '/register', name: 'register', component: RegisterView }, ] const router = createRouter({ history: createWebHistory(), routes }) // 路由守卫:没登录时不能访问需要登录的页面 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login' }) } else { next() } }) export default router说下动态路由,就是“/scenic/:id”这种,一个路径对应一个详情页,根据id不同展示不同内容。在页面组件里可以用this.$route.params.id或者route.params.id取到这个id,然后调后端接口。这里有个新手常见错误:上一个详情页的数据会残留,因为组件被复用了。解决办法是在watch里监听route变化,再次请求数据。
4.3 列表页开发:从axios封装到渲染数据
我习惯先把所有接口调用统一封装到一个文件里,避免每个组件都写一个完整的axios配置:
import axios from 'axios' const request = axios.create({ baseURL: '/api', // 开发环境走代理,所以这里直接用/api timeout: 10000 }) // 请求拦截器:自动带上token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => response.data, error => { console.error('请求出错:', error) return Promise.reject(error) } ) export const getScenicList = (params) => request.get('/scenic/list/', { params }) export const login = (data) => request.post('/user/login/', data) export const register = (data) => request.post('/user/register/', data)然后景点列表页调用接口、渲染数据:
<template> <div class="scenic-page"> <h2>热门景点</h2> <div class="filter-bar"> <el-select v-model="city" placeholder="选择城市" @change="loadData"> <el-option label="全部" value="" /> <el-option label="北京" value="北京" /> <el-option label="杭州" value="杭州" /> <el-option label="成都" value="成都" /> </el-select> </div> <div class="scenic-grid"> <ScenicCard v-for="spot in spots" :key="spot.id" :name="spot.name" :city="spot.city" :price="spot.price" :image="spot.image" :id="spot.id" /> </div> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getScenicList } from '../api/index.js' import ScenicCard from '../components/ScenicCard.vue' const spots = ref([]) const city = ref('') async function loadData() { const res = await getScenicList({ city: city.value }) if (res.code === 0) { spots.value = res.data } } onMounted(() => { loadData() }) </script>这里有个细节:vue.config.js里的代理配置,只对开发服务器有效。如果你把前端打包后用Nginx部署,或者扔到Django的static目录里,路径就会变成实际后端地址,代理配置就不生效了。所以上线部署时,要么把接口baseURL直接改成后端IP加端口,要么让Nginx同样配置一层反向代理。
4.4 详情页、收藏和评论的交互逻辑
景点详情页比列表页多几个交互:
- 展示景点图片:如果是Vue 3,直接用:src绑定地址;如果后端返回的相对路径,前面要拼上后端域名
- 收藏按钮:点击后判断用户是否登录,没登录跳登录页,登录了就调收藏接口
- 评论区:展示已有评论,用户可以提交新评论
这里有个很常见的报错,就是图片显示404。排查思路一般两条:一是后端MEDIA_ROOT和MEDIA_URL有没有配好,二是前端用的地址是不是绝对路径。别一上来就怀疑前端写法,先把接口返回的地址复制到浏览器地址栏直接访问,能打开就是前端问题,打不开就是后端没配好。
4.5 Vue视图更新和数据绑定容易踩的小坑
Vue 3的reactive封装了响应式数据,但是如果你给数组直接用索引赋值,比如spots[0] = {...},是不会触发视图更新的。要用push、splice或者整个替换数组。这个知识点基本每个Vue项目里都会遇到一次。
还有很多人喜欢在模板里写很长的逻辑表达式,我看了不少学弟代码,一个v-for的循环里嵌套三四个v-if,性能差不说,可读性也差。建议适当抽成计算属性或者方法,代码会清爽很多。
4.6 slot插槽的用法:组件复用里的小技巧
热搜词里有“slot vue”,这个知识点在开发公共组件时特别有用。以ScenicCard为例,如果你想实现“卡片默认显示景点信息,但个别地方需要在卡片底部额外加一块内容(比如收藏按钮、删除按钮)”,就可以用插槽:
<template> <div class="scenic-card"> <img :src="image" :alt="name" /> <h3>{{ name }}</h3> <p>{{ city }} - ¥{{ price }}</p> <slot name="extra"></slot> </div> </template>使用的时候:
<ScenicCard v-for="spot in spots" ...> <template #extra> <el-button type="danger" @click="collectSpot(spot.id)">收藏</el-button> </template> </ScenicCard>插槽让你的组件复用性大增,一套卡片组件就能在不同页面展示不同操作按钮,不用复制粘贴一堆重复代码。
5. 联调和部署阶段的实战踩坑:这些问题至少浪费我一下午
写代码最耗时间的不是写,是调。下面几个坑是学生项目里最高频的,我在帮人改项目的过程中一遍又一遍地遇到,直接列出来对照查。
5.1 Django的CSRF验证导致的POST请求403
当前端用axios往Django发POST请求时,如果Django的CSRF中间件开启(默认开启),请求会返回403。因为Django要求POST请求必须带csrfmiddlewaretoken,而前后端分离项目一般不会自动带上。
解决方法一般有三种:
- 在视图函数上添加@csrf_exempt装饰器:
from django.views.decorators.csrf import csrf_exempt @csrf_exempt @require_POST def login_view(request): ...在settings.py里把CsrfViewMiddleware注释掉。这个方法简单粗暴,但安全性下降,课程设计答辩时如果老师懂行会扣分,不推荐。
前端请求时获取csrftoken并放到header里。需要后端配置一个获取token的接口,比较费事。
开发效率优先的话,推荐方案一,在每个POST接口上加@csrf_exempt。等以后去企业实习了,再慢慢学完整的CSRF防护逻辑。
5.2 图片上传失败,页面刷新图片又丢了
这个问题的根源通常是前端把图片转成base64之后没有正确处理,或者后端ImageField没有配MEDIA_ROOT。
一个稳定的做法是后端提供专门的上传接口:
@csrf_exempt @require_POST def upload_image(request): file = request.FILES.get('image') if not file: return JsonResponse({'code': 1, 'message': '没有接收到文件'}) # 直接交给ImageField的存储逻辑 spot = ScenicSpot(image=file) spot.save() url = request.build_absolute_uri(spot.image.url) return JsonResponse({'code': 0, 'data': {'url': url}})记得在settings.py里配置:
MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'不然存不了文件,或者存了访问不了。
5.3 Vue打包后接口地址写死
前端开发时用代理很爽,但打包后dist里的文件是纯静态的,没有代理环境。如果你直接把dist扔给Django,前端请求/api开头的地址就会落到Django的域名下,但Django并没有/api代理转发能力。所以打包前一定记得把api/index.js里的baseURL改回后端真实地址,比如http://127.0.0.1:8000,或者更专业的写法是基于环境变量的判断:
const baseURL = process.env.NODE_ENV === 'production' ? 'http://127.0.0.1:8000/api' : '/api'这个坑查起来最冤,前端看着代码没错,后端接口也能访问,但前端请求过去就是404。
5.4 pip安装慢、装不上包怎么办
热搜词里有“pycharm怎么安装pandas包”、“python安装sklearn库”,说明很多人在Pycharm里手动画包。Pycharm确实提供了一个可视化的包管理界面,但有时候会因为网络问题装上半天没反应。
命令行才是最快的路子,激活虚拟环境后执行:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple django国内镜像一把梭。pip如果提示版本太低,记得先执行python -m pip install --upgrade pip升级一下。
5.5 Django执行查询和删除时报错:“TypeError: 'xxx' object is not iterable”或其他常见报错
新手问得最多的几个ORM相关报错,我总结一下:
- 查询没有结果,返回None,然后继续调用.id属性,报AttributeError
- filter的字段名写错,报FieldError,Django会提示“Cannot resolve keyword”
- 在模板里遍历一个普通对象而不是QuerySet,报TypeError
这些都是对ORM返回类型理解不够清楚导致的。记住三点:all()和filter()返回QuerySet(可以遍历、可以切片);get()返回模型实例(单数,没有列表操作);first()返回模型实例或None。
6. 后端选型的另一条路:如果老师指定了Flask怎么平移
说句实在话,Flask本身并不难,难在于它没有Django那么齐全的“全家桶”,什么东西都要自己拼。但如果你已经用Django做完了一版,老师突然说要用Flask,或者你自己非要两开花,那平移思路是清晰的。
6.1 Flask的核心依赖替换
| Django | Flask生态 |
|---|---|
| django.db.models(ORM) | Flask-SQLAlchemy |
| django.contrib.auth(认证) | Flask-Login 或自己写装饰器 |
| django-views + serializer | Flask路由函数 + 手动jsonify |
| django-admin(后台) | Flask-Admin(可选) |
| CSRF中间件 | Flask-WTF(或忽略) |
以查询和删除为例,Django的写法:
spots = ScenicSpot.objects.filter(city='北京').delete()换成Flask-SQLAlchemy:
spots = ScenicSpot.query.filter_by(city='北京').all() for spot in spots: db.session.delete(spot) db.session.commit()6.2 Flask项目的最小结构
一个Flask写旅游系统后端的最小代码框架:
from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///travel.db' db = SQLAlchemy(app) class ScenicSpot(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100)) city = db.Column(db.String(50)) price = db.Column(db.Numeric(8, 2)) @app.route('/api/scenic/list/', methods=['GET']) def scenic_list(): city = request.args.get('city', '') query = ScenicSpot.query if city: query = query.filter_by(city=city) spots = query.all() return jsonify({'code': 0, 'data': [{ 'id': s.id, 'name': s.name, 'city': s.city, 'price': str(s.price) } for s in spots]})Flask的部署比Django简单,用自带的开发服务器就能跑,如果想要更稳定可以上gunicorn。
如果你之前没写过Flask,我建议不要在一开始就同时学Django和Flask。先把Django这套跑通,Flask作为拓展思路了解一下即可。两个都学只会让人混乱,因为它们在设计哲学上的差异比代码上的差异大得多。
7. WebSocket和实时推送:如何让你的系统在答辩时加印象分
当初看到热词里有“django websocket实现后台有数据前端推送”的时候,我愣了一下,仔细想想这是很多人在做管理系统时会搜索的点。如果你想让自己的旅游系统不止于增删改查,加一个实时功能,比如“用户提交评论之后,所有在线用户都能实时看到新评论”,或者“后台某个热门景点票务紧张时给用户推送提醒”,就可以用WebSocket来做。
7.1 Django实现WebSocket的常见路径
Django自带的开发服务器是不支持WebSocket的,你需要用Django Channels(官方推荐的异步扩展)或者用第三方库django-sockjs之类。Channels是主流方案,但整套配置对新手有一定门槛,需要装channels、channels-redis,还要配置ASGI应用。
整个思路是这样:
- 项目从WSGI模式切换到ASGI模式
- 创建一个WebSocket消费者(Consumer),处理连接、断开、接收消息
- 前端用Vue里的WebSocket对象建立连接,监听消息并实时更新页面
7.2 简化版方案:不用Channels也能做的“伪实时推送”
说实话,课程设计里的实时需求,用轮询就够用了。所谓轮询,就是前端每隔几秒调一次接口看看有没有新数据。虽然不优雅,但实现成本极低、稳定可靠:
setInterval(async () => { const res = await getCommentList(scenicId) comments.value = res.data }, 5000)5秒刷新一次,演示时效果几乎等同实时,但代码量只有WebSocket方案的十分之一。
如果非要真WebSocket学一下,我建议单独做个小Demo练手,不要一开始就硬塞进旅游系统里。先把Channels的官方文档通读一遍,照着教程起一个简单的聊天室,再迁移过来。WebSocket本身的坑——比如连接断开重连、心跳保活、身份鉴权——都是独立的复杂度,没法跳过。
7.3 用轮询还是WebSocket,怎么跟答辩老师说
答辩时如果被问“为什么你的系统没有用WebSocket”,你可以说:当前系统对实时性的需求不强,评论和收藏的延迟在几秒内不影响体验,因此采用了轮询策略来降低系统复杂度;如果后续需要做即时消息通知,可以平滑升级到WebSocket方案。这个回答既说明你懂WebSocket,又会权衡技术选型,反而比硬上一个半吊子的WebSocket加分。
8. 项目从开发到交付:代码整理、数据库备份和演示准备的完整清单
很多学生把功能写完就觉得结束了,结果交作业或者答辩的时候手忙脚乱。这一节说说我每次带项目时最后一周会盯着学生做的那些事情。
8.1 代码整理的规范
- 删除无用代码和注释:那种“# 这里试一下”、“print(1)调试”注释,该删就删
- 添加基本的注释:每个视图函数写清楚接口的作用,模型字段写清楚含义
- 整理requirements.txt:
pip freeze > requirements.txt这个文件不整理,别人复现你项目的时候第一步就杵在那。
- README写清楚启动步骤:里面至少包含Python版本、如何创建虚拟环境、如何安装依赖、如何迁移数据库、创建后台管理员、启动后端、启动前端。别觉得自己清楚就行,两周后你自己也忘了。
8.2 数据库和演示数据的准备
这里有个很多新手不做但特别重要的步骤:预置演示数据。
你的系统给老师演示的时候,数据库里最好已经有10个以上的景点、5条路线、若干条评论和收藏记录。要是打开个空壳系统,全程都在演示注册和添加数据,效果会大打折扣。
预置数据的方法:
- 后台Admin界面手动录入,最直观,但特别浪费时间
- 写一个management command,用脚本批量生成数据
- 用Django Fixture:先把数据准备好,然后dumpdata到json文件,别人只需要loaddata就能复现
我个人推荐写个一次性脚本:
import os import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'travel_backend.settings') django.setup() from travel.models import ScenicSpot spots_data = [ {'name': '故宫博物院', 'city': '北京', 'price': 60, 'description': '世界上现存规模最大、保存最为完整的木质结构古建筑群之一。'}, {'name': '西湖', 'city': '杭州', 'price': 0, 'description': '以其秀丽的湖光山色和众多的名胜古迹而闻名中外。'}, {'name': '九寨沟', 'city': '阿坝', 'price': 169, 'description': '以翠海、叠瀑、彩林、雪峰、藏情、蓝冰“六绝”著称。'}, ] for item in spots_data: ScenicSpot.objects.create(**item)跑一遍脚本,数据就有了。这个脚本甚至可以直接放进README里告诉别人怎么初始化演示数据。
8.3 答辩演示的完整流程设计
演示系统的时候,别从注册开始演示,那会浪费时间。合理顺序是:
- 先打开系统首页,展示景点列表
- 点进景点详情,展示图片信息、评论
- 演示登录,然后收藏一个景点
- 打开后台管理,演示添加一个景点并设置上线
- 回到前端,刷新页面,演示新增的景点出现
- 如果做了搜索,演示按城市搜索
整套演示控制在5到8分钟,逻辑链是完整的:前台浏览、用户交互、后台管理、数据联动。
答辩老师爱问的几个问题要提前准备:
- “为什么选Django/Flask?”——基于开发效率、生态完善度、ORM方便做课程设计体量的系统
- “为什么用前后端分离?”——前端交互体验好、接口可复用、符合工程化实践
- “数据库表是怎么设计的?”——按业务对象拆成用户、景点、路线、评论、收藏,说明外键关系和级联逻辑
- “系统的安全性考虑?”——从用户认证、CSRF、权限控制几个维度回答,哪怕只做了基础也要能说出来
8.4 记录开发过程中的图片和日志
毕设和课程设计都需要结题报告或者论文,里面要放系统截图。我强烈建议从第一天开始就随手截图:
- 建好项目结构后截一张
- 数据库表创建成功截一张
- 每一块功能跑通后截一张前端效果图
- 把过程中遇到的报错截图也存档(写“遇到的问题与解决”章节时很用)
等最后写报告,这些素材都是现成的,不用回头补。
9. 把几星期踩过的坑浓缩成最后几点心得
带过几轮做这种选题的学生之后,我最大的体会是:这类管理系统项目,难度不在任何一个单独的技术点上,而在把这些技术点串起来的过程。写一个Django接口不难,写一个Vue页面也不难,但是当你的知识是零散的,任何一个中间环节——跨域、联调、打包、部署——都能卡住你一整天。
我的具体建议是这样的:
第一,模型设计阶段一定要慢。拿一张纸把你想要的业务实体写下来,标清楚字段和外键关系。表结构设计对了,后面几乎是水到渠成;表结构设计错了,改一次连带前端全部要跟着改。
第二,前端页面不要贪多求全。Vue的组件和页面,做一个能跑通的完整闭环(列表页→详情页→登录→收藏),比堆五个没做完的半成品页面强得多。每次演示和答辩,评委看的是逻辑完整性和核心功能是否可用,不是页面数量。
第三,接口返回的数据格式一开始就统一。比如固定用{code: 0, data: ..., message: 'success'}这套包裹结构,前端和后端都按这个约定走,联调的时候能省很多扯皮的精力。写时间长了就会发现,接口约定比接口数量重要得多。
第四,Git不熟练没关系,但一定要学会经常备份。最简单的方式就是定期把整个项目压缩存档,或者用Gitee建个私有仓库,每完成一个模块就commit一次。关键词:别到写坏了一天代码之后才想起来“刚才那版还能跑”。
第五,如果你时间很紧,优先保证后端接口的正确性,前端样式可以放一放。功能能跑起来,数据能通,已经80分了。反过来,如果前端花里胡哨但接口调不通,演示的时候一戳就穿帮。样式是锦上添花,不是雪中送炭。
最后说一个我个人的体会:做这个项目最有价值的不是那个分数,而是你第一次亲手把一个从前到后的完整系统跑通的经验。它让你看见一个Web应用到底是由哪些零件组成的,这些东西在未来任何一门和后端、前端有关的工作里都会反复用到。慢慢写,扎实跑通每个环节,项目结束时你会发现自己已经在不知不觉中跨过了“只会抄代码”到“能独立搭一个系统”的分界线。