IT策士 10余年一線大廠經(jīng)驗,專注 IT 思維、架構(gòu)、職場進階。我會在公眾號、今日頭條持續(xù)發(fā)布最新文章,助你少走彎路。
上一篇我們完成了購物車模型的最終設(shè)計,還跑通了單元測試。今天,模型里的字段將真正“活”起來——我們要實現(xiàn)購物車的完整增刪改查,包括從商品詳情頁一鍵加入購物車、在購物車頁面調(diào)整數(shù)量、勾選結(jié)算、刪除商品,以及全選和合計金額的實時計算。
這篇代碼量不小,前端交互也比較豐富,但每一步我都會把邏輯講透。跟上節(jié)奏,做完這一篇,你的電商項目就已經(jīng)能跑通“選商品 → 加購物車 → 改數(shù)量”的完整閉環(huán)了。
一、需求回顧
我們今天要實現(xiàn)的功能清單:
加入購物車:在商品詳情頁選中規(guī)格后,點擊“加入購物車”,AJAX 提交,成功后給出提示。
-
購物車列表頁:
以表格形式展示商品圖片、名稱、規(guī)格、單價、數(shù)量調(diào)整器、小計、勾選框。
支持修改數(shù)量(+/- 按鈕或直接輸入),自動更新小計和合計,且不能超過庫存。
單行勾選/取消勾選,支持全選/取消全選。
勾選商品后,底部合計金額實時更新。
刪除單個商品(帶確認)。
批量刪除勾選商品。
有“去結(jié)算”按鈕,跳轉(zhuǎn)到確認訂單頁(后續(xù)實現(xiàn))。
二、配置 URL 路由
在 apps/cart/ 下創(chuàng)建 urls.py(如果還沒有):
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:
urlpatterns = [
# ... 其他路由 ...
path('cart/', include('apps.cart.urls')),
]三、編寫視圖
編輯 apps/cart/views.py(新創(chuàng)建該文件):
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': '參數(shù)錯誤'}, 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:
# 已存在,累加數(shù)量
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):
"""更新購物車商品數(shù)量"""
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': '參數(shù)錯誤'}, status=400)
if new_qty < 1:
return JsonResponse({'ok': False, 'msg': '數(shù)量不能小于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):
"""切換單個商品的勾選狀態(tài)"""
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,
})視圖關(guān)鍵點:
所有操作都通過
login_required保護。加入購物車使用
get_or_create,存在則累加數(shù)量,不存在則新建。數(shù)量修改嚴格校驗庫存上下限。
每次操作后都重新計算總價并返回 JSON,前端可以直接更新顯示。
批量刪除接收前端傳來的
item_ids[]數(shù)組。
四、創(chuàng)建購物車頁面模板
創(chuàng)建 apps/cart/templates/cart/cart_list.html:
{% 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;">數(shù)量</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">去結(jié)算</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));
}
// 更新頁面上的總價和選中數(shù)量
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;
}
}
// 更新全部勾選框的狀態(tài)
function updateCheckAll() {
const allCheckboxes = document.querySelectorAll('.item-checkbox');
const allChecked = Array.from(allCheckboxes).every(cb => cb.checked);
document.getElementById('check-all').checked = allChecked;
}
// 數(shù)量減少
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);
}
});
});
});
// 數(shù)量增加
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);
}
});
});
});
// 數(shù)量直接輸入
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(); // 購物車為空,刷新顯示空狀態(tài)
}
}
});
});
});
// 單選框勾選
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 和數(shù)量。
在詳情頁的 {% block extra_js %} 中追加以下代碼(在已有的規(guī)格切換邏輯之后):
// 獲取當前選中的 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('請選擇完整的規(guī)格');
return;
}
// 獲取數(shù)量(如果以后有數(shù)量選擇器)
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('已加入購物車!');
// 可選:更新導(dǎo)航欄購物車數(shù)量徽標
} else {
alert(data.msg);
}
});
});
}
// 獲取 CSRF Token(若詳情頁沒有,需增加該函數(shù))
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:
{% 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 %}六、更新導(dǎo)航欄,顯示購物車數(shù)量
修改 templates/base.html,在導(dǎo)航欄購物車鏈接上加入徽標(利用 user.cart_items.count,需在視圖中傳遞或使用模板 context processor。簡單起見,我們可以使用 Django 模板中直接訪問 user.cart_items.count,但可能增加查詢。更優(yōu)雅的方式是自定義一個 context processor,但現(xiàn)在先簡單處理,僅在有數(shù)據(jù)時展示):
<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>七、測試完整流程
啟動服務(wù)器:
python manage.py runserver7.1 加入購物車測試
登錄后進入 iPhone 15 詳情頁
/products/spu/1/選擇 128GB 午夜色,點擊“加入購物車”
彈出“已加入購物車!”,導(dǎo)航欄購物車圖標旁出現(xiàn)數(shù)字 1
控制臺輸出:
[25/May/2026 09:30:12] "POST /cart/add/ HTTP/1.1" 200 457.2 購物車列表查看
點擊導(dǎo)航欄“購物車”,進入 /cart/
頁面展示:
表格顯示:圖片、iPhone 15 128GB 午夜色、單價 ¥5999.00、數(shù)量 1、小計 ¥5999.00、勾選框已勾選、操作有刪除按鈕。
底部:已選 1 件,合計 ¥5999.00,去結(jié)算按鈕。
終端:
[25/May/2026 09:31:05] "GET /cart/ HTTP/1.1" 200 98677.3 修改數(shù)量
點擊 + 按鈕,數(shù)量變?yōu)?2,小計變?yōu)?¥11998.00,合計隨之更新。終端輸出:
[25/May/2026 09:31:30] "POST /cart/update/1/ HTTP/1.1" 200 67若增加到超過庫存(例如 101),彈出“庫存不足”。
7.4 勾選操作
取消第一個商品的勾選,合計立即變?yōu)?¥0.00,全選框自動取消。終端:
[25/May/2026 09:32:00] "POST /cart/check/1/ HTTP/1.1" 200 78再次勾選,合計恢復(fù) ¥11998.00,全選框恢復(fù)勾選。
7.5 全選與批量刪除
加入另一個商品(如 Samsung Galaxy S24),購物車有兩個商品,都勾選。點擊“刪除選中”,確認后兩個商品消失,頁面刷新為空購物車狀態(tài)。
[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八、總結(jié)與下集預(yù)告
今天我們完成了一個功能豐富、交互流暢的購物車頁面:
AJAX 加入購物車,實時反饋;
購物車列表支持數(shù)量增減、直接輸入,庫存校驗嚴格;
單行勾選、全選/取消全選、批量刪除;
所有操作實時更新合計金額,無需刷新頁面;
導(dǎo)航欄購物車數(shù)量徽標。
購物車模塊全線竣工!下一步,用戶就要把購物車里的寶貝變成訂單了。第 18 篇,我們將進入下單流程的鋪墊——訂單模型設(shè)計,回顧訂單表結(jié)構(gòu),梳理訂單狀態(tài)流轉(zhuǎn),為確認訂單頁和提交訂單做好數(shù)據(jù)準備。精彩繼續(xù),明天見!
想了解更多還可以去公眾號、今日頭條搜索「IT策士」,一起升級 IT 思維 !
本文為《Django 從 0 到 1 打造完整電商平臺》系列第 17 篇。