IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在公众号、今日头条持续发布最新文章,助你少走弯路。
上一篇我们实现了商品列表页,用户可以浏览分类、翻页查看商品卡片,电商的"门面"已经立起来了。但进入商品详情页后,图片展示还非常单薄------只显示一张主图,完全不足以打动用户。今天我们就来重点打磨 商品详情页的图片展示,让多图切换、缩略图导航、主图预览一气呵成,配上规格联动,体验直接拉满。
我们的 ProductImage 模型早已就位,只是还没被充分利用。接下来我会带你改造前端,让 SKU 切换时图片也跟着变,同时实现"点击缩略图切换大图"的效果。
一、需求分析
商品详情页的图片区需要具备以下能力:
-
多图展示:一个 SKU 可以有多个图片(正面、侧面、细节等),主图优先显示。
-
缩略图导航:下方显示所有图片的缩略图,点击哪张,大图就切换为哪张。
-
规格联动:用户切换规格(如颜色、内存)时,图片区自动切换到新 SKU 对应的图片组。
-
无图占位:如果 SKU 没有任何图片,显示默认占位图。
-
响应式布局:大图区域和缩略图区域在不同屏幕下都能良好展示。
我们直接在第 11 篇的 spu_detail.html 基础上进行升级,尽量用最少的前端代码完成这些需求。
二、视图微调------确保图片数据准备就绪
打开 apps/products/views.py,找到 spu_detail 视图。目前我们已经使用了 prefetch_related('skus__images'),这会一次性把该 SPU 下所有 SKU 及其图片都加载到内存中,无需再改。但为了模板更方便,我们可以给每个 SKU 的主图增加一个便捷属性(也可以在模板中用 filter 处理,这里采用模型方法)。
编辑 apps/products/models.py,在 SKU 模型中添加:
bash
class SKU(models.Model):
# ... 字段省略 ...
@property
def main_image_url(self):
"""获取主图的 URL,如果没有图片则返回占位图路径"""
main_image = self.images.filter(is_main=True).first()
if main_image:
return main_image.image.url
first_image = self.images.first()
if first_image:
return first_image.image.url
# 返回静态占位图
from django.contrib.staticfiles.storage import staticfiles_storage
return staticfiles_storage.url('images/placeholder.png')
这里用 @property 把主图获取逻辑封装在模型中,视图中无需改动。
注意:你需要确保
static/images/placeholder.png存在(随便放一张占位图),否则 404。
然后生成迁移(虽然没改数据库,但模型方法不需要迁移),可以直接用。
视图保持不变即可,数据已经足够丰富。
三、重写商品详情模板
我们将完全替换 apps/products/templates/products/spu_detail.html 的内容。保留原有的规格切换逻辑,在此基础上重写图片区。
bash
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ spu.name }}{% endblock %}
{% block content %}
<div class="row">
<!-- 图片展示区 -->
<div class="col-md-5">
<div class="card shadow-sm mb-3">
<!-- 大图显示 -->
<div class="text-center p-3" id="main-image-container">
<img src="{{ default_sku.main_image_url }}" />
</div>
<!-- 缩略图列表 -->
<div class="px-3 pb-3">
<div class="d-flex flex-wrap gap-2" id="thumbnail-list">
{% for img in default_sku.images.all %}
<img src="{{ img.image.url }}" />
{% empty %}
<img src="{% static 'images/placeholder.png' %}" />
{% endfor %}
</div>
</div>
</div>
</div>
<!-- 商品信息区(与之前类似,增加了规格联动更新图片的逻辑) -->
<div class="col-md-7">
<h2 class="fw-bold">{{ spu.name }}</h2>
{% if spu.brand %}
<p class="text-muted">品牌:{{ spu.brand }}</p>
{% endif %}
<p class="text-muted">{{ spu.desc }}</p>
<!-- 价格区 -->
<div class="my-3">
<span class="fs-3 text-danger fw-bold" id="sku-price">
¥{{ default_sku.price }}
</span>
{% if default_sku.cost_price %}
<span class="text-muted text-decoration-line-through ms-2">
¥{{ default_sku.cost_price }}
</span>
{% endif %}
</div>
<!-- 库存 -->
<p class="mb-1">库存:<span id="sku-stock">{{ default_sku.stock }}</span> 件</p>
<p class="mb-3">销量:{{ default_sku.sales }} 件</p>
<!-- 规格选择区 -->
<div id="specs-area">
{% for key, values in specs.items %}
<div class="mb-3">
<label class="form-label fw-bold">{{ key }}:</label>
<div class="btn-group" data-spec-key="{{ key }}">
{% for val in values %}
<button type="button"
class="btn btn-outline-secondary spec-btn {% if forloop.first %}active{% endif %}"
data-spec-value="{{ val }}">
{{ val }}
</button>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- 数量与购买按钮(后续激活) -->
<div class="mt-4">
<button class="btn btn-primary btn-lg" id="add-to-cart-btn" disabled>加入购物车</button>
<button class="btn btn-danger btn-lg ms-2" disabled>立即购买</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 将 SKU 数据和图片信息传递给前端
const skusData = [
{% for sku in skus %}
{
id: {{ sku.id }},
specs: {{ sku.specs|safe }},
price: "{{ sku.price }}",
stock: {{ sku.stock }},
sales: {{ sku.sales }},
main_image_url: "{{ sku.main_image_url }}",
images: [
{% for img in sku.images.all %}
{
url: "{{ img.image.url }}",
is_main: {{ img.is_main|yesno:"true,false" }}
}{% if not forloop.last %},{% endif %}
{% endfor %}
]
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
let selectedSpecs = {};
// 初始化默认选中规格
document.querySelectorAll('.spec-btn.active').forEach(btn => {
const key = btn.closest('[data-spec-key]').dataset.specKey;
selectedSpecs[key] = btn.dataset.specValue;
});
// 根据选中规格匹配 SKU
function findMatchingSku() {
return skusData.find(sku => {
return Object.keys(selectedSpecs).every(key => {
return sku.specs[key] === selectedSpecs[key];
});
});
}
// 更新页面显示(价格、库存、图片区)
function updateDisplay(sku) {
if (!sku) return;
// 更新价格和库存
document.getElementById('sku-price').textContent = '¥' + sku.price;
document.getElementById('sku-stock').textContent = sku.stock;
// 更新大图
const mainImg = document.getElementById('main-image');
mainImg.src = sku.main_image_url;
// 更新缩略图列表
const thumbnailList = document.getElementById('thumbnail-list');
thumbnailList.innerHTML = '';
if (sku.images.length > 0) {
sku.images.forEach(img => {
const thumb = document.createElement('img');
thumb.src = img.url;
thumb.className = 'img-thumbnail thumbnail-img';
if (img.is_main) thumb.classList.add('border-primary');
thumb.style = 'width: 70px; height: 70px; object-fit: cover; cursor: pointer;';
thumb.dataset.fullUrl = img.url;
thumb.onclick = function() { switchMainImage(this); };
thumbnailList.appendChild(thumb);
});
} else {
// 无图片时显示占位图
const placeholderUrl = "{% static 'images/placeholder.png' %}";
const thumb = document.createElement('img');
thumb.src = placeholderUrl;
thumb.className = 'img-thumbnail thumbnail-img border-primary';
thumb.style = 'width: 70px; height: 70px; object-fit: cover; cursor: pointer;';
thumb.dataset.fullUrl = placeholderUrl;
thumb.onclick = function() { switchMainImage(this); };
thumbnailList.appendChild(thumb);
}
}
// 点击缩略图切换大图
function switchMainImage(element) {
// 切换主图
document.getElementById('main-image').src = element.dataset.fullUrl;
// 移除所有缩略图的主图高亮
document.querySelectorAll('.thumbnail-img').forEach(img => img.classList.remove('border-primary'));
// 为当前点击的缩略图添加高亮
element.classList.add('border-primary');
}
// 绑定规格按钮点击事件
document.querySelectorAll('.spec-btn').forEach(btn => {
btn.addEventListener('click', function() {
const group = this.closest('[data-spec-key]');
const key = group.dataset.specKey;
const value = this.dataset.specValue;
// 切换同组按钮的 active 状态
group.querySelectorAll('.spec-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
// 更新当前选中规格
selectedSpecs[key] = value;
// 匹配 SKU 并更新
const matched = findMatchingSku();
updateDisplay(matched);
});
});
</script>
{% endblock %}
前端逻辑要点:
-
skusData数组里每个 SKU 都带着其所有图片的 URL 和is_main标记。 -
updateDisplay在切换规格时,不仅更新价格库存,还重绘整个缩略图列表和大图。 -
switchMainImage实现点击缩略图切换大图,同时高亮当前选中的缩略图。 -
无图片时自动使用静态占位图。
四、确保占位图存在
在 static/images/ 目录下放一张 placeholder.png,随便找张灰色背景图即可,尺寸 400x400。如果不想用静态文件,也可以在模型方法中返回空字符串,前端再判断,但用占位图更直观。
bash
# 示例:创建一个简单的占位图(如果没有)
# 可以下载或自制一个 PNG 图片,放置到 static/images/placeholder.png
五、测试流程与输出
启动服务器:
bash
python manage.py runserver
5.1 访问详情页
访问一个有多张图片的 SKU 所在的 SPU,比如 iPhone 15(SPU ID=1)。如果还没有给 SKU 添加图片,先去 Admin 为 iPhone 15 128GB 午夜色 上传几张测试图片(通过 ProductImage 内联添加),并标记一张为主图。
Admin 操作:
- 进入 SKU 列表,点编辑,在下方 ProductImage 区域上传 2-3 张图片,设置其中一张的
is_main=True,设置排序值。
5.2 查看图片展示
刷新详情页 http://127.0.0.1:8000/products/spu/1/。
-
大图区域显示主图。
-
下方缩略图列表显示所有图片,主图的缩略图带有蓝色边框高亮。
-
点击其他缩略图,大图立即切换,蓝色边框转移到该缩略图。
终端输出:
bash
[24/May/2026 10:10:10] "GET /products/spu/1/ HTTP/1.1" 200 13452
[24/May/2026 10:10:10] "GET /media/products/2026/05/iphone15_main.jpg HTTP/1.1" 200 45321
[24/May/2026 10:10:10] "GET /media/products/2026/05/iphone15_side.jpg HTTP/1.1" 200 41230
5.3 切换规格测试图片联动
点击 256GB 按钮,如果该 SKU 也有自己的图片组(可以到 Admin 添加不同的图片),则大图和缩略图都会切换为新 SKU 的图片组。若新 SKU 没有图片,则显示占位图(确保 placeholder.png 存在)。
终端输出(点击规格切换后,没有新图片请求,因为图片数据已随 JSON 下发):
无额外请求,说明图片切换在前端完成,没有服务器请求。
5.4 占位图测试
如果某个 SKU 完全没有图片,访问它的 SPU 并切换到该规格。大图和缩略图都显示为占位图,并且占位图没有切换功能(保持一个缩略图)。
验证:
-
给一个 SKU 删除所有图片,刷新页面。
-
切换到该 SKU,大图变占位图,缩略图只有一个占位图图标。
六、总结与下集预告
今天我们把商品详情页的图片展示提升到了实战水准:
-
利用模型
main_image_url属性统一获取主图; -
前端通过 JSON 传递所有 SKU 的图片数据,实现规格联动切换图片组;
-
点击缩略图切换大图,UI 反馈清晰;
-
无图时自动回退到占位图,体验完整。
现在用户浏览商品时,可以看到多角度的商品图片,购物决策体验大幅提升。接下来我们要让用户更轻松地找到商品------第 14 篇 将实现 商品搜索功能,基于 Django ORM 的简单搜索,支持按名称、品牌模糊匹配,让商城越来越像真的。
想了解更多还可以去公众号、今日头条搜索「IT策士」,一起升级 IT 思维 !
本文为《Django 从 0 到 1 打造完整电商平台》系列第 13 篇。