ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Django与Vue的外卖点餐系统全栈开发实战

基于Django与Vue的外卖点餐系统全栈开发实战 简介一份基于PythonDjangoVue的外卖点餐系统毕业设计源码面向计算机相关专业毕业生及需要课程设计项目的开发者完整实现了前后端分离的B/S架构。前台覆盖首页、菜品详情、订单中心与用户中心后台提供订单、菜品、分类、标签、评论、用户、运营、日志及系统信息等管理模块业务链路清晰适合学习Django接口开发与Vue组件化实践。压缩包共422个文件以Python后端脚本、Vue前端组件、TypeScript脚本以及JPG/PNG/SVG图片素材为主整体大小约23.81MB源码目录分为server与web并附有pip依赖清单便于按端定位与快速搭建运行环境。目前已有609人学习下载可直接部署验证也可作为毕业设计答辩或课程设计报告的配套材料适合需要完整项目参考与二次开发的读者。1. 外卖点餐系统值不值得做一个DjangoVue全栈项目的真实边界毕业设计选“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 django4.2.* djangorestframework3.14.* django-cors-headers4.*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_length50, verbose_name店铺名称) address models.CharField(max_length200) phone models.CharField(max_length20) rating models.FloatField(default4.5) # 评分保留一位小数 is_open models.BooleanField(defaultTrue) # 营业状态 class Dish(models.Model): shop models.ForeignKey(Shop, on_deletemodels.CASCADE, related_namedishes) name models.CharField(max_length100) price models.DecimalField(max_digits6, decimal_places2) # 用Decimal不用Float image models.ImageField(upload_todishes/, blankTrue, nullTrue) stock models.IntegerField(default99) # 库存用于超卖控制 class Cart(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE) dish models.ForeignKey(Dish, on_deletemodels.CASCADE) quantity models.PositiveIntegerField(default1) class Order(models.Model): STATUS_CHOICES ( (pending, 待支付), (paid, 已支付), (delivering, 配送中), (done, 已完成), (cancelled, 已取消), ) user models.ForeignKey(User, on_deletemodels.CASCADE, related_nameorders) shop models.ForeignKey(Shop, on_deletemodels.CASCADE) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpending) total_price models.DecimalField(max_digits8, decimal_places2) created_at models.DateTimeField(auto_now_addTrue)价格字段必须用DecimalField而不是FloatField这是踩过钱相关计算后最深刻的教训。浮点数在累加折扣、运费时会出0.10.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(sourceshop.name, read_onlyTrue) 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_openTrue) serializer_class ShopSerializer permission_classes [AllowAny] # 查店铺不需要登录 class DishViewSet(viewsets.ReadOnlyModelViewSet): queryset Dish.objects.filter(stock__gt0) # 只返回有库存的 serializer_class DishSerializer def get_queryset(self): shop_id self.request.query_params.get(shop) if shop_id: return self.queryset.filter(shop_idshop_id) return self.querysetReadOnlyModelViewSet自带list和retrieve两个动作不需要你手动写查询逻辑适合店铺列表、菜品列表这类只读场景。get_queryset里做过滤比在序列化器里过滤更直接前端只需要传?shop1这样的参数就能拿到某家店的菜单。路由注册也要在这里一次性配好# 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 vitelatest frontend -- --template vue cd frontend npm install vue-router4 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_deletemodels.CASCADE) role models.CharField(max_length20, choicesROLE_CHOICES, defaultcustomer) phone models.CharField(max_length20) address models.CharField(max_length200, blankTrue) # 注册时创建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(usernameusername).exists(): return Response({error: 用户名已存在}, status400) user User.objects.create_user(usernameusername, passwordpassword) Profile.objects.create(useruser, rolerole) return Response({message: 注册成功}, status201)登录部分直接用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(usernameusername, passwordpassword) if not user: return Response({error: 用户名或密码错误}, status400) token, _ Token.objects.get_or_create(useruser) profile Profile.objects.get(useruser) 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(userself.request.user) return Order.objects.all() action(detailTrue, methods[post]) def pay(self, request, pkNone): order self.get_object() try: order.change_status(paid) # 只有pending能转paid return Response({message: 支付成功}) except ValueError as e: return Response({error: str(e)}, status400)支付、接单、送达全部走change_status非法迁移直接被拦截。答辩时如果评委问“怎么防止用户绕过流程直接改状态”这就是你的答案。订单创建时还要记得扣库存这是超卖控制的最后一道防线。# 创建订单时扣库存需要加事务锁 from django.db import transaction transaction.atomic def create_order(user, shop_id, items): total 0 order Order.objects.create(useruser, shop_idshop_id, statuspending, total_price0) for item in items: dish Dish.objects.select_for_update().get(iditem[dish_id]) if dish.stock item[quantity]: raise ValueError(f{dish.name} 库存不足) dish.stock - item[quantity] dish.save() OrderItem.objects.create(orderorder, dishdish, quantityitem[quantity], pricedish.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:5173Django运行在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 图片上传后页面显示404MEDIA_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_rootsettings.MEDIA_ROOT)注意static()这个写法只在DEBUGTrue时生效部署到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/Shanghaiauto_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道菜图片尺寸尽量统一订单状态分开显示待支付、已完成各一单。评委点开系统时看到的一片空白和看到的三家店铺能营业的差别是及格和优秀的差别。最后说说我做完这个项目的感受。最值钱的部分不是“我完成了毕设”而是你真的经历了一次从需求拆解、表结构设计、接口约定、前后端联调到部署上线的完整闭环。这些环节里每一步都在教你怎么跟“不确定性”相处——前端说你接口字段不对后端说你自己不看文档。别急着甩锅先把网络请求日志打开一条条对大多数问题十分钟内能定位。希望这篇笔记能帮你在同样方向上少走几步弯路。本文还有配套的精品资源点击获取
返回列表