购物车实现
创建子应用cart
cd luffyapi/apps
python ../../manage.py startapp cart
注册子应用cart
INSTALLED_APPS = [ 'ckeditor', # 富文本编辑器 'ckeditor_uploader', # 富文本编辑器上传图片模块 'home', 'users', 'courses', 'cart', ]
因为购物车中的商品(课程)信息会经常被用户操作,所以为了减轻mysql服务器的压力,可以选择把购物车信息通过redis来存储.
配置信息
dev.py
# 设置redis缓存 CACHES = { # 默认缓存 .... "cart":{ "BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://127.0.0.1:6379/3", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", } }, }
接下来商品信息存储以下内容:
购物车商品信息格式:
商品数量[因为目前路飞学城的商品是视频,所以没有数量,如果以后做到真实商品,则必须有数量]
商品id
用户id
课程有效期
商品勾选状态
五种数据类型
string字符串
键:值
hash哈希字典
键:{
域:值,
域:值,
}
list列表
键:[值1,值2,....]
set集合
键:{值1,值2,....}
zset有序集合
键:{
权重值1:值,
权重值2:值,
}
经过比较可以发现没有一种数据类型,可以同时存储4个字段数据的,所以我们才有2种数据结构来保存购物车数据
可以发现,上面5种数据类型中,哈希hash可以存储的数据量是最多的。
hash:
键[用户ID]:{
域[商品ID]:值[课程有效期],
域[商品ID]:值[课程有效期],
域[商品ID]:值[课程有效期],
域[商品ID]:值[课程有效期],
}
set:
键[用户ID]:{商品ID1,商品ID2....}
实现课程商品到购物车的api接口
cart/views.py视图,代码:
from rest_framework.viewsets import ViewSet from rest_framework.permissions import IsAuthenticated from courses.models import Course from rest_framework.response import Response from rest_framework import status from django_redis import get_redis_connection class CartAPIViewSet(ViewSet): # permission_classes = [IsAuthenticated] def add_cart(self,request): """添加商品到购物车""" # 获取用户ID user_id = 1 # request.user.id # 获取客户端发送过来的course_id course_id = request.data.get("course_id") # 验证课程是否存在 try: Course.objects.get(pk=course_id, is_show=True, is_delete=False) except: return Response({"message":"对不起,添加的商品不存在"}, status=status.HTTP_400_BAD_REQUEST) # 设置有效期和勾选状态的默认值 expire = 0 # 0表示没有设置默认值,或者将来我们完成课程的有效期时,重新定义0代表的意思。 # 打开redis的链接 redis_conn = get_redis_connection("cart") # 保存数据到redis中 # 把商品ID和商品的有效期存放到hash中 pipe = redis_conn.pipeline() pipe.multi() pipe.hset("cart_%s" % user_id, course_id, expire ) pipe.sadd("selected_%s" % user_id, course_id ) # 默认勾选的状态,如果用户不希望将来这个商品进行结算状态,我们在购物车商品列表中提供按钮给用户去掉 pipe.execute() # 返回响应结果 return Response({"message":"成功添加商品到购物车"})
redis的异常处理,utils/exceptions.py,代码:(不写)
from rest_framework.views import exception_handler from django.db import DatabaseError from rest_framework.response import Response from rest_framework import status from redis import RedisError 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) or isinstance(exc, RedisError): # 数据库异常 logger.error('[%s] %s' % (view, exc)) response = Response({'message': '服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE) return response
提供访问路由
总路由,代码:
urlpatterns = [ ... path('cart/', include("cart.urls") ), ]
子应用路由cart/urls.py,代码:
from django.urls import path,re_path from . import views urlpatterns = [ path("", views.CartAPIView.as_view({"post":"add_cart"})), ]
代码还原,把注释符号去掉:
views.py
class CartAPIViewSet(ViewSet): permission_classes = [IsAuthenticated] def add_cart(self,request): """添加商品到购物车""" # 获取用户ID user_id = request.user.id
前端提交课程到后端添加购物车数据
Detail.vue
<template> <div class="detail"> <Header/> <div class="main"> <div class="course-info"> <div class="wrap-left"> <videoPlayer class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)" /> </div> <div class="wrap-right"> <h3 class="course-name">{{course.name}}</h3> <p class="data">{{course.students}}人在学 课程总时长:{{course.lessons}}课时/{{course.lessons==course.pub_lessons?'更新完成':`已更新${course.pub_lessons}课时`}} 难度:初级</p> <div class="sale-time"> <p class="sale-type">限时免费</p> <p class="expire">距离结束:仅剩 01天 04小时 33分 <span class="second">08</span> 秒</p> </div> <p class="course-price"> <span>活动价</span> <span class="discount">¥0.00</span> <span class="original">¥{{course.price}}</span> </p> <div class="buy"> <div class="buy-btn"> <button class="buy-now">立即购买</button> <button class="free">免费试学</button> </div> <div class="add-cart" @click="add_cart"><img src="/static/image/cart-yellow.svg" alt="">加入购物车</div> </div> </div> </div> <div class="course-tab"> <ul class="tab-list"> <li :class="tabIndex==1?'active':''" @click="tabIndex=1">详情介绍</li> <li :class="tabIndex==2?'active':''" @click="tabIndex=2">课程章节 <span :class="tabIndex!=2?'free':''">(试学)</span></li> <li :class="tabIndex==3?'active':''" @click="tabIndex=3">用户评论 (42)</li> <li :class="tabIndex==4?'active':''" @click="tabIndex=4">常见问题</li> </ul> </div> <div class="course-content"> <div class="course-tab-list"> <div class="tab-item" v-if="tabIndex==1"> <div v-html="url_format(course.brief)"></div> </div> <div class="tab-item" v-if="tabIndex==2"> <div class="tab-item-title"> <p class="chapter">课程章节</p> <p class="chapter-length">共11章 147个课时</p> </div> <div class="chapter-item" v-for="chapter in chapter_list"> <p class="chapter-title"><img src="/static/image/1.svg" alt="">第{{chapter.chapter}}章·{{chapter.name}}</p> <ul class="lesson-list"> <li class="lesson-item" v-for="lesson in chapter.lessons"> <p class="name"><span class="index">{{chapter.chapter}}-{{lesson.id}}</span> {{lesson.name}}<span class="free" v-if="lesson.free_trail">免费</span></p> <p class="time">{{lesson.duration}} <img src="/static/image/chapter-player.svg"></p> <button class="try" v-if="lesson.free_trail">立即试学</button> <button class="try" v-else>立即购买</button> </li> </ul> </div> </div> <div class="tab-item" v-if="tabIndex==3"> 用户评论 </div> <div class="tab-item" v-if="tabIndex==4"> 常见问题 </div> </div> <div class="course-side"> <div class="teacher-info"> <h4 class="side-title"><span>授课老师</span></h4> <div class="teacher-content"> <div class="cont1"> <img :src="teacher.image"> <div class="name"> <p class="teacher-name">{{teacher.name}}</p> <p class="teacher-title">{{teacher.title}} {{teacher.signature}}</p> </div> </div> <p class="narrative" >{{teacher.brief}}</p> </div> </div> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" // 1.加载播放器组件 import {videoPlayer} from 'vue-video-player'; export default { name: "Detail", data(){ return { course_id: 0, // 课程ID course: {}, // 课程信息 teacher:'', chapter_list: [], // 章节列表 tabIndex:2, // 当前选项卡显示的下标 playerOptions:{ playbackRates: [0.7, 1.0, 1.5, 2.0], // 播放速度 autoplay: false, //如果true,则自动播放 muted: false, // 默认情况下将会消除任何音频。 loop: false, // 循环播放 preload: 'auto', // 建议浏览器在<video>加载元素后是否应该开始下载视频数据。auto浏览器选择最佳行为,立即开始加载视频(如果浏览器支持) language: 'zh-CN', aspectRatio: '16:9', // 将播放器置于流畅模式,并在计算播放器的动态大小时使用该值。值应该代表一个比例 - 用冒号分隔的两个数字(例如"16:9"或"4:3") fluid: true, // 当true时,Video.js player将拥有流体大小。换句话说,它将按比例缩放以适应其容器。 sources: [{ // 播放资源和资源格式 type: "video/mp4", src: "http://img.ksbbs.com/asset/Mon_1703/05cacb4e02f9d9e.mp4" //你的视频地址(必填) }], poster: "../static/image/course-cover.jpeg", //视频封面图 width: document.documentElement.clientWidth, // 默认视频全屏时的最大宽度 notSupportedMessage: '此视频暂无法播放,请稍后再试', //允许覆盖Video.js无法播放媒体源时显示的默认信息。 } } }, created(){ // 接受地址路径参数 // console.log( '路径参数: id=',this.$route.params.id ); // 接受地址的查询字符串参数 // console.log( '查询字符串: uid=', this.$route.query.uid ); this.course_id = this.$route.params.id; this.get_course(); this.get_chapter(); }, methods: { onPlayerPlay(){ alert("开始播放视频,关闭广告"); }, onPlayerPause(){ alert("暂停播放广告"); }, get_course(){ // 获取课程信息 this.$axios.get(`${this.$settings.Host}/courses/${this.course_id}/`).then(response=>{ this.course = response.data; this.teacher=response.data.teacher }).catch(error=>{ let self = this; this.$alert("无法获取当前课程信息,请联系客服工作人员","路飞学城",{ callback(){ self.$router.back(); // 等同于 self.$router.go(-1); } }); }) }, get_chapter(){ // 获取课程相关的章节课时信息 this.$axios.get(`${this.$settings.Host}/courses/chapters/`,{ // params:{ // "course": this.course_id, // } }).then(response=>{ this.chapter_list = response.data; console.log(this.chapter_list); }).catch(error=>{ this.$message.info("没有获取到当前课程的章节信息"); }); }, url_format(data){ console.log(data); while( data.search('="/media') != -1 ){ data = data.replace('="/media',`="${this.$settings.Host}/media`); } return data; }, add_cart(){ // 添加商品课程到购物车中 // 1. 先判断用户是否登录 let user_token = this.$settings.check_user_login(); if( !user_token ){ let self = this; this.$confirm('对不起,您尚未登录,请登录后继续购买, 是否继续?', '路飞学城', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { // 点击确定按钮 self.$router.push("/login"); }).catch(() => { }); return ; } this.$axios.post(`${this.$settings.Host}/cart/`,{ course_id: this.course_id },{ headers:{ // 访问需要登录认证权限的api接口必须附带token到请求头中 "Authorization": "jwt " + user_token, } }).then(response=>{ this.$message.info(response.data.message); }).catch(error=>{ if(error.response.status == 401){ // 没有登录或者登录超时 let self = this; this.$confirm('对不起,您尚未登录,请登录后继续购买, 是否继续?', '路飞学城', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { // 点击确定按钮 self.$router.push("/login"); }).catch(() => { }); return ; }else{ this.$message.error("购物车添加商品失败!请联系客服工作人员!"); } }); }, }, components:{ Header, Footer, videoPlayer, // 注册播放器组件到当前页面中 } } </script> <style scoped> .main{ background: #fff; padding-top: 30px; } .course-info{ width: 1200px; margin: 0 auto; overflow: hidden; } .wrap-left{ float: left; width: 690px; height: 388px; background-color: #000; } .wrap-right{ float: left; position: relative; height: 388px; } .course-name{ font-size: 20px; color: #333; padding: 10px 23px; letter-spacing: .45px; } .data{ padding-left: 23px; padding-right: 23px; padding-bottom: 16px; font-size: 14px; color: #9b9b9b; } .sale-time{ width: 464px; background: #fa6240; font-size: 14px; color: #4a4a4a; padding: 10px 23px; overflow: hidden; } .sale-type { font-size: 16px; color: #fff; letter-spacing: .36px; float: left; } .sale-time .expire{ font-size: 14px; color: #fff; float: right; } .sale-time .expire .second{ width: 24px; display: inline-block; background: #fafafa; color: #5e5e5e; padding: 6px 0; text-align: center; } .course-price{ background: #fff; font-size: 14px; color: #4a4a4a; padding: 5px 23px; } .discount{ font-size: 26px; color: #fa6240; margin-left: 10px; display: inline-block; margin-bottom: -5px; } .original{ font-size: 14px; color: #9b9b9b; margin-left: 10px; text-decoration: line-through; } .buy{ width: 464px; padding: 0px 23px; position: absolute; left: 0; bottom: 20px; overflow: hidden; } .buy .buy-btn{ float: left; } .buy .buy-now{ width: 125px; height: 40px; border: 0; background: #ffc210; border-radius: 4px; color: #fff; cursor: pointer; margin-right: 15px; outline: none; } .buy .free{ width: 125px; height: 40px; border-radius: 4px; cursor: pointer; margin-right: 15px; background: #fff; color: #ffc210; border: 1px solid #ffc210; } .add-cart{ float: right; font-size: 14px; color: #ffc210; text-align: center; cursor: pointer; margin-top: 10px; } .add-cart img{ width: 20px; height: 18px; margin-right: 7px; vertical-align: middle; } .course-tab{ width: 100%; background: #fff; margin-bottom: 30px; box-shadow: 0 2px 4px 0 #f0f0f0; } .course-tab .tab-list{ width: 1200px; margin: auto; color: #4a4a4a; overflow: hidden; } .tab-list li{ float: left; margin-right: 15px; padding: 26px 20px 16px; font-size: 17px; cursor: pointer; } .tab-list .active{ color: #ffc210; border-bottom: 2px solid #ffc210; } .tab-list .free{ color: #fb7c55; } .course-content{ width: 1200px; margin: 0 auto; background: #FAFAFA; overflow: hidden; padding-bottom: 40px; } .course-tab-list{ width: 880px; height: auto; padding: 20px; background: #fff; float: left; box-sizing: border-box; overflow: hidden; position: relative; box-shadow: 0 2px 4px 0 #f0f0f0; } .tab-item{ width: 880px; background: #fff; padding-bottom: 20px; box-shadow: 0 2px 4px 0 #f0f0f0; } .tab-item-title{ justify-content: space-between; padding: 25px 20px 11px; border-radius: 4px; margin-bottom: 20px; border-bottom: 1px solid #333; border-bottom-color: rgba(51,51,51,.05); overflow: hidden; } .chapter{ font-size: 17px; color: #4a4a4a; float: left; } .chapter-length{ float: right; font-size: 14px; color: #9b9b9b; letter-spacing: .19px; } .chapter-title{ font-size: 16px; color: #4a4a4a; letter-spacing: .26px; padding: 12px; background: #eee; border-radius: 2px; display: -ms-flexbox; display: flex; -ms-flex-align: center; align-items: center; } .chapter-title img{ width: 18px; height: 18px; margin-right: 7px; vertical-align: middle; } .lesson-list{ padding:0 20px; } .lesson-list .lesson-item{ padding: 15px 20px 15px 36px; cursor: pointer; justify-content: space-between; position: relative; overflow: hidden; } .lesson-item .name{ font-size: 14px; color: #666; float: left; } .lesson-item .index{ margin-right: 5px; } .lesson-item .free{ font-size: 12px; color: #fff; letter-spacing: .19px; background: #ffc210; border-radius: 100px; padding: 1px 9px; margin-left: 10px; } .lesson-item .time{ font-size: 14px; color: #666; letter-spacing: .23px; opacity: 1; transition: all .15s ease-in-out; float: right; } .lesson-item .time img{ width: 18px; height: 18px; margin-left: 15px; vertical-align: text-bottom; } .lesson-item .try{ width: 86px; height: 28px; background: #ffc210; border-radius: 4px; font-size: 14px; color: #fff; position: absolute; right: 20px; top: 10px; opacity: 0; transition: all .2s ease-in-out; cursor: pointer; outline: none; border: none; } .lesson-item:hover{ background: #fcf7ef; box-shadow: 0 0 0 0 #f3f3f3; } .lesson-item:hover .name{ color: #333; } .lesson-item:hover .try{ opacity: 1; } .course-side{ width: 300px; height: auto; margin-left: 20px; float: right; } .teacher-info{ background: #fff; margin-bottom: 20px; box-shadow: 0 2px 4px 0 #f0f0f0; } .side-title{ font-weight: normal; font-size: 17px; color: #4a4a4a; padding: 18px 14px; border-bottom: 1px solid #333; border-bottom-color: rgba(51,51,51,.05); } .side-title span{ display: inline-block; border-left: 2px solid #ffc210; padding-left: 12px; } .teacher-content{ padding: 30px 20px; box-sizing: border-box; } .teacher-content .cont1{ margin-bottom: 12px; overflow: hidden; } .teacher-content .cont1 img{ width: 54px; height: 54px; margin-right: 12px; float: left; } .teacher-content .cont1 .name{ float: right; } .teacher-content .cont1 .teacher-name{ width: 188px; font-size: 16px; color: #4a4a4a; padding-bottom: 4px; } .teacher-content .cont1 .teacher-title{ width: 188px; font-size: 13px; color: #9b9b9b; white-space: nowrap; } .teacher-content .narrative{ font-size: 14px; color: #666; line-height: 24px; } </style>
显示购物车商品列表页面
购物车页面有两部分构成:
Cart.vue,代码:
<template> <div class="cart"> <Header></Header> <div class="cart_info"> <div class="cart_title"> <span class="text">我的购物车</span> <span class="total">共4门课程</span> </div> <div class="cart_table"> <div class="cart_head_row"> <span class="doing_row"></span> <span class="course_row">课程</span> <span class="expire_row">有效期</span> <span class="price_row">单价</span> <span class="do_more">操作</span> </div> <div class="cart_course_list"> <CartItem></CartItem> <CartItem></CartItem> <CartItem></CartItem> <CartItem></CartItem> </div> <div class="cart_footer_row"> <span class="cart_select"><label> <el-checkbox v-model="checked"></el-checkbox><span>全选</span></label></span> <span class="cart_delete"><i class="el-icon-delete"></i> <span>删除</span></span> <span class="goto_pay">去结算</span> <span class="cart_total">总计:¥0.0</span> </div> </div> </div> <Footer></Footer> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" import CartItem from "./common/CartItem" export default { name: "Cart", data(){ return { checked: false, } }, methods:{ }, components:{ Header, Footer, CartItem, } } </script> <style scoped> .cart_info{ width: 1200px; margin: 0 auto 200px; } .cart_title{ margin: 25px 0; } .cart_title .text{ font-size: 18px; color: #666; } .cart_title .total{ font-size: 12px; color: #d0d0d0; } .cart_table{ width: 1170px; } .cart_table .cart_head_row{ background: #F7F7F7; width: 100%; height: 80px; line-height: 80px; padding-right: 30px; } .cart_table .cart_head_row::after{ content: ""; display: block; clear: both; } .cart_table .cart_head_row .doing_row, .cart_table .cart_head_row .course_row, .cart_table .cart_head_row .expire_row, .cart_table .cart_head_row .price_row, .cart_table .cart_head_row .do_more{ padding-left: 10px; height: 80px; float: left; } .cart_table .cart_head_row .doing_row{ width: 78px; } .cart_table .cart_head_row .course_row{ width: 530px; } .cart_table .cart_head_row .expire_row{ width: 188px; } .cart_table .cart_head_row .price_row{ width: 162px; } .cart_table .cart_head_row .do_more{ width: 162px; } .cart_footer_row{ padding-left: 30px; background: #F7F7F7; width: 100%; height: 80px; line-height: 80px; } .cart_footer_row .cart_select span{ font-size: 18px; color: #666; } .cart_footer_row .cart_delete{ margin-left: 58px; } .cart_delete .el-icon-delete{ font-size: 18px; } .cart_delete span{ margin-left: 15px; cursor: pointer; font-size: 18px; color: #666; } .cart_total{ float: right; margin-right: 62px; font-size: 18px; color: #666; } .goto_pay{ float: right; width: 159px; height: 80px; outline: none; border: none; background: #ffc210; font-size: 18px; color: #fff; text-align: center; cursor: pointer; } </style>
Cartitem.vue,代码:
<template> <div class="cart_item"> <div class="cart_column column_1"> <el-checkbox class="my_el_checkbox" v-model="checked"></el-checkbox> </div> <div class="cart_column column_2"> <img src="/static/image/course_demo.png" alt=""> <span><router-link to="/course/detail/1">爬虫从入门到进阶</router-link></span> </div> <div class="cart_column column_3"> <el-select v-model="expire" size="mini" placeholder="请选择购买有效期" class="my_el_select"> <el-option label="1个月有效" value="30" key="30"></el-option> <el-option label="2个月有效" value="60" key="60"></el-option> <el-option label="3个月有效" value="90" key="90"></el-option> <el-option label="永久有效" value="10000" key="10000"></el-option> </el-select> </div> <div class="cart_column column_4">¥499.0</div> <div class="cart_column column_4">删除</div> </div> </template> <script> export default { name: "CartItem", data(){ return { checked:false, expire: "1个月有效", } } } </script> <style scoped> .cart_item::after{ content: ""; display: block; clear: both; } .cart_column{ float: left; height: 250px; } .cart_item .column_1{ width: 88px; position: relative; } .my_el_checkbox{ position: absolute; left: 0; right: 0; bottom: 0; top: 0; margin: auto; width: 16px; height: 16px; } .cart_item .column_2 { padding: 67px 10px; width: 520px; height: 116px; } .cart_item .column_2 img{ width: 175px; height: 115px; margin-right: 35px; vertical-align: middle; } .cart_item .column_3{ width: 197px; position: relative; padding-left: 10px; } .my_el_select{ width: 117px; height: 28px; position: absolute; top: 0; bottom: 0; margin: auto; } .cart_item .column_4{ padding: 67px 10px; height: 116px; width: 142px; line-height: 116px; } </style>
前端路由:
import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) // @ 表示src目录 // ... import Cart from "@/components/Cart" // .... export default new Router({ mode:"history", routes: [ // .... { path: '/cart', name: 'Cart', component: Cart, }, // .... ] })
后端提供获取购物车课程列表信息
cart/views.py,代码:
"""功能分析: 购物车中的商品信息和商品勾选状态是分开存储的,但是我们现在需要一个接口提供所有数据,所以我们需要整合/重构数据的结构 cart_<user_id>:{ <course_id>:<expire>, <course_id>:<expire>, ... } selected_<user_id>:{<course_id>,<course_id>,....} 目标:把数据整合成一个列表,列表中每一个成员就是商品字典: data = [ { course_id:<course_id>, expire:<expire>, selected:<selected>, }, { course_id:<course_id>, expire:<expire>, selected:<selected>, }, { course_id:<course_id>, expire:<expire>, selected:<selected>, }, ] """ from rest_framework.viewsets import ViewSet from rest_framework.permissions import IsAuthenticated from courses.models import Course from rest_framework.response import Response from rest_framework import status from django_redis import get_redis_connection class CartAPIViewSet(ViewSet): permission_classes = [IsAuthenticated] def add_cart(self,request): """添加商品到购物车""" # 获取用户ID user_id = request.user.id # 获取客户端发送过来的course_id course_id = request.data.get("course_id") # 验证课程是否存在 try: Course.objects.get(pk=course_id, is_show=True, is_delete=False) except: return Response({"message":"对不起,添加的商品不存在"}, status=status.HTTP_400_BAD_REQUEST) # 设置有效期和勾选状态的默认值 expire = 0 # 0表示没有设置默认值,或者将来我们完成课程的有效期时,重新定义0代表的意思。 # 打开redis的链接 redis_conn = get_redis_connection("cart") # 保存数据到redis中 # 把商品ID和商品的有效期存放到hash中 pipe = redis_conn.pipeline() pipe.multi() pipe.hset("cart_%s" % user_id, course_id, expire ) pipe.sadd("selected_%s" % user_id, course_id ) # 默认勾选的状态,如果用户不希望将来这个商品进行结算状态,我们在购物车商品列表中提供按钮给用户去掉 pipe.execute() # 返回响应结果 return Response({"message":"成功添加商品到购物车"}) def list(self, request): """购物车商品列表""" # 获取登录用户id user_id = request.user.id # 1. 打开redis链接 redis_conn = get_redis_connection("cart") # 2. 从redis读取购物车信息[[id,course_img,name,price]]和勾选商品集合 cart_dict_bytes = redis_conn.hgetall("cart_%s" % user_id) print(cart_dict_bytes) # {b'1': b'0', b'3': b'0', b'4': b'0'} selected_set_bytes = redis_conn.smembers("selected_%s" % user_id) # 3. 把勾选状态信息和商品信息组合成一个数据列表 data = [] for course_id_bytes, expire_bytes in cart_dict_bytes.items(): course_id = course_id_bytes.decode() expire = expire_bytes.decode() try: course = Course.objects.get(pk=course_id, is_show=True, is_delete=False) except: course = None if course: data.append({ "course_id": course.id, "course_name": course.name, "course_img": course.course_img.url, "price": "%.2f" % course.price, # 将来替换成真实价格 "expire": expire, "selected": course_id_bytes in selected_set_bytes, # 勾选状态 }) # 4. 返回数据 return Response(data)
更改路由
cart/urls.py
from django.urls import path from . import views urlpatterns = [ path("", views.CartAPIViewSet.as_view({"post":"add_cart","get":"list"}) ), ]
前端请求并显示课程信息
Cart.vue
<template> <div class="cart"> <Header></Header> <div class="cart_info"> <div class="cart_title"> <span class="text">我的购物车</span> <span class="total">共4门课程</span> </div> <div class="cart_table"> <div class="cart_head_row"> <span class="doing_row"></span> <span class="course_row">课程</span> <span class="expire_row">有效期</span> <span class="price_row">单价</span> <span class="do_more">操作</span> </div> <div class="cart_course_list"> <CartItem :key="key" v-for="course,key in course_list" :course="course"></CartItem> </div> <div class="cart_footer_row"> <span class="cart_select"><label> <el-checkbox v-model="checked"></el-checkbox><span>全选</span></label></span> <span class="cart_delete"><i class="el-icon-delete"></i> <span>删除</span></span> <span class="goto_pay">去结算</span> <span class="cart_total">总计:¥0.0</span> </div> </div> </div> <Footer></Footer> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" import CartItem from "./common/CartItem" export default { name: "Cart", data(){ return { user_token: "", checked: false, // 实现全选功能的 course_list: [], // 购物车中的商品列表 } }, created(){ // 验证登录 this.user_token = this.$settings.check_user_login(); if(!this.user_token){ let self = this; this.$alert("请登录以后再访问!","路飞学城",{ callback(){ self.$router.push("/login"); } }); return ; } // 获取购物车商品列表 this.get_cart(); }, methods:{ get_cart(){ // 获取购物车中的商品列表 this.$axios.get(`${this.$settings.Host}/cart/`,{ headers:{ // 访问需要登录认证权限的api接口必须附带token到请求头中 "Authorization": "jwt " + this.user_token, } }).then(response=>{ this.course_list = response.data; }).catch(error=>{ this.$message.error("无法获取购物车商品列表,请联系客服工作人员!"); }); } }, components:{ Header, Footer, CartItem, } } </script> <style scoped> .cart_info{ width: 1200px; margin: 0 auto 200px; } .cart_title{ margin: 25px 0; } .cart_title .text{ font-size: 18px; color: #666; } .cart_title .total{ font-size: 12px; color: #d0d0d0; } .cart_table{ width: 1170px; } .cart_table .cart_head_row{ background: #F7F7F7; width: 100%; height: 80px; line-height: 80px; padding-right: 30px; } .cart_table .cart_head_row::after{ content: ""; display: block; clear: both; } .cart_table .cart_head_row .doing_row, .cart_table .cart_head_row .course_row, .cart_table .cart_head_row .expire_row, .cart_table .cart_head_row .price_row, .cart_table .cart_head_row .do_more{ padding-left: 10px; height: 80px; float: left; } .cart_table .cart_head_row .doing_row{ width: 78px; } .cart_table .cart_head_row .course_row{ width: 530px; } .cart_table .cart_head_row .expire_row{ width: 188px; } .cart_table .cart_head_row .price_row{ width: 162px; } .cart_table .cart_head_row .do_more{ width: 162px; } .cart_footer_row{ padding-left: 30px; background: #F7F7F7; width: 100%; height: 80px; line-height: 80px; } .cart_footer_row .cart_select span{ font-size: 18px; color: #666; } .cart_footer_row .cart_delete{ margin-left: 58px; } .cart_delete .el-icon-delete{ font-size: 18px; } .cart_delete span{ margin-left: 15px; cursor: pointer; font-size: 18px; color: #666; } .cart_total{ float: right; margin-right: 62px; font-size: 18px; color: #666; } .goto_pay{ float: right; width: 159px; height: 80px; outline: none; border: none; background: #ffc210; font-size: 18px; color: #fff; text-align: center; cursor: pointer; } </style>
CartItem.vue
<template> <div class="cart_item"> <div class="cart_column column_1"> <el-checkbox class="my_el_checkbox" v-model="course.selected"></el-checkbox> </div> <div class="cart_column column_2"> <img :src="$settings.Host+course.course_img" alt=""> <span><router-link :to="`/courses/${course.course_id}`">{{course.course_name}}</router-link></span> </div> <div class="cart_column column_3"> <el-select v-model="expire" size="mini" placeholder="请选择购买有效期" class="my_el_select"> <el-option label="1个月有效" value="30" key="30"></el-option> <el-option label="2个月有效" value="60" key="60"></el-option> <el-option label="3个月有效" value="90" key="90"></el-option> <el-option label="永久有效" value="10000" key="10000"></el-option> </el-select> </div> <div class="cart_column column_4">¥{{course.price}}</div> <div class="cart_column column_4">删除</div> </div> </template> <script> export default { name: "CartItem", props:["course"], data(){ return { checked:false, expire: "1个月有效", } } } </script> <style scoped> .cart_item::after{ content: ""; display: block; clear: both; } .cart_column{ float: left; height: 250px; } .cart_item .column_1{ width: 88px; position: relative; } .my_el_checkbox{ position: absolute; left: 0; right: 0; bottom: 0; top: 0; margin: auto; width: 16px; height: 16px; } .cart_item .column_2 { padding: 67px 10px; width: 520px; height: 116px; } .cart_item .column_2 img{ width: 175px; height: 115px; margin-right: 35px; vertical-align: middle; } .cart_item .column_3{ width: 197px; position: relative; padding-left: 10px; } .my_el_select{ width: 117px; height: 28px; position: absolute; top: 0; bottom: 0; margin: auto; } .cart_item .column_4{ padding: 67px 10px; height: 116px; width: 142px; line-height: 116px; } </style>
切换勾选状态
后端提供修改勾选状态的接口
视图代码
from rest_framework.viewsets import ViewSet from rest_framework.permissions import IsAuthenticated from courses.models import Course from rest_framework.response import Response from rest_framework import status from django_redis import get_redis_connection class CartAPIViewSet(ViewSet): permission_classes = [IsAuthenticated] def add_cart(self,request): // # ... def list(self, request): """购物车商品列表""" // # ... def change_select_status(self,request): """切换商品勾选状态""" # 获取用户ID # 获取登录用户id user_id = 1 # request.user.id # 获取客户端提交过来的勾选状态 selected = request.data.get("selected") # 获取课程ID course_id = request.data.get("course_id") # 验证数据 try: course = Course.objects.get(pk=course_id, is_show=True, is_delete=False) except: return Response({"message":"对不起,操作的商品不存在"}, status=status.HTTP_400_BAD_REQUEST) # 链接redis,修改指定商品的勾选状态 redis_conn = get_redis_connection("cart") if selected: """添加商品的勾选状态""" redis_conn.sadd("selected_%s" % user_id, course_id) else: """移除商品的勾选状态""" redis_conn.srem("selected_%s" % user_id, course_id) # 返回响应结果 return Response({"message":"切换勾选状态成功!"})
路由代码:
from django.urls import path from . import views urlpatterns = [ path("", views.CartAPIViewSet.as_view({"post":"add_cart", "get":"list", "patch":"change_select_status"}) ), ]
前端实现修改勾选状态的功能
CartItem.vue
<template> <div class="cart_item"> <div class="cart_column column_1"> <el-checkbox class="my_el_checkbox" v-model="course.selected"></el-checkbox> </div> <div class="cart_column column_2"> <img :src="$settings.Host+course.course_img" alt=""> <span><router-link :to="`/courses/${course.course_id}`">{{course.course_name}}</router-link></span> </div> <div class="cart_column column_3"> <el-select v-model="expire" size="mini" placeholder="请选择购买有效期" class="my_el_select"> <el-option label="1个月有效" value="30" key="30"></el-option> <el-option label="2个月有效" value="60" key="60"></el-option> <el-option label="3个月有效" value="90" key="90"></el-option> <el-option label="永久有效" value="10000" key="10000"></el-option> </el-select> </div> <div class="cart_column column_4">¥{{course.price}}</div> <div class="cart_column column_4">删除</div> </div> </template> <script> export default { name: "CartItem", props:["course"], watch:{ "course.selected": function(){ // 切换商品的勾选状态 this.change_selected_status(); } }, data(){ return { checked:false, expire: "1个月有效", } }, methods:{ change_selected_status(){ this.user_token = this.$settings.check_user_login(); // 切换商品的勾选状态 this.$axios.patch(`${this.$settings.Host}/cart/`,{ "course_id": this.course.course_id, "selected": this.course.selected, },{ headers:{ // 访问需要登录认证权限的api接口必须附带token到请求头中 "Authorization": "jwt " + this.user_token, } }).then(response=>{ }).catch(error=>{ this.$message.error("勾选状态切换失败!请刷新页面后重新尝试!"); }); } } } </script> <style scoped> .cart_item::after{ content: ""; display: block; clear: both; } .cart_column{ float: left; height: 250px; } .cart_item .column_1{ width: 88px; position: relative; } .my_el_checkbox{ position: absolute; left: 0; right: 0; bottom: 0; top: 0; margin: auto; width: 16px; height: 16px; } .cart_item .column_2 { padding: 67px 10px; width: 520px; height: 116px; } .cart_item .column_2 img{ width: 175px; height: 115px; margin-right: 35px; vertical-align: middle; } .cart_item .column_3{ width: 197px; position: relative; padding-left: 10px; } .my_el_select{ width: 117px; height: 28px; position: absolute; top: 0; bottom: 0; margin: auto; } .cart_item .column_4{ padding: 67px 10px; height: 116px; width: 142px; line-height: 116px; } </style>
删除购物车中的商品信息
后端实现删除购物车的API接口
注意:
drf中,接受put,patch和post,接收请求体都使用request.data
接受get,delete,接收参数,只能使用request.query_params,
原因是: http请求中.get,delete是没有请求体的.而post,put和patch有请求体
视图,代码:
from rest_framework.viewsets import ViewSet from rest_framework.permissions import IsAuthenticated from courses.models import Course from rest_framework.response import Response from rest_framework import status from django_redis import get_redis_connection class CartAPIViewSet(ViewSet): # permission_classes = [IsAuthenticated] def add_cart(self,request): """添加商品到购物车""" # .... def list(self, request): """购物车商品列表""" # .... def change_select_status(self,request): """切换商品勾选状态""" # ... def delete(self, request): """"删除购物车中的商品信息""" # 接受数据 user_id = request.user.id course_id = request.query_params.get("course_id") # 验证数据 try: course = Course.objects.get(pk=course_id, is_show=True, is_delete=False) except: return Response({"message":"对不起,操作的商品不存在"}, status=status.HTTP_400_BAD_REQUEST) # 操作redis redis_conn = get_redis_connection("cart") pipe = redis_conn.pipeline() pipe.multi() pipe.hdel("cart_%s" % user_id, course_id) pipe.srem("selected_%s" % user_id, course_id) pipe.execute() # 响应结果 return Response({"message":"购物车删除商品成功!"}, status=status.HTTP_204_NO_CONTENT)
路由代码:
from django.urls import path from . import views urlpatterns = [ path("", views.CartAPIViewSet.as_view({"post":"add_cart", "get":"list", "patch":"change_select_status","delete":"delete"}) ), ]
前端实现删除购物车商品功能
分析: "删除"按钮是在子组件中,但是我们不能在子组件内部删除自己,所以需要通知父组件删除自己 需要使用vue组件中子组件传递参数给给父组件
子组件CartItem.vue,代码:
<template> <div class="cart_item"> <div class="cart_column column_1"> <el-checkbox class="my_el_checkbox" v-model="course.selected"></el-checkbox> </div> <div class="cart_column column_2"> <img :src="$settings.Host+course.course_img" alt=""> <span><router-link :to="`/courses/${course.course_id}`">{{course.course_name}}</router-link></span> </div> <div class="cart_column column_3"> <el-select v-model="expire" size="mini" placeholder="请选择购买有效期" class="my_el_select"> <el-option label="1个月有效" value="30" key="30"></el-option> <el-option label="2个月有效" value="60" key="60"></el-option> <el-option label="3个月有效" value="90" key="90"></el-option> <el-option label="永久有效" value="10000" key="10000"></el-option> </el-select> </div> <div class="cart_column column_4">¥{{course.price}}</div> <div class="cart_column column_4" @click="cart_delete">删除</div> </div> </template> <script> export default { name: "CartItem", props:["course"], watch:{ "course.selected": function(){ // 切换商品的勾选状态 this.change_selected_status(); } }, data(){ return { checked:false, expire: "1个月有效", } }, created(){ this.user_token = this.$settings.check_user_login(); }, methods:{ change_selected_status(){ // 切换商品的勾选状态 this.$axios.patch(`${this.$settings.Host}/cart/`,{ "course_id": this.course.course_id, "selected": this.course.selected, },{ headers:{ // 访问需要登录认证权限的api接口必须附带token到请求头中 "Authorization": "jwt " + this.user_token, } }).then(response=>{ }).catch(error=>{ this.$message.error("勾选状态切换失败!请刷新页面后重新尝试!"); }); }, cart_delete(){ // 让用户确认是否删除商品 this.$confirm(`您确定要删除当前商品<${this.course.course_name}>么?`, '路飞学城', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }).then(() => { // 点击删除 this.deleteHandler(); }).catch(() => { }); }, deleteHandler(){ // 购物车删除商品 this.$axios.delete(`${this.$settings.Host}/cart/`,{ params:{ course_id: this.course.course_id, }, headers:{ "Authorization": "jwt " + this.user_token, } }).then(response=>{ // 通知父组件删除当前课程内容 this.$emit("delete_cart"); this.$message.success("操作成功!"); }).catch(error=>{ this.$message.error("操作失败!"); }); } } } </script> <style scoped> .cart_item::after{ content: ""; display: block; clear: both; } .cart_column{ float: left; height: 250px; } .cart_item .column_1{ width: 88px; position: relative; } .my_el_checkbox{ position: absolute; left: 0; right: 0; bottom: 0; top: 0; margin: auto; width: 16px; height: 16px; } .cart_item .column_2 { padding: 67px 10px; width: 520px; height: 116px; } .cart_item .column_2 img{ width: 175px; height: 115px; margin-right: 35px; vertical-align: middle; } .cart_item .column_3{ width: 197px; position: relative; padding-left: 10px; } .my_el_select{ width: 117px; height: 28px; position: absolute; top: 0; bottom: 0; margin: auto; } .cart_item .column_4{ padding: 67px 10px; height: 116px; width: 142px; line-height: 116px; } </style>
父组件Cart.vue,代码:
<template> <div class="cart"> <Header></Header> <div class="cart_info"> <div class="cart_title"> <span class="text">我的购物车</span> <span class="total">共4门课程</span> </div> <div class="cart_table"> <div class="cart_head_row"> <span class="doing_row"></span> <span class="course_row">课程</span> <span class="expire_row">有效期</span> <span class="price_row">单价</span> <span class="do_more">操作</span> </div> <div class="cart_course_list"> <CartItem :key="key" v-for="course,key in course_list" :course="course" @delete_cart="deleteHandler(key)"></CartItem> </div> <div class="cart_footer_row"> <span class="cart_select"><label> <el-checkbox v-model="checked"></el-checkbox><span>全选</span></label></span> <span class="cart_delete"><i class="el-icon-delete"></i> <span>删除</span></span> <span class="goto_pay">去结算</span> <span class="cart_total">总计:¥0.0</span> </div> </div> </div> <Footer></Footer> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" import CartItem from "./common/CartItem" export default { name: "Cart", data(){ return { user_token: "", checked: false, // 实现全选功能的 course_list: [], // 购物车中的商品列表 } }, created(){ // 验证登录 this.user_token = this.$settings.check_user_login(); if(!this.user_token){ let self = this; this.$alert("请登录以后再访问!","路飞学城",{ callback(){ self.$router.push("/login"); } }); return ; } // 获取购物车商品列表 this.get_cart(); }, methods:{ get_cart(){ // 获取购物车中的商品列表 this.$axios.get(`${this.$settings.Host}/cart/`,{ headers:{ // 访问需要登录认证权限的api接口必须附带token到请求头中 "Authorization": "jwt " + this.user_token, } }).then(response=>{ this.course_list = response.data; }).catch(error=>{ this.$message.error("无法获取购物车商品列表,请联系客服工作人员!"); }); }, deleteHandler(key){ // 前端实现删除商品信息 this.course_list.splice(key,1); } }, components:{ Header, Footer, CartItem, } } </script> <style scoped> .cart_info{ width: 1200px; margin: 0 auto 200px; } .cart_title{ margin: 25px 0; } .cart_title .text{ font-size: 18px; color: #666; } .cart_title .total{ font-size: 12px; color: #d0d0d0; } .cart_table{ width: 1170px; } .cart_table .cart_head_row{ background: #F7F7F7; width: 100%; height: 80px; line-height: 80px; padding-right: 30px; } .cart_table .cart_head_row::after{ content: ""; display: block; clear: both; } .cart_table .cart_head_row .doing_row, .cart_table .cart_head_row .course_row, .cart_table .cart_head_row .expire_row, .cart_table .cart_head_row .price_row, .cart_table .cart_head_row .do_more{ padding-left: 10px; height: 80px; float: left; } .cart_table .cart_head_row .doing_row{ width: 78px; } .cart_table .cart_head_row .course_row{ width: 530px; } .cart_table .cart_head_row .expire_row{ width: 188px; } .cart_table .cart_head_row .price_row{ width: 162px; } .cart_table .cart_head_row .do_more{ width: 162px; } .cart_footer_row{ padding-left: 30px; background: #F7F7F7; width: 100%; height: 80px; line-height: 80px; } .cart_footer_row .cart_select span{ font-size: 18px; color: #666; } .cart_footer_row .cart_delete{ margin-left: 58px; } .cart_delete .el-icon-delete{ font-size: 18px; } .cart_delete span{ margin-left: 15px; cursor: pointer; font-size: 18px; color: #666; } .cart_total{ float: right; margin-right: 62px; font-size: 18px; color: #666; } .goto_pay{ float: right; width: 159px; height: 80px; outline: none; border: none; background: #ffc210; font-size: 18px; color: #fff; text-align: center; cursor: pointer; } </style>