ARTICLE DETAIL

资讯详情

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

Vue3+Django实战:校园二手交易系统前后端分离开发全记录

Vue3+Django实战:校园二手交易系统前后端分离开发全记录 做校园二手交易系统这个项目说实话最开始我是被一个场景触发的。学校二手群里全是消息刷屏出高数教材9成新5栋自提电动车出一辆校内交易信息很快被淹没想找特定的东西得翻几百条记录。后来接了一个课程设计的需求要做一个校园二手物品交易系统我就顺势用 vue3 Django 这套前后端分离的方案把它落地了。这个系统能做什么一句话总结学生可以注册登录、发布闲置物品、浏览和搜索商品、收藏、下单买卖双方在站内约定校内交易管理员在后台审核商品、管理用户和分类。它本质上不是个电商系统而是一个信息撮合 轻量订单管理平台所以做起来比淘宝简单得多但比一个单纯的信息发布站又多了一层交易闭环。如果你正准备做类似的毕设、课设或者想在一个真实项目里把 vue3 和 Django 前后端串联起来这篇文章值得你完整看一遍。我会把我踩过的坑、设计取舍和部署细节都写出来不是把官方文档复述一遍而是讲实际怎么落地。1. 需求拆解校园二手交易系统做给谁用和普通电商差在哪1.1 校园交易场景的三个特殊点第一线下交付。校园二手交易基本不可能走快递绝大多数是校内自提所以系统里不需要购物车之外的支付、物流能力。商品详情里应该标注交易地点、可交易时间订单状态流转也跟快递链路完全不一样。这个决定了订单模块比商用电商简单很多但也别简单到只是下单-确认两步至少要有待付款、待完成、已完成、已取消这套状态否则买卖双方容易扯皮。第二强身份信任。交易限定在校内用户注册时最好绑定学号、学院、宿舍楼等信息可以降低欺诈风险。做数据库设计时我预留了 UserProfile 扩展字段没有直接改 Django 自带的 User 表这样后续接学校统一认证也方便不会把自己的用户体系焊死。第三学期周期明显。开学、期末是活跃高峰教材、小电器、生活用品是主要品类分类设计要贴近学生需求。比如教材这个分类下还可以细分公共课、专业课、考研资料这个细节很影响用户检索效率。1.2 功能清单前台C端 后台管理端正式动手前我画了一张功能清单把前台和后台分开列每项都标清楚是必须做还是可以砍掉这个动作帮我省了大量无用功。前台核心功能功能模块具体功能优先级用户注册、登录、JWT登出、个人资料编辑必须商品发布、编辑、下架、列表展示、搜索、分类筛选必须商品详情多图展示、卖家信息、收藏、留言必须订单下单、订单状态流转、买卖双方订单列表必须个人中心我的发布、我的订单、我的收藏必须社区感商品留言、浏览计数可选后台核心功能直接用 Django admin 做基础版商品审核、用户管理、分类管理、订单查看都够了。如果你还想做得更像样可以再用 Vue3 做一套简单的管理界面但第一版没有必要admin 能让你把精力全部放在前台。1.3 技术选型坐实为什么是 vue3 Django我之前也纠结过要不要用 Flask 或者 Node 写后端后来综合了几个原因定了这套组合Django 的 ORM 和 admin 后台能极大缩短开发时间商品、订单这类模型关系相对复杂用 Django 写起来非常顺。DRF 把接口开发从手写每个视图函数变成配置序列化器和视图集一套 ModelViewSet 就能把增删改查全出完比 Flask 手动路由优雅很多。Vue3 用 Composition API 组织业务逻辑比 Options API 清晰配合 Vite 启动和热更新都很快。前后端分离之后前端只调接口后端只出接口分工明确。虽然一个人做项目不存在团队协作但调试和二次开发时边界清晰能省很多事。版本组合我当时用的是Python 3.10 Django 4.2 Vue 3.4 Vite 5 Element Plus。Django 4.2 是长期支持版本坑少适合做项目。2. Django后端建app、模型设计和DRF接口的完整落地2.1 开始前的环境准备与项目骨架我习惯先把虚拟环境建好再动手避免把系统 Python 环境搞乱。命令很简单python -m venv venv source venv/bin/activate # Windows 上执行 venv\Scripts\activate pip install django djangorestframework djangorestframework-simplejwt django-cors-headers Pillow装 Pillow 是因为商品图片用 ImageField 必须依赖它。django-cors-headers 是开发阶段给前端跨域用的部署到同源之后可以去掉。项目结构我拆成了两个 app一个管用户相关一个管商品和订单backend/ manage.py config/ # 配置文件、根路由 settings.py urls.py wsgi.py apps/ users/ # 用户扩展、注册登录 trade/ # 分类、商品、订单、收藏、留言 media/ # 上传的图片 static/ # 静态文件收集目录创建 app 的命令是python manage.py startapp users和python manage.py startapp trade然后把两个 app 注册进 INSTALLED_APPS。注意 apps 目录如果和 manage.py 不在同一层需要在 trade/apps.py 里给每个 app 加label或者在配置里写明路径否则 Django 找不到模块。2.2 数据模型设计五个核心模型各管什么事用户扩展模型我放在 users app 里from django.db import models from django.contrib.auth.models import User class UserProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameprofile) student_id models.CharField(max_length20, blankTrue) college models.CharField(max_length50, blankTrue) building models.CharField(max_length50, blankTrue) phone models.CharField(max_length20, blankTrue) avatar models.ImageField(upload_toavatars/, blankTrue, nullTrue)为什么不用 Django 自带的 User 加字段因为直接继承 AbstractUser 会导致以后接第三方认证时麻烦而且这个系统里 User 本身只负责账号、密码、邮箱学生信息这类非通用属性放 profile 里更干净。trade app 里的核心模型有这几个class Category(models.Model): name models.CharField(max_length50) parent models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.CASCADE) class Product(models.Model): STATUS ( (on_sale, 在售), (sold, 已售出), (off_sale, 已下架), ) title models.CharField(max_length100) description models.TextField() price models.DecimalField(max_digits8, decimal_places2) original_price models.DecimalField(max_digits8, decimal_places2, nullTrue, blankTrue) images models.JSONField(defaultlist) category models.ForeignKey(Category, nullTrue, on_deletemodels.SET_NULL) seller models.ForeignKey(User, on_deletemodels.CASCADE, related_nameproducts) status models.CharField(max_length20, choicesSTATUS, defaulton_sale) views models.PositiveIntegerField(default0) created_at models.DateTimeField(auto_now_addTrue)几个设计要点images用 JSONField 存图片 URL 列表比单独建一张图片表简单查询商品时少一次关联适合图片数量不多我限制最多 9 张的场景。seller的 related_name 设成products这样在用户对象上直接user.products.all()就能拿到我发布的商品不用再写过滤查询。删除商品时如果已经产生了订单不能直接把数据物理删掉所以我后面在视图里做了处理商品状态改下架而不是真正执行 DELETE。Category用自关联实现二级分类第一级是数码电子教材资料第二级是手机平板考研英语这种列表页筛选按一级分类聚合。2.3 DRF序列化与视图集怎么用最少的代码把CRUD做完整序列化器我主要用了 ModelSerializer关键点是把卖家信息嵌套进商品详情from rest_framework import serializers from .models import Product class ProductSerializer(serializers.ModelSerializer): seller_name serializers.CharField(sourceseller.username, read_onlyTrue) category_name serializers.CharField(sourcecategory.name, read_onlyTrue) class Meta: model Product fields __all__ read_only_fields (seller, views, status)注意read_only_fields里写了 seller这样 create 时不能由前端随意指定卖家而是在视图里取当前登录用户。视图集直接继承 ModelViewSetfrom rest_framework import viewsets, permissions from rest_framework.decorators import action from rest_framework.response import Response class ProductViewSet(viewsets.ModelViewSet): queryset Product.objects.filter(statuson_sale) serializer_class ProductSerializer def perform_create(self, serializer): serializer.save(sellerself.request.user) action(detailFalse, methods[get]) def mine(self, request): products request.user.products.all() serializer self.get_serializer(products, manyTrue) return Response(serializer.data)/api/products/mine/就是我的发布列表。类似的favorites、my_orders都可以用自定义 action 实现。这样一套下来商品模块的增删改查、条件过滤接口全部齐了代码量很小但功能很完整。2.4 认证与权限接入JWT后还要自己做哪些判断认证方案我选了 djangorestframework-simplejwt配置很简单REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ), DEFAULT_PERMISSION_CLASSES: ( rest_framework.permissions.IsAuthenticatedOrReadOnly, ), }schema还没有细说但这里要提一点全局权限设置成IsAuthenticatedOrReadOnly的意思是读操作公开写操作必须登录。这个正好符合二手交易系统的场景游客能看到商品列表和详情但发布、下单、收藏都要登录。还要写个自定义权限类防止用户改别人的商品from rest_framework.permissions import BasePermission class IsSellerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): if request.method in (GET, HEAD, OPTIONS): return True return obj.seller request.user然后放在 ProductViewSet 的permission_classes里。这行判断很重要不然任何登录用户都能把别人的商品改成已售出。2.5 查询优化与删除对象的那些坑商品列表页返回的数据里有卖家名字、分类名如果用框架默认的懒加载每查一个商品都会多打几条 SQL列表一长页面就慢。我在视图集里加了这两行queryset Product.objects.select_related(seller, category).filter(statuson_sale)select_related会把外键表 join 出来一次性查完几十条数据的性能差别肉眼可见。关于删除对象这是很多初学者容易踩坑的地方。DRF 的 ModelViewSet 默认 destroy 方法会直接物理删除数据库记录但对二手交易系统来说商品被删了不影响历史订单可一旦有买家已经下单删了商品订单详情页就没法展示商品信息了。所以我在视图集里重写了 destroydef perform_destroy(self, instance): instance.status off_sale instance.save()这样接口还是 DELETE 语义但实际执行的是下架数据完整性和展示完整性都保住了。这个思路在订单、用户这类核心数据上也通用。3. Vue3前端Vite初始化、路由鉴权、状态管理与axios封装3.1 从create vite到目录规划前端初始化我用的是官方推荐的 Vitenpm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router4 pinia axios element-plus项目目录我按习惯分了几个模块避免所有组件堆在一个 views 里面src/ api/ # 接口请求封装按模块拆分 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置 store/ # Pinia 状态 views/ # 页面组件这里有个实操建议不要按照页面拆分 api 文件而是按照资源类型拆。比如api/product.js统一管商品相关的所有接口api/order.js管订单api/user.js管登录注册。页面里只调用函数不直接拼 axios这样后端接口地址变了只改一个文件。3.2 路由配置静态路由、鉴权守卫和动态路由的取舍我的路由用的是常规静态配置页面不多没必要上动态权限路由。核心路由和守卫大概是这样const routes [ { path: /, component: HomeView }, { path: /product/:id, component: ProductDetailView }, { path: /login, component: LoginView }, { path: /publish, component: PublishView, meta: { requiresAuth: true } }, { path: /orders, component: OrdersView, meta: { requiresAuth: true } }, { path: /profile, component: ProfileView, meta: { requiresAuth: true } }, ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })热搜词里有vue3 vite 动态路由确实有朋友会想在权限系统里做动态路由。但我的经验是动态路由适合后台管理系统这种不同角色显示不同菜单的场景校园二手交易系统的角色就两种登录和未登录用 meta 标记加守卫足够了。动态路由还会带来刷新页面后菜单丢失的问题处理不好整个界面白屏没必要为了炫技加复杂度。3.3 Pinia与axios用户状态和接口请求的标准化处理用户状态我用 Pinia 管理主要保存用户信息和登录状态import { defineStore } from pinia import { login, getProfile } from /api/user export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: {}, }), actions: { async login(payload) { const data await login(payload) this.token data.access localStorage.setItem(token, data.access) this.userInfo await getProfile() }, logout() { this.token this.userInfo {} localStorage.removeItem(token) }, }, })axios 封装是所有接口请求的地基重点看拦截器import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) 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 { if (error.response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )拦截器里处理 401 自动跳登录页这个逻辑在 JWT token 过期时特别重要不然用户在详情页停留久了点收藏接口报错后什么反馈都没有。3.4 核心页面与组件拆分逻辑页面拆分我的原则是一个页面只管数据获取和页面编排卡片、弹窗、列表项这类独立 UI 单元拆成组件。商品卡片是复用度最高的首页列表、搜索结果、我的发布都用它。商品卡片组件接收一个 product 对象内部自己处理价格格式化和图片展示script setup import { computed } from vue const props defineProps({ product: { type: Object, required: true } }) const priceText computed(() ¥${Number(props.product.price).toFixed(2)}) /script template div classproduct-card click$router.push(/product/${product.id}) img :srcproduct.images?.[0] :altproduct.title / div classtitle{{ product.title }}/div div classprice{{ priceText }}/div /div /template价格格式化这种小逻辑用computed处理模板里就不会出现乱七八糟的运算。热搜词里有vue3 computed它是一个很基础但很常用的 API处理这种派生数据是最标准的用法。发布商品页有两个注意点富文本描述我用的 vue-quill-editor 的 Vue3 兼容版本如果你只是想要功能简单用 textarea 加多行文本也行富文本让描述排版好看但对信息检索帮助不大。分类选择器直接级联绑定一级分类和二级分类提交时把数据分别传给接口。4. 图片上传链路el-upload到Django存储的完整走通4.1 Django端MEDIA配置与Pillow依赖图片上传这块我先说后端因为大部分人卡在前端传了但后端收不到。Django 处理上传文件需要在 settings 里配置MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media然后在根路由加一个静态服务开发阶段让上传的图片能直接访问到from django.conf import settings from django.conf.urls.static import static urlpatterns [ ... ] if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)Pillow 必须安装不然 ImageField 会报错。如果你打算用 JSONField 存多图也可以用 FileField 先接收图片再手动拼接 URL但 ImageField 写起来更省事而且还能做尺寸校验。4.2 序列化器返回图片地址的坑坑点在哪里序列化器直接返回图片字段时Django 默认给的是相对路径/media/goods/xxx.jpg而你的前端部署在同源下这个问题不大但如果在开发环境前后端端口不同这个相对路径就会指向前端端口图片直接 404。解决办法是序列化器里自定义输出class ProductSerializer(serializers.ModelSerializer): image_list serializers.SerializerMethodField() class Meta: model Product fields (id, title, price, image_list, ...) def get_image_list(self, obj): request self.context.get(request) if not request: return obj.images return [request.build_absolute_uri(url) for url in obj.images]用request.build_absolute_uri把相对地址拼成完整地址这样无论前端在本地还是服务器都能正确访问图片。4.3 前端上传组件token携带、多图与回显前端我用 el-upload 实现有两个关键配置el-upload v-model:file-listfileList action/api/products/upload/ nameimage :headersuploadHeaders :limit9 list-typepicture-card :on-successhandleSuccess 第一个关键配置是 headers 里带 token因为上传接口要求登录const uploadHeaders { Authorization: Bearer ${localStorage.getItem(token)} }第二个是 on-success 回调里把后端返回的图片地址收集到表单的 images 数组const handleSuccess (response) { form.images.push(response.url) }提交发布商品时images 数组直接作为 JSON 传给后端。等回到商品编辑页时回显只需要把 images 数组转成 el-upload 需要的 fileList 格式每项有 url 字段就行。还有一个坑是跨域。开发阶段前端口是 5173后端口是 8000图片上传请求会跨域。django-cors-headers 要在中间件里加corsheaders.middleware.CorsMiddleware而且CORS_ALLOW_ALL_ORIGINS True只建议开发阶段用上线前必须关掉。5. 部署到宝塔Vue打包、uwsgi与nginx的联合配置5.1 后端部署虚拟环境、依赖和uwsgi部署我用的是宝塔面板整个过程和命令行操作没有本质区别主要是管理界面顺手。先在前端项目里导出依赖pip freeze requirements.txt把项目和前端压缩包传到服务器在宝塔里找到 Python 项目管理器新建虚拟环境。如果你不是用宝塔就自己在项目目录执行python -m venv venv source venv/bin/activate pip install -r requirements.txt关于django虚拟环境怎么删除这个问题我从热搜词里看到不少人在问。其实虚拟环境本质就是一个目录删除时直接删掉 venv 文件夹即可。但注意删之前确保当前 shell 已经退出虚拟环境也就是命令行里没有(venv)前缀直接deactivate即可。uwsgi 配置我单独建了一个uwsgi.ini[uwsgi] socket 127.0.0.1:8000 chdir /www/wwwroot/secondhand/backend module config.wsgi:application master true processes 4 threads 2 vacuum true daemonize /var/log/uwsgi/secondhand.log注意 socket 和 nginx 的 proxy_pass 地址要对应上然后启动uwsgi --ini uwsgi.ini5.2 前端构建与nginx站点配置前端在本地构建或者服务器构建都行npm run build生成 dist 目录后在宝塔的 Nginx 站点里配置三个关键 locationserver { listen 80; server_name your-domain.com; root /www/wwwroot/secondhand/dist; index 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; } location /media/ { alias /www/wwwroot/secondhand/backend/media/; } location / { try_files $uri $uri/ /index.html; } }第一个 location /api/ 把前端请求转发给 uwsgi第二个 location /media/ 暴露上传的图片第三个 location / 是 SPA 应用的核心配置了try_files之后刷新页面就不会 404 了。这是部署里最容易忘的一步Vue Router 使用 history 模式前端刷新/product/1时 nginx 会去找真实的文件路径找不到直接 404。加了try_files $uri $uri/ /index.html;后所有未知路径都回退到 index.html由前端路由接管。5.3 部署后常见的三个故障与排查思路部署完成不等于运行正常我线上踩过的坑基本集中在三个地方。第一是静态文件 404。Django admin 页面找不到 css需要先在 settings 里配置 STATIC_ROOT然后执行python manage.py collectstatic再把 STATIC_URL 对应的路径配到 nginx。第二是图片上传路径问题。媒体文件有时写到backend/media但 nginx 的 alias 指向另一个目录最简单的办法是在 Django 里打印MEDIA_ROOT确认绝对路径再和 nginx alias 路径对比。第三是端口冲突。uwsgi 起不来或者 nginx 502先看日志nginx error log 和 uwsgi log 都会给出原因最常见的其实是 Django 的ALLOWED_HOSTS没有加域名或 IPDjango 会报 400。宝塔部署的另一个建议修改任何 Python 代码后需要重启 uwsgi 才能生效不要只刷新页面。我在uwsgi.ini里加了touch-reload /www/wwwroot/secondhand/backend/reload.txt需要重启时执行touch reload.txt就行不用杀进程。最后分享一个扩展想法。这套系统把前后端接口设计得很干净商品、订单、用户模块都是标准 RESTful 接口后续如果想出微信小程序前端完全重写后端基本不用动。我在做的时候也刻意把图片上传、JWT认证这些都做成通用服务以后接其他项目直接复用。如果你是想拿这个项目练手建议不要一开始就追求花哨功能先把发布商品→浏览→下单→订单管理这条链路跑通再逐步加收藏、留言、销量排序这些锦上添花的东西。
返回列表