简介:这是一套基于Django的4S店客户管理系统项目源码,面向Python/Django/Vue学习者及需要完成毕业设计、课程设计或工程实训的学生。系统覆盖售后申请、审核、查询、安排、处理等完整环节,区分用户、管理员、员工三类角色,分别实现售后与车辆信息添加、订单/用户/售后安排管理、售后处理与查询等功能,具备清晰的业务闭环。压缩包共370个文件,以Vue前端组件、Python后端源码、SVG/JPG/PNG等静态资源为主,另含SQL数据库脚本、依赖清单及安装/运行bat脚本,整体容量约11.47MB,结构规整便于快速部署和二次开发。当前已有1819人学习/下载,适合作为Web全栈实战参考。内含可直接运行的源码、数据库文件和文档,结合配置文件备份、前端页面组件等素材,可辅助理解Django与Vue前后端交互逻辑、数据库表结构设计以及项目打包运行流程,为独立开发类似管理系统提供有价值的参考。
1. Django 做 4S 店客户管理,到底在管理什么
一个 4S 店的日常,不是卖车那一下子,而是销售顾问整天围着客户转:线上留资的、进店看车的、试驾完犹豫的、谈好价格又反悔的,每一条线索都得有人跟进、有记录、有下一步动作。传统做法是记在 Excel 和微信聊天记录里,客户一多就乱,哪个销售跟的单、客户卡在哪个环节、这个月成交率是多少,全凭一张嘴。这套基于 Django 的 4S 店客户管理系统,就是把“潜客→跟进→试驾→成交→售后”这条链路由表格变成一套可查询、可统计的数据闭环。后端用 Django 做接口和业务逻辑,前端用 Vue 做操作页面,既覆盖了毕设要求的 Python 后端功力,又能演示前后端分离的项目结构。适合两类人:一是正在做毕业设计的 Python 方向学生,想找一个能跑通完整流程的实战源码;二是刚入行做业务系统的开发,想看看 Django 的多模块项目里,用户、客户、跟进、订单之间是怎么解耦又怎么关联的。
2. 项目骨架怎么拆:Django 模块划分与三个必坑的配置项
2.1 为什么选 Django + Vue,而不是纯 Django 模板
4S 店客户管理系统的核心操作其实很常规:新增客户、改状态、记跟进、查订单、看统计。这类系统用 Django 自带的模板系统也能做,admin 后台甚至可以免费送你一套管理界面,但有两个问题:一是模板页面做不出销售看板那种交互效果,二是答辩时考官盯着问“前后端怎么分离的”,如果你回答“模板渲染的”,技术亮点直接少一半。
所以这个项目把 Django 当成纯后端 API 来用,前端用 Vue 单独维护。Django 负责三件事:数据建模、RESTful 接口、权限校验;Vue 负责展示和交互。拆开的代价是你得同时维护两套代码、处理跨域、管理 token,一个人做毕设容易顾此失彼。我的建议是:Vue 只写登录页、客户列表页、跟进记录页、统计看板这四个核心页面,其余数据维护类的功能全部交给 Django admin。admin 不是摆设,它是你批量录入测试数据的利器,也是答辩时展示“Django 自带后台”的加分项。
2.2 建项目与创建 app:我把哪些模块拆成了独立 app
初始化项目时,我习惯把配置目录命名为 config,业务代码全部收敛到一个 apps 包下,这样根目录不至于散落一堆 app 文件夹。下面是创建命令:
# 1. 创建 Django 项目,配置目录叫 config django-admin startproject config . # 2. 创建独立的业务 app,按业务边界拆分 python manage.py startapp apps/user python manage.py startapp apps/customer python manage.py startapp apps/followup python manage.py startapp apps/order python manage.py startapp apps/dashboard这里说明一下拆分的逻辑。apps/user 负责登录认证和员工账号,虽然没有写太多业务字段,但它是 JWT 鉴权的基础;apps/customer 是客户主表,包括基本信息和当前销售状态;apps/followup 记录每一次跟进动作,比如电话、微信、试驾、到店;apps/order 存放成交订单,金额、付款状态、交车日期都在这里;apps/dashboard 不做数据存储,只负责聚合统计接口,比如按周统计成交量、按来源统计线索量。拆成五个 app 不是炫技,而是为了让你在答辩时能讲清楚“高内聚、低耦合”这句话,每个 app 都有自己的 models、views、serializers,改订单逻辑不会动到客户逻辑。
需要注意一个细节:startapp 的路径写法直接传 apps/user 是可行的,Django 会以 apps 作为父包。但创建完成后,你需要在 apps 目录下放一个空的init.py,同时把 config/settings.py 里的 INSTALLED_APPS 写成带完整路径的应用名,比如 "apps.customer",而不是 "customer"。
2.3 settings 配置的经验值:数据库、时区、静态文件与跨域
settings.py 是整个项目最容易翻车的地方。很多毕设源码下载下来跑不起来,一半问题都出在配置漏项或者配置冲突上。我这里给一份经过验证的配置骨架:
# config/settings.py from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "corsheaders", "apps.user", "apps.customer", "apps.followup", "apps.order", "apps.dashboard", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", # CORS 中间件必须放在 CommonMiddleware 之前 "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] TIME_ZONE = "Asia/Shanghai" USE_TZ = True DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "car4s", "USER": "root", "PASSWORD": "your_password", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": {"charset": "utf8mb4"}, } } CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", ] REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 10, "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), }逐条说几个关键参数。TIME_ZONE 配成 Asia/Shanghai 只是第一步,USE_TZ 保持 True,Django 在数据库里存的一律是 UTC 时间,输出到前端时才转成上海时间,这个机制后面会专门说坑。DATABASES 我建议直接用 MySQL,不要用 SQLite 凑合,因为毕设答辩现场很可能要求你换电脑演示,MySQL 的库可以通过 dump 文件迁移,SQLite 的库文件一换环境就各种路径报错。CORS_ALLOWED_ORIGINS 只允许 Vue 开发服务器的两个地址,别图省事写成 CORS_ALLOW_ALL_ORIGINS=True,答辩时考官问“跨域怎么处理的”,你能说清楚具体允许了哪些源,比一句“我全放开了”体面得多。
3. 数据库建模与 ORM 查询:客户、跟进、订单三张核心表的关联
3.1 客户表(Customer)字段设计与选择理由
客户是这套系统的数据核心,所有功能都围绕它展开。建模的时候不要一味堆字段,而是要站在销售的业务视角想:一条客户记录需要支撑哪些动作?筛选高意向客户、按来源统计、跟进时看到客户预算和意向车型。下面是我在项目里实际使用的客户表模型:
# apps/customer/models.py from django.db import models class Customer(models.Model): """客户主表:每个进过线的潜在客户占一行""" STATUS_CHOICES = [ ("new", "新建线索"), ("intention", "高意向"), ("bargaining", "议价中"), ("deal", "已成交"), ("lose", "战败"), ] SOURCE_CHOICES = [ ("online", "线上留资"), ("store", "进店"), ("referral", "转介绍"), ("old_customer", "老客户增购"), ] name = models.CharField("客户姓名", max_length=32) phone = models.CharField("手机号", max_length=20, unique=True) gender = models.CharField("性别", max_length=1, choices=[("M", "男"), ("F", "女")], blank=True, default="") status = models.CharField("当前状态", max_length=16, choices=STATUS_CHOICES, default="new", db_index=True) source = models.CharField("线索来源", max_length=16, choices=SOURCE_CHOICES, default="online", db_index=True) car_model = models.CharField("意向车型", max_length=64, blank=True, default="") budget = models.DecimalField("预算范围", max_digits=10, decimal_places=2, null=True, blank=True) note = models.TextField("备注", blank=True, default="") created_at = models.DateTimeField("创建时间", auto_now_add=True) updated_at = models.DateTimeField("更新时间", auto_now=True) class Meta: db_table = "customer" ordering = ["-updated_at"] def __str__(self): return f"{self.name}({self.phone})"字段选择上说明几个理由。phone 加 unique 约束,是为了防止销售重复录入同一个手机号,代码里再做一层 get_or_create 兜底,比单纯靠人去判断靠谱得多。status 用 CharField 加 choices,而不用 IntegerField,因为可读性更好,序列化输出时直接能用,查询也直观,比如 filter(status="intention")。budget 用 DecimalField 而不用 FloatField,是因为价格计算不允许浮点误差,这是一个很细节但答辩时能加分的点。created_at 用 auto_now_add 并且不允许手动改,updated_at 用 auto_now,每次保存自动刷新,方便按更新时间排序。
3.2 跟进记录(FollowUp)与订单(Order)的外键关系
客户表只是线索池,真正体现业务深度的是跟进记录。一个客户可以被销售跟进多次,所以这是一对多关系;而订单则是客户成交后的产物,一个客户理论上可以下多台车的订单,但业务上更常见的是一个客户对应一单。这两张表的外键设计直接决定删除行为和级联逻辑:
# apps/followup/models.py class FollowUp(models.Model): FOLLOW_TYPE = [ ("phone", "电话"), ("wechat", "微信"), ("test_drive", "试驾"), ("store", "到店"), ] customer = models.ForeignKey( "customer.Customer", related_name="followups", on_delete=models.CASCADE, verbose_name="关联客户", ) follow_type = models.CharField("跟进方式", max_length=16, choices=FOLLOW_TYPE, default="phone") summary = models.TextField("跟进内容", max_length=500) next_time = models.DateTimeField("下次跟进时间", null=True, blank=True) created_at = models.DateTimeField("跟进时间", auto_now_add=True) class Meta: db_table = "followup" ordering = ["-created_at"]# apps/order/models.py class Order(models.Model): customer = models.ForeignKey( "customer.Customer", related_name="orders", on_delete=models.PROTECT, # 订单保护客户,不允许随便删 verbose_name="成交客户", ) car_model = models.CharField("成交车型", max_length=64) order_amount = models.DecimalField("成交金额", max_digits=12, decimal_places=2) pay_status = models.CharField( "付款状态", max_length=16, choices=[("unpaid", "未付款"), ("partial", "部分付款"), ("paid", "已付款")], default="unpaid", ) delivery_date = models.DateField("交车日期", null=True, blank=True) created_at = models.DateTimeField("下单时间", auto_now_add=True) class Meta: db_table = "order" ordering = ["-created_at"]这里最关键的是 on_delete 参数的选择。FollowUp 的 customer 外键用了 CASCADE,意思是删除一条客户记录时,这个客户的所有跟进记录自动删除,这是合理的,跟进记录是客户的附属数据,客户都没了,跟进记录留着没有业务意义。但 Order 的 customer 外键用了 PROTECT,这是血泪经验换来的:订单涉及成交金额、交车时间,属于不能丢的业务凭证。如果订单外键也设成 CASCADE,删除客户时会把订单连带删掉,成交数据瞬间归零,这在真实业务里是不可接受的。PROTECT 的效果是:只要这个客户名下存在任意一张订单,Django 就会抛 ProtectedError,禁止删除客户,必须先把订单处理掉。
3.3 常用 ORM 查询与删除对象的正确姿势
模型建好之后,真正的业务逻辑都体现在查询上。这里列几个我在项目里反复用到的 ORM 片段,它们覆盖了条件查询、分组统计、删除操作:
# 业务一:销售顾问今天要跟进的高意向客户 from datetime import date from django.db.models import Count from apps.customer.models import Customer today_customers = Customer.objects.filter( status__in=["intention", "bargaining"], updated_at__date=date.today(), ).order_by("-updated_at") # 业务二:按线索来源统计潜客数量,用于前端看板 source_stats = ( Customer.objects .values("source") .annotate(total=Count("id")) .order_by("-total") ) # 业务三:清理 2025 年之前的战败客户,连同跟进记录一起删 lose_customer = Customer.objects.filter( status="lose", created_at__lt="2025-01-01" ).first() if lose_customer: lose_customer.delete()关于删除对象,这里有三个容易被忽略的点。第一,QuerySet 是惰性的,filter 执行后并没有真正发出 SQL,只有遍历或者调用 .first()、.delete() 这些求值动作才会触达数据库,所以上面第三个例子先调 .first() 再 delete(),是安全的写法。第二,customer.delete() 会触发 CASCADE 级联删除,把 followups 一并清掉,但因为 Order 外键是 PROTECT,如果客户名下有订单,这一行会直接抛异常,你在代码里需要捕获 ProtectedError 然后提示“请先处理该客户的订单”。第三,SQLite 下删除记录后自增 id 不会回退,新增客户会接着用被删掉的最大 id 加一,这是正常现象,不要以为是 bug。如果想让 id 连续,只能在迁移数据时重建表,毕设里没有必要。
4. 登录鉴权与 Vue 联调:从 JWT 到客户列表分页接口
4.1 基于 JWT 的登录流程与 token 存储
前后端分离之后,Django 自带的 session 认证就不能直接用了,因为前端不是浏览器渲染的模板,session 的 cookie 机制跨域之后非常别扭。这个项目用的是 JWT,具体实现是 rest_framework_simplejwt 这个库,它提供了现成的登录接口,省去手写 token 签发逻辑。路由配置如下:
# config/urls.py from django.contrib import admin from django.urls import path, include from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ path("admin/", admin.site.urls), path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/customers/", include("apps.customer.urls")), ]默认的 TokenObtainPairView 接收 username 和 password,返回两个 token:access 是短期令牌,默认 5 分钟过期,refresh 是用来换新 access 的长期令牌。前端拿到 access 后,每次请求在 Authorization 头带上 Bearer token,后端通过 JWTAuthentication 校验身份。access token 的过期时间在 settings.py 的 SIMPLE_JWT 配置里可以调,我一般把 ACCESS_TOKEN_LIFETIME 改成 60 分钟,避免演示到一半突然 401。token 存哪里是个值得纠结的问题:localStorage 最简单,刷新页面不丢,但存在 XSS 风险;sessionStorage 关标签页就丢;内存变量一刷新就没。毕设场景建议用 localStorage,你可以把 XSS 风险作为答辩时的“后续优化方向”说出来,而不是踩这个坑。
4.2 客户列表页的接口设计与分页参数
客户列表页是 Vue 端最核心的页面,需要支持分页、按状态筛选、按手机号搜索。Django REST framework 的 ViewSet 能快速生成标准接口,配合分页类自动返回页码结构。先看序列化器:
# apps/customer/serializers.py from rest_framework import serializers from .models import Customer class CustomerSerializer(serializers.ModelSerializer): status_display = serializers.CharField(source="get_status_display", read_only=True) class Meta: model = Customer fields = ["id", "name", "phone", "gender", "status", "status_display", "source", "car_model", "created_at", "followup_count"]注意这里我没有直接在序列化器里写 followup_count = serializers.IntegerField(source="followups.count"),虽然这样能返回跟进次数,但它会在每个客户上额外执行一次 count SQL,分页返回 10 条就多 10 条查询,是典型的 N+1 问题。正确的做法是在 ViewSet 里用 annotate 提前聚合:
# apps/customer/views.py from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from django.db.models import Count from .models import Customer from .serializers import CustomerSerializer class CustomerViewSet(viewsets.ModelViewSet): queryset = Customer.objects.annotate( followup_count=Count("followups") ).order_by("-updated_at") serializer_class = CustomerSerializer permission_classes = [IsAuthenticated] def get_queryset(self): qs = super().get_queryset() status = self.request.query_params.get("status") keyword = self.request.query_params.get("keyword") if status: qs = qs.filter(status=status) if keyword: qs = qs.filter(phone__icontains=keyword) return qs这里有几个参数要说明。annotate(followup_count=Count("followups")) 在数据库层面用 LEFT JOIN + GROUP BY 把每个客户的跟进次数一次性算出来,比序列化器里逐个 count 快一个量级。get_queryset 里的 keyword 参数用的是 icontains,大小写不敏感,手机号场景下可能用不上但合法。permission_classes = [IsAuthenticated] 表示这个接口必须携带有效 token 才能访问,如果你在浏览器里直接打开 /api/customers/ 看到的是 401 而不是 JSON,不要慌,这是预期行为。
4.3 Vue 端 axios 封装与接口调用
前端这边的关键不只是调用接口,而是把 token 注入、401 跳转、错误提示这些横切逻辑收敛在一个文件里。我通常会封装一个 request.js:
// src/utils/request.js import axios from "axios" const request = axios.create({ baseURL: "http://127.0.0.1:8000/api", timeout: 10000 }) // 请求拦截器:自动注入 JWT request.interceptors.request.use(config => { const token = localStorage.getItem("access_token") if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理 401 request.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { localStorage.removeItem("access_token") window.location.href = "/login" } return Promise.reject(error) } ) export default request登录接口和客户列表接口的调用方式如下:
// src/api/customer.js import request from "@/utils/request" export async function login(username, password) { const { data } = await request.post("/auth/login/", { username, password }) localStorage.setItem("access_token", data.access) localStorage.setItem("refresh_token", data.refresh) return data } export function fetchCustomers(params) { return request.get("/customers/", { params }) }fetchCustomers 的 params 对象里可以传 page、page_size、status、keyword。DRF 的 PageNumberPagination 默认使用 page 参数指定页码,page_size 需要额外设置,如果你不传 page_size,它就用 settings.py 里的 PAGE_SIZE=10。前端翻页时把页码传给接口,然后从 response.data 里取 count 和 results,count 是符合条件的总条数,results 才是当前页的客户数组。这里有个容易错的地方:response.data 的结构是 { count, next, previous, results },Vue 模板里遍历的是 data.results,不是 data 本身。
5. 避坑:毕业设计最常见的六个翻车现场
5.1 时间少 8 小时:今天创建的客户统计成 0
现象:前端看板“今日新增客户”显示 0,但数据库里明明有当天记录。
原因:USE_TZ=True 时,Django 在数据库里存的是 UTC 时间。如果你用 date.today() 去过滤 created_at__date,拿到的是本地日期,而数据库里的日期字段是按 UTC 存的,两者错位 8 小时,导致过滤结果为空。
解决:查询时用 Django 的 timezone 工具函数获取本地日期:
from django.utils import timezone today_start = timezone.localdate() # 返回 Asia/Shanghai 时区的今天 customers = Customer.objects.filter(created_at__date=today_start)如果数据已经入库,直接用 date.today() 查出来的可能就是空的,这个坑在答辩前最容易出现,因为考官很可能让你当场演示“今日数据”。另外,前端拿到的 created_at 是 ISO 8601 带时区的字符串,Vue 里直接用会导致显示时间比预期晚 8 小时,你需要在序列化器里格式化,或者前端用 dayjs 转成本地时间。
5.2 跨域配置写了还是报 CORS
现象:axios 请求发出去了,后端日志也能看到请求,但浏览器就是报 “Access-Control-Allow-Origin” 错误。
原因:corsheaders 这个 app 加了,CORS_ALLOWED_ORIGINS 也写了,但中间件顺序不对。CorsMiddleware 必须放在 CommonMiddleware 之前,否则浏览器预检请求(OPTIONS)被 CommonMiddleware 拦截,响应头上没有 CORS 字段。
解决:检查 MIDDLEWARE 列表,把 corsheaders.middleware.CorsMiddleware 放在 django.middleware.common.CommonMiddleware 上面一行。如果前端换过端口,比如从 5173 改成 5174,记得同步更新 CORS_ALLOWED_ORIGINS。开发阶段也可以直接把前端接口代理到 Django 端口,但那就绕过了跨域,不是真联调,还是建议开着跨域测。
5.3 删除客户把订单也删了:级联方向写反
现象:在 admin 后台删掉一条客户记录,结果订单列表里对应的成交单也没了。
原因:Order.customer 外键的 on_delete 误用了 CASCADE。Django 的级联删除是跟着外键走的,订单外键指向客户,CASCADE 意味着删客户时连坐订单。
解决:把订单表的外键改成 on_delete=models.PROTECT。这个坑之所以严重,是因为它不报错,静默地把数据带走了,等你发现时订单已经没了。PROTECT 会在删除时抛出 ProtectedError,你在代码里 catch 一下,提示“该客户存在订单,不能删除,请先处理订单”。跟进记录保留 CASCADE,因为子记录跟着主表走是合理的。从那以后我设计外键时都会先问自己一句:这个数据删了,业务凭证还在吗?
5.4 SQLite 换成 MySQL 之后中文乱码
现象:开发时用 SQLite 一切正常,部署时换成 MySQL,迁移后中文全变成问号。
原因:MySQL 建库时没有指定字符集,默认可能是 latin1,Django 连接时虽然传了 charset=utf8mb4,但数据库本身字符集不对,写入就丢了。
解决:建库时显式指定字符集:
CREATE DATABASE car4s DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;同时在 settings.py 的 DATABASES OPTIONS 里保持 "charset": "utf8mb4"。如果你已经建错库了,先把数据导出,drop 掉重建,再导入。这一步不是 bug,是环境初始化顺序问题,但几乎每个从 SQLite 切 MySQL 的人都会撞上一次。
5.5 ORM 惰性求值:循环查询让页面卡了几秒
现象:客户列表页接口耗时从 200ms 变成 3s,刷新越久越慢。
原因:序列化器里写了 source="followups.count",每返回一个客户就执行一条 count SQL。分页 10 条就是 10 条额外查询,如果前端再循环展示每个客户的订单数,查询次数直接翻倍。ORM 的惰性求值是个黑匣子,不打印 SQL 你根本看不到这些隐式查询。
解决:用 annotate 在 ViewSet 层面对查询集做聚合,返回的字段已经是计算好的数字,序列化器只负责透传。这个坑的排查方法很简单:把 Django 的 DEBUG 打开,看页面加载时底层跑了多少条 SQL。如果发现 SQL 数量超过 20 条,基本可以断定有 N+1 问题。
5.6 前端拿到 status 是 "intention",而不是“高意向”
现象:客户列表下拉框显示的是英文值,不是中文状态名。
原因:序列化器默认返回的是数据库里存的值,即 choices 的 key,不是 label。前端拿到 status="intention",直接渲染就成了英文。
解决:在序列化器里显式加一个字段返回展示文本:
class CustomerSerializer(serializers.ModelSerializer): status_display = serializers.CharField(source="get_status_display", read_only=True) class Meta: model = Customer fields = ["id", "name", "phone", "status", "status_display", "source", "created_at"]get_status_display 是 Django 模型自带的方法,会在 choices 里查 key 对应的 label。前端下拉框显示 status_display,选中后提交 status 本身,这样展示和存储分离,后端不用改表结构。这个细节不解决,答辩演示时客户状态那一列全是英文,非常掉价。
6. 从能跑到能答辩:构建产物收编与关键路径验证
6.1 把 Vue 构建产物交给 Django
前后端联调通过之后,最后的演示形态有两种:一种是开发模式同时开两个服务,Vue 跑 5173 端口,Django 跑 8000 端口,答辩时用浏览器访问 Vue 开发服务器;另一种是把 Vue 构建成静态文件,交给 Django 统一服务,只开一个 8000 端口。我推荐第二种,因为现场演示最怕的就是端口冲突、依赖安装失败。操作路径如下:
# 在 frontend 目录下执行构建 cd frontend npm run build # 构建产物在 frontend/dist 目录,把它配置到 Django 的静态文件搜索路径里 python manage.py collectstatic --noinputsettings.py 里需要加上:
STATICFILES_DIRS = [ BASE_DIR / "frontend" / "dist", ]collectstatic 会把 dist 里的 index.html 和 JS/CSS 资源收到 STATIC_ROOT 指向的目录,Django 后面就能直接伺服这些文件。注意 index.html 里的静态资源引用路径要写成相对路径或者 /static/ 前缀,否则部署到子目录时资源 404。
6.2 答辩前必过的四条验证路径
构建完成不代表系统可用,我会强制自己走一遍四条关键路径:第一,登录接口,输入正确的用户名密码能拿到 token,刷新页面后 localStorage 里的 token 还有效,接口请求没有 401;第二,客户列表分页,把 PAGE_SIZE 调成 5,翻到第二页数据不重复、总数正确;第三,删除保护,选一个有订单的客户尝试删除,系统提示无法删除而不是报 500;第四,时间统计,查看“今日新增客户”数量和数据库里当天记录数一致,不差 8 小时。这四条路径每一条都对应一个真实的踩坑点,走完它们,基本能覆盖项目 80% 的演示场景。
最初我做这套系统时,也是先看别人拆好的源码,再自己改数据库、加字段、接前端,中间翻过车也踩过坑。到后来我每次拿到一个新环境,跑完迁移部署之后都会强制自己走一遍这四条验证路径,没有一次是白走的。希望帮到你。
本文还有配套的精品资源,点击获取