路飞学城五: 动态价格策略&购物车模块开发
五: 动态价格策略&购物车模块开发
课程动态价格策略实现
动态价格策略设计
商城往往为了提高销量都会出现活动内容,商品因为参加了活动所以会产生价格的变动。
价格优惠活动类型名称: 限时免费, 限时折扣, 限时减免, 限时满减, 积分抵扣, 优惠券
针对单个商品的动态价格策略,公式:
限时免费 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
因为此处滑块验证码过期,所以先暂时关闭验证码功能,后期添加
购物车实现
准备工作
创建子应用 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'
}
客户端登录成功以后,显示当前用户的购物车中的商品课程总数量。
刚注册的用户是不会在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>
课程详情页显示购物车商品总数
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>
提交代码版本
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
客户端展示购物车课程信息
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
购物车商品的勾选状态切换
商品课程的勾选状态发生改变时,同步到服务端中的购物车。
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
服务端实现购物车的全选和反选
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
客户端实现删除课程的功能
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