课程管理模块-课程购买

准备工作
创建子应用 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 = [ 'simpleui', # admin界面美化,必须写在admin上面 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', "rest_framework", # 注意:记得加入 rest_framework 'corsheaders', # cors跨域子应用 'ckeditor', # 富文本编辑器 'ckeditor_uploader', # 富文本编辑器上传文件子用用 'stdimage', # 生成缩略图 'haystack', # 搜索引擎框架 "home", 'users', 'courses', '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('admin/', admin.site.urls), re_path(r'uploads/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}), path('ckeditor/', include('ckeditor_uploader.urls')), path('', include("home.urls")), path("users/", include("users.urls")), path("courses/", include("courses.urls")), path("cart/", include("cart.urls")), ]
因为购物车中的商品(课程)信息会经常被用户操作,所以为了减轻mysql服务器的压力,可以选择把购物车信息通过redis来存储.
配置信息
settings/dev.py,配置文件
# 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 get_category = ()=>{ // 获取课程分类 course.get_course_category().then(response=>{ course.category_list = response.data; }) } get_category(); const get_hot_word = ()=>{ // 搜索热门关键字列表 course.get_hot_word().then(response=>{ course.hot_word_list = response.data }) } const get_course_list = ()=>{ // 获取课程列表 let ret = null // 预设一个用于保存服务端返回的数据 if(course.text) { ret = course.search_course() }else{ ret = course.get_course_list() } ret.then(response=>{ course.course_list = response.data.results; // 总数据量 course.count = response.data.count; course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值 course.has_next = !!response.data.next; // 优惠活动的倒计时 course.start_timer(); }) // 每次获取课程都同事获取一次热搜词列表 get_hot_word(); } get_course_list(); // 当热搜词被点击,进行搜索 const search_by_hotword = (hot_word)=>{ course.text = hot_word get_course_list() } // 添加课程到购物车 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(); } ) watch( // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息 ()=> course.current_category, ()=>{ // 重置排序条件 course.ordering = "-id"; get_course_list(); } ) watch( // 监听课程切换不同的排序条件 ()=>course.ordering, ()=>{ get_course_list(); } ) // 监听页码 watch( ()=>course.page, ()=>{ // 重新获取课程信息 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; // 简单判断课程ID是否合法 if(course.course_id > 0){ // 根据课程ID到服务端获取课程详情数据 course.get_course().then(response=> { course.info = response.data; clearInterval(course.timer); course.timer = setInterval(() => { if (course.info.discount.expire && course.info.discount.expire > 0) { course.info.discount.expire-- } }, 1000); }).catch(error=>{ ElMessage.error({ message: "非法的URL地址,无法获取课程信息!", duration: 1000, onClose(){ router.go(-1); } }) }) // 获取课程章节信息 course.get_course_chapters().then(response=>{ course.chapter_list = response.data }) }else{ ElMessage.error({ message: "非法的URL地址,无法获取课程信息!", duration: 1000, onClose(){ router.go(-1) } }) } // 阿里云播放器的选项参数 const options = reactive({ // source: "/src/assets/1.mp4", // cover: "/src/assets/course-1.png", autoplay: false, // 是否自动播放 preload: true, // 是否自动预加载 isLive: false, // 切换为直播流的时候必填true // format: 'm3u8' // 切换为直播流的时候必填 }) const onPlay = (event)=>{ console.log("播放视频"); console.log(player.value.getCurrentTime()); // 当前视频播放时间 } const onPause = (event)=>{ console.log("暂停播放"); console.log(player.value.getCurrentTime()); } const onPlaying = (event)=>{ console.log("播放中"); console.log(player.value.getCurrentTime()); console.log(player.value.getDuration()); // 获取视频长度 } // 添加商品到购物车 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() // 获取学习方向的列表数据 course.get_course_direction().then(response=>{ course.direction_list = response.data; }) // 获取课程分类的列表数据 const get_category = ()=>{ // 获取课程分类 course.get_course_category().then(response=>{ course.category_list = response.data; }) } get_category(); const get_hot_word = ()=>{ // 搜索热门关键字列表 course.get_hot_word().then(response=>{ course.hot_word_list = response.data }) } const get_course_list = ()=>{ // 获取课程列表 let ret = null // 预设一个用于保存服务端返回的数据 if(course.text) { ret = course.search_course() }else{ ret = course.get_course_list() } ret.then(response=>{ course.course_list = response.data.results; // 总数据量 course.count = response.data.count; course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值 course.has_next = !!response.data.next; // 优惠活动的倒计时 course.start_timer(); }) // 每次获取课程都同事获取一次热搜词列表 get_hot_word(); } get_course_list(); // 当热搜词被点击,进行搜索 const search_by_hotword = (hot_word)=>{ course.text = hot_word get_course_list() } // 添加课程到购物车 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) 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(); } ) watch( // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息 ()=> course.current_category, ()=>{ // 重置排序条件 course.ordering = "-id"; get_course_list(); } ) watch( // 监听课程切换不同的排序条件 ()=>course.ordering, ()=>{ get_course_list(); } ) // 监听页码 watch( ()=>course.page, ()=>{ // 重新获取课程信息 get_course_list(); } ) </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 show_captcha = ()=>{ var captcha1 = new TencentCaptcha(settings.captcha_app_id, (res)=>{ // 接收验证结果的回调函数 /* res(验证成功) = {ret: 0, ticket: "String", randstr: "String"} res(客户端出现异常错误 仍返回可用票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"} res(用户主动关闭验证码)= {ret: 2} */ console.log(res); // 调用登录处理 loginhandler(res); }); captcha1.show(); // 显示验证码 } // 登录处理 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=>{ // 先删除之前存留的状态 localStorage.removeItem("token"); sessionStorage.removeItem("token"); // 根据用户选择是否记住登录密码,保存token到不同的本地存储中 if(user.remember){ // 记录登录状态 localStorage.token = response.data.token }else{ // 不记录登录状态 sessionStorage.token = response.data.token } ElMessage.success("登录成功!"); // 登录后续处理,通知父组件,当前用户已经登录成功 user.account = "" user.password = "" user.mobile = "" user.code = "" user.remember = false // vuex存储用户登录信息,保存token,并根据用户的选择,是否记住密码 let payload = response.data.token.split(".")[1] // 载荷 let payload_data = JSON.parse(atob(payload)) // 用户信息 console.log("payload_data=", payload_data) 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) // 获取url地址栏上的课程ID course.course_id = route.params.id; // 简单判断课程ID是否合法 if(course.course_id > 0){ // 根据课程ID到服务端获取课程详情数据 course.get_course().then(response=> { course.info = response.data; clearInterval(course.timer); course.timer = setInterval(() => { if (course.info.discount.expire && course.info.discount.expire > 0) { course.info.discount.expire-- } }, 1000); }).catch(error=>{ ElMessage.error({ message: "非法的URL地址,无法获取课程信息!", duration: 1000, onClose(){ router.go(-1); } }) }) // 获取课程章节信息 course.get_course_chapters().then(response=>{ course.chapter_list = response.data }) }else{ ElMessage.error({ message: "非法的URL地址,无法获取课程信息!", duration: 1000, onClose(){ router.go(-1) } }) } // 阿里云播放器的选项参数 const options = reactive({ // source: "/src/assets/1.mp4", // cover: "/src/assets/course-1.png", autoplay: false, // 是否自动播放 preload: true, // 是否自动预加载 isLive: false, // 切换为直播流的时候必填true // format: 'm3u8' // 切换为直播流的时候必填 }) const onPlay = (event)=>{ console.log("播放视频"); console.log(player.value.getCurrentTime()); // 当前视频播放时间 } const onPause = (event)=>{ console.log("暂停播放"); console.log(player.value.getCurrentTime()); } const onPlaying = (event)=>{ console.log("播放中"); console.log(player.value.getCurrentTime()); console.log(player.value.getDuration()); // 获取视频长度 } // 添加商品到购物车 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: '/', // uri访问地址 name: "Home", component: ()=> import("../views/Home.vue") }, { meta:{ title: "luffy2.0-用户登录", keepAlive: true }, path:'/login', // uri访问地址 name: "Login", component: ()=> import("../views/Login.vue") }, { meta:{ title: "luffy2.0-用户注册", keepAlive: true }, path: '/register', name: "Register", // 路由名称 component: ()=> import("../views/Register.vue"), // uri绑定的组件页面 }, { meta:{ title: "luffy2.0-个人中心", keepAlive: true, authorization: true, }, path: '/user', name: "User", component: ()=> import("../views/User.vue"), }, { meta:{ title: "luffy2.0-课程列表", keepAlive: true, }, path: '/project', name: "Course", component: ()=> import("../views/Course.vue"), }, { 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"), } ] // 路由对象实例化 const router = createRouter({ // history, 指定路由的模式 history: createWebHistory(), // 路由列表 routes, }); // 导航守卫 router.beforeEach((to, from, next)=>{ document.title=to.meta.title // 登录状态验证 if (to.meta.authorization && !store.getters.getUserInfo) { next({"name": "Login"}) }else{ next() } }) // 暴露路由对象 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 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_delete=False] try: # 判断课程是否存在 # todo 判断用户是否已经购买了 course = Course.objects.get(is_show=True, is_delete=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) 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_delete=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 post(self, request): """添加课程商品到购物车中""" # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态 # 用户ID 可以通过self.request.user.id 或 request.user.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}") 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: 勾选状态 } """ 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) 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({"errmsg":"购物车没有任何商品。"}) 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() print(course_list) 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}) 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 post(self, request): """添加课程商品到购物车中""" # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态 # 用户ID 可以通过self.request.user.id 或 request.user.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}") 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: 勾选状态 } """ 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) 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({"errmsg":"购物车没有任何商品。"}) 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() print(course_list) 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}) 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"}) 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, // 购物车中是否全选商品了 // 添加课程到购物车 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, } }) }, 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 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); } } ) 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

浙公网安备 33010602011771号