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è)的核心功能:
默認(rèn)展示所有上架 SKU,按創(chuàng)建時(shí)間倒序排列。
支持按分類(lèi)篩選:通過(guò) URL 查詢(xún)參數(shù)
?category_id=1過(guò)濾該分類(lèi)及其子分類(lèi)下的商品。分頁(yè)展示:每頁(yè) 12 個(gè)商品,底部生成 Bootstrap 風(fēng)格的分頁(yè)導(dǎo)航。
商品卡片:顯示主圖(或占位圖)、名稱(chēng)、價(jià)格、銷(xiāo)量等信息,點(diǎn)擊跳轉(zhuǎn)到 SPU 詳情頁(yè)。
排序暫時(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ò)濾。Paginator的page()方法會(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 runserver7.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 96547.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 76327.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 96547.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_related 和 prefetch_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策士。