购物车实现
创建子应用 cart
cd luffy/apps
python ../../manage.py startapp cart
INSTALLED_APPS = [ 'ckeditor', # 富文本编辑器 'ckeditor_uploader', # 富文本编辑器上传图片模块 'home', 'users', 'courses', 'cart', ]
配置信息 (settings)
# 设置redis缓存 CACHES = { # 默认缓存 .... "cart":{ "BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://127.0.0.1:6379/3", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", } }, }
接下来商品信息存储以下内容:
course_id 购物车中的商品ID course_expire 购物车中商品的有效期 is_selected 购物车中对应商品的勾选状态 # 上面三个数据,实际上存储到redis中,要以什么类型来存储呢? # redis一共5种数据类型,我们就应该考虑到哪种数据类型保存上面的数据最方便我们读写. cart_<user_id>: { "商品ID1":"有效期", "商品ID1":"有效期", "商品ID1":"有效期", } # 把已经勾选的商品ID记录到无序集合中 cart_selected_<user_id>:{ "商品ID1", "商品ID2", "商品ID3", }
cart/views.py视图,代码:
from django.shortcuts import render # Create your views here. from rest_framework.views import APIView from courses.models import Course from rest_framework.response import Response from django_redis import get_redis_connection from rest_framework.permissions import IsAuthenticated from rest_framework import status class CartAPIView(APIView): permission_classes = [IsAuthenticated] """购物车视图""" def post(self,request): """购物车添加商品""" # 获取客户端发送过来的课程ID course_id = request.data.get("course_id") # 验证课程ID是否有效 try: Course.objects.get(pk=course_id,is_delete=False,is_show=True) except Course.DoesNotExist: return Response({"message":"当前课程不存在!"},status=status.HTTP_400_BAD_REQUEST) # 组装基本数据[课程ID,有效期]保存到redis redis = get_redis_connection("cart") # user_id = 1 user_id = request.user.id try: # 添加一个成员到指定名称的hash数据中[如果对应名称的hash数据不存在,则自动创建] # hset(名称,键,值) redis.hset("cart_%s" % user_id, course_id, -1) # -1表示购买的课程永久有效 # 添加一个成员到制定名称的set数据中[如果对应名称的set数据不存在,则自动创建] # sadd(名称,成员) redis.sadd("cart_selected_%s" % user_id, course_id ) except: return Response({"message": "添加课程到购物车失败!请联系客服人员~"},status=status.HTTP_507_INSUFFICIENT_STORAGE) # 返回结果 return Response({"message":"成功添加课程到购物车!"},status=status.HTTP_200_OK)
总路由,代码
urlpatterns = [ ... path('cart/', include("cart.urls")), ]
子应用路由cart/urls.py,代码:
from django.urls import path, re_path from . import views urlpatterns = [ path(r"course/",views.CartAPIView.as_view()), ]
默认用户添加课程到购物车就已经勾选了商品,视图代码:
from django.shortcuts import render # Create your views here. from rest_framework.views import APIView from courses.models import Course from rest_framework.response import Response from django_redis import get_redis_connection from rest_framework.permissions import IsAuthenticated from rest_framework import status class CartAPIView(APIView): permission_classes = [IsAuthenticated] """购物车视图""" def post(self,request): """购物车添加商品""" # 获取客户端发送过来的课程ID course_id = request.data.get("course_id") # 验证课程ID是否有效 try: Course.objects.get(pk=course_id,is_delete=False,is_show=True) except Course.DoesNotExist: return Response({"message":"当前课程不存在!"},status=status.HTTP_400_BAD_REQUEST) # 组装基本数据[课程ID,有效期]保存到redis redis = get_redis_connection("cart") # user_id = 1 user_id = request.user.id # transation: 事务 # 作用: 可以设置多个数据库操作看成一个整体,这个整理里面每一条数据库操作都成功了,事务才算成功, # 如果出现其中任意一个数据库操作失败,则整体一起失败! # 事务可以提供 提交事务 和 回滚事务 的功能 # 不仅mysql中存在事务,在redis中也有事务的概念,但是叫"管道 pipeline" try: # 创建事务[管道]对象 pipeline = redis.pipeline() # 开启事务 pipeline.multi() # 添加一个成员到指定名称的hash数据中[如果对应名称的hash数据不存在,则自动创建] # hset(名称,键,值) pipeline.hset("cart_%s" % user_id, course_id, -1) # -1表示购买的课程永久有效 # 添加一个成员到制定名称的set数据中[如果对应名称的set数据不存在,则自动创建] # sadd(名称,成员) pipeline.sadd("cart_selected_%s" % user_id, course_id ) # 提交事务[如果不提交,则事务会自动回滚] pipeline.execute() except: return Response({"message": "添加课程到购物车失败!请联系客服人员~"},status=status.HTTP_507_INSUFFICIENT_STORAGE) # 返回结果 return Response({"message": "成功添加课程到购物车!"}, status=status.HTTP_200_OK)
Detai.vue
<template> <div class="detail"> <Header/> <div class="main"> <div class="course-info"> <div class="wrap-left"> <video-player class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)" > </video-player> </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+"课程")}} 难度:{{course.course_level}}</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 @click="cartAddHander" class="add-cart"><img src="@/assets/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="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">共{{chapter_list.length}}章 147个课时</p> </div> <div class="chapter-item" v-for="chapter in chapter_list"> <p class="chapter-title"><img src="@/assets/1.svg" alt="">第{{chapter.chapter}}章·{{chapter.name}}</p> <ul class="lesson-list"> <li class="lesson-item" v-for="lesson in chapter.coursesections"> <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="@/assets/chapter-player.svg"></p> <button class="try" v-if="lesson.free_trail"><router-link :to="{path: '/player',query:{'vid':lesson.section_link}}">立即试学</router-link></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="course.teacher.image"> <div class="name"> <p class="teacher-name">{{course.teacher.name}} {{course.teacher.title}}</p> <p class="teacher-title">{{course.teacher.signature}}</p> </div> </div> <p class="narrative" >Linux运维技术专家,老男孩Linux金牌讲师,讲课风趣幽默、深入浅出、声音洪亮到爆炸</p> </div> </div> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" import {videoPlayer} from 'vue-video-player'; export default { name: "Detail", data(){ return { token:sessionStorage.token || localStorage.token, user_id:sessionStorage.user_id || localStorage.user_id, user_name:sessionStorage.user_name || localStorage.user_name, tabIndex:1, // 当前选项卡显示的下标 course_id:0, // 当前页面对应的课程ID course: { teacher: {}, }, // 课程详情信息 chapter_list:{}, 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/courses/675076.jpeg", //视频封面图 document.documentElement.clientWidth, // 默认视频全屏时的最大宽度 notSupportedMessage: '此视频暂无法播放,请稍后再试', //允许覆盖Video.js无法播放媒体源时显示的默认信息。 } } }, watch:{ course(data){ // 替换视频地址 this.playerOptions.sources[0].src = data.video; // 替换视频封面 this.playerOptions.poster = data.course_img; // 替换科恒信息中的详情介绍里面的图片路径 while(data.brief.search(`"/media`) != -1 ){ data.brief = data.brief.replace(`"/media`,`"${this.$settings.Host}/media`) } }, tabIndex(data){ if(data==2){ //获取当前课程对应的章节列表和课时列表 this.$axios.get(`${this.$settings.Host}/courses/chapters/?course=${this.course_id}`).then(response=>{ this.chapter_list = response.data; }).catch(error=>{ console.log(error.response) }) } } }, created(){ // 获取当前课程ID this.course_id = this.$route.query.id - 0; // 判断ID基本有效性 let _this = this; if( isNaN(this.course_id) || this.course_id < 1 ){ _this.$alert("无效的课程ID!","错误",{ callback(){ _this.$router.go(-1); }}); } // 发送请求获取后端课程数据 this.$axios.get(this.$settings.Host+`/courses/detail/${this.course_id}/`).then(response=>{ this.course = response.data; // 修改视频中的封面图片 this.playerOptions.poster = this.course.course_img; }).catch(error=>{ console.log(error.response) }); }, methods: { // 视频播放事件 onPlayerPlay(player) { alert("play"); }, // 视频暂停播放事件 onPlayerPause(player){ alert("pause"); }, // 视频插件初始化 player() { return this.$refs.videoPlayer.player; }, // 添加商品课程到购物车 cartAddHander(){ // 1. 判断用户是否已经登录了. if(!this.token){ this.$confirm("对不起,您尚未登录!请登录",'提示').then(() => { this.$router.push("/login"); }); } // 2. 发起请求 this.$axios.post(this.$settings.Host+`/carts/course/`,{ course_id: this.course_id, },{ headers:{ // 注意:jwt后面必须有且只有一个空格!!!! "Authorization":"jwt " + this.token } }).then(response=>{ // 获取购物城中商品总数 // 添加购物车成功! this.$message(response.data.message,"提示!",{ duration: 2000, // 单位: 毫秒 }); }).catch(error=>{ console.log(error.response); }) } }, components:{ Header, Footer, videoPlayer, } } </script> <style scoped> .main{ background: #fff; padding-top: 30px; } .course-info{ 1200px; margin: 0 auto; overflow: hidden; } .wrap-left{ float: left; 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{ 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{ 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{ 464px; padding: 0px 23px; position: absolute; left: 0; bottom: 20px; overflow: hidden; } .buy .buy-btn{ float: left; } .buy .buy-now{ 125px; height: 40px; border: 0; background: #ffc210; border-radius: 4px; color: #fff; cursor: pointer; margin-right: 15px; outline: none; } .buy .free{ 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{ 20px; height: 18px; margin-right: 7px; vertical-align: middle; } .course-tab{ 100%; background: #fff; margin-bottom: 30px; box-shadow: 0 2px 4px 0 #f0f0f0; } .course-tab .tab-list{ 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{ 1200px; margin: 0 auto; background: #FAFAFA; overflow: hidden; padding-bottom: 40px; } .course-tab-list{ 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{ 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{ 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{ 18px; height: 18px; margin-left: 15px; vertical-align: text-bottom; } .lesson-item .try{ 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{ 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{ 54px; height: 54px; margin-right: 12px; float: left; } .teacher-content .cont1 .name{ float: right; } .teacher-content .cont1 .teacher-name{ 188px; font-size: 16px; color: #4a4a4a; padding-bottom: 4px; } .teacher-content .cont1 .teacher-title{ 188px; font-size: 13px; color: #9b9b9b; white-space: nowrap; } .teacher-content .narrative{ font-size: 14px; color: #666; line-height: 24px; } </style>
from django.shortcuts import render # Create your views here. from rest_framework.views import APIView from courses.models import Course from rest_framework.response import Response from django_redis import get_redis_connection from rest_framework.permissions import IsAuthenticated from rest_framework import status class CartAPIView(APIView): permission_classes = [IsAuthenticated] """购物车视图""" def post(self,request): """购物车添加商品""" # 获取客户端发送过来的课程ID course_id = request.data.get("course_id") # 验证课程ID是否有效 try: Course.objects.get(pk=course_id,is_delete=False,is_show=True) except Course.DoesNotExist: return Response({"message":"当前课程不存在!"},status=status.HTTP_400_BAD_REQUEST) # 组装基本数据[课程ID,有效期]保存到redis redis = get_redis_connection("cart") # user_id = 1 user_id = request.user.id # transation: 事务 # 作用: 可以设置多个数据库操作看成一个整体,这个整理里面每一条数据库操作都成功了,事务才算成功, # 如果出现其中任意一个数据库操作失败,则整体一起失败! # 事务可以提供 提交事务 和 回滚事务 的功能 # 不仅mysql中存在事务,在redis中也有事务的概念,但是叫"管道 pipeline" try: # 创建事务[管道]对象 pipeline = redis.pipeline() # 开启事务 pipeline.multi() # 添加一个成员到指定名称的hash数据中[如果对应名称的hash数据不存在,则自动创建] # hset(名称,键,值) pipeline.hset("cart_%s" % user_id, course_id, -1) # -1表示购买的课程永久有效 # 添加一个成员到制定名称的set数据中[如果对应名称的set数据不存在,则自动创建] # sadd(名称,成员) pipeline.sadd("cart_selected_%s" % user_id, course_id ) # 提交事务[如果不提交,则事务会自动回滚] pipeline.execute() except: return Response({"message": "添加课程到购物车失败!请联系客服人员~"},status=status.HTTP_507_INSUFFICIENT_STORAGE) # 返回结果,返回购物车中的商品数量 count = redis.hlen("cart_%s" % user_id) return Response({ "message": "成功添加课程到购物车!", "count": count, }, status=status.HTTP_200_OK)
前端展示商品课程的总数
获取商品总数是在头部组件中使用到,并展示出来,但是我们后面可以在购物车中,或者商品课程的详情页中修改购物车中商品总数,因为对于一些数据,需要在多个组件中共享,这种情况,我们可以使用本地存储来完成,但是也可以通过vuex组件来完成这个功能。
安装vuex
npm install -S vuex
-
在src目录下创建store目录,并在store目录下创建一个index.js文件,index.js文件代码:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex); export const store = new Vuex.Store({ // 数据仓库,类似vue里面的data state: { }, // 数据操作方法,类似vue里面的methods mutations: { } });
-
把上面index.js中创建的store对象注册到main.js的vue中。
// The Vue build version to load with the `import` command // (runtime-only or standalone) has been set in webpack.base.conf with an alias. import Vue from 'vue' import App from './App' import router from './routers/index'; import store from './store/index'; // 手动的自定义全局配置 import settings from "./settings" Vue.prototype.$settings = settings; // elementUI 导入 import ElementUI from 'element-ui'; import 'element-ui/lib/theme-chalk/index.css'; // 调用插件 Vue.use(ElementUI); import "../static/css/reset.css" import axios from 'axios'; // 从node_modules目录中导入包 // 允许ajax发送请求时附带cookie axios.defaults.withCredentials = true; Vue.prototype.$axios = axios; // 把对象挂载vue中 Vue.config.productionTip = false; // 导入gt极验 import '../static/js/gt.js'; // vue-video视频播放插件 require('video.js/dist/video-js.css'); require('vue-video-player/src/custom-theme.css'); import VideoPlayer from 'vue-video-player' Vue.use(VideoPlayer); /* eslint-disable no-new */ new Vue({ el: '#app', router, store, components: { App }, template: '<App/>' })
-
接下来,我们就可以在组件使用到store中state里面保存的共享数据了.
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex); export default new Vuex.Store({ // 数据仓库,类似vue里面的data state: { // 购物车数据 cart:{ count: 0, // course_list: [], // 购物车里面的商品列表信息 } }, // 数据操作方法,类似vue里面的 methods mutations: { // data是调用方法,传递的购物车相关的参数 addcart(state,data){ // 修改商品课程的总数 state.cart.count = data.count; // state.cart.course_list = data.course_list; } } });
在Header.vue头部组件中,直接读取store里面的数据
<b class="goods-number">{{this.$store.state.cart.count}}</b> // this是可以省略不写。 <b class="goods-number">{{$store.state.cart.count}}</b>
我们可以在Detail.vue课程详情的组件中, 修改商品总数。
<template> <div class="detail"> <Header/> <div class="main"> <div class="course-info"> <div class="wrap-left"> <video-player class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)" > </video-player> </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+"课程")}} 难度:{{course.course_level}}</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="cartAddHander"><img src="@/assets/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="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">共{{chapter_list.length}}章 147个课时</p> </div> <div class="chapter-item" v-for="chapter in chapter_list"> <p class="chapter-title"><img src="@/assets/1.svg" alt="">第{{chapter.chapter}}章·{{chapter.name}}</p> <ul class="lesson-list"> <li class="lesson-item" v-for="lesson in chapter.coursesections"> <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="@/assets/chapter-player.svg"></p> <button class="try" v-if="lesson.free_trail"> <router-link :to="{path:'/player',query:{'vid':lesson.section_link}}">立即试学</router-link> </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="course.teacher.image"> <div class="name"> <p class="teacher-name">{{course.teacher.name}} {{course.teacher.title}}</p> <p class="teacher-title">{{course.teacher.signature}}</p> </div> </div> <p class="narrative">Linux运维技术专家,老男孩Linux金牌讲师,讲课风趣幽默、深入浅出、声音洪亮到爆炸</p> </div> </div> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" import {videoPlayer} from 'vue-video-player'; export default { name: 'Detail', data() { return { token: sessionStorage.token || localStorage.token, user_id: sessionStorage.id || localStorage.id, user_name: sessionStorage.username || localStorage.username, tabIndex: 1, // 当前选项卡显示的下标 course_id: 0, // 当前页面对应的课程ID course: { teacher: {}, }, // 课程详情信息 chapter_list: {}, 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/courses/675076.jpeg", //视频封面图 document.documentElement.clientWidth, // 默认视频全屏时的最大宽度 notSupportedMessage: '此视频暂无法播放,请稍后再试', //允许覆盖Video.js无法播放媒体源时显示的默认信息。 }, } }, watch: { course(data) { this.playerOptions.sources[0].src = data.video; this.playerOptions.poster = data.course_img; while (data.brief.search(`"/media`) != -1) { data.brief = data.brief.replace(`"/media`, `"${this.$settings.Host}/media`) } }, tabIndex(data) { if (data == 2) { //获取当前课程对应的章节列表和课时列表 this.$axios.get(`${this.$settings.Host}/courses/chapters/?course=${this.course_id}`).then(response => { this.chapter_list = response.data; }).catch(error => { console.log(error.response) }) } } }, components: { Header, Footer, videoPlayer, }, methods: { // 视频播放事件 onPlayerPlay(player) { // alert("play"); }, // 视频暂停播放事件 onPlayerPause(player) { // alert("pause"); }, // 视频插件初始化 player() { return this.$refs.videoPlayer.player; }, cartAddHander() { let token = sessionStorage.token || localStorage.token; if (!token) { console.log('confirm'); this.$confirm('对不起,您尚未登录!请登录', '提示').then(() => { this.$router.push('/login'); }).catch(()=>{ }); return; } this.$axios.post(this.$settings.Host + `/cart/course/`, { course_id: this.course_id, }, { headers: { 'Authorization': 'jwt ' + this.token }, }).then(response => { this.$store.state.cart.count = response.data.count; // this.$store.commit('addcart', response.data); this.$message({ duration: 1000, showClose: true, message: response.data.message, type: 'success', }) }).catch(error => { console.log(error.response); }) }, }, created() { let id = Number(this.$route.query.id); this.course_id = id; console.log(id); console.log(typeof id); let _this = this; if (isNaN(id) || id < 1) { _this.$alert('无效的课程ID!', '错误', { callback() { _this.$router.go(-1); } }); } this.$axios.get(this.$settings.Host + `/courses/detail/${this.course_id}/`).then(response => { this.course = response.data; this.playerOptions.poster = this.course.course_img; }).catch(error => { console.log(error.response) }) }, }; </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>
出现问题的原因:添加购物车时,判断用户是否登录依靠的是当前组件中data的token数据,而token数据是通过sessionStorage或者localStorage赋值过来的,但是当用户退出登录时,我们删除了sessionStorage或者localStorage,但是并没有删除token值。因为形成了BUG。
解决方案:退出登录进行页面跳转.或者添加商品课程到购物车时,重新对token赋值。
方案1:再header.vue中
logout(){ this.token = false; this.user_id=false; this.user_name=false; sessionStorage.removeItem("token"); sessionStorage.removeItem("user_id"); sessionStorage.removeItem("user_name"); localStorage.removeItem("token"); localStorage.removeItem("user_id"); localStorage.removeItem("user_name"); let _this = this; _this.$alert('退出登录成功!', '路飞学城', { callback(){ _this.$router.push("/"); } });
方案2: 再Detail.vue中
cartAddHander() { this.token = sessionStorage.token || localStorage.token; if (!this.token) { this.$confirm('对不起,您尚未登录!请登录', '提示').then(() => { this.$router.push('/login'); }).catch(() => { }); return; 。。。。
后端提供获取购物车课程信息
from rest_framework.views import APIView from courses.models import Course from rest_framework.response import Response from django_redis import get_redis_connection from rest_framework.permissions import IsAuthenticated from rest_framework import status from django.conf import settings class CartAPIView(APIView): permission_classes = [IsAuthenticated] """购物车视图""" def get(self,request): """获取购物车商品课程列表""" # 获取当前用户ID # user_id = 1 user_id = request.user.id # 通过用户ID获取购物车中的商品信息 redis = get_redis_connection("cart") cart_goods_list = redis.hgetall("cart_%s" % user_id ) # 商品课程列表 cart_goods_selects = redis.smembers("cart_selected_%s" % user_id) # redis里面的所有数据最终都是以bytes类型的字符串保存的 # print( cart_goods_selects ) # 格式: {b'7', b'3', b'5'} # print( cart_goods_list ) # 格式: {b'7': b'-1', b'5': b'-1'} # 遍历购物车中的商品课程到数据库获取课程的价格, 标题, 图片 data_list = [] try: for course_id_bytes,expire_bytes in cart_goods_list.items(): course_id = int( course_id_bytes.decode() ) expire = expire_bytes.decode() course = Course.objects.get(pk=course_id) data_list.append({ "id": course_id, "expire":expire, "course_img": course.course_img.url, "name": course.name, "price": course.get_course_price(), "is_select": course_id_bytes in cart_goods_selects }) except: return Response(data_list,status=status.HTTP_500_INTERNAL_SERVER_ERROR) # print(data_list) # 返回查询结果 return Response(data_list,status=status.HTTP_200_OK) def post(self,request): """购物车添加商品""" ....
前端请求显示课程信息
<template> <div class="cart"> <Header/> <div class="main"> <div class="cart-title"> <h3>我的购物车 <span> 共2门课程</span></h3> </div> <div class="cart-info"> <el-table :data="courseData" style="100%" > <el-table-column type="selection" label="" width="87"></el-table-column> <el-table-column label="课程" width="540"> <template slot-scope="scope"> <div class="course-box"> <img :src="$settings.Host + scope.row.course_img" alt=""> {{scope.row.name}} </div> </template> </el-table-column> <el-table-column label="有效期" width="216"> <template slot-scope="scope"> <el-form ref="form" label-width="60px"> <el-form-item> <el-select v-model="scope.row.expire" placeholder="请选择有效期"> <el-option v-for="item in expire_list" :key="item.id" :label="item.title" :value="scope.row.id"></el-option> </el-select> </el-form-item> </el-form> </template> </el-table-column> <el-table-column label="单价" width="162"> <template slot-scope="scope">¥{{ scope.row.price.toFixed(2) }}</template> </el-table-column> <el-table-column label="操作" width="162"> <template slot-scope="scope"> <a @click="CartDel(scope.row.id,scope.row.name)">删除</a> </template> </el-table-column> </el-table> </div> <div class="cart-bottom"> <div class="select-all"><el-checkbox>全选</el-checkbox></div> <div class="delete-any"><img src="../../static/img/ico3.png" alt="">删除</div> <div class="cart-bottom-right"> <span class="total">总计:¥<span>0.0</span></span> <span class="go-pay">去结算</span> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" export default { name: "Cart", data(){ return{ expire:3, expire_list:[ {title:"一个月有效",id:1}, {title:"两个月有效",id:2}, {title:"三个月有效",id:3}, {title:"永久有效",id:-1}, ], courseData:[] } }, created(){ // 判断是否登录 this.token = sessionStorage.token || localStorage.token; if( !this.token ){ this.$confirm("对不起,您尚未登录!请登录",'提示').then(() => { this.$router.push("/login"); }).catch(()=>{ this.$router.go(-1); }); }else{ // 获取购物车商品数据 this.$axios.get(this.$settings.Host+"/carts/course/",{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token } }).then(response=>{ this.courseData = response.data; // 更新在vuex里面的数据 this.$store.state.cart.count = response.data.length; }) } }, methods:{ CartDel(course_id,course_name){ this.$confirm(`您确定要从购物车删除<<${course_name}>>这个课程么?`,"提示!").then(()=>{ this.$message("删除成功!"); }).catch(()=>{ // 取消操作 }); } }, components:{Header,Footer} } </script>
<template> <div class="cart"> <Header/> <div class="main"> <div class="cart-title"> <h3>我的购物车 <span> 共2门课程</span></h3> </div> <div class="cart-info"> <el-table :data="courseData" style="100%" ref="multipleTable" > <el-table-column type="selection" width="87"></el-table-column> <el-table-column label="课程" width="540"> <template slot-scope="scope"> <div class="course-box"> <img :src="$settings.Host + scope.row.course_img" alt=""> {{scope.row.name}} </div> </template> </el-table-column> <el-table-column label="有效期" width="216"> <template slot-scope="scope"> <el-select v-model="scope.row.expire" placeholder="请选择"> <el-option v-for="item in expire_list" :key="item.id" :label="item.title" :value="item.id"></el-option> </el-select> </template> </el-table-column> <el-table-column label="单价" width="162"> <template slot-scope="scope">¥{{ scope.row.price.toFixed(2) }}</template> </el-table-column> <el-table-column label="操作" width="162"> <template slot-scope="scope"> <a @click="CartDel(scope.row.id,scope.row.name)">删除</a> </template> </el-table-column> </el-table> </div> <div class="cart-bottom"> <div class="select-all"><el-checkbox>全选</el-checkbox></div> <div class="delete-any"><img src="../../static/img/ico3.png" alt="">删除</div> <div class="cart-bottom-right"> <span class="total">总计:¥<span>0.0</span></span> <span class="go-pay">去结算</span> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" export default { name: "Cart", data(){ return{ expire:3, expire_list:[ {title:"一个月有效",id:1}, {title:"两个月有效",id:2}, {title:"三个月有效",id:3}, {title:"永久有效",id:-1}, ], courseData:[] }, created(){ // 判断是否登录 this.token = sessionStorage.token || localStorage.token; if( !this.token ){ this.$confirm("对不起,您尚未登录!请登录",'提示').then(() => { this.$router.push("/login"); }).catch(()=>{ this.$router.go(-1); }); }else{ // 获取购物车商品数据 this.$axios.get(this.$settings.Host+"/carts/course/",{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token } }).then(response=>{ this.courseData = response.data; // 更新在vuex里面的数据 this.$store.state.cart.count = response.data.length; // 调整因为ajax数据请求导致勾选状态人没有出现的原因,使用定时器进行延时调用 setTimeout(()=>{ let expire_data = []; this.expire_list.forEach(row=>{ expire_data[row.id] = row.title; }); // row 就是字典数据[json] this.courseData.forEach(row => { // 设置商品课程的选中状态 ,加上延时处理 if(row.is_select){ this.$refs.multipleTable.toggleRowSelection(row); } // 调整有效期选项中数值变成文本内容 row.expire = expire_data[row.expire]; }); },0) }).catch(error=>{ let status = error.response.status; if( status == 401 ){ this.token = null; sessionStorage.removeItem("token"); localStorage.removeItem("token"); let _this = this; this.$alert("您尚未登录或登录超时!请重新登录","警告",{ callback(){ _this.$router.push("/login"); } }); } }) } }, methods:{ CartDel(course_id,course_name){ this.$confirm(`您确定要从购物车删除<<${course_name}>>这个课程么?`,"提示!").then(()=>{ this.$message("删除成功!"); }).catch(()=>{ // 取消操作 }); } }, components:{Header,Footer} }
切换勾选状态
后端提供修改勾选状态的接口
视图代码:
from django.shortcuts import render # Create your views here. from rest_framework.views import APIView from courses.models import Course from rest_framework.response import Response from django_redis import get_redis_connection from rest_framework.permissions import IsAuthenticated from rest_framework import status class CartAPIView(APIView): permission_classes = [IsAuthenticated] """购物车视图""" def get(self,request): """获取购物车商品课程列表""" # 获取当前用户ID # user_id = 1 user_id = request.user.id # 通过用户ID获取购物车中的商品信息 redis = get_redis_connection("cart") cart_goods_list = redis.hgetall("cart_%s" % user_id ) # 商品课程列表 cart_goods_selects = redis.smembers("cart_selected_%s" % user_id) # redis里面的所有数据最终都是以bytes类型的字符串保存的 # print( cart_goods_selects ) # 格式: {b'7', b'3', b'5'} # print( cart_goods_list ) # 格式: {b'7': b'-1', b'5': b'-1'} # 遍历购物车中的商品课程到数据库获取课程的价格, 标题, 图片 data_list = [] try: for course_id_bytes,expire_bytes in cart_goods_list.items(): course_id = int( course_id_bytes.decode() ) expire = expire_bytes.decode() course = Course.objects.get(pk=course_id) data_list.append({ "id": course_id, "expire":expire, "course_img": course.course_img.url, "name": course.name, "price": course.get_course_price(), "is_select": course_id_bytes in cart_goods_selects }) except: return Response(data_list,status=status.HTTP_500_INTERNAL_SERVER_ERROR) # print(data_list) # 返回查询结果 return Response(data_list,status=status.HTTP_200_OK) def post(self,request): """购物车添加商品""" # 获取客户端发送过来的课程ID course_id = request.data.get("course_id") # 验证课程ID是否有效 try: Course.objects.get(pk=course_id,is_delete=False,is_show=True) except Course.DoesNotExist: return Response({"message":"当前课程不存在!"},status=status.HTTP_400_BAD_REQUEST) # 组装基本数据[课程ID,有效期]保存到redis redis = get_redis_connection("cart") # user_id = 1 user_id = request.user.id # transation: 事务 # 作用: 可以设置多个数据库操作看成一个整体,这个整理里面每一条数据库操作都成功了,事务才算成功, # 如果出现其中任意一个数据库操作失败,则整体一起失败! # 事务可以提供 提交事务 和 回滚事务 的功能 # 不仅mysql中存在事务,在redis中也有事务的概念,但是叫"管道 pipeline" try: # 创建事务[管道]对象 pipeline = redis.pipeline() # 开启事务 pipeline.multi() # 添加一个成员到指定名称的hash数据中[如果对应名称的hash数据不存在,则自动创建] # hset(名称,键,值) pipeline.hset("cart_%s" % user_id, course_id, -1) # -1表示购买的课程永久有效 # 添加一个成员到制定名称的set数据中[如果对应名称的set数据不存在,则自动创建] # sadd(名称,成员) pipeline.sadd("cart_selected_%s" % user_id, course_id ) # 提交事务[如果不提交,则事务会自动回滚] pipeline.execute() except: return Response({"message": "添加课程到购物车失败!请联系客服人员~"},status=status.HTTP_507_INSUFFICIENT_STORAGE) # 返回结果,返回购物车中的商品数量 count = redis.hlen("cart_%s" % user_id) return Response({ "message": "成功添加课程到购物车!", "count": count, }, status=status.HTTP_200_OK) def put(self,request): """购物车更新商品信息""" # 获取当前登录用户ID user_id = request.user.id # 接受课程ID,判断课程ID是否存在 course_id = request.data.get("course_id") try: Course.objects.get(pk=course_id,is_delete=False,is_show=True) except Course.DoesNotExist: return Response({"message":"当前课程不存在!"},status=status.HTTP_400_BAD_REQUEST) # 获取勾选状态 is_select = request.data.get("is_select") # 链接redis redis = get_redis_connection("cart") # 修改购物车中指定商品课程的信息 if is_select: # 从勾选集合中新增一个课程ID redis.sadd("cart_selected_%s" % user_id, course_id ) else: redis.srem("cart_selected_%s" % user_id, course_id ) return Response({ "message": "修改购物车信息成功!" }, status=status.HTTP_200_OK)
前端发送更新勾选状态的请求
<el-table :data="courseData" style="100%" ref="multipleTable" @select="currentSelected" >
script新增一个 操作方法:
methods:{ CartDel(course_id,course_name){ .... }, currentSelected(selection,row){ // selection 表示所有被勾选的信息 // row 当前操作的数据 let is_select = true; if( selection.indexOf(row) == -1 ){ is_select = false; } // 获取当前课程ID let course_id = row.id; // 切换勾选状态 // 发送请求 this.$axios.put(this.$settings.Host+"/carts/course/",{ course_id: course_id, is_select: is_select, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ this.$message(response.data.message,"提示"); }).catch(error=>{ console.log(error.response) }) } },
课程有效期
后端提供修改勾选状态的接口
因为前面实现修改购物车商品课程的勾选状态已经使用put方法提供API接口了,所以我们现在要修改课程有效期,业务上来说也是更新购物车中商品信息,所以我们可以继续在put里面通过判断是否有expire参数来执行不同代码,也可以使用http的patch操作来完成更新课程有效期的功能。
def patch(self,request): """更新购物城中的商品信息""" # 获取当前登录的用户ID # user_id = 1 user_id = request.user.id # 获取当前操作的课程ID course_id = request.data.get("course_id") # 获取新的有效期 expire = request.data.get("expire") # 获取redis链接 redis = get_redis_connection("cart") # 更新购物中商品课程的有效期 redis.hset("cart_%s" % user_id,course_id, expire) return Response({ "message": "修改购物车信息成功!" }, status=status.HTTP_200_OK)
<script> import Header from "./common/Header" import Footer from "./common/Footer" export default { name: "Cart", data(){ return{ expire:3, expire_list:[ {title:"一个月有效",id:1}, {title:"两个月有效",id:2}, {title:"三个月有效",id:3}, {title:"永久有效",id:-1}, ], courseData:[] } }, created(){ // 判断是否登录 this.token = sessionStorage.token || localStorage.token; if( !this.token ){ this.$confirm("对不起,您尚未登录!请登录",'提示').then(() => { this.$router.push("/login"); }).catch(()=>{ this.$router.go(-1); }); }else{ // 获取购物车商品数据 this.$axios.get(this.$settings.Host+"/carts/course/",{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token } }).then(response=>{ this.courseData = response.data; // 更新在vuex里面的数据 this.$store.state.cart.count = response.data.length; // 调整因为ajax数据请求导致勾选状态人没有出现的原因,使用定时器进行延时调用 setTimeout(()=>{ let expire_data = []; this.expire_list.forEach(row=>{ expire_data[row.id] = row.title; }); // row 就是字典数据[json] this.courseData.forEach(row => { // 设置商品课程的选中状态 if(row.is_select){ this.$refs.multipleTable.toggleRowSelection(row); } // 调整有效期选项中数值变成文本内容 row.expire = expire_data[row.expire]; }); },0) }).catch(error=>{ let status = error.response.status; if( status == 401 ){ this.token = null; sessionStorage.removeItem("token"); localStorage.removeItem("token"); let _this = this; this.$alert("您尚未登录或登录超时!请重新登录","警告",{ callback(){ _this.$router.push("/login"); } }); } }) } }, methods:{ CartDel(course,course_name){ this.$confirm(`您确定要从购物车删除<<${course_name}>>这个课程么?`,"提示!").then(()=>{ let course_id = course.id; // 发送请求 this.$axios.delete(this.$settings.Host+"/carts/course/",{ params:{ course_id:course_id, }, headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ let index = this.courseData.indexOf(course); this.courseData.splice(index,1); console.log(this.courseData); this.$message("删除成功!"); }).catch(error=>{ console.log(error.response); }); }).catch(()=>{ // 取消操作 }); }, currentSelected(selection,row){ // selection 表示所有被勾选的信息 // row 当前操作的数据 let is_select = true; if( selection.indexOf(row) == -1 ){ is_select = false; } // 获取当前课程ID let course_id = row.id; // 切换勾选状态 // 发送请求 this.$axios.put(this.$settings.Host+"/carts/course/",{ course_id: course_id, is_select: is_select, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ this.$message(response.data.message,"提示"); }).catch(error=>{ console.log(error.response) }) }, // 更新课程的有效期 ChangeExpire(course){ // 获取课程ID和有效期 let course_id = course.id; let expire = course.expire // 发送patch请求更新有效期 this.$axios.patch(this.$settings.Host+"/carts/course/",{ course_id, expire, // 这里是简写,相当于 expire:expire, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ this.$message(response.data.message,"提示"); }); } }, components:{Header,Footer} } </script>
删除购物车中的商品信息
后端提供从购物车中删除商品课程的API接口
def delete(self,request): """从购物车中删除数据""" # 获取当前登录用户ID # user_id = 1 user_id = request.user.id # 获取课程ID course_id = request.query_params.get("course_id") redis = get_redis_connection("cart") pipeline = redis.pipeline() pipeline.multi() # 从购物车中删除指定商品课程 pipeline.hdel("cart_%s" % user_id, course_id) # 从勾选集合中移除指定商品课程 pipeline.srem("cart_selected_%s" % user_id, course_id ) pipeline.execute() # 返回操作结果 return Response({"message":"删除商品课程成功!"},status=status.HTTP_204_NO_CONTENT)
前端请求删除商品课程
tempalte,代码:
<template slot-scope="scope"> <a @click="CartDel(scope.row,scope.row.name)">删除</a> </template>
script标签代码:
methods:{ CartDel(course,course_name){ this.$confirm(`您确定要从购物车删除<<${course_name}>>这个课程么?`,"提示!").then(()=>{ let course_id = course.id; // 发送请求 this.$axios.delete(this.$settings.Host+"/carts/course/",{ params:{ course_id:course_id, }, headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ let index = this.courseData.indexOf(course); this.courseData.splice(index,1); console.log(this.courseData); this.$message("删除成功!"); }).catch(error=>{ console.log(error.response); }); }).catch(()=>{ // 取消操作 }); },
<template> <div class="cart"> <Header/> <div class="main"> <div class="cart-title"> <h3>我的购物车 <span> 共2门课程</span></h3> </div> <div class="cart-info"> <el-table :data="courseData" style="100%" ref="multipleTable" @select="currentSelected" @selection-change="SelectionChange" > <el-table-column type="selection" width="87"></el-table-column> <el-table-column label="课程" width="540"> <template slot-scope="scope"> <div class="course-box"> <img :src="$settings.Host + scope.row.course_img" alt=""> <router-link :to="'/detail?id='+scope.row.id">{{scope.row.name}}</router-link> </div> </template> </el-table-column> <el-table-column label="有效期" width="216"> <template slot-scope="scope"> <el-select @change="ChangeExpire(scope.row)" v-model="scope.row.expire" placeholder="请选择"> <el-option v-for="item in expire_list" :key="item.id" :label="item.title" :value="item.id"></el-option> </el-select> </template> </el-table-column> <el-table-column label="单价" width="162"> <template slot-scope="scope">¥{{ scope.row.price.toFixed(2) }}</template> </el-table-column> <el-table-column label="操作" width="162"> <template slot-scope="scope"> <a @click="CartDel(scope.row,scope.row.name)">删除</a> </template> </el-table-column> </el-table> </div> <div class="cart-bottom"> <div class="select-all"><el-checkbox>全选</el-checkbox></div> <div class="delete-any"><img src="../../static/img/ico3.png" alt="">删除</div> <div class="cart-bottom-right"> <span class="total">总计:¥<span>{{total_price}}</span></span> <span class="go-pay">去结算</span> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" export default { name: "Cart", data(){ return{ expire:3, expire_list:[ {title:"一个月有效",id:1}, {title:"两个月有效",id:2}, {title:"三个月有效",id:3}, {title:"永久有效",id:-1}, ], courseData:[], // 购物车中的商品信息 selection:[], // 购物车中被勾选的商品信息 total_price:0.00, } }, watch:{ selection(){ // 当课程勾选状态发生变化时核算价格 this.getTotalPrice(); }, courseData(){ // 当课程数量发生变化时核算价格 this.getTotalPrice(); }, }, created(){ // 判断是否登录 this.token = sessionStorage.token || localStorage.token; if( !this.token ){ this.$confirm("对不起,您尚未登录!请登录",'提示').then(() => { this.$router.push("/login"); }).catch(()=>{ this.$router.go(-1); }); }else{ // 获取购物车商品数据 this.$axios.get(this.$settings.Host+"/carts/course/",{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token } }).then(response=>{ this.courseData = response.data; // 更新在vuex里面的数据 this.$store.state.cart.count = response.data.length; // 调整因为ajax数据请求导致勾选状态人没有出现的原因,使用定时器进行延时调用 setTimeout(()=>{ let expire_data = []; this.expire_list.forEach(row=>{ expire_data[row.id] = row.title; }); // row 就是字典数据[json] this.courseData.forEach(row => { // 设置商品课程的选中状态 if(row.is_select){ this.$refs.multipleTable.toggleRowSelection(row); } // 调整有效期选项中数值变成文本内容 row.expire = expire_data[row.expire]; }); },0) }).catch(error=>{ let status = error.response.status; if( status == 401 ){ this.token = null; sessionStorage.removeItem("token"); localStorage.removeItem("token"); let _this = this; this.$alert("您尚未登录或登录超时!请重新登录","警告",{ callback(){ _this.$router.push("/login"); } }); } }) } }, methods:{ getTotalPrice(){ // 核算购物车中所有勾选商品的总价格 let total = 0; this.selection.forEach(row=>{ total += row.price; }); // 保留2个小数位 this.total_price = total.toFixed(2); }, CartDel(course,course_name){ this.$confirm(`您确定要从购物车删除<<${course_name}>>这个课程么?`,"提示!").then(()=>{ let course_id = course.id; // 发送请求 this.$axios.delete(this.$settings.Host+"/carts/course/",{ params:{ course_id:course_id, }, headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ let index = this.courseData.indexOf(course); this.courseData.splice(index,1); this.$message("删除成功!"); }).catch(error=>{ console.log(error.response); }); }).catch(()=>{ // 取消操作 }); }, Selected(selection,row){ // selection 表示所有被勾选的信息 // row 当前操作的数据 let is_select = true; if( selection.indexOf(row) == -1 ){ is_select = false; } // 获取当前课程ID let course_id = row.id; // 切换勾选状态 // 发送请求 this.$axios.put(this.$settings.Host+"/carts/course/",{ course_id: course_id, is_select: is_select, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ this.$message(response.data.message,"提示"); }).catch(error=>{ console.log(error.response) }) }, // 更新课程的有效期 ChangeExpire(course){ // 获取课程ID和有效期 let course_id = course.id; let expire = course.expire // 发送patch请求更新有效期 this.$axios.patch(this.$settings.Host+"/carts/course/",{ course_id, expire, // 这里是简写,相当于 expire:expire, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ this.$message(response.data.message,"提示"); }); }, // 获取勾选过的商品课程列表 SelectionChange(data){ this.selection = data; } }, components:{Header,Footer} } </script>
价格策略模型
价格优惠活动类型名称: 限时免费, 限时折扣, 限时减免, 积分抵扣, 满减, 优惠券
公式:
限时免费 原价-原价
限时折扣 原价*0.8
限时减免 原价-减免价
积分抵扣 原价-(积分计算后换算价格) ->> 积分换算比率
满减 原价-(满减计算后换算价格)
优惠券 原价-优惠券价格 -->> 优惠券
模型代码:
from django.db import models from luffy.utils.models import BaseModel from datetime import datetime from decimal import Decimal # Create your models here. class CourseCategory(BaseModel): """ 课程分类 """ name = models.CharField(max_length=64, unique=True, verbose_name="分类名称") class Meta: db_table = "ly_course_category" verbose_name = "课程分类" verbose_name_plural = "课程分类" def __str__(self): return "%s" % self.name from ckeditor_uploader.fields import RichTextUploadingField class Course(BaseModel): """ 专题课程 """ course_type = ( (0, '付费'), (1, 'VIP专享'), (2, '学位课程') ) level_choices = ( (0, '初级'), (1, '中级'), (2, '高级'), ) status_choices = ( (0, '上线'), (1, '下线'), (2, '预上线'), ) name = models.CharField(max_length=128, verbose_name="课程名称") course_img = models.ImageField(upload_to="course", max_length=255, verbose_name="封面图片", blank=True, null=True) course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型") # 使用这个字段的原因 video = models.FileField(upload_to="video", null=True,blank=True,default=None, verbose_name="封面视频") brief = RichTextUploadingField(max_length=2048, verbose_name="详情介绍", null=True, blank=True) level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级") pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True) period = models.IntegerField(verbose_name="建议学习周期(day)", default=7) attachment_path = models.FileField(max_length=128, verbose_name="课件路径", blank=True, null=True) status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态") course_category = models.ForeignKey("CourseCategory", on_delete=models.CASCADE, null=True, blank=True,verbose_name="课程分类") students = models.IntegerField(verbose_name="学习人数",default = 0) lessons = models.IntegerField(verbose_name="总课时数量",default = 0) pub_lessons = models.IntegerField(verbose_name="课时更新数量",default = 0) price = models.DecimalField(max_digits=6,decimal_places=2, verbose_name="课程原价",default=0) teacher = models.ForeignKey("Teacher",on_delete=models.DO_NOTHING, null=True, blank=True,verbose_name="授课老师") class Meta: db_table = "ly_course" verbose_name = "专题课程" verbose_name_plural = "专题课程" def __str__(self): return "%s" % self.name def get_course_discount_type(self): now = datetime.now() try: course_prices = self.prices.get(start_time__lte=now, end_time__gte=now, is_delete=False, is_show=True) # 获取优惠活动类型 return course_prices.discount.discount_type.name except: return "" def get_course_price(self): # 获取当前课程的真实价格 # 获取当前课程的价格策略 now = datetime.now() try: course_prices = self.prices.get( start_time__lte=now,end_time__gte=now, is_delete=False,is_show=True) # 价格优惠条件判断,原价大于优惠条件才参与活动 if self.price < course_prices.discount.condition: return self.price # 当优惠公式为多行文本,则表示满减 if course_prices.discount.sale[0]=="满": sale_list = course_prices.discount.sale.split(" ") sale_price_list = [] # 通过遍历提取所有策略项的优惠价格值 for sale_item in sale_list: sale = int( sale_item[1: sale_item.index("-")] ) sele_price = int( sale_item[sale_item.index("-")+1:] ) if self.price >= sale: sale_price_list.append( sele_price ) # 当前课程只能享受一个最大优惠 return self.price - max( sale_price_list ) # 当优惠公式为-1,则表示真实价格为0 if course_prices.discount.sale == "-1": return 0 # 当优惠公式为*开头,则表示折扣 if course_prices.discount.sale[0] == "*": sale = Decimal( course_prices.discount.sale[1:] ) return self.price * sale # 当优惠公式为负数,则表示减免 if course_prices.discount.sale[0] == "-": sale = Decimal( course_prices.discount.sale[1:] ) real_price = self.price - sale return real_price if real_price > 0 else 0 except: print("---没有优惠---") return self.price def lesson_list(self): """获取当前课程的前8个课时展示到列表中""" # 获取所有章节 chapters_list = self.coursechapters.filter(is_delete=False,is_show=True) lesson_list = [] if chapters_list: for chapter in chapters_list: lessons = chapter.coursesections.filter(is_delete=False,is_show=True)[:4] if lessons: for lesson in lessons: lesson_list.append({ "id":lesson.id, "name":lesson.name, "free_trail":lesson.free_trail }) return lesson_list[:4] def course_level(self): """把课程难度数值转换成文本""" return self.level_choices[self.level][1] class Teacher(BaseModel): """讲师、导师表""" role_choices = ( (0, '讲师'), (1, '导师'), (2, '班主任'), ) name = models.CharField(max_length=32, verbose_name="讲师title") role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份") title = models.CharField(max_length=64, verbose_name="职位、职称") signature = models.CharField(max_length=255, verbose_name="导师签名", help_text="导师签名", blank=True, null=True) image = models.ImageField(upload_to="teacher", null=True, blank=True, verbose_name = "讲师封面") brief = models.TextField(max_length=1024, verbose_name="讲师描述") class Meta: db_table = "ly_teacher" verbose_name = "讲师导师" verbose_name_plural = "讲师导师" def __str__(self): return "%s" % self.name class CourseChapter(BaseModel): """课程章节""" course = models.ForeignKey("Course", related_name='coursechapters', on_delete=models.CASCADE, verbose_name="课程名称") chapter = models.SmallIntegerField(verbose_name="第几章", default=1) name = models.CharField(max_length=128, verbose_name="章节标题") summary = models.TextField(verbose_name="章节介绍", blank=True, null=True) pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True) class Meta: db_table = "ly_course_chapter" verbose_name = "课程章节" verbose_name_plural = "课程章节" def __str__(self): return "%s:(第%s章)%s" % (self.course, self.chapter, self.name) class CourseLesson(BaseModel): """课程课时""" section_type_choices = ( (0, '文档'), (1, '练习'), (2, '视频') ) chapter = models.ForeignKey("CourseChapter", related_name='coursesections', on_delete=models.CASCADE,verbose_name="课程章节") name = models.CharField(max_length=128,verbose_name = "课时标题") orders = models.PositiveSmallIntegerField(verbose_name="课时排序") section_type = models.SmallIntegerField(default=2, choices=section_type_choices, verbose_name="课时种类") section_link = models.CharField(max_length=255, blank=True, null=True, verbose_name="课时链接", help_text = "若是video,填vid,若是文档,填link") duration = models.CharField(verbose_name="视频时长", blank=True, null=True, max_length=32) # 仅在前端展示使用 pub_date = models.DateTimeField(verbose_name="发布时间", auto_now_add=True) free_trail = models.BooleanField(verbose_name="是否可试看", default=False) class Meta: db_table = "ly_course_lesson" verbose_name = "课程课时" verbose_name_plural = "课程课时" def __str__(self): return "%s-%s" % (self.chapter, self.name) """价格相关的模型""" class PriceDiscountType(BaseModel): """课程优惠类型""" name = models.CharField(max_length=32, verbose_name="类型名称") remark = models.CharField(max_length=250,blank=True, null=True, verbose_name="备注信息") class Meta: db_table = "ly_price_discount_type" verbose_name = "课程优惠类型" verbose_name_plural = "课程优惠类型" def __str__(self): return "%s" % (self.name) class PriceDiscount(BaseModel): """课程优惠模型""" discount_type = models.ForeignKey("PriceDiscountType",on_delete=models.CASCADE, related_name='pricediscounts',verbose_name="优惠类型") discount_name = models.CharField(max_length=150,verbose_name="优惠活动名称") condition = models.IntegerField(blank=True,default=0,verbose_name="满足优惠的价格条件") sale = models.TextField(verbose_name="优惠公式",help_text=""" -1表示免费;<br> *号开头表示折扣价,例如*0.82表示八二折;<br> $号开头表示积分兑换,例如$50表示可以兑换50积分<br> 表示满减,则需要使用 原价-优惠价格,例如表示,课程价格大于100,优惠10;大于200,优惠20,格式如下:<br> 满100-10<br> 满200-20<br> """) class Meta: db_table = "ly_price_discount" verbose_name = "价格优惠策略" verbose_name_plural = "价格优惠策略" def __str__(self): return "价格优惠:%s,优惠条件:%s,优惠值:%s" % (self.discount_type.name,self.condition,self.sale) class CoursePriceDiscount(BaseModel): """课程与优惠策略的关系表""" course = models.ForeignKey("Course",on_delete=models.CASCADE, related_name="prices",verbose_name="课程") discount = models.ForeignKey("PriceDiscount",on_delete=models.CASCADE,related_name="courses",verbose_name="优惠活动") start_time = models.DateTimeField(verbose_name="优惠策略的开始时间") end_time = models.DateTimeField(verbose_name="优惠策略的结束时间") class Meta: db_table = "ly_course_price_dicount" verbose_name="课程与优惠策略的关系表" verbose_name_plural="课程与优惠策略的关系表" def __str__(self): return "优惠: %s,开始时间:%s,结束时间:%s" % (self.discount.discount_name, self.start_time,self.end_time)
python manage.py makemigrations
python manage.py migrate
后端在模型中计算课程真实价格
因为课程的优惠是具有时效性的,所以我们计算价格的时候需要先判断当前优惠是否过期了。
settings/dev.py,代码:USE_TZ = False
courses/models.py,代码:
from django.db import models from luffy.utils.models import BaseModel from datetime import datetime from decimal import Decimal # Create your models here. class CourseCategory(BaseModel): """ 课程分类 """ 。。。 from ckeditor_uploader.fields import RichTextUploadingField class Course(BaseModel): """ 专题课程 """ course_type = ( (0, '付费'), (1, 'VIP专享'), (2, '学位课程') ) level_choices = ( (0, '初级'), (1, '中级'), (2, '高级'), ) status_choices = ( (0, '上线'), (1, '下线'), (2, '预上线'), ) name = models.CharField(max_length=128, verbose_name="课程名称") course_img = models.ImageField(upload_to="course", max_length=255, verbose_name="封面图片", blank=True, null=True) course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型") # 使用这个字段的原因 video = models.FileField(upload_to="video", null=True,blank=True,default=None, verbose_name="封面视频") brief = RichTextUploadingField(max_length=2048, verbose_name="详情介绍", null=True, blank=True) level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级") pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True) period = models.IntegerField(verbose_name="建议学习周期(day)", default=7) attachment_path = models.FileField(max_length=128, verbose_name="课件路径", blank=True, null=True) status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态") course_category = models.ForeignKey("CourseCategory", on_delete=models.CASCADE, null=True, blank=True,verbose_name="课程分类") students = models.IntegerField(verbose_name="学习人数",default = 0) lessons = models.IntegerField(verbose_name="总课时数量",default = 0) pub_lessons = models.IntegerField(verbose_name="课时更新数量",default = 0) price = models.DecimalField(max_digits=6,decimal_places=2, verbose_name="课程原价",default=0) teacher = models.ForeignKey("Teacher",on_delete=models.DO_NOTHING, null=True, blank=True,verbose_name="授课老师") class Meta: db_table = "ly_course" verbose_name = "专题课程" verbose_name_plural = "专题课程" def __str__(self): return "%s" % self.name def get_course_discount_type(self): now = datetime.now() try: course_prices = self.prices.get(start_time__lte=now, end_time__gte=now, is_delete=False, is_show=True) # 获取优惠活动类型 return course_prices.discount.discount_type.name except: return "" def get_course_price(self): # 获取当前课程的真实价格 # 获取当前课程的价格策略 now = datetime.now() try: course_prices = self.prices.get( start_time__lte=now,end_time__gte=now, is_delete=False,is_show=True) # 价格优惠条件判断,原价大于优惠条件才参与活动 if self.price < course_prices.discount.condition: return self.price # 当优惠公式为多行文本,则表示满减 if course_prices.discount.sale[0]=="满": sale_list = course_prices.discount.sale.split(" ") sale_price_list = [] # 通过遍历提取所有策略项的优惠价格值 for sale_item in sale_list: sale = int( sale_item[1: sale_item.index("-")] ) sele_price = int( sale_item[sale_item.index("-")+1:] ) if self.price >= sale: sale_price_list.append( sele_price ) # 当前课程只能享受一个最大优惠 return self.price - max( sale_price_list ) # 当优惠公式为-1,则表示真实价格为0 if course_prices.discount.sale == "-1": return 0 # 当优惠公式为*开头,则表示折扣 if course_prices.discount.sale[0] == "*": sale = Decimal( course_prices.discount.sale[1:] ) return self.price * sale # 当优惠公式为负数,则表示减免 if course_prices.discount.sale[0] == "-": sale = Decimal( course_prices.discount.sale[1:] ) real_price = self.price - sale return real_price if real_price > 0 else 0 except: print("---没有优惠---") return self.price def lesson_list(self): """获取当前课程的前8个课时展示到列表中""" # 获取所有章节 chapters_list = self.coursechapters.filter(is_delete=False,is_show=True) lesson_list = [] if chapters_list: for chapter in chapters_list: lessons = chapter.coursesections.filter(is_delete=False,is_show=True)[:4] if lessons: for lesson in lessons: lesson_list.append({ "id":lesson.id, "name":lesson.name, "free_trail":lesson.free_trail }) return lesson_list[:4] def course_level(self): """把课程难度数值转换成文本""" return self.level_choices[self.level][1] class Teacher(BaseModel): """讲师、导师表""" 。。。 class CourseChapter(BaseModel): """课程章节""" 。。。 class CourseLesson(BaseModel): """课程课时""" 。。。 """价格相关的模型""" class PriceDiscountType(BaseModel): """课程优惠类型""" 。。。 class PriceDiscount(BaseModel): """课程优惠模型""" 。。。 class CoursePriceDiscount(BaseModel): """课程与优惠策略的关系表""" 。。。
修改序列化器,增加返回字段[优惠类型和优惠策略]
from rest_framework import serializers from .models import CourseCategory,Course class CourseCategoryModelSerializer(serializers.ModelSerializer): class Meta: model = CourseCategory fields = ("id","name") from .models import Teacher class TeacherModelSerializer(serializers.ModelSerializer): class Meta: model = Teacher fields = ("id","name","title") class CourseModelSerializer(serializers.ModelSerializer): """课程列表页的序列化器""" # 默认情况,序列化器转换模型数据时,默认会把外键直接转成主键ID值 # 所以我们需要重新设置在序列化器中针对外键的序列化 # 这种操作就是一个序列器里面调用另一个序列化器了.叫"序列化器嵌套" teacher = TeacherModelSerializer() # coursechapters = CourseChapterModelSerializer(many=True) class Meta: model = Course fields = ("id","name","course_img","students","lessons","pub_lessons","price","teacher","lesson_list","get_course_price","get_course_discount_type") class TeacherDetailModelSerializer(serializers.ModelSerializer): 。。。。 class CourseDetailModelSerializer(serializers.ModelSerializer): """课程详情页的序列化器""" teacher = TeacherDetailModelSerializer() class Meta: model = Course # fields = ("id","name", "video", "course_img", "students","lessons","pub_lessons","price","teacher","course_level","brief") fields = ("id","name", "course_img", "students","lessons","pub_lessons","price","teacher","course_level","brief","get_course_price","get_course_discount_type")
判断是否有课程类型,如果有,则显示优惠价格。没有则显示课程原价。
<template> <div class="course"> <Header/> <div class="main"> <!-- 筛选功能 --> <div class="top"> <ul class="condition condition1"> <li class="cate-condition">课程分类:</li> <li class="item" :class="query_params.course_category==0?'current':''" @click="query_params.course_category=0">全部</li> <li :class="query_params.course_category==catetory.id?'current':''" @click="query_params.course_category=catetory.id" v-for="catetory in catetory_list" :data-key="catetory.id" class="item">{{catetory.name}}</li> </ul> <ul class="condition condition2"> <li class="cate-condition">筛 选:</li> <li class="item" :class="(query_params.ordering=='-id' || query_params.ordering=='id')?'current':''" @click="select_ordering('id')">默认</li> <li class="item" :class="(query_params.ordering=='-students' || query_params.ordering=='students')?'current':''" @click="select_ordering('students')">人气</li> <li class="item" :class="query_params.ordering=='price'?'current price':(query_params.ordering=='-price'?'current price2':'')" @click="select_ordering('price')">价格</li> <li class="course-length">共21个课程</li> </ul> </div> <!-- 课程列表 ---> <div class="list"> <ul> <li class="course-item" v-for="course in course_list"> <router-link :to="{path: '/detail',query:{id:course.id}}" class="course-link"> <div class="course-cover"> <img :src="course.course_img" alt=""> </div> <div class="course-info"> <div class="course-title"> <h3>{{course.name}}</h3> <span>{{course.students}}人已加入学习</span> </div> <p class="teacher"> <span class="info">{{course.teacher.name}} {{course.teacher.title}}</span> <span class="lesson">共{{course.lessons}}课时/{{course.lessons==course.pub_lessons?'更新完成':('已更新'+course.pub_lessons+"课时")}}</span> </p> <ul class="lesson-list"> <li v-for="lesson,key in course.lesson_list"> <p class="lesson-title">0{{key+1}} | {{lesson.name}}</p> <span v-if="lesson.free_trail" class="free">免费</span> </li> </ul> <div class="buy-info"> <div v-if="course.get_course_discount_type"> <span class="discount">{{course.get_course_discount_type}}</span> <span class="present-price">¥{{course.get_course_price}}元</span> <span class="original-price">原价:{{course.price}}元</span> </div> <span v-else class="present-price">¥{{course.price}}元</span> <button class="buy-now">立即购买</button> </div> </div> </router-link> </li> </ul> </div> <div class="pagination"> <el-pagination @current-change="handleCurrentChange" :current-page="query_params.current_page" background layout="prev, pager, next" :page-size="course_page_size" :total="course_count"> </el-pagination> </div> </div> <Footer/> </div> </template>
<template> <div class="detail"> <Header/> <div class="main"> <div class="course-info"> <div class="wrap-left"> <video-player class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)" > </video-player> </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+"课程")}} 难度:{{course.course_level}}</p> <div v-if="course.get_course_discount_type"> <div class="sale-time"> <p class="sale-type">{{course.get_course_discount_type}}</p> <p class="expire">距离结束:仅剩 01天 04小时 33分 <span class="second">08</span> 秒</p> </div> <p class="course-price"> <span>活动价</span> <span class="discount">¥{{course.get_course_price}}</span> <span class="original">¥{{course.price}}</span> </p> </div> <div v-else> <div class="sale-time"> <p class="sale-type">价格: ¥{{course.price}}</p> </div> </div> <div class="buy"> <div class="buy-btn"> <button class="buy-now">立即购买</button> <button class="free">免费试学</button> </div> <div @click="cartAddHander" class="add-cart"><img src="@/assets/cart-yellow.svg" alt="">加入购物车</div> </div> </div> </div> 。。。。 </div> <Footer/> </div> </template>
实现课程详情页倒计时功能
courses/models.py中,Course模型新增计算剩余时间的方法,代码:
from ckeditor_uploader.fields import RichTextUploadingField class Course(BaseModel): """ 专题课程 """ 。。。 def has_time(self): """计算活动的剩余时间""" now = datetime.now() try: course_prices = self.prices.get(start_time__lte=now, end_time__gte=now, is_delete=False, is_show=True) # 把 活动结束时间 - 当前时间 = 剩余时间 return int( course_prices.end_time.timestamp() - now.timestamp() ) except: print("---活动过期了----") return 0
序列化器,新增返回字段
class CourseDetailModelSerializer(serializers.ModelSerializer): """课程详情页的序列化器""" teacher = TeacherDetailModelSerializer() class Meta: model = Course # fields = ("id","name", "video", "course_img", "students","lessons","pub_lessons","price","teacher","course_level","brief") fields = ("id","name", "course_img", "students","lessons","pub_lessons","price","teacher","course_level","brief","get_course_price","get_course_discount_type","has_time")
前端使用定时器setInterval完成倒计时功能
<template> <div class="detail"> <Header/> <div class="main"> <div class="course-info"> <div class="wrap-left"> 。。。。 <div class="sale-time"> <p class="sale-type">{{course.get_course_discount_type}}</p> <p class="expire">距离结束:仅剩 {{Math.floor(course.has_time/86400)}}天 {{Math.floor(course.has_time%86400/3600)}}小时 {{Math.floor(course.has_time%86400%3600/60)}}分 <span class="second">{{Math.floor(course.has_time%86400%3600%60)}}</span> 秒</p> </div> <p class="course-price"> <span>活动价</span> <span class="discount">¥{{course.get_course_price}}</span> <span class="original">¥{{course.price}}</span> </p> </div> <div v-else> <div class="sale-time"> <p class="sale-type">价格: ¥{{course.price}}</p> </div> </div> 。。。。 </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" import {videoPlayer} from 'vue-video-player'; export default { name: "Detail", 。。。。 created(){ // 获取当前课程ID this.course_id = this.$route.query.id - 0; // 判断ID基本有效性 let _this = this; if( isNaN(this.course_id) || this.course_id < 1 ){ _this.$alert("无效的课程ID!","错误",{ callback(){ _this.$router.go(-1); }}); } // 发送请求获取后端课程数据 this.$axios.get(this.$settings.Host+`/courses/detail/${this.course_id}/`).then(response=>{ this.course = response.data; // 修改视频中的封面图片 this.playerOptions.poster = this.course.course_img; // 倒计时 if(this.course.has_time > 1){ let timer = setInterval(()=>{ if( this.course.has_time > 1 ){ this.course.has_time-=1; }else{ clearInterval(timer); location.reload(); } },1000); } }).catch(error=>{ console.log(error.response) }); }, 。。。。 } </script>
后端实现提供课程有效期的API
模型代码:
"""课程有效期""" class CourseTime(BaseModel): """课程有效期表""" timer = models.IntegerField(verbose_name="购买周期",default=30,help_text="单位:天<br>建议按月书写,例如:1个月,则为30.") title = models.CharField(max_length=150, null=True, blank=True, verbose_name="购买周期的文本提示", default="1个月有效", help_text="要根据上面的购买周期,<br>声明对应的提示内容,<br>展示在购物车商品列表中") course = models.ForeignKey("Course", on_delete=models.CASCADE, related_name="prices", verbose_name="课程") price = models.DecimalField(max_digits=6, decimal_places=2, verbose_name="课程原价", default=0) class Meta: db_table = "ly_course_time" verbose_name = "课程有效期表" verbose_name_plural = "课程有效期表" def __str__(self): return "课程:%s,周期:%s,价格:%s" % (self.course, self.timer, self.price)
数据迁移
python manage.py makemigrations
python manage.py migrate
把模型注册到xadmin中。
from .models import CourseTime class CourseTimeModelAdmin(object): """课程与价格优惠关系模型管理类""" list_display = ["course","title","timer","price"] xadmin.site.register(CourseTime, CourseTimeModelAdmin)
购物车视图中,返回购买课程的周期列表,cart/views.py,代码
class CartAPIView(APIView): permission_classes = [IsAuthenticated] """购物车视图""" def get(self,request): """获取购物车商品课程列表""" # 获取当前用户ID # user_id = 1 user_id = request.user.id # 通过用户ID获取购物车中的商品信息 redis = get_redis_connection("cart") cart_goods_list = redis.hgetall("cart_%s" % user_id ) # 商品课程列表 cart_goods_selects = redis.smembers("cart_selected_%s" % user_id) # redis里面的所有数据最终都是以bytes类型的字符串保存的 # print( cart_goods_selects ) # 格式: {b'7', b'3', b'5'} # print( cart_goods_list ) # 格式: {b'7': b'-1', b'5': b'-1'} # 遍历购物车中的商品课程到数据库获取课程的价格, 标题, 图片 data_list = [] try: for course_id_bytes,expire_bytes in cart_goods_list.items(): course_id = int( course_id_bytes.decode() ) expire = expire_bytes.decode() course = Course.objects.get(pk=course_id) # 获取购买的课程的周期价格列表 expires = course.coursetimes.all() # 默认具有永久价格 expire_list = [{ "title": "永久有效", "timer": -1, "price": course.price }] for item in expires: expire_list.append({ "title":item.title, "timer":item.timer, "price":item.price, }) data_list.append({ "id": course_id, "expire":expire, "course_img": course.course_img.url, "name": course.name, "price": course.get_course_price(), "is_select": course_id_bytes in cart_goods_selects, "expire_list": expire_list, }) except: return Response(data_list,status=status.HTTP_500_INTERNAL_SERVER_ERROR) # print(data_list) # 返回查询结果 return Response(data_list,status=status.HTTP_200_OK)
前端展示购物车中每一个商品课程的购买周期,代码:
<template> 。。。。 <el-table-column label="有效期" width="216"> <template slot-scope="scope"> <el-select @change="ChangeExpire(scope.row)" v-model="scope.row.expire" placeholder="请选择"> <el-option v-for="item in scope.row.expire_list" :key="item.timer" :label="item.title" :value="item.timer"></el-option> </el-select> </template> </el-table-column> 。。。。 </div> </template> <script> import Header from "./common/Header" import Footer from "./common/Footer" export default { name: "Cart", data(){ return{ // 注释掉原来的有效周期测试数据 // expire:3, // expire_list:[] courseData:[], // 购物车中的商品信息 selection:[], // 购物车中被勾选的商品信息 total_price:0.00, } }, 。。。。 created(){ // 判断是否登录 this.token = sessionStorage.token || localStorage.token; if( !this.token ){ this.$confirm("对不起,您尚未登录!请登录",'提示').then(() => { this.$router.push("/login"); }).catch(()=>{ this.$router.go(-1); }); }else{ // 获取购物车商品数据 this.$axios.get(this.$settings.Host+"/carts/course/",{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token } }).then(response=>{ this.courseData = response.data; // 更新在vuex里面的数据 this.$store.state.cart.count = response.data.length; // 调整因为ajax数据请求导致勾选状态人没有出现的原因,使用定时器进行延时调用 setTimeout(()=>{ let expire_data = []; this.courseData.forEach(course=>{ course.expire_list.forEach(row=>{ expire_data[row.timer] = row.title; }); }); // row 就是字典数据[json] this.courseData.forEach(row => { // 设置商品课程的选中状态 if(row.is_select){ this.$refs.multipleTable.toggleRowSelection(row); } // 调整有效期选项中数值变成文本内容 row.expire = expire_data[row.expire]; }); },0) }).catch(error=>{ let status = error.response.status; if( status == 401 ){ this.token = null; sessionStorage.removeItem("token"); localStorage.removeItem("token"); let _this = this; this.$alert("您尚未登录或登录超时!请重新登录","警告",{ callback(){ _this.$router.push("/login"); } }); } }) } }, 。。。 } </script>
当切换课程周期时,后端重新计算价格并返回
from ckeditor_uploader.fields import RichTextUploadingField class Course(BaseModel): """ 专题课程 """ 。。。。 def get_course_price(self,price=0): # 获取当前课程的真实价格 self.price = price if price != 0 else self.price # 判断调用当前方法时,是否定义了价格 。。。。
视图中修改patch方法,在用户切换购买课程周期时,重新计算真实课程价格,代码:
def patch(self,request): """更新购物城中的商品信息[切换课程有效期]""" # 获取当前登录的用户ID # user_id = 1 user_id = request.user.id # 获取当前操作的课程ID course_id = request.data.get("course_id") # 获取新的有效期 expire = request.data.get("expire") # 获取redis链接 redis = get_redis_connection("cart") # 更新购物中商品课程的有效期 redis.hset("cart_%s" % user_id,course_id, expire) # 根据新的课程有效期获取新的课程原价 try: coursetime = CourseTime.objects.get(course=course_id, timer=expire) # 根据新的课程价格,计算真实课程价格 price = coursetime.course.get_course_price(coursetime.price) except: # 这里给price设置一个默认值,当值-1,则前段不许要对价格进行调整 course = Course.objects.get(pk=course_id) price = course.get_course_price() return Response({ "price": price, "message": "修改购物车信息成功!" }, status=status.HTTP_200_OK)
// 更新课程的有效期 ChangeExpire(course){ // 获取课程ID和有效期 let course_id = course.id; let expire = course.expire; // 发送patch请求更新有效期 this.$axios.patch(this.$settings.Host+"/carts/course/",{ course_id, expire, // 这里是简写,相当于 expire:expire, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ // 更新购买的商品课程的价格 course.price = response.data.price; this.$message(response.data.message,"提示"); }); },
carts/views.py的CartAPIView
def get(self,request): """获取购物车商品课程列表""" # 获取当前用户ID # user_id = 1 user_id = request.user.id # 通过用户ID获取购物车中的商品信息 redis = get_redis_connection("cart") cart_goods_list = redis.hgetall("cart_%s" % user_id ) # 商品课程列表 cart_goods_selects = redis.smembers("cart_selected_%s" % user_id) # redis里面的所有数据最终都是以bytes类型的字符串保存的 # print( cart_goods_selects ) # 格式: {b'7', b'3', b'5'} # print( cart_goods_list ) # 格式: {b'7': b'-1', b'5': b'-1'} # 遍历购物车中的商品课程到数据库获取课程的价格, 标题, 图片 data_list = [] try: for course_id_bytes,expire_bytes in cart_goods_list.items(): course_id = int( course_id_bytes.decode() ) expire = expire_bytes.decode() course = Course.objects.get(pk=course_id) # 获取购买的课程的周期价格列表 expires = course.coursetimes.all() # 默认具有永久价格 expire_list = [{ "title": "永久有效", "timer": -1, "price": course.price }] for item in expires: expire_list.append({ "title":item.title, "timer":item.timer, "price":item.price, }) try: # 根据课程有效期传入课程原价 coursetime = CourseTime.objects.get(course=course_id, timer=expire) # 根据新的课程价格,计算真实课程价格 price= coursetime.price except: price = 0 data_list.append({ "id": course_id, "expire":expire, "course_img": course.course_img.url, "name": course.name, "price": course.get_course_price(price), "is_select": course_id_bytes in cart_goods_selects, "expire_list": expire_list, }) except: return Response(data_list,status=status.HTTP_500_INTERNAL_SERVER_ERROR) # print(data_list) # 返回查询结果 return Response(data_list,status=status.HTTP_200_OK)
最后,修复在用户切换购买周期时,前端需要重新计算购物车中所有商品的总价格。
// 更新课程的有效期 ChangeExpire(course){ // 获取课程ID和有效期 let course_id = course.id; let expire = course.expire; // 发送patch请求更新有效期 this.$axios.patch(this.$settings.Host+"/carts/course/",{ course_id, expire, // 这里是简写,相当于 expire:expire, },{ headers:{ // 注意下方的空格!!! "Authorization":"jwt " + this.token }, }).then(response=>{ // 更新购买的商品课程的价格 course.price = response.data.price; // 重新计算购物车中的商品总价 this.getTotalPrice(); this.$message(response.data.message,"提示"); }); },