Django 从 0 到 1 打造完整电商平台:购物车页面增删改查商品数量

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我也会在其它平台持续发布最新文章,助你少走弯路。


上一篇我们完成了购物车模型的最终设计,还跑通了单元测试。今天,模型里的字段将真正"活"起来------我们要实现购物车的完整增删改查,包括从商品详情页一键加入购物车、在购物车页面调整数量、勾选结算、删除商品,以及全选和合计金额的实时计算。

这篇代码量不小,前端交互也比较丰富,但每一步我都会把逻辑讲透。跟上节奏,做完这一篇,你的电商项目就已经能跑通"选商品 → 加购物车 → 改数量"的完整闭环了。


一、需求回顾

我们今天要实现的功能清单:

  1. 加入购物车:在商品详情页选中规格后,点击"加入购物车",AJAX 提交,成功后给出提示。

  2. 购物车列表页

    • 以表格形式展示商品图片、名称、规格、单价、数量调整器、小计、勾选框。

    • 支持修改数量(+/- 按钮或直接输入),自动更新小计和合计,且不能超过库存。

    • 单行勾选/取消勾选,支持全选/取消全选。

    • 勾选商品后,底部合计金额实时更新。

    • 删除单个商品(带确认)。

    • 批量删除勾选商品。

    • 有"去结算"按钮,跳转到确认订单页(后续实现)。


二、配置 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 加入购物车测试
  1. 登录后进入 iPhone 15 详情页 /products/spu/1/

  2. 选择 128GB 午夜色,点击"加入购物车"

  3. 弹出"已加入购物车!",导航栏购物车图标旁出现数字 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 篇。

相关推荐
小黑随笔17 小时前
Python asyncio 模块学习总结:从“等着”到“切出去干点别的”
开发语言·python·学习
还是鼠鼠17 小时前
AI掘金头条新闻系统 (Toutiao News)-封装通用成功响应格式
数据库·后端·python·fastapi·web
猎嘤一号17 小时前
Python 打包成 EXE 完整教程(单文件 \+ 整个工程)
数据库·python·microsoft
qq_2949405517 小时前
Python环境搭建
开发语言·python
暴躁小师兄数据学院17 小时前
【AI大模型应用开发工程师特训】第01讲—AI在企业中的定位
大数据·python·ai·语言模型
Rauser Mack17 小时前
编程零基础五分钟用AI做了个贪吃蛇(附prompt)
人工智能·python·html·prompt·ai编程
我是一颗柠檬17 小时前
【JDK8新特性】接口默认方法与静态方法Day8
java·开发语言·后端·intellij-idea
lulu121654407817 小时前
【开发者指南】Gemini 3.5开发入门:从API调用到Agent构建
java·开发语言·人工智能·python·ai编程
沐风。5617 小时前
AI 实战
python