IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我也会在其它平台持续发布最新文章,助你少走弯路。
上一篇我们完成了购物车模型的最终设计,还跑通了单元测试。今天,模型里的字段将真正"活"起来------我们要实现购物车的完整增删改查,包括从商品详情页一键加入购物车、在购物车页面调整数量、勾选结算、删除商品,以及全选和合计金额的实时计算。
这篇代码量不小,前端交互也比较丰富,但每一步我都会把逻辑讲透。跟上节奏,做完这一篇,你的电商项目就已经能跑通"选商品 → 加购物车 → 改数量"的完整闭环了。
一、需求回顾
我们今天要实现的功能清单:
-
加入购物车:在商品详情页选中规格后,点击"加入购物车",AJAX 提交,成功后给出提示。
-
购物车列表页:
-
以表格形式展示商品图片、名称、规格、单价、数量调整器、小计、勾选框。
-
支持修改数量(+/- 按钮或直接输入),自动更新小计和合计,且不能超过库存。
-
单行勾选/取消勾选,支持全选/取消全选。
-
勾选商品后,底部合计金额实时更新。
-
删除单个商品(带确认)。
-
批量删除勾选商品。
-
有"去结算"按钮,跳转到确认订单页(后续实现)。
-
二、配置 URL 路由
在 apps/cart/ 下创建 urls.py(如果还没有):
bash
from django.urls import path
from . import views
app_name = 'cart'
urlpatterns = [
path('', views.cart_list, name='cart_list'),
path('add/', views.cart_add, name='cart_add'),
path('update/<int:item_id>/', views.cart_update, name='cart_update'),
path('delete/<int:item_id>/', views.cart_delete, name='cart_delete'),
path('batch_delete/', views.cart_batch_delete, name='cart_batch_delete'),
path('check/<int:item_id>/', views.cart_check, name='cart_check'),
path('check_all/', views.cart_check_all, name='cart_check_all'),
]
然后在项目 django_ecommerce/urls.py 中 include:
bash
urlpatterns = [
# ... 其他路由 ...
path('cart/', include('apps.cart.urls')),
]
三、编写视图
编辑 apps/cart/views.py(新创建该文件):
bash
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.db import transaction
from .models import CartItem
from products.models import SKU
@login_required(login_url='users:login')
def cart_list(request):
"""购物车列表页"""
cart_items = CartItem.objects.filter(user=request.user).select_related('sku__spu').prefetch_related('sku__images')
total_price = sum(item.sku.price * item.quantity for item in cart_items if item.is_checked)
total_count = sum(1 for item in cart_items if item.is_checked)
return render(request, 'cart/cart_list.html', {
'cart_items': cart_items,
'total_price': total_price,
'total_count': total_count,
})
@require_POST
@login_required(login_url='users:login')
def cart_add(request):
"""加入购物车(AJAX)"""
sku_id = request.POST.get('sku_id')
quantity = int(request.POST.get('quantity', 1))
if not sku_id:
return JsonResponse({'ok': False, 'msg': '参数错误'}, status=400)
sku = get_object_or_404(SKU, pk=sku_id, is_active=True)
if quantity > sku.stock:
return JsonResponse({'ok': False, 'msg': f'库存不足(仅剩 {sku.stock} 件)'}, status=400)
# 查找是否已存在该 SKU
cart_item, created = CartItem.objects.get_or_create(
user=request.user,
sku=sku,
defaults={'quantity': quantity}
)
if not created:
# 已存在,累加数量
new_quantity = cart_item.quantity + quantity
if new_quantity > sku.stock:
return JsonResponse({'ok': False, 'msg': f'库存不足(当前已有 {cart_item.quantity} 件,仅剩 {sku.stock} 件)'}, status=400)
cart_item.quantity = new_quantity
cart_item.save()
return JsonResponse({
'ok': True,
'msg': '已加入购物车',
'cart_count': request.user.cart_items.count()
})
@require_POST
@login_required(login_url='users:login')
def cart_update(request, item_id):
"""更新购物车商品数量"""
cart_item = get_object_or_404(CartItem, pk=item_id, user=request.user)
action = request.POST.get('action') # 'increase', 'decrease', 'set'
quantity = request.POST.get('quantity')
if action == 'increase':
new_qty = cart_item.quantity + 1
elif action == 'decrease':
new_qty = cart_item.quantity - 1
elif action == 'set' and quantity:
new_qty = int(quantity)
else:
return JsonResponse({'ok': False, 'msg': '参数错误'}, status=400)
if new_qty < 1:
return JsonResponse({'ok': False, 'msg': '数量不能小于1'}, status=400)
if new_qty > cart_item.sku.stock:
return JsonResponse({'ok': False, 'msg': f'库存不足(最多可买 {cart_item.sku.stock} 件)'}, status=400)
cart_item.quantity = new_qty
cart_item.save()
# 重新计算勾选商品的总价
checked_items = request.user.cart_items.filter(is_checked=True).select_related('sku')
total_price = sum(item.sku.price * item.quantity for item in checked_items)
return JsonResponse({
'ok': True,
'new_quantity': cart_item.quantity,
'subtotal': float(cart_item.sku.price * cart_item.quantity),
'total_price': float(total_price),
})
@require_POST
@login_required(login_url='users:login')
def cart_delete(request, item_id):
"""删除单个商品"""
cart_item = get_object_or_404(CartItem, pk=item_id, user=request.user)
cart_item.delete()
# 重新计算
checked_items = request.user.cart_items.filter(is_checked=True).select_related('sku')
total_price = sum(item.sku.price * item.quantity for item in checked_items)
return JsonResponse({
'ok': True,
'msg': '已删除',
'total_price': float(total_price),
})
@require_POST
@login_required(login_url='users:login')
def cart_batch_delete(request):
"""批量删除勾选的商品"""
item_ids = request.POST.getlist('item_ids[]')
if item_ids:
CartItem.objects.filter(pk__in=item_ids, user=request.user).delete()
checked_items = request.user.cart_items.filter(is_checked=True).select_related('sku')
total_price = sum(item.sku.price * item.quantity for item in checked_items)
return JsonResponse({
'ok': True,
'msg': '已删除选中商品',
'total_price': float(total_price),
})
@require_POST
@login_required(login_url='users:login')
def cart_check(request, item_id):
"""切换单个商品的勾选状态"""
cart_item = get_object_or_404(CartItem, pk=item_id, user=request.user)
cart_item.is_checked = not cart_item.is_checked
cart_item.save(update_fields=['is_checked'])
checked_items = request.user.cart_items.filter(is_checked=True).select_related('sku')
total_price = sum(item.sku.price * item.quantity for item in checked_items)
return JsonResponse({
'ok': True,
'is_checked': cart_item.is_checked,
'total_price': float(total_price),
'checked_count': checked_items.count(),
'all_checked': not request.user.cart_items.filter(is_checked=False).exists(),
})
@require_POST
@login_required(login_url='users:login')
def cart_check_all(request):
"""全选或取消全选"""
checked = request.POST.get('checked') == 'true'
request.user.cart_items.update(is_checked=checked)
total_price = 0.0
if checked:
items = request.user.cart_items.select_related('sku')
total_price = sum(item.sku.price * item.quantity for item in items)
return JsonResponse({
'ok': True,
'total_price': float(total_price),
'checked_count': request.user.cart_items.filter(is_checked=True).count() if checked else 0,
})
视图关键点:
-
所有操作都通过
login_required保护。 -
加入购物车使用
get_or_create,存在则累加数量,不存在则新建。 -
数量修改严格校验库存上下限。
-
每次操作后都重新计算总价并返回 JSON,前端可以直接更新显示。
-
批量删除接收前端传来的
item_ids[]数组。
四、创建购物车页面模板
创建 apps/cart/templates/cart/cart_list.html:
bash
{% extends 'base.html' %}
{% load static %}
{% block title %}我的购物车{% endblock %}
{% block content %}
<h3 class="mb-4">🛒 我的购物车</h3>
{% if cart_items %}
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover mb-0" id="cart-table">
<thead class="table-light">
<tr>
<th style="width: 40px;">
<input type="checkbox" id="check-all" {% if total_count == cart_items|length and cart_items %}checked{% endif %}>
</th>
<th>商品信息</th>
<th style="width: 120px;">单价</th>
<th style="width: 180px;">数量</th>
<th style="width: 120px;">小计</th>
<th style="width: 80px;">操作</th>
</tr>
</thead>
<tbody>
{% for item in cart_items %}
<tr id="cart-item-{{ item.id }}">
<td>
<input type="checkbox" class="item-checkbox" data-item-id="{{ item.id }}" {% if item.is_checked %}checked{% endif %}>
</td>
<td>
<div class="d-flex align-items-center">
<img src="{{ item.sku.main_image_url }}" alt="{{ item.sku.name }}"
style="width: 60px; height: 60px; object-fit: cover;" class="me-3 rounded">
<div>
<a href="{% url 'products:spu_detail' item.sku.spu.id %}" class="text-decoration-none">
{{ item.sku.name }}
</a>
</div>
</div>
</td>
<td>¥{{ item.sku.price }}</td>
<td>
<div class="input-group input-group-sm">
<button class="btn btn-outline-secondary btn-minus" data-item-id="{{ item.id }}">−</button>
<input type="text" class="form-control text-center quantity-input"
value="{{ item.quantity }}" data-item-id="{{ item.id }}"
data-stock="{{ item.sku.stock }}" style="max-width: 60px;">
<button class="btn btn-outline-secondary btn-plus" data-item-id="{{ item.id }}">+</button>
</div>
</td>
<td class="subtotal" id="subtotal-{{ item.id }}">
¥{{ item.sku.price|floatformat:2 }}
</td>
<td>
<button class="btn btn-sm btn-outline-danger btn-delete" data-item-id="{{ item.id }}">删除</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 底部操作栏 -->
<div class="card shadow-sm mt-3">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<button class="btn btn-outline-danger btn-sm" id="batch-delete-btn">删除选中</button>
</div>
<div class="text-end">
<span class="me-3">
已选 <strong id="checked-count">{{ total_count }}</strong> 件,
合计:<span class="text-danger fs-4 fw-bold" id="total-price">¥{{ total_price|floatformat:2 }}</span>
</span>
<a href="#" class="btn btn-danger btn-lg" id="checkout-btn">去结算</a>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5">
<p class="text-muted fs-5">购物车还是空的哦~</p>
<a href="{% url 'products:sku_list' %}" class="btn btn-primary">去逛逛</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// 获取 CSRF Token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// 封装 AJAX POST
function postJSON(url, data, callback) {
fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(data)
})
.then(response => response.json())
.then(callback)
.catch(error => console.error('Error:', error));
}
// 更新页面上的总价和选中数量
function updateSummary(total_price, checked_count) {
document.getElementById('total-price').textContent = '¥' + total_price.toFixed(2);
if (checked_count !== undefined) {
document.getElementById('checked-count').textContent = checked_count;
}
}
// 更新全部勾选框的状态
function updateCheckAll() {
const allCheckboxes = document.querySelectorAll('.item-checkbox');
const allChecked = Array.from(allCheckboxes).every(cb => cb.checked);
document.getElementById('check-all').checked = allChecked;
}
// 数量减少
document.querySelectorAll('.btn-minus').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.dataset.itemId;
const input = document.querySelector(`.quantity-input[data-item-id="${itemId}"]`);
let qty = parseInt(input.value);
if (qty <= 1) return;
postJSON(`/cart/update/${itemId}/`, { action: 'decrease' }, data => {
if (data.ok) {
input.value = data.new_quantity;
document.getElementById(`subtotal-${itemId}`).textContent = '¥' + data.subtotal.toFixed(2);
updateSummary(data.total_price);
} else {
alert(data.msg);
}
});
});
});
// 数量增加
document.querySelectorAll('.btn-plus').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.dataset.itemId;
const input = document.querySelector(`.quantity-input[data-item-id="${itemId}"]`);
const stock = parseInt(input.dataset.stock);
let qty = parseInt(input.value);
if (qty >= stock) {
alert('库存不足');
return;
}
postJSON(`/cart/update/${itemId}/`, { action: 'increase' }, data => {
if (data.ok) {
input.value = data.new_quantity;
document.getElementById(`subtotal-${itemId}`).textContent = '¥' + data.subtotal.toFixed(2);
updateSummary(data.total_price);
} else {
alert(data.msg);
}
});
});
});
// 数量直接输入
document.querySelectorAll('.quantity-input').forEach(input => {
input.addEventListener('change', function() {
const itemId = this.dataset.itemId;
const stock = parseInt(this.dataset.stock);
let qty = parseInt(this.value);
if (isNaN(qty) || qty < 1) qty = 1;
if (qty > stock) qty = stock;
this.value = qty;
postJSON(`/cart/update/${itemId}/`, { action: 'set', quantity: qty }, data => {
if (data.ok) {
document.getElementById(`subtotal-${itemId}`).textContent = '¥' + data.subtotal.toFixed(2);
updateSummary(data.total_price);
} else {
alert(data.msg);
}
});
});
});
// 单个删除
document.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.dataset.itemId;
if (!confirm('确定要删除该商品吗?')) return;
postJSON(`/cart/delete/${itemId}/`, {}, data => {
if (data.ok) {
document.getElementById(`cart-item-${itemId}`).remove();
updateSummary(data.total_price);
updateCheckAll();
if (document.querySelectorAll('#cart-table tbody tr').length === 0) {
location.reload(); // 购物车为空,刷新显示空状态
}
}
});
});
});
// 单选框勾选
document.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', function() {
const itemId = this.dataset.itemId;
postJSON(`/cart/check/${itemId}/`, {}, data => {
if (data.ok) {
updateSummary(data.total_price, data.checked_count);
updateCheckAll();
}
});
});
});
// 全选/取消全选
document.getElementById('check-all').addEventListener('change', function() {
const checked = this.checked;
postJSON(`/cart/check_all/`, { checked: checked }, data => {
if (data.ok) {
updateSummary(data.total_price, data.checked_count);
document.querySelectorAll('.item-checkbox').forEach(cb => {
cb.checked = checked;
});
}
});
});
// 批量删除
document.getElementById('batch-delete-btn').addEventListener('click', function() {
const checkedBoxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedBoxes.length === 0) {
alert('请选择要删除的商品');
return;
}
if (!confirm(`确定要删除选中的 ${checkedBoxes.length} 件商品吗?`)) return;
const itemIds = Array.from(checkedBoxes).map(cb => cb.dataset.itemId);
postJSON(`/cart/batch_delete/`, { 'item_ids[]': itemIds }, data => {
if (data.ok) {
checkedBoxes.forEach(cb => {
document.getElementById(`cart-item-${cb.dataset.itemId}`).remove();
});
updateSummary(data.total_price, 0);
updateCheckAll();
if (document.querySelectorAll('#cart-table tbody tr').length === 0) {
location.reload();
}
}
});
});
// 小计初始化(模板中的单价默认小计可能不对,页面加载时计算一下)
document.querySelectorAll('.subtotal').forEach(td => {
const row = td.closest('tr');
const input = row.querySelector('.quantity-input');
const price = parseFloat(row.querySelector('td:nth-child(3)').textContent.replace('¥', ''));
const qty = parseInt(input.value);
td.textContent = '¥' + (price * qty).toFixed(2);
});
</script>
{% endblock %}
五、完善商品详情页的"加入购物车"
打开 apps/products/templates/products/spu_detail.html,需要给"加入购物车"按钮绑定 AJAX 请求,并获取当前选中的 SKU ID 和数量。
在详情页的 {% block extra_js %} 中追加以下代码(在已有的规格切换逻辑之后):
bash
// 获取当前选中的 SKU ID
function getCurrentSkuId() {
const matched = findMatchingSku();
return matched ? matched.id : null;
}
// 加入购物车按钮
const addToCartBtn = document.getElementById('add-to-cart-btn');
if (addToCartBtn) {
addToCartBtn.addEventListener('click', function() {
const skuId = getCurrentSkuId();
if (!skuId) {
alert('请选择完整的规格');
return;
}
// 获取数量(如果以后有数量选择器)
const quantity = 1;
fetch('{% url "cart:cart_add" %}', {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ sku_id: skuId, quantity: quantity })
})
.then(response => response.json())
.then(data => {
if (data.ok) {
alert('已加入购物车!');
// 可选:更新导航栏购物车数量徽标
} else {
alert(data.msg);
}
});
});
}
// 获取 CSRF Token(若详情页没有,需增加该函数)
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
同时,确保模板中按钮是启用的(已登录),不再 disabled:
bash
{% if user.is_authenticated %}
<button class="btn btn-primary btn-lg" id="add-to-cart-btn">加入购物车</button>
{% else %}
<a href="{% url 'users:login' %}?next={{ request.path }}" class="btn btn-primary btn-lg">登录后购买</a>
{% endif %}
六、更新导航栏,显示购物车数量
修改 templates/base.html,在导航栏购物车链接上加入徽标(利用 user.cart_items.count,需在视图中传递或使用模板 context processor。简单起见,我们可以使用 Django 模板中直接访问 user.cart_items.count,但可能增加查询。更优雅的方式是自定义一个 context processor,但现在先简单处理,仅在有数据时展示):
bash
<li class="nav-item">
<a class="nav-link" href="{% url 'cart:cart_list' %}">
购物车
{% if user.is_authenticated and user.cart_items.count > 0 %}
<span class="badge bg-danger">{{ user.cart_items.count }}</span>
{% endif %}
</a>
</li>
七、测试完整流程
启动服务器:
bash
python manage.py runserver
7.1 加入购物车测试
-
登录后进入 iPhone 15 详情页
/products/spu/1/ -
选择 128GB 午夜色,点击"加入购物车"
-
弹出"已加入购物车!",导航栏购物车图标旁出现数字 1
控制台输出:
bash
[25/May/2026 09:30:12] "POST /cart/add/ HTTP/1.1" 200 45
7.2 购物车列表查看
点击导航栏"购物车",进入 /cart/
页面展示:
-
表格显示:图片、iPhone 15 128GB 午夜色、单价 ¥5999.00、数量 1、小计 ¥5999.00、勾选框已勾选、操作有删除按钮。
-
底部:已选 1 件,合计 ¥5999.00,去结算按钮。
终端:
bash
[25/May/2026 09:31:05] "GET /cart/ HTTP/1.1" 200 9867
7.3 修改数量
点击 + 按钮,数量变为 2,小计变为 ¥11998.00,合计随之更新。终端输出:
bash
[25/May/2026 09:31:30] "POST /cart/update/1/ HTTP/1.1" 200 67
若增加到超过库存(例如 101),弹出"库存不足"。
7.4 勾选操作
取消第一个商品的勾选,合计立即变为 ¥0.00,全选框自动取消。终端:
bash
[25/May/2026 09:32:00] "POST /cart/check/1/ HTTP/1.1" 200 78
再次勾选,合计恢复 ¥11998.00,全选框恢复勾选。
7.5 全选与批量删除
加入另一个商品(如 Samsung Galaxy S24),购物车有两个商品,都勾选。点击"删除选中",确认后两个商品消失,页面刷新为空购物车状态。
bash
[25/May/2026 09:33:10] "POST /cart/batch_delete/ HTTP/1.1" 200 56
[25/May/2026 09:33:10] "GET /cart/ HTTP/1.1" 200 4987
八、总结与下集预告
今天我们完成了一个功能丰富、交互流畅的购物车页面:
-
AJAX 加入购物车,实时反馈;
-
购物车列表支持数量增减、直接输入,库存校验严格;
-
单行勾选、全选/取消全选、批量删除;
-
所有操作实时更新合计金额,无需刷新页面;
-
导航栏购物车数量徽标。
购物车模块全线竣工!下一步,用户就要把购物车里的宝贝变成订单了。第 18 篇 ,我们将进入下单流程的铺垫------订单模型设计,回顾订单表结构,梳理订单状态流转,为确认订单页和提交订单做好数据准备。
想了解更多也可以去其它的平台搜索「IT策士」,一起升级 IT 思维 !
本文为《Django 从 0 到 1 打造完整电商平台》系列第 17 篇。