简介:这套婚庆系统源码基于Python与Django框架开发,前端采用layui及Bootstrap样式库,面向正在学习Web开发、需要完成课程设计或毕业设计的高校学生。项目包含完整的后端逻辑与页面模板,可实现婚礼策划、场地、订单等模块的管理与展示。压缩包共261个文件,以Python源码(py/pyc)、JavaScript脚本、HTML页面及CSS样式为主,辅以gif演示图、jpg/png图片、字体图表等资源,整体大小12.62MB,结构清晰便于二次开发。已有1043人浏览学习,适合作为Django入门到进阶的参考案例,借助源码可以快速理解MTV架构、ORM查询、模板渲染及layui组件整合等关键知识点,并根据自身需求进行功能扩展与个性化改造。
1. 把 Django 后台直接做成管理界面,婚庆系统最缺的就是这一步
拿到「python+Django+layui实现婚庆系统源码.zip」这个标题,很多人的第一反应是找个现成项目改改就能上线。但真正做过婚庆业务系统的人会告诉你,这类系统最麻烦的从来不是订单表和套餐表怎么写,而是「谁在用、怎么录入、怎么审核、怎么统计」这一整条管理链路。Django 自带的 admin 能管数据,但那种灰底白框的界面扔给婚庆公司的运营人员,对方大概率会皱眉。layui 的价值就在于它能把后台管理界面做得像模像样,表格、表单、弹窗、日期选择器都是现成的,不用你从零写 CSS 和 JS。
这篇文章不假设你已经拿到了某份具体源码,而是顺着「Django 做业务后端、layui 做后台界面」这条主线,把一套婚庆系统从数据建模到界面联调、再到部署上线的完整路径讲清楚。你会看到模型怎么设计才不返工、layui 表格怎么对接 Django 的分页接口、日期筛选和订单状态流转的坑在哪,以及用宝塔部署时最容易踩的静态文件问题。不管你是打算拿这套代码二次开发,还是想自己从零搭一个,下文的内容都按可复现的标准来写。
2. Django 模型层设计:婚庆订单、套餐与档期怎么建模才不返工
2.1 先理清婚庆系统的核心业务对象
婚庆系统的数据模型并不复杂,但很容易在设计阶段埋雷。最常见的错误是把所有字段塞进一张「订单表」里,结果后期加场地费、加摄像师、加婚礼司仪时,只能不停 ALTER TABLE。常见做法是先拆出几个核心业务对象:客户、套餐、订单、档期、员工(司仪/摄像/化妆),再加上一个关联订单和具体服务项的中间表。
订单表需要区分「订单状态」和「支付状态」两个字段,前者管业务流转(待确认、已确认、服务中、已完成、已取消),后者管钱(未支付、部分支付、已付清、已退款)。档期表单独拆出来,因为同一天可能有多场婚礼,但一个司仪一天只能接一场,这个约束放在订单表里很难做唯一性校验,放档期表里就能用联合唯一索引解决。
模型层的另一个重点是软删除。婚庆行业的订单经常被误操作,运营人员删掉一笔订单后才发现要恢复。与其在视图层做各种防护,不如给核心表加一个is_active字段,查询时默认过滤掉已删除记录。
2.2 用代码定义婚庆系统的四个核心模型
下面是一份可以直接跑通的 Django 模型代码,覆盖了客户、套餐、档期和订单四张核心表。实际项目中你可以根据业务再扩展,但骨架建议保持这个结构。
from django.db import models from django.core.validators import MinValueValidator from django.utils import timezone class Customer(models.Model): """新人客户信息""" name = models.CharField('客户姓名', max_length=50) phone = models.CharField('联系电话', max_length=20, unique=True) wechat = models.CharField('微信', max_length=50, blank=True) wedding_date = models.DateField('婚礼日期', null=True, blank=True) remark = models.TextField('备注', blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) is_active = models.BooleanField('有效标记', default=True) class Meta: db_table = 'wed_customer' verbose_name = '客户' verbose_name_plural = '客户' def __str__(self): return f'{self.name} ({self.phone})' class Package(models.Model): """套餐表:含价格和封面图""" name = models.CharField('套餐名称', max_length=100) price = models.DecimalField('价格', max_digits=10, decimal_places=2, validators=[MinValueValidator(0)]) cover_image = models.ImageField('封面图', upload_to='packages/', blank=True) description = models.TextField('套餐说明', blank=True) is_active = models.BooleanField('上架', default=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'wed_package' class WeddingDate(models.Model): """档期表:一个日期一个场次,关联司仪/摄像等资源""" date = models.DateField('档期日期', db_index=True) session = models.CharField('场次', max_length=10, choices=[('morning', '午宴'), ('evening', '晚宴')]) is_booked = models.BooleanField('已预订', default=False) book_order = models.ForeignKey('Order', on_delete=models.SET_NULL, null=True, blank=True, related_name='dates') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'wed_wedding_date' constraints = [ models.UniqueConstraint(fields=['date', 'session'], name='uniq_date_session') ] class Order(models.Model): """订单表:关联客户、套餐和档期""" STATUS_CHOICES = [ ('pending', '待确认'), ('confirmed', '已确认'), ('serving', '服务中'), ('done', '已完成'), ('canceled', '已取消'), ] PAY_CHOICES = [ ('unpaid', '未支付'), ('partial', '部分支付'), ('paid', '已付清'), ('refunded', '已退款'), ] order_no = models.CharField('订单号', max_length=32, unique=True) customer = models.ForeignKey(Customer, on_delete=models.PROTECT, verbose_name='客户') package = models.ForeignKey(Package, on_delete=models.PROTECT, verbose_name='套餐') book_date = models.ForeignKey(WeddingDate, on_delete=models.PROTECT, verbose_name='档期') status = models.CharField('订单状态', max_length=16, choices=STATUS_CHOICES, default='pending') pay_status = models.CharField('支付状态', max_length=16, choices=PAY_CHOICES, default='unpaid') total_amount = models.DecimalField('订单金额', max_digits=10, decimal_places=2) discount = models.DecimalField('优惠金额', max_digits=10, decimal_places=2, default=0) remark = models.TextField('备注', blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: db_table = 'wed_order' ordering = ['-created_at'] @property def pay_amount(self): """实付金额 = 订单金额 - 优惠金额""" return self.total_amount - self.discount这段代码里有两个细节值得注意。第一是ForeignKey的on_delete全部用了PROTECT,这样即使客户或套餐被误删,只要还有关联订单,数据库就会拒绝删除操作,避免出现孤儿订单。第二是前文提到的UniqueConstraint联合唯一约束,它保证了「同一天同一场次」的档期记录只能有一条,后续下单时只要去尝试创建WeddingDate并捕获IntegrityError,就能防止两个订单占用同一个档期。
2.3 档期冲突检测:先查后插并不安全,必须用数据库约束兜底
很多新手写档期预订逻辑时,喜欢先查再插:
if WeddingDate.objects.filter(date=d, session=s, is_booked=False).exists(): WeddingDate.objects.create(date=d, session=s, is_booked=True, ...)这在单机开发环境没问题,但放到线上有两个隐患。第一,如果两个请求同时通过exists()校验,可能同时走到创建语句,产生两条重复档期;第二,is_booked只是一个普通布尔字段,没有和订单号建立硬关联。正确的做法是先创建Order对象(不提交),再尝试创建WeddingDate,如果IntegrityError被触发就回滚整个事务:
from django.db import transaction, IntegrityError @transaction.atomic def create_order_with_date(customer, package, wedding_date, session, amount): # 先锁定档期记录,防止并发下两个请求同时改同一行 date_slot, created = WeddingDate.objects.select_for_update().get_or_create( date=wedding_date, session=session, defaults={'is_booked': False} ) if not created and date_slot.is_booked: raise ValueError(f'{wedding_date} {session} 已被预订') order = Order.objects.create( order_no=generate_order_no(), customer=customer, package=package, book_date=date_slot, status='pending', pay_status='unpaid', total_amount=amount, ) date_slot.is_booked = True date_slot.book_order = order date_slot.save(update_fields=['is_booked', 'book_order']) return orderselect_for_update()在 MySQL InnoDB 引擎下会对命中行加写锁,直到事务结束才释放。这样即使两个请求几乎同时到达,第二个请求也会等第一个事务提交后再读取,此时is_booked已经是True,直接抛出ValueError。这个模式比纯「先查后插」稳妥得多,也是婚庆这类强时间约束业务的标准做法。
3. 基于 layui 搭建后台管理界面:表格渲染、弹窗表单与 Django 接口对接
3.1 为什么选 layui 而不选 Vue + Element UI
技术选型上,layui 和 Vue 系框架是两条完全不同的路线。Vue 适合前后端分离、交互复杂度高的中大型系统,但需要 Node 构建链路、跨域配置、token 鉴权等一系列配套工程。layui 的优势在于它是一套「服务端渲染友好」的前端框架,直接引入 CSS 和 JS 文件就能工作,表格的数据走接口返回 JSON,然后table.render()负责渲染,不需要构建工具。对婚庆系统这种典型的管理后台来说,用 layui 省掉的工程量非常可观,尤其是当你打算直接基于 Django 模板渲染页面时。
layui 的表单、弹窗、日期选择器、分页组件都很成熟,layui.date可以做日期选择限制(比如婚礼日期不能早于今天),layui.select动态赋值配合下拉框联动是常考的点。另一个不该忽略的场景是「layui 可以用 vue 吗」——可以同时用,但没必要,混用会让 DOM 渲染控制权变得混乱,二开时维护成本直线上升。如果后续确实要上复杂仪表盘,再拆前后端分离也不迟。
3.2 准备 layui 静态资源和基础页面模板
先从 layui 官网下载最新版压缩包,解压后把layui目录放到项目static/vendor/下。然后在 Django 的settings.py里配置静态文件路径:
STATIC_URL = '/static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ] STATIC_ROOT = BASE_DIR / 'staticfiles'写一个基础模板templates/admin/base.html,所有后台页面都继承它。头部引入 layui 的 CSS,底部引入layui.all.js,再留一个{% block content %}给子页面填充:
{% load static %} <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}婚庆后台管理{% endblock %}</title> <link rel="stylesheet" href="{% static 'vendor/layui/css/layui.css' %}"> </head> <body class="layui-layout-body"> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <div class="layui-logo layui-hide-xs layui-bg-black">婚庆管理系统</div> <ul class="layui-nav layui-layout-right"> <li class="layui-nav-item">{{ request.user.username }}</li> </ul> </div> <div class="layui-side layui-bg-black"> <div class="layui-side-scroll"> <ul class="layui-nav layui-nav-tree" lay-filter="nav"> <li class="layui-nav-item"> <a href="javascript:;">订单管理</a> <dl class="layui-nav-child"> <dd><a href="{% url 'order_list' %}">订单列表</a></dd> <dd><a href="{% url 'order_create' %}">新建订单</a></dd> </dl> </li> <li class="layui-nav-item"> <a href="{% url 'package_list' %}">套餐管理</a> </li> <li class="layui-nav-item"> <a href="{% url 'date_list' %}">档期管理</a> </li> </ul> </div> </div> <div class="layui-body"> {% block content %}{% endblock %} </div> </div> <script src="{% static 'vendor/layui/layui.all.js' %}"></script> {% block script %}{% endblock %} </body> </html>这里有个容易踩的坑:如果直接用layui.all.js,table、layer等模块都已经打包在里面,子页面可以直接使用;如果改用layui.js+use()的方式按需加载,模板里就必须先声明layui.use(['table', 'layer'], function(){}),否则组件渲染不出来。建议前期用layui.all.js降低排查成本。
3.3 订单列表页:layui table 对接 Django 分页接口
layui table 的数据格式是固定的,必须返回{"code":0, "msg":"", "count":123, "data":[...]}结构。Django 侧用Paginator做分页,把page和limit两个参数接住,再序列化成上面的格式返回。
下面是视图层代码:
from django.core.paginator import Paginator from django.http import JsonResponse from django.views.decorators.http import require_GET from .models import Order @require_GET def order_table_data(request): """layui table 数据接口""" page = int(request.GET.get('page', 1)) limit = int(request.GET.get('limit', 20)) keyword = request.GET.get('keyword', '').strip() status = request.GET.get('status', '') qs = Order.objects.select_related('customer', 'package', 'book_date') if keyword: qs = qs.filter(customer__name__icontains=keyword) if status: qs = qs.filter(status=status) paginator = Paginator(qs, limit) page_obj = paginator.get_page(page) data = [{ 'id': o.id, 'order_no': o.order_no, 'customer_name': o.customer.name, 'customer_phone': o.customer.phone, 'package_name': o.package.name, 'wedding_date': o.book_date.date, 'session': o.get_book_date_session_display() if hasattr(o, 'get_book_date_session_display') else o.book_date.session, 'total_amount': str(o.total_amount), 'pay_status': o.get_pay_status_display(), 'status': o.get_status_display(), 'created_at': o.created_at.strftime('%Y-%m-%d %H:%M'), } for o in page_obj] return JsonResponse({'code': 0, 'msg': '', 'count': paginator.count, 'data': data})注意session字段在Order模型里没有直接定义,它是WeddingDate上的字段,所以序列化时取的是o.book_date.session。total_amount转成字符串再返回,是因为 JSON 无法直接序列化Decimal,不转的话会报类型错误。created_at同理,datetime对象需要手动格式化成字符串。
前端模板里渲染表格的代码如下:
{% extends 'admin/base.html' %} {% block title %}订单列表{% endblock %} {% block content %} <div class="layui-card" style="margin: 15px;"> <div class="layui-card-header"> <div class="layui-form layui-form-pane"> <div class="layui-inline"> <input type="text" name="keyword" class="layui-input" placeholder="客户姓名搜索"> </div> <div class="layui-inline"> <select name="status"> <option value="">全部状态</option> <option value="pending">待确认</option> <option value="confirmed">已确认</option> <option value="serving">服务中</option> <option value="done">已完成</option> </select> </div> <button class="layui-btn" id="searchBtn">搜索</button> </div> </div> <div class="layui-card-body"> <table id="orderTable" lay-filter="orderTable"></table> </div> </div> {% endblock %} {% block script %} <script> layui.use(['table', 'form'], function() { var table = layui.table; var form = layui.form; var $ = layui.$; // 搜索表单渲染,否则 select 不会显示 form.render(); table.render({ elem: '#orderTable', url: '{% url "order_table_data" %}', page: true, limit: 20, limits: [10, 20, 50], cols: [[ {field: 'order_no', title: '订单号', width: 150}, {field: 'customer_name', title: '客户', width: 100}, {field: 'customer_phone', title: '电话', width: 130}, {field: 'package_name', title: '套餐', minWidth: 120}, {field: 'wedding_date', title: '婚期', width: 110}, {field: 'total_amount', title: '金额', width: 100}, {field: 'pay_status', title: '支付', width: 90}, {field: 'status', title: '状态', width: 90}, {field: 'created_at', title: '创建时间', width: 160} ]], done: function(res) { // 数据加载完成后可以在这里做额外处理 } }); $('#searchBtn').on('click', function() { var keyword = $('input[name="keyword"]').val(); var status = $('select[name="status"]').val(); table.reload('orderTable', { where: {keyword: keyword, status: status}, page: {curr: 1} }); }); }); </script> {% endblock %}table.reload时把搜索参数放到where里,layui 会自动把它们拼接到请求的查询字符串中,Django 侧读request.GET就能拿到。这里必须注意:form.render()要在页面加载后调用一次,否则layui select动态赋值后下拉框不会显示选中项,这个几乎是新人必踩的坑。
3.4 弹窗表单:layui 的 open + iframe 模式处理新增和编辑
订单的新增和编辑适合用弹窗完成。常见做法是用layer.open打开一个 iframe,iframe 里加载独立的 Django 表单页面,提交成功后通过parent.layui.table.reload刷新父页面的表格。
表单页templates/admin/order_form.html的提交逻辑如下:
layui.use(['form', 'layer'], function() { var form = layui.form; var layer = layui.layer; var $ = layui.$; form.on('submit(orderSubmit)', function(data) { var field = data.field; $.ajax({ url: '{% url "order_save" %}', type: 'POST', data: field, dataType: 'json', headers: {'X-CSRFToken': '{{ csrf_token }}'}, success: function(res) { if (res.code === 0) { var index = parent.layer.getFrameIndex(window.name); parent.layer.close(index); parent.layui.table.reload('orderTable'); parent.layer.msg('保存成功'); } else { layer.msg(res.msg, {icon: 2}); } }, error: function() { layer.msg('网络错误', {icon: 2}); } }); return false; // 阻止表单默认提交 }); });parent.layui.table.reload是关键,它告诉父页面的表格重新向接口拉数据。CSRF 令牌放在请求头里传给 Django,因为这是跨 iframe 的 AJAX 请求,Django 默认会校验这个令牌,不传就报 403。
Django 侧保存订单的视图需要同时处理POST和GET两种场景:
from django.shortcuts import render from django.http import JsonResponse from django.views.decorators.http import require_http_methods @require_http_methods(['GET', 'POST']) def order_save(request): if request.method == 'GET': # 编辑时加载已有数据 order_id = request.GET.get('order_id') order = Order.objects.filter(pk=order_id).first() return render(request, 'admin/order_form.html', {'order': order}) # POST:保存 customer_id = request.POST.get('customer_id') package_id = request.POST.get('package_id') wedding_date = request.POST.get('wedding_date') session = request.POST.get('session') total_amount = request.POST.get('total_amount') try: customer = Customer.objects.get(pk=customer_id) package = Package.objects.get(pk=package_id) order = create_order_with_date(customer, package, wedding_date, session, total_amount) return JsonResponse({'code': 0, 'msg': 'ok'}) except (Customer.DoesNotExist, Package.DoesNotExist): return JsonResponse({'code': 1, 'msg': '客户或套餐不存在'}) except ValueError as e: return JsonResponse({'code': 1, 'msg': str(e)})这个视图把创建订单和档期占用放在同一个事务里,由create_order_with_date统一处理,业务逻辑保持在一个地方。
4. 订单查询与状态流转:日期筛选、layui select 动态赋值、批量操作
4.1 婚期日期范围筛选的接口实现
订单列表只按关键词搜索是不够的,婚庆运营人员最常问的是「下个月的所有婚礼」或者「这周末还有哪些单子」,所以日期范围筛选必须做进接口里。Django 侧用date__gte和date__lte两个条件做区间查询:
start_date = request.GET.get('start_date', '').strip() end_date = request.GET.get('end_date', '').strip() if start_date: qs = qs.filter(book_date__date__gte=start_date) if end_date: qs = qs.filter(book_date__date__lte=end_date)前端在已有的搜索栏里加两个日期输入框,用 layui 的laydate渲染,并限制结束日期不能早于开始日期:
layui.use(['laydate'], function() { var laydate = layui.laydate; var startDate = laydate.render({ elem: '#startDate', done: function(value) { // 动态修改结束日期的 min 属性 endDate.config.min = value; } }); var endDate = laydate.render({ elem: '#endDate', done: function(value) { startDate.config.max = value; } }); });layui date 最大日期当前日期这个需求在婚庆系统里很常见:后台录单时,婚礼日期不能选昨天或更早,防止把过去的日期当作可用档期。实现方式是在渲染日期选择器时直接把max设为当前日期:
var bookDate = laydate.render({ elem: '#bookDate', max: new Date().toISOString().split('T')[0], // 今天 min: '2025-01-01' });这里max接收的是字符串日期,需要自己拼年-月-日格式。直接用Date对象会被 laydate 内部处理成带时分秒的时间戳,导致边界判断不准确。
4.2 layui select 动态赋值的常见坑与正确写法
编辑订单时,表单里需要回填客户、套餐、订单状态等下拉框的值。如果只在value里写死,页面刷新后选中的还是第一项,因为 layui 的 select 组件需要显式触发form.render('select')来刷新渲染。正确写法分两步:
function fillForm(data) { // 1. 原生 select 赋值 $('select[name="customer_id"]').val(data.customer_id); $('select[name="package_id"]').val(data.package_id); $('select[name="status"]').val(data.status); // 2. 重新渲染下拉框,否则界面不变化 form.render('select'); }第二步的form.render('select')是 layui 系列里最容易被忽略的 API。它不是可有可无的刷新,而是把 layui 自制的下拉组件树重建一遍,不调用的话无论你怎么val()界面都纹丝不动。另一个相关场景是级联操作:选了套餐后,动态把价格填充到金额输入框里。这个用form.on('select(packageSelect)', function(data){ ... })即可,在回调里通过data.value拿到套餐 ID,再发 AJAX 查套餐价格。
4.3 订单状态批量变更:用 layui 的 toolbar 事件 + Django 批量更新
运营人员经常需要把多个「待确认」订单一键改为「已确认」。layui 表格支持在工具栏里放自定义按钮,然后监听toolbar事件。前端代码:
table.render({ elem: '#orderTable', toolbar: '#toolbarTemplate', // 模板里放批量确认按钮 ... }); // 监听行工具栏事件 table.on('toolbar(orderTable)', function(obj) { var checkStatus = table.checkStatus(obj.config.id); var selectedIds = checkStatus.data.map(function(item) { return item.id; }); if (selectedIds.length === 0) { layer.msg('请先勾选订单'); return; } // 批量修改状态 $.ajax({ url: '{% url "order_batch_update" %}', type: 'POST', data: {ids: JSON.stringify(selectedIds), action: 'confirm'}, headers: {'X-CSRFToken': '{{ csrf_token }}'}, success: function(res) { if (res.code === 0) { table.reload('orderTable'); layer.msg('操作成功'); } } }); });Django 侧接收ids和action,批量更新时用__in查询:
@require_http_methods(['POST']) def order_batch_update(request): import json ids = json.loads(request.POST.get('ids', '[]')) action = request.POST.get('action') if action == 'confirm': count = Order.objects.filter(id__in=ids, status='pending').update( status='confirmed', updated_at=timezone.now()) return JsonResponse({'code': 0, 'msg': f'已确认 {count} 笔订单'}) if action == 'cancel': count = Order.objects.filter(id__in=ids).exclude(status='canceled').update( status='canceled', updated_at=timezone.now()) # 同时释放档期 WeddingDate.objects.filter(book_order_id__in=ids).update(is_booked=False, book_order=None) return JsonResponse({'code': 0, 'msg': f'已取消 {count} 笔订单'}) return JsonResponse({'code': 1, 'msg': '未知操作'})批量取消时千万别忘了同步释放档期,这是婚庆系统里最容易遗漏的业务闭环。订单取消后,如果档期还是is_booked=True,后续再录同一日期的单子就会误判为已经被占。这里把book_order置为None是必要的,因为WeddingDate的book_order字段是外键,直接赋值为None才能解除关联。
5. 统计看板与导出:用 Django ORM 聚合查询给运营做决策支持
5.1 月度业绩与订单状态占比
婚庆公司的老板和店长需要看「这个月签了多少单、回款多少、哪些套餐卖得好」。Django 的 ORM 聚合函数在这里派上大用场。常见做法是写一个独立的统计视图,返回 JSON 给前端,前端用 layui 的echarts或简单表格展示。
按月维度统计各套餐的订单数和金额:
from django.db.models import Sum, Count from django.db.models.functions import TruncMonth def package_statistics(request): """按套餐统计订单量和销售额""" stats = (Order.objects .filter(status__in=['confirmed', 'serving', 'done']) .values('package__name') .annotate( order_count=Count('id'), total_sales=Sum('pay_amount'), ) .order_by('-total_sales')) data = [ { 'package_name': item['package__name'], 'order_count': item['order_count'], 'total_sales': float(item['total_sales'] or 0), } for item in stats ] return JsonResponse({'code': 0, 'data': data})TruncMonth可以按月份截断日期字段,用来生成每个月的订单趋势:
monthly_trend = (Order.objects .filter(status__in=['confirmed', 'serving', 'done']) .annotate(month=TruncMonth('created_at')) .values('month') .annotate(month_total=Sum('pay_amount'), order_count=Count('id')) .order_by('month'))filter(status__in=...)排除了pending和canceled状态的订单,因为只有真正成交的订单才计入业绩。注意pay_amount是Order模型的@property字段,ORM 聚合时Sum('pay_amount')实际上会被 Django 翻译成SUM(total_amount - discount),两列相减在 SQL 层完成,性能没问题。
5.2 用 openpyxl 把订单列表导出为 Excel
运营拿到筛选结果后通常想导成 Excel 发给同事,这比在页面上分页看高效得多。后端用openpyxl生成 xlsx 文件,通过StreamingHttpResponse返回。Django 官方文档里推荐用StreamingHttpResponse处理大文件流式输出,但订单导出量一般不大,直接用HttpResponse也可以。下面是一种稳妥写法:
from openpyxl import Workbook from openpyxl.styles import Font, PatternFill from django.http import HttpResponse def export_orders_excel(request): """导出订单列表为 Excel 文件""" # 复用列表页的筛选逻辑 qs = Order.objects.select_related('customer', 'package', 'book_date').all() keyword = request.GET.get('keyword', '').strip() status = request.GET.get('status', '') if keyword: qs = qs.filter(customer__name__icontains=keyword) if status: qs = qs.filter(status=status) wb = Workbook() ws = wb.active ws.title = '订单列表' headers = ['订单号', '客户', '电话', '套餐', '婚期', '金额', '支付状态', '订单状态'] ws.append(headers) # 表头加粗和背景色 header_font = Font(bold=True) header_fill = PatternFill(start_color='4F81BD', end_color='4F81BD', fill_type='solid') for cell in ws[1]: cell.font = header_font cell.fill = header_fill for o in qs: ws.append([ o.order_no, o.customer.name, o.customer.phone, o.package.name, o.book_date.date, str(o.pay_amount), o.get_pay_status_display(), o.get_status_display(), ]) # 设置列宽 for col in ws.columns: max_len = max(len(str(cell.value)) if cell.value else 0 for cell in col) ws.column_dimensions[col[0].column_letter].width = max_len + 4 response = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) response['Content-Disposition'] = 'attachment; filename="orders.xlsx"' wb.save(response) return responseContent-Disposition里的filename如果包含中文,需要考虑 URI 编码,否则部分浏览器下载的文件名会乱码。简单升级方案是把文件名改成 ASCII,或者用urllib.parse.quote处理后再拼进响应头。
前端触发下载最简单的方式是window.location.href = '/admin/orders/export/?start_date=2025-06-01&end_date=2025-06-30',浏览器会直接响应文件流并开始下载,不需要 AJAX。
6. 一处容易被忽略的联动逻辑与验证方法
6.1 用 Django shell 验证档期唯一约束
代码写完后,不要直接开浏览器点两下就完事。档期唯一约束是这套系统的命脉,建议先在 Django shell 里做一次并发场景模拟。用python manage.py shell进入交互环境,然后执行:
from django.db import transaction from wed.models import WeddingDate, Customer, Order c = Customer.objects.create(name='测试', phone='12345678901') # 手动插入一条占位数据 WeddingDate.objects.create(date='2025-10-01', session='morning', is_booked=True) # 尝试第二次创建同日期同场次 try: with transaction.atomic(): WeddingDate.objects.create(date='2025-10-01', session='morning', is_booked=True) except Exception as e: print('唯一约束生效:', type(e).__name__, e) else: print('错误:重复数据插入成功!')如果输出唯一约束生效: IntegrityError ...,说明UniqueConstraint在数据库层确实行了;如果走到else分支,说明 Django 模型里的constraints没有被同步到数据库。后者通常是因为没有执行迁移,直接python manage.py makemigrations && python manage.py migrate即可解决。
6.2 订单取消后档期释放的联动自检
批量取消订单时释放档期的逻辑在 4.3 节里写到了,但很多人改完代码后忘了验证「取消订单 -> 档期是否真的变回可预订」。提供一个简单的验证脚本思路:
# 先造一笔订单 order = Order.objects.create( order_no='ORD-TEST-001', customer=c, package=package, book_date=date_slot, status='confirmed', pay_status='paid', total_amount=1000 ) date_slot.is_booked = True date_slot.book_order = order date_slot.save() # 模拟批量取消 from django.utils import timezone Order.objects.filter(pk=order.pk).update(status='canceled', updated_at=timezone.now()) WeddingDate.objects.filter(book_order_id=order.pk).update(is_booked=False, book_order=None) # 验证 date_slot.refresh_from_db() assert date_slot.is_booked is False assert date_slot.book_order is None print('档期释放成功')这个脚本不需要跑起来,它演示的是一个可以固化成tests.py里的单元测试用例。婚庆系统的状态流转特别多,每改一次订单状态、支付状态,都应该配套这样的断言测试,否则上线三个月后一定会出现「订单取消了但档期还锁着」的脏数据。
6.3 隐藏的第三方回调场景:支付状态回写后的联动处理
如果婚庆系统接了微信或支付宝支付,支付回调会把订单改成已支付。这个场景下你需要在收到回调时同时更新pay_status和status(比如从待确认变已确认),但回调接口是没有登录态的,不能用依赖request.user的视图函数判断权限。常见做法是单独建一个notify视图,用 Django 的@csrf_exempt装饰器关闭 CSRF 校验,再在回调内部做签名验证,这里不做代码展开了。核心原则是:凡是涉及订单状态变更的地方,都要追溯会不会影响档期表的一致性。这个检查清单你应该贴在开发环境的显示器旁边——订单取消要释放档期,订单改期要换档期,订单删除(软删除)也要释放档期,遗漏任何一条都会造成业务数据对不上。
本文还有配套的精品资源,点击获取