简介:一份基于Python+Django+Vue的外卖点餐系统毕业设计源码,面向计算机相关专业毕业生及需要课程设计项目的开发者,完整实现了前后端分离的B/S架构。前台覆盖首页、菜品详情、订单中心与用户中心,后台提供订单、菜品、分类、标签、评论、用户、运营、日志及系统信息等管理模块,业务链路清晰,适合学习Django接口开发与Vue组件化实践。压缩包共422个文件,以Python后端脚本、Vue前端组件、TypeScript脚本以及JPG/PNG/SVG图片素材为主,整体大小约23.81MB,源码目录分为server与web,并附有pip依赖清单,便于按端定位与快速搭建运行环境。目前已有609人学习下载,可直接部署验证,也可作为毕业设计答辩或课程设计报告的配套材料,适合需要完整项目参考与二次开发的读者。
1. 外卖点餐系统值不值得做:一个Django+Vue全栈项目的真实边界
毕业设计选“python外卖点餐系统”,本质上是在赌一个确定性:技术栈足够主流,业务场景足够直观,评委不需要听你讲完需求才明白你在做什么。Django负责后端API、用户认证和订单状态流转,Vue负责前端页面和交互,两者通过JSON通信,这套组合在你答辩时几乎不会遇到“你这个系统到底解决了什么问题”的追问,因为它就是美团外卖的极简版。但这个项目也藏着不少隐形工作量,比如菜品图片怎么存、订单状态怎么流转、多人同时下单时库存怎么扣,这些才是真正拉开分数的地方。
适合谁做?已经有Python基础、但没系统写过Web项目的人;或者前端只听说过Vue、想借毕设把前后端联调走通的人。它不像算法类题目那样需要数学功底,也不像硬件项目那样依赖实验室设备,只要一台电脑、一个能联网的环境,就能从零跑出一个能点餐、能结算、能看订单的完整网站。下面按我自己的实现路径,从建项目到部署讲一遍,参数和坑都放在对应章节里。
2. 先把后端骨架立住:Django项目结构、数据建模与API设计
2.1 创建项目与App:一条命令背后的结构选择
我习惯先建一个独立的虚拟环境,再装Django和Django REST Framework。这个顺序不要反,因为直接pip install到全局环境里,后期装别的库容易把版本搞乱。
# 创建并激活虚拟环境 python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate # 安装核心依赖 pip install django==4.2.* djangorestframework==3.14.* django-cors-headers==4.*Django版本我锁在4.2,这是一个LTS版本,稳定性和第三方库兼容性都比较好。Django 5出来之后不是不能用,但很多旧教程和插件还停在4.x,毕设周期短,没必要为了追新版本去踩兼容性坑。
# 创建项目和应用 django-admin startproject takeout cd takeout python manage.py startapp user python manage.py startapp shop python manage.py startapp order三个App分别管用户、商家/菜品、订单。为什么不只建一个App?因为外卖系统天然有角色边界——顾客、商家、骑手(如果做配送),后期要加权限控制时,按App分能让你直接对每个App设置独立的视图集和序列化器。如果全塞在一个App里,到第五六天改权限时,光找文件就够你烦的。
2.2 数据建模:六张表的字段设计与外键关系
外卖系统的核心模型其实就六张:用户、商家、菜品、购物车、订单、订单项。下面是一份我调过三轮的模型定义,重点看外键关系和字段约束。
# order/models.py from django.db import models from django.contrib.auth.models import User class Shop(models.Model): name = models.CharField(max_length=50, verbose_name="店铺名称") address = models.CharField(max_length=200) phone = models.CharField(max_length=20) rating = models.FloatField(default=4.5) # 评分,保留一位小数 is_open = models.BooleanField(default=True) # 营业状态 class Dish(models.Model): shop = models.ForeignKey(Shop, on_delete=models.CASCADE, related_name="dishes") name = models.CharField(max_length=100) price = models.DecimalField(max_digits=6, decimal_places=2) # 用Decimal不用Float image = models.ImageField(upload_to="dishes/", blank=True, null=True) stock = models.IntegerField(default=99) # 库存,用于超卖控制 class Cart(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) dish = models.ForeignKey(Dish, on_delete=models.CASCADE) quantity = models.PositiveIntegerField(default=1) class Order(models.Model): STATUS_CHOICES = ( ('pending', '待支付'), ('paid', '已支付'), ('delivering', '配送中'), ('done', '已完成'), ('cancelled', '已取消'), ) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="orders") shop = models.ForeignKey(Shop, on_delete=models.CASCADE) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') total_price = models.DecimalField(max_digits=8, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True)价格字段必须用DecimalField而不是FloatField,这是踩过钱相关计算后最深刻的教训。浮点数在累加折扣、运费时会出0.1+0.2这种精度问题,对账时差一分钱都说不清。外卖系统的金额虽然不大,但答辩时一旦被问到“金额精度怎么保证”,用Decimal就能讲出设计感。
菜品图片我用ImageField,但这意味着要处理静态文件。开发环境下Django能直接服务媒体文件,部署后得交给Nginx,这个坑在第5章里细说。
2.3 Django REST Framework:写API而不是写页面
外卖系统是前后端分离的,Django只负责输出JSON,不再用模板渲染HTML。这样做的直接好处是,Vue那边可以完全独立修改页面样式,不碰后端代码。我一般会先定义好API的返回格式,再让前端照着调。
# order/serializers.py from rest_framework import serializers from .models import Shop, Dish class DishSerializer(serializers.ModelSerializer): shop_name = serializers.CharField(source="shop.name", read_only=True) class Meta: model = Dish fields = ["id", "name", "price", "image", "stock", "shop_name"] # order/views.py from rest_framework import viewsets from rest_framework.permissions import AllowAny class ShopViewSet(viewsets.ReadOnlyModelViewSet): queryset = Shop.objects.filter(is_open=True) serializer_class = ShopSerializer permission_classes = [AllowAny] # 查店铺不需要登录 class DishViewSet(viewsets.ReadOnlyModelViewSet): queryset = Dish.objects.filter(stock__gt=0) # 只返回有库存的 serializer_class = DishSerializer def get_queryset(self): shop_id = self.request.query_params.get("shop") if shop_id: return self.queryset.filter(shop_id=shop_id) return self.querysetReadOnlyModelViewSet自带list和retrieve两个动作,不需要你手动写查询逻辑,适合店铺列表、菜品列表这类只读场景。get_queryset里做过滤,比在序列化器里过滤更直接,前端只需要传?shop=1这样的参数就能拿到某家店的菜单。
路由注册也要在这里一次性配好:
# takeout/urls.py from rest_framework.routers import DefaultRouter from order.views import ShopViewSet, DishViewSet router = DefaultRouter() router.register("shops", ShopViewSet) router.register("dishes", DishViewSet) urlpatterns = [ path("api/", include(router.urls)), ]DefaultRouter会自动生成/api/shops/和/api/shops/1/两种URL格式,省去手写path的麻烦。注意我加了api/前缀,这样后面接Nginx时,可以直接把/api/转发给Django,其他静态资源交给Nginx,路径清晰不会混。
3. Vue前端与页面路由:从静态页面到可点餐的交互界面
3.1 Vue环境与依赖安装:node版本、npm镜像与脚手架
Vue这边我推荐用Vue 3 + Vite的组合,不用Vue CLI。Vite启动速度快,热更新反应快,毕设演示现场改代码时体验差距很大。Node版本建议16以上,但别急着装最新的Node 21,有些依赖还没跟上,容易报些莫名其妙的错误。
# 创建Vue项目,router和pinia一起装上 npm create vite@latest frontend -- --template vue cd frontend npm install vue-router@4 pinia axiosnpm装依赖慢的话,把镜像切到国内源,能省下大量等待时间:
npm config set registry https://registry.npmmirror.com前端的目录结构我按页面维度拆:
src/ views/ HomeView.vue # 店铺列表 ShopView.vue # 店铺详情/点餐页 CartView.vue # 购物车 OrderView.vue # 下单/订单列表 LoginView.vue # 登录 components/ DishCard.vue # 菜品卡片(复用) ShopHeader.vue # 店铺头部信息 stores/ cart.js # 购物车状态(Pinia) router/ index.js # 路由配置 api/ request.js # axios封装views和components的区分标准很简单:一个页面独有的是views,多个页面共享的是components。菜品卡片会被店铺页和推荐页共用,所以放在components里。
3.2 路由与页面骨架:Vue Router的四种跳转场景
外卖系统的页面跳转有清晰的层级:首页进店铺,店铺加菜进购物车,购物车提交生成订单,订单列表可以再看详情。路由配置要能反映这个层级,同时要处理登录守卫。
// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/HomeView.vue') }, { path: '/shop/:id', component: () => import('../views/ShopView.vue') }, { path: '/cart', component: () => import('../views/CartView.vue'), meta: { requiresAuth: true } }, { path: '/orders', component: () => import('../views/OrderView.vue'), meta: { requiresAuth: true } }, { path: '/login', component: () => import('../views/LoginView.vue') }, ] const router = createRouter({ history: createWebHistory(), routes, }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })购物车和订单页必须登录才能看,这是外卖系统的基本业务规则。用meta.requiresAuth把需要登录的页面标记出来,再通过beforeEach统一检查token,比在每个页面里单独写判断要省事得多。createWebHistory让URL看起来是干净的/shop/3而不是/#/shop/3,但部署时有个坑——刷新404,解决办法在第5章里。
3.3 用axios接后端:请求封装与拦截器
axios封装的核心目的有两个:统一加token,统一处理错误码。不然每个页面都写一遍headers: { Authorization: ... },既难看又容易漏。
// api/request.js import axios from 'axios' import router from '../router/index' const request = axios.create({ baseURL: 'http://127.0.0.1:8000/api/', timeout: 10000, }) // 请求拦截器:自动带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `JWT ${token}` } return config }) // 响应拦截器:统一处理401和错误提示 request.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } ) export default request这里用了JWT认证,token存localStorage,拦截器在请求发出前自动读取。好处是刷新页面后登录状态不丢,坏处是XSS攻击下token会泄露,但毕设场景下这个权衡是合理的,你可以在答辩时主动提这一点,反而显得你有安全意识。
// 点餐页实际调接口的写法 import request from '../api/request' const getDishes = async (shopId) => { try { const dishes = await request.get(`/dishes/?shop=${shopId}`) dishesList.value = dishes } catch (e) { console.error('获取菜品失败', e) } }注意拦截器里统一返回了response.data,所以业务代码里拿到的直接是数据本体,不用每处都写res.data.data。这个习惯能让你在后端字段改名时,只改一处封装,而不是全局搜索替换。
4. 打通前后端业务闭环:购物车、订单状态机与权限控制
4.1 登录与权限:三种角色的身份识别
外卖系统至少有三种角色:普通用户(点餐)、商家(上架菜品/接单)、管理员(管理店铺)。Django自带User模型可以扩展角色,但更干净的做法是建一个Profile表,用一对一关系挂User。
# user/models.py class Profile(models.Model): ROLE_CHOICES = ( ('customer', '顾客'), ('merchant', '商家'), ('admin', '管理员'), ) user = models.OneToOneField(User, on_delete=models.CASCADE) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='customer') phone = models.CharField(max_length=20) address = models.CharField(max_length=200, blank=True) # 注册时创建Profile # user/views.py from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from django.contrib.auth.models import User @api_view(['POST']) @permission_classes([AllowAny]) def register(request): username = request.data.get('username') password = request.data.get('password') role = request.data.get('role', 'customer') if User.objects.filter(username=username).exists(): return Response({"error": "用户名已存在"}, status=400) user = User.objects.create_user(username=username, password=password) Profile.objects.create(user=user, role=role) return Response({"message": "注册成功"}, status=201)登录部分直接用Django REST Framework的ObtainAuthToken能省很多事,但返回的token格式是默认的。我一般会重写一个login视图,让返回值里带上用户名和角色,前端拿一次就能知道是顾客还是商家,从而显示不同的菜单入口。
# user/views.py 登录接口 @api_view(['POST']) @permission_classes([AllowAny]) def login(request): username = request.data.get('username') password = request.data.get('password') user = authenticate(username=username, password=password) if not user: return Response({"error": "用户名或密码错误"}, status=400) token, _ = Token.objects.get_or_create(user=user) profile = Profile.objects.get(user=user) return Response({ "token": token.key, "username": user.username, "role": profile.role, })权限控制的粒度:创建菜品、修改库存这些操作只允许merchant和admin;用户查看订单列表只能看到自己的;商家查看订单能看到对应店铺的。用Django REST Framework的IsAuthenticated加自定义权限类即可。
4.2 购物车实现:从加菜到结算的状态流转
购物车在前后端各有存储:前端Pinia负责展示层的即时反馈(加了菜立刻显示角标),后端Django负责持久化(刷新页面购物车还在)。两者之间的同步靠“加入购物车”和“获取购物车”两个API。
// stores/cart.js Pinia购物车 import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ items: [], // [{ dish_id, dish_name, price, quantity, shop_id }] }), getters: { totalCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0), totalPrice: (state) => state.items.reduce((sum, item) => sum + item.quantity * item.price, 0), }, actions: { addItem(dish) { const existing = this.items.find(item => item.dish_id === dish.id) if (existing) { existing.quantity++ } else { this.items.push({ dish_id: dish.id, dish_name: dish.name, price: dish.price, quantity: 1, shop_id: dish.shop_id }) } }, removeItem(dishId) { this.items = this.items.filter(item => item.dish_id !== dishId) }, } })注意getters里用reduce累加数量和金额,这是前端实时重新计算,不依赖后端接口。结算时再把整个购物车内容一次性提交给订单接口。为什么要前端先算、后端再算?因为用户看着页面上的价格跳变会难受,但最终金额一定以后端为准,防止有人手动改请求体里的价格字段。
4.3 订单状态机:状态迁移图的代码化
订单状态是最容易做得含糊的部分。很多毕设只给了状态字段,但没限制状态怎么迁,结果数据库里出现“待支付直接变已完成”这种非法路径。解决方式是用状态机,显式定义每个状态允许跳转到哪里。
# order/models.py 订单状态迁移 class Order(models.Model): # ... 字段同上 TRANSITIONS = { 'pending': ['paid', 'cancelled'], 'paid': ['delivering', 'cancelled'], 'delivering': ['done'], 'done': [], 'cancelled': [], } def change_status(self, new_status): if new_status not in self.TRANSITIONS.get(self.status, []): raise ValueError(f"非法状态迁移: {self.status} -> {new_status}") self.status = new_status self.save()# order/views.py 接单/配送动作 from rest_framework.decorators import action class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() serializer_class = OrderSerializer def get_queryset(self): # 普通用户只能看自己的订单 if self.request.user.profile.role == 'customer': return Order.objects.filter(user=self.request.user) return Order.objects.all() @action(detail=True, methods=['post']) def pay(self, request, pk=None): order = self.get_object() try: order.change_status('paid') # 只有pending能转paid return Response({"message": "支付成功"}) except ValueError as e: return Response({"error": str(e)}, status=400)支付、接单、送达全部走change_status,非法迁移直接被拦截。答辩时如果评委问“怎么防止用户绕过流程直接改状态”,这就是你的答案。订单创建时还要记得扣库存,这是超卖控制的最后一道防线。
# 创建订单时扣库存,需要加事务锁 from django.db import transaction @transaction.atomic def create_order(user, shop_id, items): total = 0 order = Order.objects.create(user=user, shop_id=shop_id, status='pending', total_price=0) for item in items: dish = Dish.objects.select_for_update().get(id=item['dish_id']) if dish.stock < item['quantity']: raise ValueError(f"{dish.name} 库存不足") dish.stock -= item['quantity'] dish.save() OrderItem.objects.create(order=order, dish=dish, quantity=item['quantity'], price=dish.price) total += dish.price * item['quantity'] order.total_price = total order.save() return orderselect_for_update是行级锁,多个用户同时对同一道菜下单时,后到的请求会阻塞到前一个事务结束,从而避免超卖。库存字段虽然在第2章模型里写过,但真正用事务保护是在这里,这属于“代码里看不见但绝对要写”的部分。
5. 常见问题与踩坑排查:从环境配置到接口联调的5条翻车记录
5.1 跨域CORS配置:前端调接口报“blocked by CORS policy”
现象:Vue页面里调用request.get('/shops/'),浏览器控制台报跨域错误,后端日志里看不到任何请求记录。
原因:前端运行在http://localhost:5173,Django运行在http://127.0.0.1:8000,端口不同即跨域。Django默认拒绝来自其他来源的Ajax请求。
解决:安装的django-cors-headers派上用场,但配置有讲究。
# settings.py INSTALLED_APPS = [ # ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # 尽量放在中间件最上方 # ... ] CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", ]提示:开发阶段为了省事可以设CORS_ALLOW_ALL_ORIGINS = True,但部署前必须改回白名单模式,不然任何网站都能请求你的API,这是安全问题。
5.2 图片上传后页面显示404:MEDIA_URL和MEDIA_ROOT没配对
现象:菜品图片上传成功,Django后台也能看到文件,但前端<img>标签的src指向的文件路径返回404。
原因:ImageField上传的文件保存在MEDIA_ROOT目录,Django开发环境下默认不提供媒体文件访问,需要在urls.py里单独配static()。
解决:
# settings.py MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' # takeout/urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns = [ # ... ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)注意:static()这个写法只在DEBUG=True时生效,部署到Nginx后需要在Nginx配置里加location /media/指向媒体目录。如果你部署后图片仍然404,先确认Nginx配置里有没有这一段。
5.3 刷新页面404路由失效:Vue Router的history模式与Nginx冲突
现象:在首页点进店铺页正常,但手动刷新http://yourdomain/shop/3时浏览器显示404。
原因:createWebHistory让Vue接管了URL,但Nginx找不到/shop/3对应的真实文件,直接按404处理。
解决:在Nginx配置里加一个try_files指令,把所有非静态文件请求都指回index.html。
location / { root /var/www/frontend/dist/; index index.html; try_files $uri $uri/ /index.html; }这是毕设部署最常见的坑之一。你可以在答辩时主动说“我处理了history模式下的路由回退问题”,至少值两分钟的提问时间。
5.4 JWT token在请求头带不上:浏览器对Authorization的跨域预检
现象:用JWT前缀携带token时,请求发出前先触发OPTIONS预检,预检失败导致实际请求被浏览器拦截。
原因:JWT不是AllowAll认证方案需要的标准格式,Django REST Framework的JWT需要从simplejwt库解析Bearer前缀。如果用了ObtainAuthToken生成的普通Token,前缀不需要。
解决:统一用simplejwt替代ObtainAuthToken,前端拦截器改成Bearer ${token}。
// 前端请求头 config.headers.Authorization = `Bearer ${token}` # settings.py REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], }如果你前后端都自己写,这个问题其实很小。但如果你在网上抄了一段旧教程的登录代码,用的是ObtainAuthToken,而前端又套了新模板的Bearer写法,两边就对不上,排查起来特别耗时间。
5.5 时区问题:订单时间显示比北京时间早8小时
现象:本地开发的订单创建时间正常,部署到云服务器(通常是UTC时区)后,订单列表里的时间整体少了8小时。
原因:Django的TIME_ZONE默认是UTC,如果没改成Asia/Shanghai,auto_now_add生成的时间存的是UTC。本地电脑系统时区一般是北京时间,所以开发时看不出问题。
解决:改两个地方。
# settings.py TIME_ZONE = 'Asia/Shanghai' USE_TZ = True注意:改了TIME_ZONE之后,之前已经入库的UTC时间不会自动转换,只对之后新产生的时间生效。好在这个问题在开发阶段基本不会遇到,等你部署上线再发现也来得及改。
6. 收尾技巧:把毕设做到能演示、能答辩、能体面部署
到这里,后端API、前端页面、订单流转已经全部跑通。医院里最常被追讨的是延迟渲染跟数据安全,对外卖系统来说,评委最常问的是“你这个系统放到真实环境能不能扛住”。所以最后一章我分享两个验收技巧,一个关于演示准备,一个关于部署收尾。
先用小学生都能看懂的“表演路径”过一遍全流程:注册一个顾客账号、浏览店铺列表、进店把三道菜加入购物车、结算生成订单、模拟支付成功后状态变“已支付”。这套路径要在答辩当天走三遍以上,第一遍确认功能没毛病,第二遍计时看整体耗时,第三遍刻意把浏览器缩放到75%——因为投影仪分辨率通常是1024x768,你的Vue页面如果按1920设计的,缩放后布局会乱。
部署建议用经典方案:Django跑在Gunicorn,静态资源交给Nginx,前端dist目录直接放进Nginx根目录。我不建议在这一步上花太多时间研究Docker或K8s,毕设演示只需一台云服务器,两个服务即可:
# 构建Vue前端 npm run build # dist/ 目录会生成在 frontend/ 下 # 安装gunicorn并启动Django pip install gunicorn gunicorn takeout.wsgi:application --bind 127.0.0.1:8000 --workers 3Nginx里区分动态和静态请求:/api/和/media/转发给Django,其余全部指向前端dist目录。三个worker对毕设演示足够,不要贪多,配多了反而暴露你对自己项目吞吐量的预估不切实际。
另一项容易被忽略的准备工作是准备一份演示数据。至少选三家店铺,每家有6到8道菜,图片尺寸尽量统一,订单状态分开显示(待支付、已完成各一单)。评委点开系统时看到的一片空白和看到的三家店铺能营业的差别,是及格和优秀的差别。
最后说说我做完这个项目的感受。最值钱的部分不是“我完成了毕设”,而是你真的经历了一次从需求拆解、表结构设计、接口约定、前后端联调到部署上线的完整闭环。这些环节里每一步都在教你怎么跟“不确定性”相处——前端说你接口字段不对,后端说你自己不看文档。别急着甩锅,先把网络请求日志打开,一条条对,大多数问题十分钟内能定位。希望这篇笔记能帮你在同样方向上少走几步弯路。
本文还有配套的精品资源,点击获取