
做这类“信息发布类”全栈项目我最大的体会是它不像电商或者社交项目有那么复杂的业务规则但该有的前后端难点一个都不少。Vue 负责页面交互和状态管理Python 负责提供 API 和数据持久化两边一拆开发效率高后面扩展也舒服。这篇文章我就以“基于 Python Vue 的都市供求信息网”为例从项目拆解、环境搭建、数据库设计、后端 API 实现、前端页面联调到部署上线和常见坑完整走一遍。无论你是拿它做毕业设计还是想把它改造成本地生活服务小程序的后台都能直接参考。1. 项目整体设计与思路拆解1.1 这类“都市供求信息网”到底在做什么供求信息网的核心业务很简单就是让用户发布“我要卖什么 / 我要找什么”的信息然后让其他用户浏览、搜索、联系发布者。常见的形态包括二手交易、租房买房、招聘求职、家政服务、拼车顺风车等等。如果用一句话概括项目价值它用最小成本实现了“信息聚合 信息检索 用户发布”的闭环。和电商网站不同它不需要购物车、不需要支付、不需要订单履约所以非常适合作为全栈项目练手也适合用来快速搭建一个垂直领域的分类信息平台。我见过不少同学一开始就把这类项目想复杂了加了一堆优惠券、积分、会员等级之类的东西。其实没必要。做供求信息网核心功能就是三件事用户注册登录能区分普通用户和管理员信息分类管理用户能按分类发布、修改、下架信息浏览检索游客能看列表、看详情、按关键词搜、按分类筛。把这三条主线做好项目就已经完整了。其他都是锦上添花。1.2 为什么是 Python Vue 这套组合后端我用 Python具体框架选了 Django DRFDjango REST Framework。原因很简单Python 上手快ORM 写起来比原生 SQL 省太多事Django 自带 Admin 后台管理供求信息时直接改改配置就能用DRF 的序列化器让前端交互变得非常规范接口文档也可以自动生成。有人会问 Flask 行不行当然行。但如果项目里有多张表关联、需要做用户权限、需要后台管理Django 的自带功能能帮你省掉至少三天的开发量。Flask 更自由但你需要自己拼装很多组件。前端我选了 Vue 3 Vite Element Plus。Vue 的响应式机制和组件化开发方式本身就适合这种“列表 详情 表单”的站点Element Plus 能直接提供表格、表单、分页、弹窗这些成熟组件不用自己从零写样式。前后端分离的好处在于前端只关心页面渲染和用户交互后端只关心数据校验和业务逻辑两边通过 JSON 接口通信。这样项目结构清晰分工明确后续部署也可以把前端静态文件丢到 Nginx后端单独跑。1.3 目录结构与项目分层我前后端分两个目录放开发时用 Vite 代理解决跨域生产时用 Nginx 统一转发。一个推荐的项目结构是这样的supply-demand-platform/ ├── backend/ # Django 项目 │ ├── manage.py │ ├── requirements.txt │ ├── config/ # 项目配置settings、urls │ └── apps/ │ ├── users/ # 用户模块 │ ├── category/ # 分类模块 │ ├── info/ # 供求信息模块 │ └── common/ # 公共工具、分页、验证 └── frontend/ # Vue 3 项目 ├── package.json ├── vite.config.js ├── index.html └── src/ ├── api/ # axios 请求封装 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面组件 ├── components/ # 通用组件 └── utils/ # 工具函数这样做的最大好处是职责清晰。比如后端想加一个“信息审核”功能只需要在 info 应用里加字段和接口前端想改首页布局只需要动 views/Home.vue完全不影响其他模块。2. 环境搭建与核心依赖配置2.1 后端 Python 环境准备做 Python 项目第一步永远是建虚拟环境。千万别图省事直接装到全局否则不同项目的依赖版本互相冲突排查起来非常痛苦。# 创建虚拟环境 python -m venv venv # 激活环境Windows venv\Scripts\activate # 激活环境Mac / Linux source venv/bin/activateWindows 下如果提示执行策略限制先执行这个Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser然后安装依赖。我用的 Django 版本是 4.x 稳定版DRF 和 Django 配合也有固定版本要求建议直接按我这份 requirements.txt 来Django4.2.7 djangorestframework3.14.0 django-cors-headers4.3.1 PyMySQL1.1.0 Pillow10.1.0 python-dotenv1.0.0安装命令pip install -r requirements.txt数据库我日常开发用 MySQL部署也继续用 MySQL。Django 默认是 SQLite本地测试没问题但上线后并发一高SQLite 的写锁问题就会让人头疼。所以项目一开始就直接切到 MySQL 更省事。在 config/settings.py 里配置数据库DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: supply_db, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, } } }记得先手动创建数据库CREATE DATABASE supply_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;用 utf8mb4 而不是 utf8是因为用户发布的信息里很可能出现 emoji 表情utf8 存不了四个字节的字符。2.2 前端 Vue 环境准备前端我用 Vite 创建 Vue 3 项目Node.js 版本要求在 16 以上。装完 Node 后通过 npm 或 pnpm 管理依赖。# 创建 Vue 3 项目 npm create vitelatest frontend -- --template vue # 进入目录 cd frontend # 安装基础依赖 npm install # 安装路由、状态管理、UI 组件库 npm install vue-router4 pinia element-plus axios装完依赖后Vite 开发服务器的默认端口是 5173Django 跑在 8000 端口。开发时跨域问题通过 Vite 代理解决在 vite.config.js 里配置import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })这样前端代码里请求/api/info/list时实际会被转发到http://127.0.0.1:8000/api/info/list浏览器层面不会出现跨域报错。生产环境则不需要这个代理Nginx 会把/api路径统一转发给后端的 Gunicorn 服务。注意不要在生产环境依赖 Vite 代理那只为本地开发服务。生产环境要用 Nginx 或 Caddy 这类反向代理来处理前后端路由。2.3 安装过程里的常见坑我帮别人排查过很多次环境问题发现 80% 都出在这几个地方第一个坑Python 装完但命令行里找不到。Windows 下安装后检查一下python --version是否正常。如果之前装过多个 Python 版本建议直接用py -3.10这样的方式指定版本或者在系统环境变量里把 Python 安装路径和 Scripts 路径都配置好。第二个坑pip 安装超时。国内网络环境下经常遇到解决办法是改成国内镜像源pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple第三个坑Node 版本太高导致安装依赖失败。比如某些老项目不兼容 Node 18 以上版本会报Unsupported engine警告。如果出现这种问题用 nvm 切换 Node 版本。但我们的 Vue 3 项目一般没有这个问题。3. 数据库表设计与建模思路数据表设计是整个项目的地基我通常在写代码前先画出所有表和关系。供求信息网的核心表包括用户表、分类表、信息表、图片表、收藏表。3.1 用户表用户表需要保存用户名、密码哈希、手机号、头像、角色等信息。密码绝对不能明文存储Django 自带make_password和check_password来做哈希校验。3.2 分类表分类表是树形结构因为有些信息平台会分两级分类比如“二手交易”下面还有“手机数码”“家具家电”。用parent自关联字段来实现层级关系。3.3 信息表信息表是核心字段包括标题、内容、价格、分类、发布者、所在城市、联系方式、状态、发布时间、浏览次数等。一个用户能发多条信息一个分类下有多条信息都是外键关系。3.4 图片表一个信息可以有多张图片所以图片单独建一张表通过外键关联到信息表。这样查询信息列表时不需要一次性把大字段加载出来加载详情时才去查图。3.5 收藏表收藏功能是“用户和信息”的多对多关系可以用一张中间表实现。Django 里也可以用ManyToManyField但用中间表的好处是以后可以扩展“收藏时间”“收藏备注”等字段。下面是核心模型代码我贴的是精简版# apps/category/models.py from django.db import models class Category(models.Model): name models.CharField(max_length50, verbose_name分类名称) parent models.ForeignKey( self, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namechildren, verbose_name父级分类 ) sort models.IntegerField(default0, verbose_name排序) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 信息分类 verbose_name_plural verbose_name def __str__(self): return self.name# apps/info/models.py from django.db import models from django.contrib.auth.models import User from apps.category.models import Category class Info(models.Model): STATUS_CHOICES ( (published, 已发布), (draft, 草稿), (disabled, 已下架), (reviewing, 审核中), ) title models.CharField(max_length100, verbose_name标题) content models.TextField(verbose_name详细描述) price models.DecimalField(max_digits10, decimal_places2, nullTrue, blankTrue, verbose_name价格) city models.CharField(max_length30, verbose_name城市) district models.CharField(max_length30, blankTrue, verbose_name区域) contact_name models.CharField(max_length30, verbose_name联系人) contact_phone models.CharField(max_length20, verbose_name联系电话) publisher models.ForeignKey(User, on_deletemodels.CASCADE, related_nameinfos, verbose_name发布者) category models.ForeignKey(Category, on_deletemodels.PROTECT, related_nameinfos, verbose_name分类) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpublished, verbose_name状态) views_count models.IntegerField(default0, verbose_name浏览次数) created_at models.DateTimeField(auto_now_addTrue, verbose_name发布时间) updated_at models.DateTimeField(auto_nowTrue, verbose_name更新时间) class Meta: ordering [-created_at] verbose_name 供求信息 verbose_name_plural verbose_name def __str__(self): return self.title分类的on_deletemodels.PROTECT是刻意的如果分类下面还有信息就不允许直接删分类防止出现“孤儿数据”。这在真实项目里比CASCADE更安全。写好模型后执行迁移命令python manage.py makemigrations python manage.py migrate4. 后端接口开发与核心逻辑实现4.1 接口设计概览前后端分离项目的关键约定是接口规范。我习惯使用 RESTful 风格资源用名词动作用 HTTP 方法。供求信息网站点主要接口如下方法路径功能是否需要登录POST/api/users/register/用户注册否POST/api/users/login/用户登录否GET/api/category/list/分类列表否GET/api/info/list/信息列表支持分页、搜索、筛选否GET/api/info/detail/{id}/信息详情否POST/api/info/publish/发布信息是PUT/api/info/{id}/update/修改自己的信息是DELETE/api/info/{id}/delete/下架删除是POST/api/favorite/add/收藏是DELETE/api/favorite/remove/取消收藏是4.2 实现列表接口最关键的一个接口列表接口是整个项目最常被调用的接口也是性能关键点。它要支持分页、分类筛选、关键词搜索、城市筛选、排序。用 DRF 实现时我推荐用ListAPIView搭配filter_backends# apps/info/views.py from rest_framework import generics, filters from rest_framework.pagination import PageNumberPagination from .models import Info from .serializers import InfoListSerializer class InfoPagination(PageNumberPagination): page_size 12 page_size_query_param page_size max_page_size 50 class InfoListView(generics.ListAPIView): queryset Info.objects.filter(statuspublished).select_related(category, publisher) serializer_class InfoListSerializer pagination_class InfoPagination filter_backends [filters.SearchFilter, filters.OrderingFilter] search_fields [title, content] ordering_fields [created_at, views_count, price] ordering [-created_at] def get_queryset(self): queryset super().get_queryset() category_id self.request.query_params.get(category) city self.request.query_params.get(city) keyword self.request.query_params.get(keyword) if category_id: # 包含子分类 child_ids Category.objects.filter(parent_idcategory_id).values_list(id, flatTrue) queryset queryset.filter(category_id__in[category_id, *child_ids]) if city: queryset queryset.filter(citycity) if keyword: queryset queryset.filter(Q(title__icontainskeyword) | Q(content__icontainskeyword)) return querysetselect_related的作用是连表查询时一次性把外键对象加载出来避免循环查询。如果没有这行列表页渲染 20 条信息就会产生 20 次额外的分类查询和用户查询数据库压力会大很多。这是新手最容易忽略的性能问题。4.3 发布与修改信息的权限控制发布信息必须校验用户登录。Django 自带IsAuthenticated权限类直接配置在视图上即可。from rest_framework.permissions import IsAuthenticated class InfoPublishView(generics.CreateAPIView): queryset Info.objects.all() serializer_class InfoPublishSerializer permission_classes [IsAuthenticated] def perform_create(self, serializer): serializer.save(publisherself.request.user)修改和删除时要额外校验“这条信息是不是当前用户发布的”。这里的写法值得注意不能只靠get_object()要同时过滤publisher字段。from rest_framework.exceptions import PermissionDenied class InfoUpdateView(generics.UpdateAPIView): queryset Info.objects.all() serializer_class InfoUpdateSerializer permission_classes [IsAuthenticated] def get_queryset(self): return super().get_queryset().filter(publisherself.request.user) def perform_update(self, serializer): # 防止用户把状态随意改回 published if serializer.instance.status ! published and serializer.validated_data.get(status) published: raise PermissionDenied(该信息已下架不能重新上架请联系管理员) serializer.save()4.4 用户注册登录的逻辑用户注册用 Django 自带的 User 模型但密码字段需要处理。DRF 的CreateAPIView默认不会自动调用create_user所以要在序列化器里重写create方法from django.contrib.auth.models import User from django.contrib.auth.hashers import make_password from rest_framework import serializers class RegisterSerializer(serializers.ModelSerializer): username serializers.CharField() password serializers.CharField(write_onlyTrue, min_length6) confirm_password serializers.CharField(write_onlyTrue) class Meta: model User fields [username, password, confirm_password] def validate_username(self, value): if User.objects.filter(usernamevalue).exists(): raise serializers.ValidationError(用户名已存在) return value def validate(self, attrs): if attrs[password] ! attrs[confirm_password]: raise serializers.ValidationError(两次密码不一致) return attrs def create(self, validated_data): validated_data.pop(confirm_password) user User.objects.create_user( usernamevalidated_data[username], passwordvalidated_data[password] ) return user登录用 DRF 自带的TokenAuthentication最省事。配置好后登录接口返回一个 token前端把它存起来后续请求带在 Header 里Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b在 settings.py 里配置认证方式REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework.authentication.TokenAuthentication, rest_framework.authentication.SessionAuthentication, ], DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 12, }如果要做长期项目建议后面换成 JWT 认证因为 token 可以设置过期时间更安全。但做课程设计或快速上线DRF 自带的 token 足够。5. 前端核心页面与交互实现5.1 前端整体路由结构前端路由用 vue-router 4按页面拆分成这样// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(../views/Home.vue) }, { path: /category/:id, name: category-list, component: () import(../views/CategoryList.vue) }, { path: /info/:id, name: info-detail, component: () import(../views/InfoDetail.vue) }, { path: /publish, name: info-publish, component: () import(../views/InfoPublish.vue), meta: { requiresAuth: true } }, { path: /login, name: login, component: () import(../views/Login.vue) }, { path: /register, name: register, component: () import(../views/Register.vue) }, { path: /user/center, name: user-center, component: () import(../views/UserCenter.vue), meta: { requiresAuth: true } }, { path: /user/favorites, name: user-favorites, component: () import(../views/UserFavorites.vue), meta: { requiresAuth: true } }, { path: /:pathMatch(.*)*, component: () import(../views/NotFound.vue) } ] const router createRouter({ history: createWebHistory(), routes }) // 全局前置守卫未登录不能访问需要认证的页面 router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ name: login, query: { redirect: to.fullPath } }) } else { next() } }) export default router5.2 列表页核心实现信息卡片流信息列表页是用户最常看到的页面需要考虑加载状态、空状态、分页逻辑。Element Plus 的el-card配合 v-for 就能做出不错的列表。!-- src/views/Home.vue模板部分 -- template div classinfo-list el-input v-modelkeyword placeholder搜索标题或内容 clearable keyup.enterhandleSearch / el-select v-modelcategoryId placeholder选择分类 clearable changehandleSearch el-option v-foritem in categories :keyitem.id :labelitem.name :valueitem.id / /el-select el-button typeprimary clickhandleSearch搜索/el-button div v-loadingloading classcard-grid el-card v-forinfo in infoList :keyinfo.id classinfo-card clickgoDetail(info.id) template #header div classcard-header span{{ info.title }}/span /div /template p classprice{{ info.price }}/p p classmeta{{ info.city }} · {{ info.category_name }} · {{ info.created_at }}/p /el-card /div el-pagination v-model:current-pagecurrentPage :page-sizepageSize :totaltotal layoutprev, pager, next current-changefetchList / /div /template对应的请求逻辑我通常把所有接口统一封装在src/api/info.js里import request from ../utils/request export function getInfoList(params) { return request.get(/api/info/list/, { params }) } export function getInfoDetail(id) { return request.get(/api/info/detail/${id}/) } export function publishInfo(data) { return request.post(/api/info/publish/, data) }src/utils/request.js是 axios 实例负责拦截器和 token 注入import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /, timeout: 10000 }) // 请求拦截器自动带上 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Token ${token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { localStorage.removeItem(token) router.push({ name: login }) } else if (status 403) { ElMessage.error(没有权限执行此操作) } else if (status 500) { ElMessage.error(服务器开小差了请稍后重试) } else { const msg error.response?.data?.detail || 请求失败 ElMessage.error(msg) } return Promise.reject(error) } ) export default request5.3 发布页的关键校验发布信息表单前后端都要校验。前端校验是为了用户体验后端校验是为了数据安全两者一个都不能少。发布页的表单字段包括标题、分类、城市、区域、价格、联系人、联系电话、详细描述、图片上传。Element Plus 的表单校验规则const publishRules { title: [ { required: true, message: 请输入标题, trigger: blur }, { min: 5, max: 100, message: 标题长度在 5 到 100 个字符之间, trigger: blur } ], category: [ { required: true, message: 请选择分类, trigger: change } ], contact_phone: [ { required: true, message: 请输入联系电话, trigger: blur }, { pattern: /^1[3-9]\d{9}$/, message: 手机号格式不正确, trigger: blur } ] }图片上传用 Element Plus 的el-upload要注意后端接收文件的接口需要支持multipart/form-data。我通常会配置 upload 组件的action指向后端的图片上传接口或者用http-request自定义上传逻辑。5.4 详情页浏览量统计详情页有个小逻辑每次打开详情浏览数加 1。但要防止刷新一次就重复计数这里使用 sessionStorage 来标记同一会话内只统计一次。const infoId route.params.id const viewedKey info_viewed_${infoId} if (!sessionStorage.getItem(viewedKey)) { await incrementViews(infoId) sessionStorage.setItem(viewedKey, 1) }后端对应接口class InfoDetailView(generics.RetrieveAPIView): queryset Info.objects.all() serializer_class InfoDetailSerializer def retrieve(self, request, *args, **kwargs): instance self.get_object() # 浏览量自增 Info.objects.filter(pkinstance.pk).update(views_countF(views_count) 1) serializer self.get_serializer(instance) data serializer.data data[views_count] instance.views_count 1 return Response(data)这里的F(views_count)是数据库层面的原子操作避免并发时计数不准确。5.5 用户中心用户中心主要展示用户发布的信息列表和收藏列表。我把它做成一个带标签页的布局用el-tabs区分“我发布的”和“我的收藏”。发布管理里面有“编辑”“下架”“删除”三个操作。下架使用 PUT 请求把状态改为disabled不是直接删除这样可以保留数据后续管理员也有据可查。6. 联调、部署与常见问题排查6.1 跨域问题最典型的联调痛点开发环境下即使 Vite 配了代理如果路径没写对还是会遇到跨域。生产环境前后端分离部署后跨域更是家常便饭。我建议后端直接配置django-cors-headers这样无论从哪个域名访问 API 都能兼容。settings.py 里配置INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] CORS_ALLOW_ALL_ORIGINS True # 开发环境先这样生产改成具体域名注意CorsMiddleware要放在CommonMiddleware之前否则某些场景下跨域响应头不会生效。6.2 生产部署方案我推荐部署方案是Nginx Gunicorn Django MySQL前端构建后的静态文件交给 Nginx。前端构建npm run build生成dist目录把里面内容上传到服务器比如放到/var/www/supply_frontend/。后端用 Gunicorn 跑gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3Nginx 配置示例server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/supply_frontend; index index.html; location / { try_files $uri $uri/ /index.html; } # API 反向代理 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; } # 上传的图片等静态资源 location /media/ { alias /path/to/backend/media/; } }try_files $uri $uri/ /index.html是 SPA 路由的核心没有这一行刷新非首页路由时会 404。6.3 常见问题速查表问题现象可能原因解决办法前端请求接口报 404Nginx 配了location /的 try_files但没把/api单独转发检查 Nginx 配置文件确保/api/有独立 location接口报 403 跨域没安装 corsheaders 或配置顺序不对参考 6.1 配置用户登录后刷新 token 消失localStorage 存了 token但刷新时没有做用户信息恢复页面加载时根据 token 调一次/api/users/profile/图片上传成功但访问 404Django 生产环境没有配置 MEDIA_URL 的静态服务Nginx 里加/media/的 alias时间显示是 UTCDjango 默认时区不是本地时区settings.py 里改TIME_ZONE Asia/Shanghai并设USE_TZ True修改密码后旧 token 仍有效DRF Token 不会自动失效需要重写 token 刷新逻辑删除旧 token 再生成新 token6.4 一个容易被忽略的性能问题N1 查询列表接口我做过性能优化用select_related预查询外键关系。如果你发现接口响应越来越慢首先要怀疑的就是 N1 查询。最简单的方法Django Debug Toolbar 看一下 SQL 执行数量。如果列表页 20 条数据却执行了几十次 SQL那基本就是没用select_related或prefetch_related。再就是给常用的查询字段加索引class Info(models.Model): ... class Meta: indexes [ models.Index(fields[status, -created_at]), models.Index(fields[category, status]), ]加索引后按状态排序、按分类筛选这两类高频查询会有明显改善。7. 功能扩展与后续优化建议7.1 从信息展示升级为带互动的平台基础供求信息网做完后想让它更像一个完整产品可以考虑加这些功能信息审核机制。用户发布的信息先进入“审核中”状态管理员在 Django Admin 后台审核通过后才公开展示。实现上就是在信息表加status字段管理员可改。这个功能对真实运营几乎是必须的否则垃圾信息会淹没平台。站内消息。用户对某条信息感兴趣时可以点击“联系发布者”给发布者发站内消息。用 Django Channels 可以做 WebSocket 实时通知或者先用简单的表实现站内信。信息到期自动下架。普通分类信息有有效期比如 30 天。定时任务用 Celery beat 每天跑一次把超过有效期仍未续期的信息状态改为disabled。地图展示。如果用到了“城市”和“区域”字段可以在详情页集成腾讯地图或高德地图把位置用经纬度展示出来。前端用地图 JS SDK后端给经纬度字段即可。7.2 Vue 前端后续可以打磨的点前端有太多细节能提升体验骨架屏列表页数据加载前用el-skeleton显示占位视觉上比 loading 转圈高级很多虚拟滚动如果列表页一页 50 条、每条卡片信息量大页面会卡可以用虚拟滚动组件优化富文本编辑器信息描述支持富文本后用户体验提升明显可以集成 wangEditor 或 Quill图片懒加载使用 v-lazy 指令图片进入视口再加载减少首屏流量。7.3 数据层面要考虑的问题随着用户量上来搜索就是个大问题。早期用title__icontains做模糊搜索数据量到十万条以后性能会明显下降。这时候有两条路一是用 MySQL 全文索引FULLTEXT简单有效但分词效果一般二是引入 Elasticsearch 或 Meilisearch 做专业搜索。Meilisearch 部署简单支持中文分词对个人项目来说性价比最高。考虑到排序和筛选可以把价格、浏览量、发布时间等字段建联合索引。如果线上数据多建议再引入 Redis 做列表页缓存把热门的分类列表页缓存 60 秒压力立刻缓解。8. 写在最后的实操心得做完全部功能后我最大的感受是这个项目真正考验人的不是某个单一技术而是前后端联调时的整体思维。比如列表页的分页前端展示的页码要和后端page参数对齐筛选条件一多前端要正确拼接 query 参数后端要用filter_backends正确解析上传图片时前端要处理 FormData后端要处理 request.FILES。每个环节都不难但串起来就是一个完整的项目经验。我自己踩过的一个印象深刻的坑是开发时一直用 Vite 代理觉得跨域不是问题结果部署到服务器后用户浏览器请求接口直接报 CORS 错误。原因就是后端没配 corsheaders开发环境被 Vite 代理掩盖了问题。现在我做前后端分离项目第一步就会把后端 CORS 配好这样开发、测试、生产环境的行为才一致。还有一个经验是数据库表设计一定要预留扩展字段。比如信息表一开始就加了status字段后来加审核功能时基本没改表结构只是加了几个枚举值。如果当初没设计这个字段后面改起来就要迁移数据非常麻烦。如果有读者想在这套基础上继续扩展我建议先做管理员后台的数据统计模块把每日发布量、分类分布、热门城市这些数据可视化出来既能作为面试时展示的项目亮点也是让平台从“能用”走向“好用”的第一步。