Django 從 0 到 1 打造完整電商平臺(tái):商品列表頁(yè)實(shí)現(xiàn)

IT策士 10余年一線(xiàn)大廠經(jīng)驗(yàn),專(zhuān)注 IT 思維、架構(gòu)、職場(chǎng)進(jìn)階。我會(huì)在公眾號(hào)、今日頭條持續(xù)發(fā)布最新文章,助你少走彎路。


上一篇我們搞定了商品分類(lèi)樹(shù)和 SPU 詳情頁(yè)的規(guī)格切換,商品模塊的骨架已經(jīng)撐起來(lái)了。但一個(gè)商城不能只靠詳情頁(yè)活著——用戶(hù)得有地方“逛”,能看到一頁(yè)一頁(yè)的商品,像刷貨架一樣。今天我們就來(lái)實(shí)現(xiàn)電商的 商品列表頁(yè),并且加入經(jīng)典的分頁(yè)功能。

需求很明確:所有上架的商品(SKU)以網(wǎng)格形式展示,用戶(hù)可以通過(guò)分類(lèi)鏈接進(jìn)入某個(gè)分類(lèi)下的商品列表,每頁(yè)展示固定數(shù)量,底部有分頁(yè)導(dǎo)航。全程使用 Django 內(nèi)置的 Paginator,無(wú)需第三方庫(kù),簡(jiǎn)單可靠。


一、需求分析

商品列表頁(yè)的核心功能:

  1. 默認(rèn)展示所有上架 SKU,按創(chuàng)建時(shí)間倒序排列。

  2. 支持按分類(lèi)篩選:通過(guò) URL 查詢(xún)參數(shù) ?category_id=1 過(guò)濾該分類(lèi)及其子分類(lèi)下的商品。

  3. 分頁(yè)展示:每頁(yè) 12 個(gè)商品,底部生成 Bootstrap 風(fēng)格的分頁(yè)導(dǎo)航。

  4. 商品卡片:顯示主圖(或占位圖)、名稱(chēng)、價(jià)格、銷(xiāo)量等信息,點(diǎn)擊跳轉(zhuǎn)到 SPU 詳情頁(yè)。

  5. 排序暫時(shí)留到第 15 篇,這里保持簡(jiǎn)單。


二、視圖實(shí)現(xiàn)

我們將使用 Django 的 ListView 來(lái)快速構(gòu)建,但為了更靈活地處理分類(lèi)篩選(包含子分類(lèi)),還是采用函數(shù)視圖 + Paginator 手寫(xiě)。

編輯 apps/products/views.py,在已有內(nèi)容基礎(chǔ)上追加:

from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from .models import SKU, Category


def sku_list(request):
    # 獲取所有上架 SKU,預(yù)加載關(guān)聯(lián)數(shù)據(jù),減少數(shù)據(jù)庫(kù)查詢(xún)
    skus = SKU.objects.filter(is_active=True).select_related('spu__category').prefetch_related('images').order_by('-create_time')

    # 分類(lèi)篩選
    category_id = request.GET.get('category_id')
    if category_id:
        try:
            category = Category.objects.get(pk=category_id, is_active=True)
            # 獲取該分類(lèi)及其所有子分類(lèi)的 ID 列表
            category_ids = [category.id]
            # 簡(jiǎn)單處理兩級(jí)分類(lèi):查找子分類(lèi)
            children = Category.objects.filter(parent=category, is_active=True)
            category_ids.extend(children.values_list('id', flat=True))
            # 過(guò)濾出屬于這些分類(lèi)的 SPU 的 SKU
            skus = skus.filter(spu__category_id__in=category_ids)
        except Category.DoesNotExist:
            # 分類(lèi)不存在時(shí)返回空列表
            skus = skus.none()

    # 分頁(yè):每頁(yè) 12 個(gè)
    paginator = Paginator(skus, 12)
    page_number = request.GET.get('page', 1)

    try:
        page_obj = paginator.page(page_number)
    except PageNotAnInteger:
        page_obj = paginator.page(1)
    except EmptyPage:
        page_obj = paginator.page(paginator.num_pages)

    # 傳遞當(dāng)前分類(lèi)信息供模板使用
    current_category = None
    if category_id:
        try:
            current_category = Category.objects.get(pk=category_id)
        except Category.DoesNotExist:
            pass

    return render(request, 'products/sku_list.html', {
        'page_obj': page_obj,
        'current_category': current_category,
    })

代碼要點(diǎn):

  • select_related('spu__category') 連表查詢(xún) SPU 和分類(lèi),避免循環(huán)查詢(xún)。

  • prefetch_related('images') 預(yù)取圖片,供模板取主圖用。

  • 分類(lèi)篩選時(shí),先取出當(dāng)前分類(lèi)及其子分類(lèi)的 ID,再通過(guò) spu__category_id__in 過(guò)濾。

  • Paginatorpage() 方法會(huì)自動(dòng)處理頁(yè)碼越界,我們捕獲異常并返回首頁(yè)或末頁(yè)。


三、URL 配置

apps/products/urls.py 中添加路由:

urlpatterns = [
    path('categories/', views.category_tree, name='category_tree'),
    path('spu/<int:spu_id>/', views.spu_detail, name='spu_detail'),
    path('list/', views.sku_list, name='sku_list'),   # 新增
]

這樣商品列表頁(yè)的 URL 就是 /products/list/,帶分類(lèi)參數(shù)則是 /products/list/?category_id=2。


四、模板設(shè)計(jì)

創(chuàng)建 apps/products/templates/products/sku_list.html

{% extends 'base.html' %}
{% load static %}

{% block title %}
    {% if current_category %}
        {{ current_category.name }} - 商品列表
    {% else %}
        全部商品 - Django 商城
    {% endif %}
{% endblock %}

{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
    <h3>
        {% if current_category %}
            ?? {{ current_category.name }}
        {% else %}
            ??? 全部商品
        {% endif %}
    </h3>
    <span class="text-muted">共 {{ page_obj.paginator.count }} 件商品</span>
</div>

<!-- 分類(lèi)導(dǎo)航(可復(fù)用上一篇的分類(lèi)樹(shù)鏈接) -->
<div class="mb-3">
    <a href="{% url 'products:sku_list' %}" class="btn btn-sm {% if not current_category %}btn-primary{% else %}btn-outline-primary{% endif %}">全部</a>
    {% for cat in top_categories %}
        <a href="{% url 'products:sku_list' %}?category_id={{ cat.id }}" class="btn btn-sm {% if current_category.id == cat.id %}btn-primary{% else %}btn-outline-primary{% endif %}">{{ cat.name }}</a>
    {% endfor %}
</div>

<!-- 商品網(wǎng)格 -->
<div class="row">
    {% for sku in page_obj %}
        <div class="col-md-3 col-sm-6 mb-4">
            <div class="card h-100 shadow-sm">
                <a href="{% url 'products:spu_detail' sku.spu.id %}">
                    {% with sku.images.all|first as main_image %}
                        {% if main_image %}
                            <img src="{{ main_image.image.url }}" class="card-img-top" alt="{{ sku.name }}" style="height:200px; object-fit:cover;">
                        {% else %}
                            <img src="{% static 'images/placeholder.png' %}" class="card-img-top" alt="暫無(wú)圖片" style="height:200px; object-fit:cover;">
                        {% endif %}
                    {% endwith %}
                </a>
                <div class="card-body d-flex flex-column">
                    <h6 class="card-title">
                        <a href="{% url 'products:spu_detail' sku.spu.id %}" class="text-decoration-none text-dark">
                            {{ sku.name }}
                        </a>
                    </h6>
                    <div class="mt-auto">
                        <span class="fs-5 text-danger fw-bold">¥{{ sku.price }}</span>
                        <span class="text-muted small ms-2">已售 {{ sku.sales }}</span>
                    </div>
                </div>
            </div>
        </div>
    {% empty %}
        <div class="col-12">
            <div class="alert alert-info">該分類(lèi)下暫無(wú)商品。</div>
        </div>
    {% endfor %}
</div>

<!-- 分頁(yè)導(dǎo)航 -->
{% if page_obj.has_other_pages %}
<nav aria-label="商品列表分頁(yè)">
    <ul class="pagination justify-content-center">
        <!-- 上一頁(yè) -->
        {% if page_obj.has_previous %}
            <li class="page-item">
                <a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">? 上一頁(yè)</a>
            </li>
        {% else %}
            <li class="page-item disabled"><span class="page-link">? 上一頁(yè)</span></li>
        {% endif %}

        <!-- 頁(yè)碼 -->
        {% for num in page_obj.paginator.page_range %}
            {% if num == page_obj.number %}
                <li class="page-item active"><span class="page-link">{{ num }}</span></li>
            {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ num }}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">{{ num }}</a>
                </li>
            {% endif %}
        {% endfor %}

        <!-- 下一頁(yè) -->
        {% if page_obj.has_next %}
            <li class="page-item">
                <a class="page-link" href="?page={{ page_obj.next_page_number }}{% if current_category %}&category_id={{ current_category.id }}{% endif %}">下一頁(yè) ?</a>
            </li>
        {% else %}
            <li class="page-item disabled"><span class="page-link">下一頁(yè) ?</span></li>
        {% endif %}
    </ul>
</nav>
{% endif %}
{% endblock %}

模板說(shuō)明:

  • 在頂部我們使用了一個(gè)分類(lèi)快捷導(dǎo)航條,但這需要傳入 top_categories 變量。所以需要修改視圖,把頂級(jí)分類(lèi)也傳給模板。

更新視圖:在 sku_list 中添加:

top_categories = Category.objects.filter(parent__isnull=True, is_active=True).order_by('sort')

然后在 return render 的 context 里加上 'top_categories': top_categories。

這樣就可以在列表頁(yè)快速切換分類(lèi)。


五、視圖最終版本(補(bǔ)充 top_categories)

def sku_list(request):
    # ... 之前的 skus 查詢(xún)和分類(lèi)篩選 ...

    # 頂級(jí)分類(lèi),供分類(lèi)導(dǎo)航使用
    top_categories = Category.objects.filter(parent__isnull=True, is_active=True).order_by('sort')

    # ... 分頁(yè)邏輯 ...

    return render(request, 'products/sku_list.html', {
        'page_obj': page_obj,
        'current_category': current_category,
        'top_categories': top_categories,
    })

六、添加更多測(cè)試數(shù)據(jù)

為了讓分頁(yè)效果明顯,我們需要超過(guò) 12 個(gè) SKU??梢栽?Admin 中多添加幾個(gè),或者編寫(xiě)一個(gè)臨時(shí)命令來(lái)快速填充。

apps/products/management/commands/init_product_data.py 中可以追加一些商品,但更簡(jiǎn)單的是直接在 dbshell 中插入幾條記錄,不過(guò)我們保持規(guī)范,可以再創(chuàng)建一個(gè)命令 create_test_skus.py 來(lái)批量生成。這里提供快捷方式:

在項(xiàng)目根目錄執(zhí)行 python manage.py shell

from products.models import SPU, SKU, Category

# 假設(shè)已有電子產(chǎn)品分類(lèi)下的手機(jī)子分類(lèi)
phone_cat = Category.objects.get(name='手機(jī)')
spu_iphone = SPU.objects.get(name='iPhone 15')
spu_samsung = SPU.objects.create(name='Samsung Galaxy S24', brand='Samsung', desc='三星旗艦手機(jī)', category=phone_cat)

# 為三星創(chuàng)建幾個(gè) SKU
SKU.objects.create(spu=spu_samsung, name='Samsung Galaxy S24 256GB 黑色', specs={'顏色':'黑色','存儲(chǔ)':'256GB'}, price=6999, stock=60, is_active=True)
SKU.objects.create(spu=spu_samsung, name='Samsung Galaxy S24 512GB 黑色', specs={'顏色':'黑色','存儲(chǔ)':'512GB'}, price=7999, stock=30, is_active=True)

# 為 iPhone 再多加幾個(gè)顏色
SKU.objects.create(spu=spu_iphone, name='iPhone 15 128GB 星光色', specs={'顏色':'星光色','存儲(chǔ)':'128GB'}, price=5999, stock=80, is_active=True)
SKU.objects.create(spu=spu_iphone, name='iPhone 15 256GB 星光色', specs={'顏色':'星光色','存儲(chǔ)':'256GB'}, price=6999, stock=40, is_active=True)

現(xiàn)在數(shù)據(jù)足夠了。如果使用之前的初始化命令,可能只有 5 個(gè) SKU,加上手動(dòng)添加的,總數(shù)超過(guò) 12,分頁(yè)效果就出來(lái)了。


七、測(cè)試流程與輸出

啟動(dòng)服務(wù)器:

python manage.py runserver

7.1 全部商品列表

訪問(wèn) http://127.0.0.1:8000/products/list/

頁(yè)面展示:

  • 頂部顯示“??? 全部商品”,共 N 件。

  • 分類(lèi)快捷按鈕:全部(高亮)、電子產(chǎn)品、服裝。

  • 商品網(wǎng)格,每行 4 個(gè)卡片,顯示圖片、名稱(chēng)、價(jià)格、銷(xiāo)量。

  • 底部有分頁(yè)導(dǎo)航:? 上一頁(yè) 1 [2] 下一頁(yè) ?

終端輸出:

[23/May/2026 10:15:00] "GET /products/list/ HTTP/1.1" 200 9654

7.2 按分類(lèi)篩選

點(diǎn)擊“電子產(chǎn)品”按鈕,或訪問(wèn) http://127.0.0.1:8000/products/list/?category_id=1(假設(shè)電子產(chǎn)品分類(lèi) ID=1)。

頁(yè)面只顯示 iPhone 和 Samsung 的 SKU,分類(lèi)按鈕“電子產(chǎn)品”高亮。

終端輸出:

[23/May/2026 10:16:12] "GET /products/list/?category_id=1 HTTP/1.1" 200 7632

7.3 分頁(yè)測(cè)試

如果當(dāng)前商品數(shù)量超過(guò) 12,底部出現(xiàn)分頁(yè)。點(diǎn)擊“下一頁(yè)”,URL 變?yōu)??page=2(若帶有 category_id 會(huì)保留)。商品卡片內(nèi)容變化,且分頁(yè)導(dǎo)航當(dāng)前頁(yè)碼高亮。

終端輸出(點(diǎn)擊下一頁(yè)):

[23/May/2026 10:17:45] "GET /products/list/?page=2 HTTP/1.1" 200 9654

7.4 空分類(lèi)

訪問(wèn)一個(gè)不存在的分類(lèi) ID,如 /products/list/?category_id=999,頁(yè)面顯示“該分類(lèi)下暫無(wú)商品。”,總數(shù) 0。

終端輸出:

[23/May/2026 10:18:33] "GET /products/list/?category_id=999 HTTP/1.1" 200 4512

八、SQL 查詢(xún)分析(簡(jiǎn)要)

Django 的 connection.queries 可以在開(kāi)發(fā)環(huán)境查看 SQL。但此處不展開(kāi),只需要知道通過(guò) select_relatedprefetch_related 我們的查詢(xún)次數(shù)控制在 3~5 次內(nèi)(分類(lèi)、SKU 列表、圖片),沒(méi)有 N+1 問(wèn)題。后續(xù)性能優(yōu)化篇會(huì)詳解。


九、總結(jié)與下集預(yù)告

今天我們讓商品“上架”到了用戶(hù)面前,實(shí)現(xiàn)了:

  • 商品列表頁(yè)視圖,支持按分類(lèi)篩選(包含子分類(lèi));

  • 利用 Django 內(nèi)置分頁(yè)器 Paginator 實(shí)現(xiàn)高效分頁(yè);

  • 響應(yīng)式商品卡片布局,展示主圖、價(jià)格、銷(xiāo)量;

  • 分類(lèi)快捷導(dǎo)航,方便用戶(hù)瀏覽。

現(xiàn)在,用戶(hù)可以瀏覽全部商品,也能按分類(lèi)查看,還能跳轉(zhuǎn)到詳情頁(yè)查看規(guī)格。但詳情頁(yè)的圖片展示還很簡(jiǎn)陋,下一站我們就來(lái)解決這個(gè)問(wèn)題。第 13 篇,我將帶你完善 商品詳情頁(yè)與圖片展示,包括多圖切換、主圖高亮、縮略圖導(dǎo)航等,讓詳情頁(yè)真正“活”起來(lái)。保持節(jié)奏,明天見(jiàn)!

想了解更多還可以去公眾號(hào)、今日頭條搜索「IT策士」,一起升級(jí) IT 思維 !


本文為《Django 從 0 到 1 打造完整電商平臺(tái)》系列第 12 篇,作者:IT策士。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容