Django 从 0 到 1 打造完整电商平台:商品详情页与图片展示

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在公众号、今日头条持续发布最新文章,助你少走弯路。


上一篇我们实现了商品列表页,用户可以浏览分类、翻页查看商品卡片,电商的"门面"已经立起来了。但进入商品详情页后,图片展示还非常单薄------只显示一张主图,完全不足以打动用户。今天我们就来重点打磨 商品详情页的图片展示,让多图切换、缩略图导航、主图预览一气呵成,配上规格联动,体验直接拉满。

我们的 ProductImage 模型早已就位,只是还没被充分利用。接下来我会带你改造前端,让 SKU 切换时图片也跟着变,同时实现"点击缩略图切换大图"的效果。


一、需求分析

商品详情页的图片区需要具备以下能力:

  1. 多图展示:一个 SKU 可以有多个图片(正面、侧面、细节等),主图优先显示。

  2. 缩略图导航:下方显示所有图片的缩略图,点击哪张,大图就切换为哪张。

  3. 规格联动:用户切换规格(如颜色、内存)时,图片区自动切换到新 SKU 对应的图片组。

  4. 无图占位:如果 SKU 没有任何图片,显示默认占位图。

  5. 响应式布局:大图区域和缩略图区域在不同屏幕下都能良好展示。

我们直接在第 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 篇。

相关推荐
Larcher10 小时前
新手入门:从前端三件套到动态数据渲染
前端·后端·代码规范
胡萝卜术10 小时前
从“用户管理”全栈项目深挖模块化、RESTful 与语义化之道
前端·后端
用户2986985301410 小时前
告别手动复制:Java 拆分 Word 文档的两种实用方案
java·后端
Determined_man10 小时前
Spring 事务原子性问题排查与修复
后端
GuWenyue10 小时前
从零搭建用户管理系统!60分钟搞定RESTful接口+Bootstrap语义化首页
前端·后端
Re_zero10 小时前
从乐观锁被冲烂到原子扣减稳如磐石:高并发防超卖方案的三次迭代
java·后端
pixcarp10 小时前
Redis ZSet:底层设计与实践
数据库·redis·后端·学习·golang·web
小橙编码日志10 小时前
MCP(Model Context Protocol)详解
后端
PythonAI实战君10 小时前
若依后台管理系统 - Docker Compose 阿里云部署指南
后端·docker