购物车实现

课程购买 - 图1

准备工作

创建子应用 cart

  1. cd /home/moluo/Desktop/luffycity/
  2. git checkout master
  3. git merge feature/discount
  4. git checkout -b feature/cart
  5. cd luffycityapi/apps
  6. python ../../manage.py startapp cart

注册子应用cart

settings/dev.py,配置文件

  1. INSTALLED_APPS = [
  2. 'simpleui', # admin界面美化,必须写在admin上面
  3. 'django.contrib.admin',
  4. 'django.contrib.auth',
  5. 'django.contrib.contenttypes',
  6. 'django.contrib.sessions',
  7. 'django.contrib.messages',
  8. 'django.contrib.staticfiles',
  9. "rest_framework", # 注意:记得加入 rest_framework
  10. 'corsheaders', # cors跨域子应用
  11. 'ckeditor', # 富文本编辑器
  12. 'ckeditor_uploader', # 富文本编辑器上传文件子用用
  13. 'stdimage', # 生成缩略图
  14. 'haystack', # 搜索引擎框架
  15. "home",
  16. 'users',
  17. 'courses',
  18. 'cart',
  19. ]

注册子应用到总路由

cart/urls.py,代码:

  1. from django.urls import path
  2. from . import views
  3. urlpatterns = [
  4. ]

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

  1. from django.contrib import admin
  2. from django.urls import path,include,re_path
  3. from django.conf import settings
  4. from django.views.static import serve # 静态文件代理访问模块
  5. urlpatterns = [
  6. path('admin/', admin.site.urls),
  7. re_path(r'uploads/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
  8. path('ckeditor/', include('ckeditor_uploader.urls')),
  9. path('', include("home.urls")),
  10. path("users/", include("users.urls")),
  11. path("courses/", include("courses.urls")),
  12. path("cart/", include("cart.urls")),
  13. ]

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

配置信息

  1. # redis configration
  2. # 设置redis缓存
  3. CACHES = {
  4. # 默认缓存
  5. "default": {
  6. "BACKEND": "django_redis.cache.RedisCache",
  7. # 项目上线时,需要调整这里的路径
  8. # "LOCATION": "redis://:密码@IP地址:端口/库编号",
  9. "LOCATION": "redis://:@127.0.0.1:6379/0",
  10. "OPTIONS": {
  11. "CLIENT_CLASS": "django_redis.client.DefaultClient",
  12. "CONNECTION_POOL_KWARGS": {"max_connections": 10}, # 连接池
  13. }
  14. },
  15. # 提供给admin运营站点的session存储
  16. "session": {
  17. "BACKEND": "django_redis.cache.RedisCache",
  18. "LOCATION": "redis://:@127.0.0.1:6379/1",
  19. "OPTIONS": {
  20. "CLIENT_CLASS": "django_redis.client.DefaultClient",
  21. "CONNECTION_POOL_KWARGS": {"max_connections": 10},
  22. }
  23. },
  24. # 提供存储短信验证码
  25. "sms_code": {
  26. "BACKEND": "django_redis.cache.RedisCache",
  27. "LOCATION": "redis://:@127.0.0.1:6379/2",
  28. "OPTIONS": {
  29. "CLIENT_CLASS": "django_redis.client.DefaultClient",
  30. "CONNECTION_POOL_KWARGS": {"max_connections": 10},
  31. }
  32. },
  33. # 提供存储搜索热门关键字
  34. "hot_word": {
  35. "BACKEND": "django_redis.cache.RedisCache",
  36. "LOCATION": "redis://:@127.0.0.1:6379/3",
  37. "OPTIONS": {
  38. "CLIENT_CLASS": "django_redis.client.DefaultClient",
  39. }
  40. },
  41. # 提供存储购物车课程商品
  42. "cart": {
  43. "BACKEND": "django_redis.cache.RedisCache",
  44. "LOCATION": "redis://:@127.0.0.1:6379/4",
  45. "OPTIONS": {
  46. "CLIENT_CLASS": "django_redis.client.DefaultClient",
  47. }
  48. },
  49. }

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

  1. 购物车中的商品数据的格式:
  2. *商品数量[因为目前的商品是课程,属于虚拟商品,所以没有数量的,如果以后做到真实商品,则必须有数量]
  3. 商品id
  4. 用户id
  5. 商品勾选状态----> 在用户勾选了商品以后,该商品才会在下单结算阶段中出现。没勾选则会保留在购物车中,等下次购买。
  6. 五种数据类型
  7. hash哈希字典
  8. 用户ID:{ # 使用哈希记录用户添加到购物车中的所有商品
  9. 商品ID1: 商品数量,
  10. 商品ID2: 商品数量,
  11. }
  12. 用户ID:{商品1,商品2,....} # 使用无需集合被勾选的商品列表
  13. list列表
  14. 用户ID: [商品1, 商品,....] # 使用列表记录用户添加到购物车中的商品ID
  15. 用户ID:{商品1,商品2,....} # 使用无需集合被勾选的商品列表
  16. set集合
  17. 键: {值1,值2,....}
  18. 经过比较可以发现没有一种数据类型完整有效的存储购物车数据,勉强可以保存的只有hash,但是hash默认情况下只会保存3种数据而已,当如果再需要保存1种,则可能需要花费更多的操作完成这个存储过程,所以我们完全使用redis2种数据结构或多种数据结构来分别保存购物车相关数据
  19. 可以发现,上面5种数据类型中,哈希hash可以存储的数据量是最多的。因为购物车中的商品不需要顺序,反而需要在勾选的时候进行唯一的处理,所以选用set
  20. 当然,现在我们实现的在线教育商城只需要保存的字段只有:用户ID,商品ID,勾选状态即可。所以我们采用hash一种数据结构即可。
  21. 当前在线教育商城的购物车数据结构:
  22. hash
  23. 键[用户ID]:{
  24. 域[商品ID]:勾选状态,
  25. 域[商品ID]:勾选状态,
  26. 域[商品ID]:勾选状态,
  27. 域[商品ID]:勾选状态,
  28. }
  29. 如果将来保存有数量的商品:
  30. hash
  31. 键[用户ID]:{
  32. 域[商品ID]:商品数量,
  33. 域[商品ID]:商品数量,
  34. 域[商品ID]:商品数量,
  35. 域[商品ID]:商品数量,
  36. }
  37. set:
  38. 键[用户ID]:{商品ID1,商品ID2....}

添加课程商品到购物车

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

  1. from rest_framework.views import APIView
  2. from rest_framework.permissions import IsAuthenticated
  3. from rest_framework.response import Response
  4. from rest_framework import status
  5. from django_redis import get_redis_connection
  6. from courses.models import Course
  7. # Create your views here.
  8. class CartAPIView(APIView):
  9. permission_classes = [IsAuthenticated] # 保证用户必须时登录状态才能调用当前视图
  10. def post(self, request):
  11. """添加课程商品到购物车中"""
  12. # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态
  13. user_id = request.user.id
  14. course_id = request.data.get("course_id", None)
  15. selected = 1 # 默认商品是勾选状态的
  16. print(f"user_id={user_id},course_id={course_id}")
  17. # 2. 验证课程是否允许购买[is_show=True, is_deleted=False]
  18. try:
  19. # 判断课程是否存在
  20. # todo 判断用户是否已经购买了
  21. course = Course.objects.get(is_show=True, is_deleted=False, pk=course_id)
  22. except:
  23. return Response({"errmsg": "当前课程不存在!"}, status=status.HTTP_400_BAD_REQUEST)
  24. # 3. 添加商品到购物车
  25. redis = get_redis_connection("cart")
  26. """
  27. cart_用户ID: {
  28. 课程ID: 1
  29. }
  30. """
  31. redis.hset(f"cart_{user_id}", course_id, selected)
  32. # 4. 获取购物车中的商品课程数量
  33. cart_total = redis.hlen(f"cart_{user_id}")
  34. # 5. 返回结果给客户端
  35. return Response({"errmsg": "成功添加商品课程到购物车!", "cart_total": cart_total}, status=status.HTTP_201_CREATED)

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

  1. from rest_framework.views import exception_handler
  2. from django.db import DatabaseError
  3. from redis import RedisError
  4. from rest_framework.response import Response
  5. from rest_framework import status
  6. import logging
  7. logger = logging.getLogger('django')
  8. def custom_exception_handler(exc, context):
  9. """
  10. 自定义异常处理工具函数
  11. :param exc: 异常类
  12. :param context: 抛出异常的执行上下文
  13. :return: Response响应对象
  14. """
  15. # 先调用drf框架原生的异常处理方法
  16. response = exception_handler(exc, context)
  17. if response is None:
  18. view = context['view']
  19. # 判断是否发生了数据库异常
  20. if isinstance(exc, DatabaseError):
  21. # 数据库异常
  22. logger.error('mysql数据库异常![%s] %s' % (view, exc))
  23. response = Response({'message': '服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
  24. elif isinstance(exc, RedisError):
  25. logger.error('redis数据库异常![%s] %s' % (view, exc))
  26. response = Response({'message': '缓存服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
  27. elif isinstance(exc, ZeroDivisionError):
  28. response = Response({'message': '0不能作为除数!'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
  29. return response

cart/urls.py,代码:

  1. from django.urls import path
  2. from . import views
  3. urlpatterns = [
  4. path("", views.CartAPIView.as_view()),
  5. ]

提交代码版本

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

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

api/cart.js,代码:

  1. import http from "../utils/http";
  2. import {reactive, ref} from "vue"
  3. const cart = reactive({
  4. // 添加课程到购物车
  5. add_course_to_cart(course_id, token) {
  6. return http.post("/cart/", {
  7. course_id: course_id
  8. }, {
  9. // 因为当前课程端添加课程商品到购物车必须登录,所以接口操作时必须发送jwt
  10. headers: {
  11. Authorization: "jwt " + token,
  12. }
  13. })
  14. }
  15. })
  16. export default cart;

views/Course.vue,代码:

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

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

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

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


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();


const get_hot_word = ()=>{
  // 搜索热门关键字列表
  course.get_hot_word().then(response=>{
    course.hot_word_list = response.data
  })
}


const get_course_list = ()=>{
  // 获取课程列表
  let ret  = null // 预设一个用于保存服务端返回的数据
  if(course.text) {
    ret = course.search_course()
  }else{
    ret = course.get_course_list()
  }

  ret.then(response=>{
    course.course_list = response.data.results;
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;
    // 优惠活动的倒计时
    course.start_timer();
  })

  // 每次获取课程都同事获取一次热搜词列表
  get_hot_word();

}

get_course_list();


// 当热搜词被点击,进行搜索
const search_by_hotword = (hot_word)=>{
  course.text = hot_word
  get_course_list()
}


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


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

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        get_course_list();
    }
)


watch(
    // 监听课程切换不同的排序条件
    ()=>course.ordering,
    ()=>{
        get_course_list();
    }
)

// 监听页码
watch(
    ()=>course.page,
    ()=>{
        // 重新获取课程信息
        get_course_list();
    }
)



</script>

views/Info.vue,代码:

            <div class="buy">
              <div class="buy-btn">
                <button class="buy-now">立即购买</button>
                <button class="free">免费试学</button>
              </div>
              <el-popconfirm title="您确认添加当前课程加入购物车吗?" @confirm="add_cart" confirmButtonText="买买买!" cancelButtonText="误操作!">
                <template #reference>
                  <div class="add-cart"><img src="../assets/cart-yellow.svg" alt="">加入购物车</div>
                </template>
              </el-popconfirm>
              <!--              <div class="add-cart"><img src="../assets/cart-yellow.svg" alt="">加入购物车</div>-->
            </div>
<script setup>
import {reactive,ref, watch} from "vue"
import {useRoute, useRouter} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import { AliPlayerV3 } from "vue-aliplayer-v3"
import course from "../api/course"
import cart from "../api/cart";
import { ElMessage } from 'element-plus'
import {fill0} from "../utils/func";
import {useStore} from "vuex";

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

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


// 简单判断课程ID是否合法
if(course.course_id > 0){
  // 根据课程ID到服务端获取课程详情数据
  course.get_course().then(response=> {
    course.info = response.data;
    clearInterval(course.timer);
    course.timer = setInterval(() => {
      if (course.info.discount.expire && course.info.discount.expire > 0) {
        course.info.discount.expire--
      }
    }, 1000);
  }).catch(error=>{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1);
      }
    })
  })

  // 获取课程章节信息
  course.get_course_chapters().then(response=>{
    course.chapter_list = response.data
  })

}else{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1)
      }
    })
}


// 阿里云播放器的选项参数
const options = reactive({
  // source: "/src/assets/1.mp4",
  // cover: "/src/assets/course-1.png",
  autoplay: false,   // 是否自动播放
  preload: true,     // 是否自动预加载
  isLive: false,     // 切换为直播流的时候必填true
  // format: 'm3u8'  // 切换为直播流的时候必填
})

const onPlay = (event)=>{
  console.log("播放视频");
  console.log(player.value.getCurrentTime());  // 当前视频播放时间
}

const onPause = (event)=>{
  console.log("暂停播放");
  console.log(player.value.getCurrentTime());
}

const onPlaying = (event)=>{
  console.log("播放中");
  console.log(player.value.getCurrentTime());
  console.log(player.value.getDuration());  // 获取视频长度
}


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


</script>

提交代码版本

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

显示购物车的商品数量

components/Header.vue,代码:

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

store/index.js,代码:

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

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

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

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

views/Course.vue,代码:

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

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

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


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();


const get_hot_word = ()=>{
  // 搜索热门关键字列表
  course.get_hot_word().then(response=>{
    course.hot_word_list = response.data
  })
}


const get_course_list = ()=>{
  // 获取课程列表
  let ret  = null // 预设一个用于保存服务端返回的数据
  if(course.text) {
    ret = course.search_course()
  }else{
    ret = course.get_course_list()
  }

  ret.then(response=>{
    course.course_list = response.data.results;
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;
    // 优惠活动的倒计时
    course.start_timer();
  })

  // 每次获取课程都同事获取一次热搜词列表
  get_hot_word();

}

get_course_list();


// 当热搜词被点击,进行搜索
const search_by_hotword = (hot_word)=>{
  course.text = hot_word
  get_course_list()
}


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


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

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        get_course_list();
    }
)


watch(
    // 监听课程切换不同的排序条件
    ()=>course.ordering,
    ()=>{
        get_course_list();
    }
)

// 监听页码
watch(
    ()=>course.page,
    ()=>{
        // 重新获取课程信息
        get_course_list();
    }
)



</script>

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

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

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

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

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

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

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

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

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

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

// 显示验证码
const show_captcha = ()=>{
  var captcha1 = new TencentCaptcha(settings.captcha_app_id, (res)=>{
      // 接收验证结果的回调函数
      /* res(验证成功) = {ret: 0, ticket: "String", randstr: "String"}
         res(客户端出现异常错误 仍返回可用票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
         res(用户主动关闭验证码)= {ret: 2}
      */
      console.log(res);
      // 调用登录处理
      loginhandler(res);
  });
  captcha1.show(); // 显示验证码
}

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

  // 登录请求处理
  user.login({
    ticket: res.ticket,
    randstr: res.randstr,
  }).then(response=>{
    // 先删除之前存留的状态
    localStorage.removeItem("token");
    sessionStorage.removeItem("token");
    // 根据用户选择是否记住登录密码,保存token到不同的本地存储中
    if(user.remember){
      // 记录登录状态
      localStorage.token = response.data.token
    }else{
      // 不记录登录状态
      sessionStorage.token = response.data.token
    }
    ElMessage.success("登录成功!");
    // 登录后续处理,通知父组件,当前用户已经登录成功
    user.account = ""
    user.password = ""
    user.mobile = ""
    user.code = ""
    user.remember = false

    // vuex存储用户登录信息,保存token,并根据用户的选择,是否记住密码
    let payload = response.data.token.split(".")[1]  // 载荷
    let payload_data = JSON.parse(atob(payload)) // 用户信息
    console.log("payload_data=", payload_data)
    store.commit("login", payload_data)
    store.commit("cart_total", response.data.cart_total)
    emit("successhandle")
  }).catch(error=>{
    ElMessage.error("登录失败!");
  })
}

</script>

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

views/Info.vue,代码:

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

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

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


// 简单判断课程ID是否合法
if(course.course_id > 0){
  // 根据课程ID到服务端获取课程详情数据
  course.get_course().then(response=> {
    course.info = response.data;
    clearInterval(course.timer);
    course.timer = setInterval(() => {
      if (course.info.discount.expire && course.info.discount.expire > 0) {
        course.info.discount.expire--
      }
    }, 1000);
  }).catch(error=>{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1);
      }
    })
  })

  // 获取课程章节信息
  course.get_course_chapters().then(response=>{
    course.chapter_list = response.data
  })

}else{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1)
      }
    })
}


// 阿里云播放器的选项参数
const options = reactive({
  // source: "/src/assets/1.mp4",
  // cover: "/src/assets/course-1.png",
  autoplay: false,   // 是否自动播放
  preload: true,     // 是否自动预加载
  isLive: false,     // 切换为直播流的时候必填true
  // format: 'm3u8'  // 切换为直播流的时候必填
})

const onPlay = (event)=>{
  console.log("播放视频");
  console.log(player.value.getCurrentTime());  // 当前视频播放时间
}

const onPause = (event)=>{
  console.log("暂停播放");
  console.log(player.value.getCurrentTime());
}

const onPlaying = (event)=>{
  console.log("播放中");
  console.log(player.value.getCurrentTime());
  console.log(player.value.getDuration());  // 获取视频长度
}


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


</script>

提交代码版本

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

购物车商品列表展示

客户端页面展示

src/router/index.js,代码:

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

// 路由列表
const routes = [
  {
    meta:{
        title: "luffy2.0-站点首页",
        keepAlive: true
    },
    path: '/',         // uri访问地址
    name: "Home",
    component: ()=> import("../views/Home.vue")
  },
  {
    meta:{
        title: "luffy2.0-用户登录",
        keepAlive: true
    },
    path:'/login',      // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")
  },
  {
      meta:{
        title: "luffy2.0-用户注册",
        keepAlive: true
      },
      path: '/register',
      name: "Register",            // 路由名称
      component: ()=> import("../views/Register.vue"),         // uri绑定的组件页面
  },
  {
    meta:{
        title: "luffy2.0-个人中心",
        keepAlive: true,
        authorization: true,
    },
    path: '/user',
    name: "User",
    component: ()=> import("../views/User.vue"),
  },
  {
    meta:{
        title: "luffy2.0-课程列表",
        keepAlive: true,
    },
    path: '/project',
    name: "Course",
    component: ()=> import("../views/Course.vue"),
  },
  {
    meta:{
        title: "luffy2.0-课程详情",
        keepAlive: true
    },
    path: '/project/:id',     // :id vue的路径参数,代表了课程的ID
    name: "Info",
    component: ()=> import("../views/Info.vue"),
  },{
      meta:{
        title: "luffy2.0-购物车",
        keepAlive: true
      },
      path: '/cart',
      name: "Cart",
      component: ()=> import("../views/Cart.vue"),
    }
]


// 路由对象实例化
const router = createRouter({
  // history, 指定路由的模式
  history: createWebHistory(),
  // 路由列表
  routes,
});

// 导航守卫
router.beforeEach((to, from, next)=>{
  document.title=to.meta.title
  // 登录状态验证
  if (to.meta.authorization && !store.getters.getUserInfo) {
    next({"name": "Login"})
  }else{
    next()
  }
})


// 暴露路由对象
export default router

views/Cart.vue,代码:

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

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

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

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

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

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

.cart-header .left {
    float: left
}

.cart-header .right {
    float: right
}

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

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

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

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

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

.l {
    float: left;
}

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

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

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

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

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

body {
    background: #f8fafc
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

修改多选框的外观效果

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

src/App.vue,代码:

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

提交代码版本

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

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

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

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

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

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

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

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

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


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

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

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

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

提交代码版本

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

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

api/cart.js,代码:

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

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

export default cart;

views/Cart.vue,代码:

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

              <div class="item-3">
                  <div class="price" v-if="course_info.discount.price>=0">
                      <span class="discount-price"><em>¥</em><span>{{course_info.discount.price.toFixed(2)}}</span></span><br>
                      <span class="original-price"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></span>
                  </div>
                  <div class="price" v-else>
                      <div class="discount-price"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></div>
                  </div>
              </div>
              <div class="item-4"><el-icon :size="26" class="close"><Close /></el-icon></div>
          </div>
          <div class="cart-body-bot fixed">
            <div class=" cart-body-bot-box">
              <div class="right">
                <div class="add-coupon-box">
                  <div class="li-left">
                    <div class="li-2">
                      <span class="topdiv w70">总计金额:</span>
                      <span class="price price-red w100">
                        <em>¥</em>
                        <span>{{cart.total_price.toFixed(2)}}</span>
                      </span>
                    </div>
                  </div>
                  <div class="li-3"><span class="btn">去结算</span></div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer/>
  </div>
</template>
<script setup>
import {Close} from '@element-plus/icons-vue'
import {reactive} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import cart from "../api/cart"
import { ElMessage } from 'element-plus'
let state = reactive({
  checked: false,
})


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

get_cart()


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

</script>

提交代码版本

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

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

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

cart/views.py,代码:

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


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

    def post(self, request):
        """添加课程商品到购物车中"""
        # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态
        # 用户ID 可以通过self.request.user.id 或 request.user.id 来获取
        user_id = request.user.id
        course_id = request.data.get("course_id", None)
        selected = 1  # 默认商品是勾选状态的
        print(f"user_id={user_id},course_id={course_id}")

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

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

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

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

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

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

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

        redis = get_redis_connection("cart")

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

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

提交代码版本:

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

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

courses.views,代码:

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


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

    def post(self, request):
        """添加课程商品到购物车中"""
        # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态
        # 用户ID 可以通过self.request.user.id 或 request.user.id 来获取
        user_id = request.user.id
        course_id = request.data.get("course_id", None)
        selected = 1  # 默认商品是勾选状态的
        print(f"user_id={user_id},course_id={course_id}")

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

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

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

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

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

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

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

        redis = get_redis_connection("cart")

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

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

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

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

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

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

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

提交代码版本:

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

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

api/cart.js,代码:

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

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

export default cart;

views/Cart.vue,代码:

          <div class="item"  v-for="course_info in cart.course_list">
              <div class="item-1">
                  <el-checkbox v-model="course_info.selected" @change="change_select_course(course_info)"></el-checkbox>
              </div>
<script setup>
import {Close} from '@element-plus/icons-vue'
import {reactive, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import cart from "../api/cart"
import { ElMessage } from 'element-plus'
let state = reactive({
  checked: false,
})


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

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

  })
}

get_cart()


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


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


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

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

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

</script>

提交代码版本:

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

删除购物车中的商品

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

cart/views.py,代码:

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


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

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

提交代码版本:

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

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

api/cart.js,代码:

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

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

export default cart;

views/Cart.vue,代码:

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

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

const store = useStore()

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

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

  })
}

get_cart()


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


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


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

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

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

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


</script>

提交代码版本:

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