路飞学城五: 动态价格策略&购物车模块开发

五: 动态价格策略&购物车模块开发

课程动态价格策略实现

动态价格策略设计

商城往往为了提高销量都会出现活动内容,商品因为参加了活动所以会产生价格的变动。

价格优惠活动类型名称: 限时免费, 限时折扣, 限时减免, 限时满减, 积分抵扣, 优惠券
针对单个商品的动态价格策略,公式:
限时免费       0
限时折扣       原价*0.8
限时减免       原价-减免价

针对单次下单的动态价格策略,公式:
限时满减      总价-(满减计算后换算价格) 
积分抵扣      总价-(积分计算后换算价格)         ->> 积分与现金换算比率
优惠券         总价-(优惠券计算后的优惠价格)   ->> 优惠券

模型创建

新增4个课程优惠相关的4个模型,courses/models.py,代码:

from django.utils import timezone as datetime


class Activity(BaseModel):
    start_time = models.DateTimeField(default=datetime.now, verbose_name="开始时间")
    end_time = models.DateTimeField(default=datetime.now, verbose_name="结束时间")
    description = RichTextUploadingField(blank=True, null=True, verbose_name="活动介绍")
    remark = models.TextField(blank=True, null=True, verbose_name="备注信息")

    class Meta:
        db_table = "lf_activity"
        verbose_name = "优惠活动"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class DiscountType(BaseModel):
    remark = models.CharField(max_length=250, blank=True, null=True, verbose_name="备注信息")

    class Meta:
        db_table = "lf_discount_type"
        verbose_name = "优惠类型"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class Discount(BaseModel):
    discount_type = models.ForeignKey("DiscountType", on_delete=models.CASCADE, related_name='discount_list', db_constraint=False, verbose_name="优惠类型")
    condition = models.IntegerField(blank=True, default=0, verbose_name="满足优惠的价格条件", help_text="设置享受优惠的价格条件,如果不填或0则没有优惠门槛")
    sale = models.TextField(verbose_name="优惠公式", help_text="""
    0表示免费;<br>
    *号开头表示折扣价,例如填写*0.82,则表示八二折;<br>
    -号开头表示减免价, 例如填写-100,则表示减免100;<br>""")

    class Meta:
        db_table = "lf_discount"
        verbose_name = "优惠公式"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "价格优惠:%s,优惠条件:%s,优惠公式: %s" % (self.discount_type.name, self.condition, self.sale)


class CourseActivityPrice(BaseModel):
    activity = models.ForeignKey("Activity", on_delete=models.CASCADE, related_name='price_list', db_constraint=False, verbose_name="活动")
    course = models.ForeignKey("Course", on_delete=models.CASCADE, related_name='price_list', db_constraint=False, verbose_name="课程")
    discount = models.ForeignKey("Discount", on_delete=models.CASCADE, related_name='price_list', db_constraint=False, verbose_name="优惠")

    class Meta:
        db_table = "lf_course_activity_price"
        verbose_name = "课程参与活动的价格表"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "活动:%s-课程:%s-优惠公式:%s" % (self.activity.name, self.course.name, self.discount.sale)

执行数据迁移

cd luffycityapi
python manage.py makemigrations
python manage.py migrate

提交代码版本

# 合并前面的course分支到master
git checkout master
git merge feature/course

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 动态价格策略模型的创建"
git push

git checkout -b feature/discount

admin站点配置活动相关模型管理器

courses/admin.py,代码:

from .models import Activity, DiscountType, Discount, CourseActivityPrice

class ActivityModelAdmin(admin.ModelAdmin):
    """优惠活动的模型管理器"""
    list_display = ["id", "name", "start_time", "end_time", "remark"]

admin.site.register(Activity, ActivityModelAdmin)

class DiscountTypeModelAdmin(admin.ModelAdmin):
    """优惠类型的模型管理器"""
    list_display = ["id", "name", "remark"]

admin.site.register(DiscountType, DiscountTypeModelAdmin)


class DiscountModelAdmin(admin.ModelAdmin):
    """优惠公式的模型管理器"""
    list_display = ["id", "name","discount_type","condition","sale"]

admin.site.register(Discount, DiscountModelAdmin)


class CourseActivityPriceModelAdmin(admin.ModelAdmin):
    """课程活动价格的模型管理器"""
    list_display = ["id", "activity", "course","discount"]

admin.site.register(CourseActivityPrice, CourseActivityPriceModelAdmin)

因为涉及到时间的转换计算,所以此处我们需要在settings/dev.py中设置时区相关的配置信息。

LANGUAGE_CODE = 'zh-hans'

TIME_ZONE = 'Asia/Shanghai'

USE_I18N = True

USE_L10N = True

USE_TZ = False # 关闭时区转换以后,django会默认使用TIME_ZONE作为时区。

添加测试数据

INSERT INTO luffycity.lf_activity (id, name, orders, is_show, is_deleted, created_time, updated_time, start_time, end_time, description, remark) VALUES (1, '路飞学城-5周年庆', 1, 1, 0, '2022-02-17 10:42:54.340893', '2022-02-17 10:42:54.340933', '2022-02-17 00:00:00', '2021-08-01 00:00:00', '<p>5周年庆,各种活动促销内容展示图片</p>', '负责人:
组织:
外勤:');

INSERT INTO luffycity.lf_discount_type (id, name, orders, is_show, is_deleted, created_time, updated_time, remark) VALUES (1, '免费', 1, 1, 0, '2022-02-17 10:43:38.546870', '2022-02-17 10:43:38.546901', null);
INSERT INTO luffycity.lf_discount_type (id, name, orders, is_show, is_deleted, created_time, updated_time, remark) VALUES (2, '折扣', 1, 1, 0, '2022-02-17 10:43:49.161997', '2022-02-17 11:19:58.799363', null);
INSERT INTO luffycity.lf_discount_type (id, name, orders, is_show, is_deleted, created_time, updated_time, remark) VALUES (3, '减免', 1, 1, 0, '2022-02-17 10:44:05.712935', '2022-02-17 11:41:16.504340', null);
INSERT INTO luffycity.lf_discount_type (id, name, orders, is_show, is_deleted, created_time, updated_time, remark) VALUES (4, '限时免费', 1, 1, 0, '2022-02-17 10:44:23.053845', '2022-02-17 10:44:23.053925', null);
INSERT INTO luffycity.lf_discount_type (id, name, orders, is_show, is_deleted, created_time, updated_time, remark) VALUES (5, '限时折扣', 1, 1, 0, '2022-02-17 10:44:31.999352', '2022-02-17 10:44:31.999382', null);
INSERT INTO luffycity.lf_discount_type (id, name, orders, is_show, is_deleted, created_time, updated_time, remark) VALUES (6, '限时减免', 1, 1, 0, '2022-02-17 10:44:39.100270', '2022-02-17 10:44:39.100305', null);


INSERT INTO luffycity.lf_discount (id, name, orders, is_show, is_deleted, created_time, updated_time, `condition`, sale, discount_type_id) VALUES (1, '免费购买', 1, 1, 0, '2022-02-17 10:45:54.027034', '2022-02-17 10:45:54.027079', 0, '0', 4);
INSERT INTO luffycity.lf_discount (id, name, orders, is_show, is_deleted, created_time, updated_time, `condition`, sale, discount_type_id) VALUES (2, '九折折扣', 1, 1, 0, '2022-02-17 10:47:12.855454', '2022-02-17 11:32:27.148655', 1, '*0.9', 2);
INSERT INTO luffycity.lf_discount (id, name, orders, is_show, is_deleted, created_time, updated_time, `condition`, sale, discount_type_id) VALUES (3, '课程减免100', 1, 1, 0, '2022-02-17 11:40:44.499026', '2022-02-17 11:40:44.499060', 300, '-100', 3);


INSERT INTO luffycity.lf_course_activity_price (id, name, orders, is_show, is_deleted, created_time, updated_time, activity_id, course_id, discount_id) VALUES (1, '九折-3天Typescript', 1, 1, 0, '2022-02-17 10:48:12.600755', '2022-02-17 10:48:12.600801', 1, 2, 2);
INSERT INTO luffycity.lf_course_activity_price (id, name, orders, is_show, is_deleted, created_time, updated_time, activity_id, course_id, discount_id) VALUES (2, '免费送课', 1, 1, 0, '2022-02-17 11:36:34.192896', '2022-02-17 11:36:34.192941', 1, 1, 1);
INSERT INTO luffycity.lf_course_activity_price (id, name, orders, is_show, is_deleted, created_time, updated_time, activity_id, course_id, discount_id) VALUES (3, '减免课程', 1, 1, 0, '2022-02-17 11:40:49.240245', '2022-02-17 11:40:49.240276', 1, 3, 3);

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: admin站点配置活动相关的模型管理器并添加测试数据"
git push --set-upstream origin feature/discount

在课程模型中计算课程优惠信息

courses/models.py,代码:

class Course(BaseModel):
    ...

    @property
    def discount(self):
        """通过计算获取当前课程的折扣优惠相关的信息"""
        # 获取折扣优惠相关的信息
        now_time = datetime.now()  # 活动__结束时间 > 当前时间  and 活动__开始时间 < 当前时间(29)
        # 获取当前课程参与的最新活动记录
        last_activity_log = self.price_list.filter(
            activity__end_time__gt=now_time,
            activity__start_time__lt=now_time
        ).order_by("-id").first()

        type_text = ""  # 优惠类型的默认值
        price = -1      # 优惠价格
        expire = 0      # 优惠剩余时间

        if last_activity_log:
            # 获取优惠类型的提示文本
            type_text = last_activity_log.discount.discount_type.name

            # 获取限时活动剩余时间戳[单位:s]
            expire = last_activity_log.activity.end_time.timestamp() - now_time.timestamp()

            # 判断当前课程的价格是否满足优惠条件
            course_price = float(self.price)
            condition_price = float(last_activity_log.discount.condition)
            if course_price >= condition_price:
                # 计算本次课程参与了优惠以后的价格
                sale = last_activity_log.discount.sale
                print(f"{type_text}-{sale}")
                if sale == "0":
                    # 免费,则最终价格为0
                    price = 0
                elif sale[0] == "*":
                    # 折扣
                    price = course_price * float(sale[1:])
                elif sale[0] == "-":
                    # 减免
                    price = course_price - float(sale[1:])

                price = float(f"{price:.2f}")

        data = {}
        if type_text:
            data["type"] = type_text
        if expire > 0:
            data["expire"] = expire
        if price != -1:
            data["price"] = price

        return data

    def discount_json(self):
        # 必须转成字符串才能保存到es中。所以该方法提供给es使用的。
        return json.dumps(self.discount)

    @property
    def can_free_study(self):
        """是否允许试学"""
        lesson_list = self.lesson_list.filter(is_delete=False, is_show=True, free_trail=True).order_by("orders").all()
        return len(lesson_list) > 0

给Elasticsearch重建索引

cd /home/moluo/Desktop/luffycity/luffycityapi
python manage.py rebuild_index

客户端课程列表页展示课程优惠价格时增加免费的判断逻辑

views/Course.vue,代码:

                    <p class="two clearfix">
                        <span class="price l red bold" v-if="course_info.discount.price>=0">¥{{parseFloat(course_info.discount.price).toFixed(2)}}</span>
                        <span class="price l red bold" v-else>¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="origin-price l delete-line" v-if="course_info.discount.price>=0">¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>

客户端课程详情页展示真实课程的价格

views/Info.vue,代码:

            <p class="course-price" v-if="course.info.discount.price >= 0">
              <span>活动价</span>
              <span class="discount">¥{{parseFloat(course.info.discount.price).toFixed(2)}}</span>
              <span class="original">¥{{parseFloat(course.info.price).toFixed(2)}}</span>
            </p>

提交代码版本

git add .
git commit -m "feature: 课程优惠活动的实现"
git push

因为此处滑块验证码过期,所以先暂时关闭验证码功能,后期添加

1662791327494

1662791295410

购物车实现

image-20220601062437812

准备工作

创建子应用 cart

cd /home/moluo/Desktop/luffycity/
git checkout master
git merge feature/discount
git checkout -b feature/cart

cd luffycityapi/apps
python ../../manage.py startapp cart

注册子应用cart

settings/dev.py,配置文件

INSTALLED_APPS = [
    ...
    'cart',
]

注册子应用到总路由

cart/urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [

]

luffycityapi/urls.py,总路由,代码:

from django.contrib import admin
from django.urls import path,include,re_path

from django.conf import settings
from django.views.static import serve # 静态文件代理访问模块

urlpatterns = [
   ...
    path("cart/", include("cart.urls")),
]

因为购物车中的商品(课程)信息会经常被用户操作,所以为了减轻mysql服务器的压力,可以选择把购物车信息通过redis来存储.

配置信息

# redis configration
# 设置redis缓存
CACHES = {
    # 默认缓存
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        # 项目上线时,需要调整这里的路径
        # "LOCATION": "redis://:密码@IP地址:端口/库编号",
        "LOCATION": "redis://:@127.0.0.1:6379/0",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},  # 连接池
        }
    },
    # 提供给admin运营站点的session存储
    "session": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},
        }
    },
    # 提供存储短信验证码
    "sms_code": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/2",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},
        }
    },
    # 提供存储搜索热门关键字
    "hot_word": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/3",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
    # 提供存储购物车课程商品
    "cart": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/4",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
}

接下来购物车中要实现记录用户添加到购物车中的商品信息,存储数据应有以下内容:

购物车中的商品数据的格式:
    *商品数量[因为目前的商品是课程,属于虚拟商品,所以没有数量的,如果以后做到真实商品,则必须有数量]
    商品id
    用户id
    商品勾选状态----> 在用户勾选了商品以后,该商品才会在下单结算阶段中出现。没勾选则会保留在购物车中,等下次购买。
    
五种数据类型
    hash哈希字典
        用户ID:{  # 使用哈希记录用户添加到购物车中的所有商品
            商品ID1: 商品数量,
            商品ID2: 商品数量,
        }
        用户ID:{商品1,商品2,....}  # 使用无需集合被勾选的商品列表
    list列表
        用户ID: [商品1, 商品,....] # 使用列表记录用户添加到购物车中的商品ID
        用户ID:{商品1,商品2,....}  # 使用无需集合被勾选的商品列表
    set集合
        键: {值1,值2,....}

经过比较可以发现没有一种数据类型完整有效的存储购物车数据,勉强可以保存的只有hash,但是hash默认情况下只会保存3种数据而已,当如果再需要保存1种,则可能需要花费更多的操作完成这个存储过程,所以我们完全使用redis的2种数据结构或多种数据结构来分别保存购物车相关数据

可以发现,上面5种数据类型中,哈希hash可以存储的数据量是最多的。因为购物车中的商品不需要顺序,反而需要在勾选的时候进行唯一的处理,所以选用set
当然,现在我们实现的在线教育商城只需要保存的字段只有:用户ID,商品ID,勾选状态即可。所以我们采用hash一种数据结构即可。

当前在线教育商城的购物车数据结构:
hash:
    键[用户ID]:{
        域[商品ID]:勾选状态,
        域[商品ID]:勾选状态,
        域[商品ID]:勾选状态,
        域[商品ID]:勾选状态,
    }

如果将来保存有数量的商品:
hash:
    键[用户ID]:{
        域[商品ID]:商品数量,
        域[商品ID]:商品数量,
        域[商品ID]:商品数量,
        域[商品ID]:商品数量,
    }
set:   
    键[用户ID]:{商品ID1,商品ID2....}

服务端添加课程商品到购物车

视图提供添加商品到购物车的api接口,cart/views.py,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course

# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated] # 保证用户必须时登录状态才能调用当前视图
    def post(self, request):
        """添加课程商品到购物车中"""
        # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态
        user_id = request.user.id
        course_id = request.data.get("course_id", None)
        selected = 1  # 默认商品是勾选状态的
        print(f"user_id={user_id},course_id={course_id}")

        # 2. 验证课程是否允许购买[is_show=True, is_deleted=False]
        try:
            # 判断课程是否存在
            # todo 判断用户是否已经购买了
            course = Course.objects.get(is_show=True, is_deleted=False, pk=course_id)
        except:
            return Response({"errmsg": "当前课程不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        # 3. 添加商品到购物车
        redis = get_redis_connection("cart")
        """
        cart_用户ID: {
           课程ID: 1
        }
        """
        redis.hset(f"cart_{user_id}", course_id, selected)

        # 4. 获取购物车中的商品课程数量
        cart_total = redis.hlen(f"cart_{user_id}")

        # 5. 返回结果给客户端
        return Response({"errmsg": "成功添加商品课程到购物车!", "cart_total": cart_total}, status=status.HTTP_201_CREATED)

redis的异常处理[当然,我们前面已经完成了,如果没完成的小伙伴确认一下],utils/exceptions.py,代码:

from rest_framework.views import exception_handler
from django.db import DatabaseError
from redis import RedisError
from rest_framework.response import Response
from rest_framework import status

import logging
logger = logging.getLogger('django')

def custom_exception_handler(exc, context):
    """
    自定义异常处理工具函数
    :param exc: 异常类
    :param context: 抛出异常的执行上下文
    :return: Response响应对象
    """
    # 先调用drf框架原生的异常处理方法
    response = exception_handler(exc, context)

    if response is None:
        view = context['view']
        # 判断是否发生了数据库异常
        if isinstance(exc, DatabaseError):
            # 数据库异常
            logger.error('mysql数据库异常![%s] %s' % (view, exc))
            response = Response({'message': '服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)

        elif isinstance(exc, RedisError):
            logger.error('redis数据库异常![%s] %s' % (view, exc))
            response = Response({'message': '缓存服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)

        elif isinstance(exc, ZeroDivisionError):
            response = Response({'message': '0不能作为除数!'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
    return response

cart/urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [
    path("", views.CartAPIView.as_view()),
]

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端实现添加商品课程到购物车"
git push --set-upstream origin feature/cart


客户端用户添加商品到购物车

api/cart.js,代码:

import http from "../utils/http";
import {reactive, ref} from "vue"

const cart = reactive({
    // 添加课程到购物车
    add_course_to_cart(course_id, token) {
        return http.post("/cart/", {
            course_id: course_id
        }, {
            // 因为当前课程端添加课程商品到购物车必须登录,所以接口操作时必须发送jwt
            headers: {
                Authorization: "jwt " + token,
            }
        })
    }
})

export default cart;


views/Course.vue,代码:

                    <p class="two clearfix">
                        <span class="price l red bold" v-if="course_info.discount.price>=0">¥{{parseFloat(course_info.discount.price).toFixed(2)}}</span>
                        <span class="price l red bold" v-else>¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="origin-price l delete-line" v-if="course_info.discount.price>=0">¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="add-shop-cart r" @click.prevent.stop="add_cart(course_info)"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加入购物车</span>
                    </p>


<script setup>
import {reactive,ref, watch} from "vue"
import { ElMessage } from 'element-plus'
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import cart   from "../api/cart";
import {fill0} from "../utils/func";

import {useStore} from "vuex";
const store = useStore()

// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


。。。。

// 添加课程到购物车
const add_cart = (course_info)=>{
  // 从本地存储中获取jwt token
  let token = sessionStorage.token || localStorage.token;
  cart.add_course_to_cart(course_info.id, token).then(response=>{
    ElMessage.success(response.data.errmsg)
  }).catch(error=>{
    if(error.response.status === 401){
      store.commit("logout");
      ElMessage.error("您尚未登录或已登录超时,请登录后继续操作!");
    }else{
      ElMessage.error("添加商品到购物车失败!");
    }
  })
}


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

...



</script>


views/Info.vue,代码:

            <div class="buy">
              <div class="buy-btn">
                <button class="buy-now">立即购买</button>
                <button class="free">免费试学</button>
              </div>
              <el-popconfirm title="您确认添加当前课程加入购物车吗?" @confirm="add_cart" confirmButtonText="买买买!" cancelButtonText="误操作!">
                <template #reference>
                  <div class="add-cart"><img src="../assets/cart-yellow.svg" alt="">加入购物车</div>
                </template>
              </el-popconfirm>
              <!--              <div class="add-cart"><img src="../assets/cart-yellow.svg" alt="">加入购物车</div>-->
            </div>


<script setup>
import {reactive,ref, watch} from "vue"
import {useRoute, useRouter} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import { AliPlayerV3 } from "vue-aliplayer-v3"
import course from "../api/course"
import cart from "../api/cart";
import { ElMessage } from 'element-plus'
import {fill0} from "../utils/func";
import {useStore} from "vuex";

const store = useStore()
let route = useRoute()
let router= useRouter()
let player = ref(null)

// 获取url地址栏上的课程ID
course.course_id = route.params.id;


...


// 添加商品到购物车
let add_cart = ()=>{
  let token = sessionStorage.token || localStorage.token
  // 详情页中添加商品到购物车,不用传递参数,直接使用state.course来获取课程信息
  cart.add_course_to_cart(course.course_id, token).then(response=>{
    ElMessage.success(response.data.errmsg)
  }).catch(error=>{
    if(error.response.status === 401){
      store.commit("logout");
      ElMessage.error("您尚未登录或已登录超时,请登录后继续操作!");
    }
    ElMessage.error("添加商品到购物车失败!")
  })
}


</script>


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端实现添加商品课程到购物车"
git push


显示购物车的商品数量

components/Header.vue,代码:

          <!-- 登录之后的登录栏 -->
          <div class="login-bar logined-bar" v-if="store.state.user.user_id">
            <div class="shop-cart ">
              <img src="../assets/cart.svg" alt="" />
              <el-badge type="danger" :value="store.state.cart_total" class="item">
              <span><router-link to="/cart">购物车</router-link></span>
              </el-badge>
            </div>


store/index.js,代码:

import {createStore} from "vuex"
import createPersistedState from "vuex-persistedstate"

// 实例化一个vuex存储库
export default createStore({
    // 调用永久存储vuex数据的插件,localstorage里会多一个名叫vuex的Key,里面就是vuex的数据
    plugins: [createPersistedState()],
    state () {  // 数据存储位置,相当于组件中的data
        return {
          user: {

          },
          cart_total: 0, // 购物车中的商品数量,默认为0
        }
    },
    getters: {
        getUserInfo(state){
            // 从jwt的载荷中提取用户信息
            let now = parseInt( (new Date() - 0) / 1000 ); // js获取本地时间戳(秒)
            if(state.user.exp === undefined) {
                // 没登录
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }

            if(parseInt(state.user.exp) < now) {
                // 过期处理
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }
            return state.user;
        }
    },
    mutations: { // 操作数据的方法,相当于methods
        login (state, user) {  // state 就是上面的state,mutations中每一个方法都默认第一个参数固定是它   state.user 就是上面的数据
          state.user = user
        },
        logout(state){ // 退出登录
            state.user = {}
            state.cart_total = 0
            localStorage.token = null;
            sessionStorage.token = null;
        },
        cart_total(state, total) {
            // 设置商品数量的总数
            state.cart_total = total
        },
    }
})


views/Course.vue,代码:

<script setup>
import {reactive,ref, watch} from "vue"
import { ElMessage } from 'element-plus'
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import cart   from "../api/cart";
import {fill0} from "../utils/func";

import {useStore} from "vuex";
const store = useStore()

....


// 添加课程到购物车
const add_cart = (course_info)=>{
  // 从本地存储中获取jwt token
  let token = sessionStorage.token || localStorage.token;
  cart.add_course_to_cart(course_info.id, token).then(response=>{
    store.commit("cart_total", response.data.cart_total)
 ...






</script>


设置jwt登录/jwt注册时返回购物车商品数量

文档:https://jpadilla.github.io/django-rest-framework-jwt/#jwt_response_payload_handler

utils/authenticate.py,自定义返回响应内容,代码:

from django_redis import get_redis_connection
def jwt_response_payload_handler(token, user, request):
    """
    增加返回购物车的商品数量
    token: jwt token
    user: 用户模型对象
    request: 客户端的请求对象
    """
    redis = get_redis_connection("cart")
    cart_total = redis.hlen(f"cart_{user.id}")

    return {
        "cart_total": cart_total,
        "token": token
    }


配置文件,settings/dev.py,代码:

import datetime
# jwt认证相关配置项
JWT_AUTH = {
    # 设置jwt的有效期
    # 如果内部站点,例如:运维开发系统,OA,往往配置的access_token有效期基本就是15分钟,30分钟,1~2个小时
    'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1),  # 一周有效
    # 自定义载荷
    'JWT_PAYLOAD_HANDLER': 'luffycityapi.utils.authenticate.jwt_payload_handler',
    # 自定义响应数据
    'JWT_RESPONSE_PAYLOAD_HANDLER': 'luffycityapi.utils.authenticate.jwt_response_payload_handler'
}



客户端登录成功以后,显示当前用户的购物车中的商品课程总数量。

1662795656219

刚注册的用户是不会在redis有商品购物车的数量。components/Login.vue,代码:

<script setup>
import user from "../api/user";
import { ElMessage } from 'element-plus'
import "../utils/TCaptcha"
const emit = defineEmits(["successhandle",])
import settings from "../settings";
import {useStore} from "vuex"
const store = useStore()



// 登录处理
const loginhandler = (res)=>{
  // 验证数据
  if(user.account.length<1 || user.password.length<1){
    // 错误提示
    console.log("错了哦,用户名或密码不能为空!");
    ElMessage.error("错了哦,用户名或密码不能为空!");
    return ;
  }

  // 登录请求处理
  user.login({
    ticket: res.ticket,
    randstr: res.randstr,
  }).then(response=>{
   ....
    store.commit("login", payload_data)
    store.commit("cart_total", response.data.cart_total)
    emit("successhandle")
  }).catch(error=>{
    ElMessage.error("登录失败!");
  })
}

</script>


1662796357327

课程详情页显示购物车商品总数

views/Info.vue,代码:

<script setup>
import {reactive,ref, watch} from "vue"
import {useRoute, useRouter} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import { AliPlayerV3 } from "vue-aliplayer-v3"
import course from "../api/course"
import cart from "../api/cart";
import { ElMessage } from 'element-plus'
import {fill0} from "../utils/func";
import {useStore} from "vuex";

const store = useStore()
let route = useRoute()
let router= useRouter()
let player = ref(null)



// 添加商品到购物车
let add_cart = ()=>{
  let token = sessionStorage.token || localStorage.token
  // 详情页中添加商品到购物车,不用传递参数,直接使用state.course来获取课程信息
  cart.add_course_to_cart(course.course_id, token).then(response=>{
    store.commit("cart_total", response.data.cart_total)
    ElMessage.success(response.data.errmsg)
  }).catch(error=>{
    if(error.response.status === 401){
      store.commit("logout");
      ElMessage.error("您尚未登录或已登录超时,请登录后继续操作!");
    }
    ElMessage.error("添加商品到购物车失败!")
  })
}


</script>


1662796557511

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 显示购物车的商品数量"
git push


购物车商品列表展示

客户端页面展示

src/router/index.js,代码:

import {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
import store from "../store";

// 路由列表
const routes = [
...
  {
    meta:{
        title: "luffy2.0-课程详情",
        keepAlive: true
    },
    path: '/project/:id',     // :id vue的路径参数,代表了课程的ID
    name: "Info",
    component: ()=> import("../views/Info.vue"),
  },{
      meta:{
        title: "luffy2.0-购物车",
        keepAlive: true
      },
      path: '/cart',
      name: "Cart",
      component: ()=> import("../views/Cart.vue"),
    }
]

...

// 暴露路由对象
export default router


views/Cart.vue,代码:

<template>
  <div class="cart">
    <Header/>
    <div class="cart-main">
      <div class="cart-header">
        <div class="cart-header-warp">
          <div class="cart-title left">
            <h1 class="left">我的购物车</h1>
            <div class="left">
              共<span>5</span>门,已选择<span>5</span>门
            </div>
          </div>
          <div class="right">
            <div class="">
              <span class="left"><router-link class="myorder-history" to="/myorder">我的订单列表</router-link></span>
            </div>
          </div>
        </div>
      </div>
      <div class="cart-body" id="cartBody">
        <div class="cart-body-title">
          <div class="item-1 l"><el-checkbox v-model="state.checked">全选</el-checkbox></div>
          <div class="item-2 l"><span class="course">课程</span></div>
          <div class="item-3 l"><span>金额</span></div>
          <div class="item-4 l"><span>操作</span></div>
        </div>
        <div class="cart-body-table">
          <div class="item">
              <div class="item-1">
                  <el-checkbox v-model="state.checked"></el-checkbox>
              </div>
              <div class="item-2">
                  <a href="" class="img-box l">
                    <img src="/src/assets/course-7.png">
                  </a>
                  <dl class="l has-package">
                    <dt>【实战课 移动端UI设置入门与实战</dt>
                    <p class="package-item">优惠价</p>
                  </dl>
              </div>

              <div class="item-3">
                  <div class="price">
                      <span class="discount-price"><em>¥</em><span>588.00</span></span><br>
                      <span class="original-price"><em>¥</em><span>1988.00</span></span>
                  </div>
              </div>
              <div class="item-4"><el-icon :size="26" class="close"><Close /></el-icon></div>
          </div>
          <div class="item">
              <div class="item-1"><el-checkbox v-model="state.checked"></el-checkbox></div>
              <div class="item-2">
                  <a href="" class="img-box l"><img src="/src/assets/course-1.png"></a>
                  <dl class="l has-package">
                      <dt>【实战课】算法与数据结构</dt>
                      <p class="package-item">限时优惠</p>
                  </dl>
              </div>
              <div class="item-3">
                <div class="price"><em>¥</em><span>299.00</span></div></div>
              <div class="item-4"><el-icon :size="26" class="close"><Close /></el-icon></div>
          </div>
          <div class="cart-body-bot fixed">
            <div class=" cart-body-bot-box">
              <div class="right">
                <div class="add-coupon-box">
                  <div class="li-left">
                    <div class="li-2">
                      <span class="topdiv w70">总计金额:</span>
                      <span class="price price-red w100">
                        <em>¥</em>
                        <span>1751.00</span>
                      </span>
                    </div>
                  </div>
                  <div class="li-3"><span class="btn">去结算</span></div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer/>
  </div>
</template>

<script setup>
import {Close} from '@element-plus/icons-vue'
import {reactive} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {} from "../api/cart"
import { ElMessage } from 'element-plus'
let state = reactive({
  checked: false,
})
</script>

<style scoped>
.cart-header {
	height: 160px;
	background-color: #e3e6e9;
	background: url("/src/assets/cart-header-bg.jpeg") repeat-x;
	background-size: 38%;
}

.cart-header .cart-header-warp {
	width: 1500px;
	height: 120px;
	line-height: 120px;
	margin-left: auto;
	margin-right: auto;
	font-size: 14px
}

.cart-header .cart-header-warp .myorder-history {
	font-weight: 200
}

.cart-header .left {
	float: left
}

.cart-header .right {
	float: right
}

.cart-header .cart-title {
	color: #4d555d;
	font-weight: 200;
	font-size: 14px
}

.cart-header .cart-title h1 {
	font-size: 32px;
	line-height: 115px;
	margin-right: 25px;
	color: #07111b;
	font-weight: 200
}

.cart-header .cart-title span {
	margin: 0 4px
}

.cart-header .cart-title .js-number-box-cart {
	line-height: 115px
}

.cart-header .num {
	display: none;
	padding: 4px 5px;
	background-color: #f01414;
	color: #fff;
	border-radius: 50%;
	text-align: center;
	font-size: 12px;
	line-height: 10px;
	margin-top: 51px;
	margin-left: 5px
}

.l {
    float: left;
}

.cart-body {
	width: 1500px;
	padding: 0 36px 32px;
	background-color: #fff;
	margin-top: -40px;
	margin-left: auto;
	margin-right: auto;
	box-shadow: 0 8px 16px 0 rgba(7,17,27,.1);
	border-radius: 8px;
	box-sizing: border-box
}

.cart-body .left {
	float: left!important
}

.cart-body .right {
	float: right!important
}

.cart-body .cart-body-title {
	min-height: 88px;
	line-height: 88px;
	border-bottom: 1px solid #b7bbbf;
	box-sizing: border-box
}

.cart-body .priceprice i {
	float: left
}

body {
	background: #f8fafc
}

.cart-body .cart-body-title span {
	font-size: 14px
}

.cart-body .cart-body-title .item-1>span,
.cart-body .cart-body-title .item-2>span,
.cart-body .cart-body-title .item-3>span,
.cart-body .cart-body-title .item-4>span {
	display: inline-block;
	font-size: 14px;
	line-height: 24px;
	color: #4d555d
}

.cart-body .cart-body-title .item-1>span {
	color: #93999f
}

.cart-body .cart-body-title .item-2>span {
	margin-left: 40px
}
.cart-body .cart-body-title .item-2 .course{
  line-height: 88px;
}
.cart-body .cart-body-title .item-4>span {
	margin-right: 32px
}

.cart-body .cart-body-table .title .title-content span {
	margin-right: 9px;
	position: relative
}

.cart-body .cart-body-table .title .title-content span::after {
	content: "/";
	position: absolute;
	right: -9px
}

.cart-body .cart-body-table .title .title-content span:last-child::after {
	content: ''
}

.cart-body .item {
	height: 88px;
	padding: 24px 0;
	border-bottom: 1px solid #d9dde1
}

.cart-body .item>div {
	float: left
}

.cart-body .item .item-1 {
	padding-top: 34px;
	position: relative;
	z-index: 1
}

.cart-body .item:last-child>.item-1::after {
	display: none
}

.cart-body .item.disabled .price,.cart-body .item.disabled dt {
	color: #93999f!important
}

.cart-body .item-1 {
	width: 120px
}

.cart-body .item-1 i {
	margin-left: 12px;
	margin-right: 8px;
	font-size: 24px
}

.cart-body .item-2 {
	width: 820px;
  position:relative;
}
.cart-body .item-2>span{
  line-height: 88px;
}
.cart-body .item-2 dl {
	width: 464px;
	margin-left: 24px;
	padding-top: 12px
}

.cart-body .item-2 dl a {
	display: block;
}

.cart-body .item-2 dl.has-package {
	padding-top: 4px;
}

.cart-body .item-2 dl.has-package .package-item {
	display: inline-block;
	padding: 0 12px;
	margin-top: 4px;
	font-size: 12px;
	color: rgba(240,20,20,.6);
	line-height: 24px;
	background: rgba(240,20,20,.08);
	border-radius: 12px;
	cursor: pointer
}

.cart-body .item-2 dl.has-package .package-item:hover {
	color: #fff;
	background: rgba(240,20,20,.2)
}

.cart-body .item-2 dt {
	font-size: 16px;
	color: #07111b;
	line-height: 24px;
	margin-bottom: 4px
}

.cart-body .item-2 .img-box {
	display: block;
  margin-left: 42px;
}
.cart-body .item-2 .img-box img{
  height: 94px;
}
.cart-body .item-2 dd {
	font-size: 12px;
	color: #93999f;
	line-height: 24px;
	font-weight: 200
}

.cart-body .item-2 dd a {
	display: inline-block;
	margin-left: 12px;
	color: rgba(240,20,20,.4)
}

.cart-body .item-2 dd a:hover {
	color: #f01414
}

.cart-body .item-3 {
	width: 280px;
	margin-left: 48px;
  position: relative;
}

.cart-body .item-3 .price {
	display: inline-block;
	color: #1c1f21;
	height: 46px;
	width: 96px;
  padding-top: 24px;
  padding-bottom: 24px;
  font-size: 18px;
}
.cart-body .item-3 .price .original-price
{
  color: #aaa;
  text-decoration: line-through;
}
.cart-body .item-4 {
	margin-left: 74px;
}

.cart-body .item-4 .close {
	font-size: 40px;
	height: 90px;
	color: #b7bbbf;
	line-height: 90px;
	cursor: pointer
}
.cart-body .item-4 .close:hover{
  color: #ff0000;
}
.cart-body .cart-body-bot.fixed {
	position: fixed;
	bottom: 0;
	left: 0;
	width: 100%;
	background-color: #fff;
	z-index: 300;
	box-shadow: 10px -2px 12px rgba(7,17,27,.2)
}

.cart-body .cart-body-bot.fixed .cart-body-bot-box {
	padding-bottom: 70px;
	width: 1500px;
  height: 20px;
  padding-top: 40px;
}

.cart-body .cart-body-bot.fixed .cart-body-bot-box .li-3 {
	margin-right: 36px
}

.cart-body .cart-body-bot .cart-body-bot-box {
	margin-left: auto;
	margin-right: auto;
	display: block;
	padding-top: 24px
}

.cart-body .cart-body-bot .cart-body-bot-box .add-coupon-box {
	display: flex;
	flex-direction: row;
	align-items: center
}

.cart-body .cart-body-bot li {
	float: left
}

.cart-body .cart-body-bot .li-left {
	text-align: right
}

.cart-body .cart-body-bot .li-3 {
	font-size: 12px;
	color: #07111b;
	line-height: 24px
}

.cart-body .cart-body-bot .li-1 em,.cart-body .cart-body-bot .li-3 em {
	font-style: normal;
	color: red
}

.cart-body .cart-body-bot .li-2 {
	font-size: 0
}

.cart-body .cart-body-bot .li-2 .topdiv {
	font-size: 14px;
	color: #07111b;
	line-height: 28px
}

.cart-body .cart-body-bot .li-2 .price {
	font-size: 16px;
	color: #f01414;
	line-height: 24px;
	font-weight: 700
}

.cart-body .cart-body-bot .li-3 .btn {
	margin-left: 38px;
	float: right;
	padding: 13px 32px;
	color: #fff;
	font-size: 16px;
	color: #fff;
	cursor: pointer;
	font-weight: 200;
	background: #f01414;
	border-radius: 4px
}

.cart-body .cart-body-bot .w70 {
	display: inline-block;
	width: 120px;
	text-align: right
}

.cart-body .cart-body-bot .w100 {
	display: inline-block;
	width: 100px;
	text-align: right
}
</style>


修改多选框的外观效果

https://element-plus.gitee.io/en-US/component/checkbox.html#checkbox

src/App.vue,代码:

<style>
/* 声明全局样式和项目的初始化样式 */
/* 前面内容省略 */
.cart .el-checkbox .el-checkbox__inner{
  width: 30px;
  height: 30px;
  border: 1px solid #aaa;
}
.cart .el-checkbox .el-checkbox__inner::after{
  height: 17px;
  left: 8px;
  width: 10px;
  border: 3px solid #FFF;
  border-left: 0;
  border-top: 0;
}
</style>


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端初步显示购物车商品列表页面"
git push

服务端提供购物车商品课程列表api接口

cart.views,当前商品课程列表与前面的添加商品课程到购物车中,使用的url地址一致。代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course

# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated] # 保证用户必须时登录状态才能调用当前视图
    。。。

    def get(self,request):
        """获取购物车中的商品列表"""
        user_id = request.user.id
        redis = get_redis_connection("cart")
        cart_hash = redis.hgetall(f"cart_{user_id}")
        """
        cart_hash = {
            // b'商品课程ID': b'勾选状态', 
            b'2': b'1', 
            b'4': b'1', 
            b'5': b'1'
        }
        """
        if len(cart_hash) < 1:
            return Response({"error":"购物车没有任何商品。"})

        cart = [(int(key.decode()), bool(value.decode())) for key, value in cart_hash.items()]
        # cart = [ (2,True) (4,True) (5,True) ]
        course_id_list = [item[0] for item in cart]
        course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
        data = []
        for course in course_list:
            data.append({
                "id": course.id,
                "name": course.name,
                "course_cover": course.course_cover.url,
                "price": float(course.price),
                "discount": course.discount,
                "course_type": course.get_course_type_display(),
                # 勾选状态:把课程ID转换成bytes类型,判断当前ID是否在购物车字典中作为key存在,如果存在,判断当前课程ID对应的值是否是字符串"1",是则返回True
                "selected": (str(course.id).encode() in cart_hash) and cart_hash[str(course.id).encode()].decode() == "1"
            })
        return Response({"errmsg": "ok!", "cart": data})


课程模型中调整了课程类型的提示文本。courses.models,代码:

class Course(BaseModel):
    course_type = (
        (0, '实战课程'),
        (1, '会员专享'),
        (2, '学位课程'),
    )


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端提供购物车的商品列表信息"
git push


1662797239694

客户端展示购物车课程信息

api/cart.js,代码:

import http from "../utils/http";
import {reactive, ref} from "vue"

const cart = reactive({
    course_list: [], // 购物车商品列表
    total_price: 0,  // 购物车中的商品总价格
    selected_course_total: 0, // 购物车中被勾选商品的数量
    checked: false,  // 购物车中是否全选商品了
    // 添加课程到购物车
    add_course_to_cart(course_id, token) {
        return http.post("/cart/", {
            course_id: course_id
        }, {
            // 因为当前课程端添加课程商品到购物车必须登录,所以接口操作时必须发送jwt
            headers: {
                Authorization: "jwt " + token,
            }
        })
    },
    get_course_from_cart(token){
        // 获取购物车的商品课程列表
        return http.get("/cart/", {
            headers:{
                Authorization: "jwt " + token,
            }
        })
    }
})

export default cart;


views/Cart.vue,代码:

<template>
  <div class="cart">
    <Header/>
    <div class="cart-main">
      <div class="cart-header">
        <div class="cart-header-warp">
          <div class="cart-title left">
            <h1 class="left">我的购物车</h1>
            <div class="left">
              共<span>{{cart.course_list.length}}</span>门,已选择<span>{{cart.selected_course_total}}</span>门
            </div>
          </div>
          <div class="right">
            <div class="">
              <span class="left"><router-link class="myorder-history" to="/myorder">我的订单列表</router-link></span>
            </div>
          </div>
        </div>
      </div>
      <div class="cart-body" id="cartBody">
        <div class="cart-body-title">
          <div class="item-1 l"><el-checkbox  v-model="cart.checked">全选</el-checkbox></div>
          <div class="item-2 l"><span class="course">课程</span></div>
          <div class="item-3 l"><span>金额</span></div>
          <div class="item-4 l"><span>操作</span></div>
        </div>
        <div class="cart-body-table">
          <div class="item"  v-for="course_info in cart.course_list">
              <div class="item-1">
                  <el-checkbox v-model="course_info.selected"></el-checkbox>
              </div>
              <div class="item-2">
                  <router-link :to="`/project/${course_info.id}`" class="img-box l">
                    <img :src="course_info.course_cover">
                  </router-link>
                  <dl class="l has-package">
                    <dt>【{{course_info.course_type}}】 {{course_info.name}}</dt>
                    <p class="package-item" v-if="course_info.discount.type">{{ course_info.discount.type }}</p>
                  </dl>
              </div>

              <div class="item-3">
                  <div class="price" v-if="course_info.discount.price>=0">
                      <span class="discount-price"><em>¥</em><span>{{course_info.discount.price.toFixed(2)}}</span></span><br>
                      <span class="original-price"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></span>
                  </div>
                  <div class="price" v-else>
                      <div class="discount-price"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></div>
                  </div>
              </div>
              <div class="item-4"><el-icon :size="26" class="close"><Close /></el-icon></div>
          </div>
          <div class="cart-body-bot fixed">
            <div class=" cart-body-bot-box">
              <div class="right">
                <div class="add-coupon-box">
                  <div class="li-left">
                    <div class="li-2">
                      <span class="topdiv w70">总计金额:</span>
                      <span class="price price-red w100">
                        <em>¥</em>
                        <span>{{cart.total_price.toFixed(2)}}</span>
                      </span>
                    </div>
                  </div>
                  <div class="li-3"><span class="btn">去结算</span></div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer/>
  </div>
</template>


<script setup>
import {Close} from '@element-plus/icons-vue'
import {reactive} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import cart from "../api/cart"
import { ElMessage } from 'element-plus'
let state = reactive({
  checked: false,
})


const get_cart = ()=>{
  // 获取购物车中的商品列表
  let token = sessionStorage.token || localStorage.token;
  cart.get_course_from_cart(token).then(response=>{
    cart.course_list = response.data.cart;
    // 获取购物车中的商品总价格
    get_cart_total();
  })
}

get_cart()


// 计算获取购物车中勾选商品课程的总价格
const get_cart_total = ()=>{
  let sum = 0;
  let select_sum = 0;
  cart.course_list.forEach((course, key)=>{
    if(course.selected){
      // 当前被勾选
      select_sum+=1;
      // 判断当前商品是否有优惠价格
      if(course.discount.price>=0){
        sum+=course.discount.price;
      }else{
        sum+=course.price;
      }
    }
    cart.total_price = sum; // 购物车中的商品总价格
    cart.selected_course_total = select_sum; // 购物车中被勾选商品的数量
    cart.checked = select_sum === cart.course_list.length;  // 购物车中是否全选商品了
  })
}

</script>


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端展示购物车的商品课程列表"
git push


1662799136374

购物车商品的勾选状态切换

商品课程的勾选状态发生改变时,同步到服务端中的购物车。

cart/views.py,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course


# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated]  # 保证用户必须时登录状态才能调用当前视图

    ...

    def patch(self, request):
        """切换购物车中商品勾选状态"""
        # 谁的购物车?user_id
        user_id = request.user.id
        # 获取购物车的课程ID与勾选状态
        course_id = int(request.data.get("course_id", 0))
        selected = int(bool(request.data.get("selected", True)))

        redis = get_redis_connection("cart")

        try:
            Course.objects.get(pk=course_id, is_show=True, is_deleted=False)
        except Course.DoesNotExist:
            redis.hdel(f"cart_{user_id}", course_id)
            return Response({"errmsg": "当前商品不存在或已经被下架!!"})

        redis.hset(f"cart_{user_id}", course_id, selected)
        return Response({"errmsg": "ok"})


提交代码版本:

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端商品勾选状态切换"
git push

1662800673143

服务端实现购物车的全选和反选

courses.views,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course


# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated]  # 保证用户必须时登录状态才能调用当前视图

    ...

    def put(self,request):
        """"全选 / 全不选"""
        user_id = request.user.id
        selected = int(bool(request.data.get("selected", True)))
        redis = get_redis_connection("cart")

        # 获取购物车中所有商品课程信息
        cart_hash = redis.hgetall(f"cart_{user_id}")
        """
        cart_hash = {
            # b'商品课程ID': b'勾选状态', 
            b'2': b'1', 
            b'4': b'1', 
            b'5': b'1'
        }
        """
        if len(cart_hash) < 1:
            return Response({"errmsg": "购物车没有任何商品。"}, status=status.HTTP_204_NO_CONTENT)

        # 把redis中的购物车课程ID信息转换成普通列表
        cart_list = [int(course_id.decode()) for course_id in cart_hash]

        # 批量修改购物车中素有商品课程的勾选状态
        pipe = redis.pipeline()
        pipe.multi()
        for course_id in cart_list:
            pipe.hset(f"cart_{user_id}", course_id, selected)
        pipe.execute()

        return Response({"errmsg": "ok"})


提交代码版本:

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端实现商品的全选与全不选的勾选状态切换"
git push


客户端实现购物车的全选和反选

api/cart.js,代码:

import http from "../utils/http";
import {reactive, ref} from "vue"

const cart = reactive({
    course_list: [], // 购物车商品列表
    total_price: 0,  // 购物车中的商品总价格
    selected_course_total: 0, // 购物车中被勾选商品的数量
    checked: false,  // 购物车中是否全选商品了
   
   ...
    select_course(course_id, selected, token){
        // 切换指定商品课程的勾选状态
        return http.patch("/cart/", {
            course_id,
            selected,
        },{
            headers:{
                Authorization: "jwt " + token,
            }
        })
    },
    select_all_course(selected, token){
        // 切换购物车对应商品课程的全选状态
        return http.put("/cart/", {
            selected,
        },{
            headers:{
                Authorization: "jwt " + token,
            }
        })
    }
})

export default cart;


views/Cart.vue,代码:

          <div class="item"  v-for="course_info in cart.course_list">
              <div class="item-1">
                  <el-checkbox v-model="course_info.selected" @change="change_select_course(course_info)"></el-checkbox>
              </div>


<script setup>
import {Close} from '@element-plus/icons-vue'
import {reactive, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import cart from "../api/cart"
import { ElMessage } from 'element-plus'
let state = reactive({
  checked: false,
})


const get_cart = ()=>{
  // 获取购物车中的商品列表
  let token = sessionStorage.token || localStorage.token;
  cart.get_course_from_cart(token).then(response=>{
    cart.course_list = response.data.cart;
    // 获取购物车中的商品总价格
    get_cart_total();

    // 监听所有课程的勾选状态是否发生
    watch(
    [...cart.course_list],  // watch多个数据必须是数组结构,但是cart.course_list是由我们通过vue.reactive装饰成响应式对象了,所以需要转换
    ()=>{
      get_cart_total();
    },
  )

  })
}

get_cart()


// 计算获取购物车中勾选商品课程的总价格
const get_cart_total = ()=>{
  let sum = 0;
  let select_sum = 0;
  cart.course_list.forEach((course, key)=>{
    if(course.selected){
      // 当前被勾选
      select_sum+=1;
      // 判断当前商品是否有优惠价格
      if(course.discount.price>=0){
        sum+=course.discount.price;
      }else{
        sum+=course.price;
      }
    }
    cart.total_price = sum; // 购物车中的商品总价格
    cart.selected_course_total = select_sum; // 购物车中被勾选商品的数量
    cart.checked = select_sum === cart.course_list.length;  // 购物车中是否全选商品了
  })
}


const change_select_course = (course)=>{
  // 切换指定课程的勾选状态
  let token = sessionStorage.token || localStorage.token;
  cart.select_course(course.id, course.selected, token).catch(error=>{
    ElMessage.error(error?.response?.data?.errmsg);
  })
}


// 监听全选按钮的状态切换
watch(
    ()=>cart.checked,
    ()=>{
      let token = sessionStorage.token || localStorage.token;
      // 如果勾选了全选,则所有课程的勾选状态都为true
      if(cart.checked){
        // 让客户端的所有课程状态先改版
        cart.course_list.forEach((course, key)=>{
          course.selected = true
        })

        // 如果是因为购物车中所有课程的勾选状态都为true的情况下,是不需要发送全选的ajax请求
        if(!(cart.selected_course_total === cart.course_list.length)){
          cart.select_all_course(true, token);
        }
      }

      // 如果在所有课程的勾选状态都为true的情况下,把全选去掉,则所有课程的勾选状态也变成false
      if((cart.checked === false) && (cart.selected_course_total === cart.course_list.length)){
        cart.course_list.forEach((course, key)=>{
          course.selected = false
        })
        cart.select_all_course(false,token);
      }
    }
)

</script>


提交代码版本:

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端实现购物车商品的勾选状态切换"
git push


删除购物车中的商品

服务端提供购物车中删除商品课程的api接口

cart/views.py,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course


# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated]  # 保证用户必须时登录状态才能调用当前视图

    #  中间代码省略.....
    def delete(self, request):
        """从购物车中删除指定商品"""
        user_id = request.user.id
        # 因为delete方法没有请求体,所以改成地址栏传递课程ID,Django restframework中通过request.query_params来获取
        course_id = int(request.query_params.get("course_id", 0))
        redis = get_redis_connection("cart")
        redis.hdel(f"cart_{user_id}", course_id)
        return Response(status=status.HTTP_204_NO_CONTENT)



提交代码版本:

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端实现删除购物车中的商品课程"
git push


1662803242993

客户端实现删除课程的功能

api/cart.js,代码:

import http from "../utils/http";
import {reactive, ref} from "vue"

const cart = reactive({
    // 中间代码省略。。。。。
    delete_course(course_id, token){
        // 从购物车中删除商品课程
        return http.delete("/cart/", {
            params:{
                course_id,  // course_id: course_id,的简写
            },
            headers:{
                Authorization: "jwt " + token,
            }
        })
    }
})

export default cart;


views/Cart.vue,代码:

<div class="cart-body-table">
         <!-- 删除操作,需要指定数组的下标 -->
          <div class="item"  v-for="course_info, key in cart.course_list">
              <div class="item-1">
                  <el-checkbox v-model="course_info.selected" @change="change_select_course(course_info)"></el-checkbox>
              </div>
              <div class="item-2">
                  <router-link :to="`/project/${course_info.id}`" class="img-box l">
                    <img :src="course_info.course_cover">
                  </router-link>
                  <dl class="l has-package">
                    <dt>【{{course_info.course_type}}】 {{course_info.name}}</dt>
                    <p class="package-item" v-if="course_info.discount.type">{{ course_info.discount.type }}</p>
                  </dl>
              </div>

              <div class="item-3">
                  <div class="price" v-if="course_info.discount.price>=0">
                      <span class="discount-price"><em>¥</em><span>{{course_info.discount.price.toFixed(2)}}</span></span><br>
                      <span class="original-price"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></span>
                  </div>
                  <div class="price" v-else>
                      <div class="discount-price"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></div>
                  </div>
              </div>
              <div class="item-4">
                <!-- 删除操作是不可逆操作,所以需要让用户确认是否真要删除 -->
                <el-popconfirm title="您确认要从购物车删除当前课程吗?" @confirm="del_cart(key)" confirmButtonText="删除!" cancelButtonText="误操作!">
                <template #reference>
                  <el-icon :size="26" class="close"><Close /></el-icon>
                </template>
              </el-popconfirm>
              </div>
          </div>


<script setup>
import {Close} from '@element-plus/icons-vue'
import {reactive, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import cart from "../api/cart"
import { ElMessage } from 'element-plus'
import {useStore} from "vuex";

const store = useStore()

....

const del_cart = (key)=>{
    // 从购物车中删除商品课程
    let token = sessionStorage.token || localStorage.token;
    let course = cart.course_list[key];
    console.log("course", course)
    cart.delete_course(course.id, token).then(response=>{
        // 当课程的勾选状态为True时,删除课程以后,把已勾选状态的课程总数-1
        cart.course_list.splice(key, 1);
        // 在store中页要同步购物车商品总数量
        store.commit("cart_total", cart.course_list.length);
        // 重新计算购物车中的商品课程的总价格
        get_cart_total();
    })
}


</script>


提交代码版本:

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端实现删除购物车中的商品课程"
git push


posted @ 2022-09-27 20:20  凫弥  阅读(146)  评论(0编辑  收藏  举报